mod api;
mod cache;
mod commands;
mod config;
mod dates;
mod error;
mod input;
mod json_path;
mod oauth;
#[cfg(feature = "secure-storage")]
mod keyring;
mod output;
mod pagination;
mod priority;
mod retry;
mod text;
#[allow(dead_code)]
mod types;
mod vcs;
use anyhow::Result;
use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
use clap_complete::{generate, Shell};
use commands::{
attachments, auth, bulk, comments, cycles, doctor, documents, export, favorites, git, history,
import, initiatives, interactive, issues, labels, metrics, notifications, project_updates,
projects, relations, roadmaps, search, sprint, statuses, sync, teams, templates, time, triage,
uploads, users, views, watch, webhooks,
};
use error::CliError;
use output::print_json_owned;
use output::{parse_filters, JsonOutputOptions, OutputOptions, SortOrder};
use pagination::PaginationOptions;
use std::io::IsTerminal;
use std::sync::OnceLock;
#[derive(Debug, Clone, Copy, Default, ValueEnum, PartialEq)]
pub enum OutputFormat {
#[default]
Table,
Json,
Ndjson,
}
#[derive(Debug, Clone, Copy, Default, ValueEnum, PartialEq)]
pub enum ColorChoice {
#[default]
Auto,
Always,
Never,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct AgentOptions {
pub quiet: bool,
pub id_only: bool,
pub dry_run: bool,
pub yes: bool,
}
static YES_MODE: OnceLock<bool> = OnceLock::new();
pub fn set_yes_mode(yes: bool) {
let _ = YES_MODE.set(yes);
}
pub fn is_yes() -> bool {
YES_MODE.get().copied().unwrap_or(false)
}
#[derive(Parser)]
#[command(name = "linear-cli")]
#[command(
about = "A powerful CLI for Linear.app - manage issues, projects, and more from your terminal"
)]
#[command(version)]
#[command(after_help = r#"QUICK START:
1. Get your API key from https://linear.app/settings/api
2. Configure the CLI:
linear config set-key YOUR_API_KEY
3. List your issues:
linear issues list
4. Create an issue:
linear issues create "Fix bug" --team ENG --priority 2
COMMON FLAGS:
--output table|json|ndjson Output format (default: table)
--color-mode auto|always|never Color output control
--no-color Disable color output
--width N Max table column width
--no-truncate Disable table truncation
--quiet Reduce decorative output
--format TEMPLATE Template output (e.g. '{{identifier}} {{title}}')
--filter field=value Filter results (=, !=, ~= operators; dot paths; case-insensitive)
--limit N Limit list/search results
--page-size N Page size for list/search
--after CURSOR Pagination cursor (after)
--before CURSOR Pagination cursor (before)
--all Fetch all pages
--profile NAME Use named profile
--schema Print JSON schema version and exit
--cache-ttl N Cache TTL in seconds
--no-cache Disable cache usage
--yes Auto-confirm all prompts
For more info on a command, run: linear <command> --help"#)]
struct Cli {
#[arg(
short,
long,
global = true,
env = "LINEAR_CLI_OUTPUT",
default_value = "table"
)]
output: OutputFormat,
#[arg(short, long, global = true)]
quiet: bool,
#[arg(long, global = true)]
id_only: bool,
#[arg(
long = "color-mode",
global = true,
value_enum,
default_value = "auto",
conflicts_with = "no_color"
)]
color_mode: ColorChoice,
#[arg(long, global = true)]
no_color: bool,
#[arg(long, global = true)]
width: Option<usize>,
#[arg(long, global = true)]
no_truncate: bool,
#[arg(long, global = true)]
compact: bool,
#[arg(long, global = true, value_delimiter = ',')]
fields: Vec<String>,
#[arg(long, global = true)]
sort: Option<String>,
#[arg(long, global = true, value_enum, default_value = "asc")]
order: SortOrder,
#[arg(long, global = true, env = "LINEAR_API_KEY")]
api_key: Option<String>,
#[arg(long, global = true, env = "LINEAR_CLI_PROFILE")]
profile: Option<String>,
#[arg(long, global = true)]
format: Option<String>,
#[arg(long, global = true)]
filter: Vec<String>,
#[arg(long, global = true)]
fail_on_empty: bool,
#[arg(long, global = true)]
limit: Option<usize>,
#[arg(long, global = true)]
after: Option<String>,
#[arg(long, global = true)]
before: Option<String>,
#[arg(long, global = true)]
page_size: Option<usize>,
#[arg(long, global = true)]
all: bool,
#[arg(long, global = true, env = "LINEAR_CLI_CACHE_TTL")]
cache_ttl: Option<u64>,
#[arg(long, global = true, env = "LINEAR_CLI_NO_CACHE")]
no_cache: bool,
#[arg(long, global = true)]
dry_run: bool,
#[arg(long, global = true, env = "LINEAR_CLI_YES")]
yes: bool,
#[arg(long, global = true, default_value = "0")]
retry: u32,
#[arg(long, global = true)]
schema: bool,
#[arg(long, global = true, env = "LINEAR_CLI_NO_PAGER")]
no_pager: bool,
#[command(subcommand)]
command: Commands,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct DisplayOptions {
pub width: Option<usize>,
pub no_truncate: bool,
}
impl DisplayOptions {
pub fn max_width(&self, default: usize) -> Option<usize> {
if self.no_truncate {
None
} else {
Some(self.width.unwrap_or(default))
}
}
}
static DISPLAY_OPTIONS: OnceLock<DisplayOptions> = OnceLock::new();
fn set_cli_state(display: DisplayOptions) {
let _ = DISPLAY_OPTIONS.set(display);
}
pub fn display_options() -> DisplayOptions {
DISPLAY_OPTIONS.get().copied().unwrap_or_default()
}
#[derive(Subcommand)]
enum Commands {
#[command(alias = "tasks")]
Common,
Agent,
#[command(alias = "att")]
#[command(after_help = r#"EXAMPLES:
linear attachments list SCW-123 # List attachments on issue
linear att get ATTACHMENT_ID # View attachment details
linear att create SCW-123 -T "Doc" -u https://example.com
linear att link-url SCW-123 https://example.com
linear att delete ATTACHMENT_ID --force # Delete attachment"#)]
Attachments {
#[command(subcommand)]
action: attachments::AttachmentCommands,
},
#[command(after_help = r#"EXAMPLES:
linear auth login # Store API key
linear auth status # Show auth status
linear auth logout # Remove current profile
linear auth oauth # Authenticate via OAuth 2.0
linear auth oauth --client-id MY_ID # Use custom OAuth app
linear auth revoke # Revoke OAuth tokens"#)]
Auth {
#[command(subcommand)]
action: auth::AuthCommands,
},
#[command(after_help = r#"EXAMPLES:
linear doctor # Check config and auth
linear doctor --check-api # Validate API access
linear doctor --fix # Auto-fix common issues"#)]
Doctor {
#[arg(long)]
check_api: bool,
#[arg(long)]
fix: bool,
},
#[command(after_help = r#"EXAMPLES:
linear api query '{ viewer { id name } }'
linear api query -v teamId=abc '...' # With variables
linear api mutate -v title=Bug '...' # Run mutations"#)]
Api {
#[command(subcommand)]
action: commands::api::ApiCommands,
},
#[command(alias = "p")]
#[command(after_help = r#"EXAMPLES:
linear projects list # List all projects
linear p list --archived # Include archived projects
linear p get PROJECT_ID # View project details
linear p create "Q1 Roadmap" -t ENG # Create a project"#)]
Projects {
#[command(subcommand)]
action: projects::ProjectCommands,
},
#[command(alias = "pu")]
#[command(after_help = r#"EXAMPLES:
linear project-updates list "My Project" # List updates
linear pu get UPDATE_ID # View update details
linear pu create "My Project" -b "On track" # Create update
linear pu archive UPDATE_ID # Archive update"#)]
ProjectUpdates {
#[command(subcommand)]
action: project_updates::ProjectUpdateCommands,
},
#[command(alias = "i")]
#[command(after_help = r#"EXAMPLES:
linear issues list # List all issues
linear i list -t ENG -s "In Progress" # Filter by team and status
linear i get LIN-123 # View issue details
linear i create "Bug fix" -t ENG -p 2 # Create high priority issue
linear i update LIN-123 -s Done # Update issue status"#)]
Issues {
#[command(subcommand)]
action: issues::IssueCommands,
},
#[command(alias = "l")]
#[command(after_help = r##"EXAMPLES:
linear labels list # List project labels
linear l list --type issue # List issue labels
linear l create "Feature" --color "#10B981"
linear l delete LABEL_ID --force"##)]
Labels {
#[command(subcommand)]
action: labels::LabelCommands,
},
#[command(alias = "t")]
#[command(after_help = r#"EXAMPLES:
linear teams list # List all teams
linear t get ENG # View team details"#)]
Teams {
#[command(subcommand)]
action: teams::TeamCommands,
},
#[command(alias = "u")]
#[command(after_help = r#"EXAMPLES:
linear users list # List all users
linear u list --team ENG # List team members
linear u me # View your profile"#)]
Users {
#[command(subcommand)]
action: users::UserCommands,
},
#[command(alias = "c")]
#[command(after_help = r#"EXAMPLES:
linear cycles list -t ENG # List team cycles
linear c current -t ENG # Show current cycle
linear c create -t ENG --name "Sprint 5" # Create a cycle
linear c update ID --name "Sprint 5b" # Update cycle name"#)]
Cycles {
#[command(subcommand)]
action: cycles::CycleCommands,
},
#[command(alias = "cm")]
#[command(after_help = r#"EXAMPLES:
linear comments list ISSUE_ID # List comments on issue
linear cm create ISSUE_ID -b "LGTM!" # Add a comment"#)]
Comments {
#[command(subcommand)]
action: comments::CommentCommands,
},
#[command(alias = "d")]
#[command(after_help = r#"EXAMPLES:
linear documents list # List all documents
linear d get DOC_ID # View document
linear d create "Design Doc" -p PROJ_ID # Create document
linear d delete DOC_ID --force # Delete document"#)]
Documents {
#[command(subcommand)]
action: documents::DocumentCommands,
},
#[command(alias = "s")]
#[command(after_help = r#"EXAMPLES:
linear search issues "auth bug" # Search issues
linear s projects "backend" # Search projects"#)]
Search {
#[command(subcommand)]
action: search::SearchCommands,
},
#[command(alias = "sy")]
#[command(after_help = r#"EXAMPLES:
linear sync status # Compare local vs Linear
linear sy push -t ENG # Create projects for folders
linear sy push -t ENG --dry-run # Preview without creating"#)]
Sync {
#[command(subcommand)]
action: sync::SyncCommands,
},
#[command(alias = "st")]
#[command(after_help = r#"EXAMPLES:
linear statuses list -t ENG # List team statuses
linear st get "In Progress" -t ENG # View status details"#)]
Statuses {
#[command(subcommand)]
action: statuses::StatusCommands,
},
#[command(alias = "g")]
#[command(after_help = r#"EXAMPLES:
linear git checkout LIN-123 # Checkout issue branch
linear g branch LIN-123 # Show branch name
linear g pr LIN-123 # Create GitHub PR
linear g pr LIN-123 --draft # Create draft PR"#)]
Git {
#[command(subcommand)]
action: git::GitCommands,
},
#[command(alias = "b")]
#[command(after_help = r#"EXAMPLES:
linear bulk update -s Done LIN-1 LIN-2 # Update multiple issues
linear b assign --user me LIN-1 LIN-2 # Assign multiple issues
linear b label --add bug LIN-1 LIN-2 # Add label to issues"#)]
Bulk {
#[command(subcommand)]
action: bulk::BulkCommands,
},
#[command(alias = "ca")]
#[command(after_help = r#"EXAMPLES:
linear cache status # Show cache status
linear ca clear # Clear all cache
linear ca clear --type teams # Clear only teams cache"#)]
Cache {
#[command(subcommand)]
action: commands::cache::CacheCommands,
},
#[command(alias = "n")]
#[command(after_help = r#"EXAMPLES:
linear notifications list # List unread notifications
linear n count # Show unread count
linear n read-all # Mark all as read
linear n archive NOTIF_ID # Archive a notification
linear n archive-all # Archive all notifications"#)]
Notifications {
#[command(subcommand)]
action: notifications::NotificationCommands,
},
#[command(alias = "tpl")]
#[command(after_help = r#"EXAMPLES:
linear templates list # List all templates
linear tpl create bug # Create a new template
linear tpl show bug # View template details"#)]
Templates {
#[command(subcommand)]
action: templates::TemplateCommands,
},
#[command(alias = "tm")]
#[command(after_help = r#"EXAMPLES:
linear time log LIN-123 2h # Log 2 hours on issue
linear tm list --issue LIN-123 # List time entries"#)]
Time {
#[command(subcommand)]
action: time::TimeCommands,
},
#[command(alias = "up")]
#[command(after_help = r#"EXAMPLES:
linear uploads fetch URL # Output to stdout (for piping)
linear up fetch URL -f file.png # Save to file
linear up fetch URL | base64 # Pipe to another tool"#)]
Uploads {
#[command(subcommand)]
action: uploads::UploadCommands,
},
#[command(alias = "int")]
#[command(after_help = r#"EXAMPLES:
linear interactive # Launch interactive mode
linear interactive --team ENG # Preselect team
Use arrow keys to navigate, Enter to select, q to quit."#)]
Interactive {
#[arg(short, long)]
team: Option<String>,
},
#[command(alias = "ctx")]
#[command(after_help = r#"EXAMPLES:
linear context # Show current issue from branch
linear ctx --output json # Get as JSON for parsing
Detects issue ID from branch names like:
- lin-123-fix-bug
- feature/LIN-456-new-feature
- scw-789-some-task"#)]
Context,
#[command(alias = "fav")]
#[command(after_help = r#"EXAMPLES:
linear favorites list # List favorites
linear fav add LIN-123 # Add issue to favorites
linear fav remove LIN-123 # Remove from favorites"#)]
Favorites {
#[command(subcommand)]
action: favorites::FavoriteCommands,
},
#[command(alias = "rm")]
#[command(after_help = r#"EXAMPLES:
linear roadmaps list # List all roadmaps
linear rm get ROADMAP_ID # View roadmap details
linear rm create "Q1 Plan" # Create a roadmap
linear rm update ID -n "Q2 Plan" # Update roadmap name"#)]
Roadmaps {
#[command(subcommand)]
action: roadmaps::RoadmapCommands,
},
#[command(alias = "init")]
#[command(after_help = r#"EXAMPLES:
linear initiatives list # List all initiatives
linear init get INITIATIVE_ID # View initiative details
linear init create "H1 Goals" # Create an initiative
linear init update ID -s "Active" # Update initiative status"#)]
Initiatives {
#[command(subcommand)]
action: initiatives::InitiativeCommands,
},
#[command(alias = "tr")]
#[command(after_help = r#"EXAMPLES:
linear triage list # List triage issues
linear tr claim LIN-123 # Claim an issue
linear tr snooze LIN-123 --duration 1w # Snooze for a week"#)]
Triage {
#[command(subcommand)]
action: triage::TriageCommands,
},
#[command(alias = "mt")]
#[command(after_help = r#"EXAMPLES:
linear metrics cycle CYCLE_ID # Cycle metrics
linear mt project PROJECT_ID # Project progress
linear mt velocity TEAM --cycles 5 # Team velocity"#)]
Metrics {
#[command(subcommand)]
action: metrics::MetricsCommands,
},
#[command(alias = "ms")]
#[command(after_help = r#"EXAMPLES:
linear milestones list -p "My Project" # List milestones
linear ms get MILESTONE_ID # View milestone details
linear ms create "Beta Release" -p PROJ # Create milestone
linear ms update ID --target-date +2w # Update target date
linear ms delete ID --force # Delete milestone"#)]
Milestones {
#[command(subcommand)]
action: commands::milestones::MilestoneCommands,
},
#[command(alias = "exp")]
#[command(after_help = r#"EXAMPLES:
linear export csv --team ENG # Export team issues to CSV
linear exp csv -f issues.csv # Export to file
linear exp json --team ENG --pretty # Export as pretty JSON
linear exp markdown --team ENG # Export as Markdown
linear exp projects-csv -f projects.csv # Export projects to CSV"#)]
Export {
#[command(subcommand)]
action: export::ExportCommands,
},
#[command(alias = "im")]
#[command(after_help = r#"EXAMPLES:
linear import csv issues.csv -t ENG # Import from CSV
linear im csv issues.csv -t ENG --dry-run # Preview without creating
linear im json issues.json -t ENG # Import from JSON"#)]
Import {
#[command(subcommand)]
action: import::ImportCommands,
},
#[command(alias = "hist")]
#[command(after_help = r#"EXAMPLES:
linear history issue LIN-123 # View issue activity
linear hist issue LIN-123 --limit 50 # More entries"#)]
History {
#[command(subcommand)]
action: history::HistoryCommands,
},
#[command(alias = "v")]
#[command(after_help = r#"EXAMPLES:
linear views list # List all custom views
linear v list --shared # List shared views only
linear v get "My View" # View details
linear v create "Bug Triage" --shared # Create a shared view
linear v delete VIEW_ID --force # Delete a view"#)]
Views {
#[command(subcommand)]
action: views::ViewCommands,
},
#[command(alias = "wh")]
#[command(after_help = r#"EXAMPLES:
linear webhooks list # List all webhooks
linear wh create URL --events Issue # Create webhook
linear wh delete WEBHOOK_ID --force # Delete webhook
linear wh rotate-secret WEBHOOK_ID # Rotate webhook secret
linear wh listen --port 9000 # Listen for events locally"#)]
Webhooks {
#[command(subcommand)]
action: webhooks::WebhookCommands,
},
#[command(after_help = r#"EXAMPLES:
linear watch issue LIN-123 # Watch single issue
linear watch issue LIN-123 --interval 30 # Poll every 30 seconds
linear watch project PROJECT_ID # Watch a project
linear watch team ENG # Watch a team"#)]
Watch {
#[command(subcommand)]
action: WatchCommands,
},
#[command(alias = "rel")]
#[command(after_help = r#"EXAMPLES:
linear relations list LIN-123 # List issue relationships
linear rel add LIN-1 -r blocks LIN-2 # LIN-1 blocks LIN-2
linear rel parent LIN-2 LIN-1 # Set LIN-1 as parent of LIN-2
linear rel unparent LIN-2 # Remove parent"#)]
Relations {
#[command(subcommand)]
action: relations::RelationCommands,
},
#[command(alias = "me")]
Whoami,
#[command(after_help = r#"EXAMPLES:
linear done # Mark current branch issue as Done
linear done --status "In Progress" # Set to specific status instead
Reads the current git branch, extracts the issue ID (e.g. feat/SCW-123-title → SCW-123),
and updates the issue status."#)]
Done {
#[arg(short, long, default_value = "Done")]
status: String,
},
#[command(after_help = r#"EXAMPLES:
linear setup # Run interactive setup wizard
Walks you through:
1. Setting your Linear API key
2. Choosing a default team
3. Selecting output format (table or json)"#)]
Setup,
#[command(alias = "sp")]
#[command(after_help = r#"EXAMPLES:
linear sprint status -t ENG # Current sprint status
linear sp progress -t ENG # Sprint progress bar
linear sp plan -t ENG # Next sprint's planned issues
linear sp carry-over -t ENG --force # Move incomplete issues to next cycle"#)]
Sprint {
#[command(subcommand)]
action: sprint::SprintCommands,
},
#[command(alias = "comp")]
#[command(after_help = r#"EXAMPLES:
linear completions bash > ~/.bash_completion.d/linear
linear completions zsh > ~/.zfunc/_linear
linear completions fish > ~/.config/fish/completions/linear.fish
linear comp powershell > linear.ps1
linear comp dynamic bash # Dynamic completions with argument value hints
linear comp dynamic zsh # Dynamic completions for zsh"#)]
Completions {
#[command(subcommand)]
action: CompletionCommands,
},
#[command(name = "_complete", hide = true)]
Complete {
#[arg(long = "type")]
type_: String,
#[arg(long, default_value = "")]
prefix: String,
#[arg(long)]
team: Option<String>,
},
#[command(after_help = r#"EXAMPLES:
linear config set-key YOUR_API_KEY # Set API key
linear config set api-key YOUR_API_KEY # Set API key (alt)
linear config get api-key # Get API key (masked)
linear config set profile work # Switch profile
linear config show # Show configuration
linear config workspace-add work KEY # Add workspace
linear config workspace-switch work # Switch workspace"#)]
Config {
#[command(subcommand)]
action: ConfigCommands,
},
}
#[derive(Subcommand)]
enum ConfigCommands {
#[command(after_help = r#"EXAMPLE:
linear config set-key lin_api_xxxxxxxxxxxxx"#)]
SetKey {
key: String,
},
Get {
key: String,
#[arg(long)]
raw: bool,
},
Set {
key: String,
value: String,
},
Show,
#[command(after_help = r#"EXAMPLES:
linear config completions bash > ~/.bash_completion.d/linear
linear config completions zsh > ~/.zfunc/_linear
linear config completions fish > ~/.config/fish/completions/linear.fish
linear config completions powershell > linear.ps1"#)]
Completions {
#[arg(value_enum)]
shell: Shell,
},
#[command(alias = "add")]
#[command(after_help = r#"EXAMPLE:
linear config workspace-add personal lin_api_xxxxxxxxxxxxx"#)]
WorkspaceAdd {
name: String,
api_key: String,
},
#[command(alias = "list")]
WorkspaceList,
#[command(alias = "use")]
#[command(after_help = r#"EXAMPLE:
linear config workspace-switch personal"#)]
WorkspaceSwitch {
name: String,
},
#[command(alias = "current")]
WorkspaceCurrent,
#[command(alias = "rm")]
WorkspaceRemove {
name: String,
},
}
#[derive(Subcommand)]
enum WatchCommands {
Issue {
id: String,
#[arg(short, long, default_value = "10")]
interval: u64,
},
Project {
id: String,
#[arg(short, long, default_value = "10")]
interval: u64,
},
Team {
team: String,
#[arg(short, long, default_value = "10")]
interval: u64,
},
}
#[derive(Subcommand)]
enum CompletionCommands {
Static {
#[arg(value_enum)]
shell: Shell,
},
Dynamic {
#[arg(value_enum)]
shell: Shell,
},
}
fn main() -> Result<()> {
let builder = std::thread::Builder::new().stack_size(8 * 1024 * 1024);
let handler = builder.spawn(|| {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("Failed to create tokio runtime")
.block_on(async_main())
})?;
handler.join().unwrap()
}
async fn async_main() -> Result<()> {
let cli = Cli::parse();
if cli.no_color || cli.color_mode == ColorChoice::Never {
colored::control::set_override(false);
} else if cli.color_mode == ColorChoice::Always {
colored::control::set_override(true);
}
set_cli_state(DisplayOptions {
width: cli.width,
no_truncate: cli.no_truncate,
});
if let Some(key) = cli.api_key.as_deref() {
std::env::set_var("LINEAR_API_KEY", key);
}
if let Some(profile) = cli.profile.as_deref() {
std::env::set_var("LINEAR_CLI_PROFILE", profile);
}
api::set_default_retry(cli.retry);
let filters = parse_filters(&cli.filter)?;
let pagination = PaginationOptions {
limit: cli.limit,
after: cli.after.clone(),
before: cli.before.clone(),
page_size: cli.page_size,
all: cli.all,
};
let json_opts = JsonOutputOptions::new(
cli.compact,
if cli.fields.is_empty() {
None
} else {
Some(cli.fields.clone())
},
cli.sort.clone(),
cli.order,
true,
);
let output = OutputOptions {
format: cli.output,
json: json_opts,
format_template: cli.format.clone(),
filters,
fail_on_empty: cli.fail_on_empty,
pagination,
cache: cache::CacheOptions {
ttl_seconds: cli.cache_ttl,
no_cache: cli.no_cache,
},
dry_run: cli.dry_run,
};
let agent_opts = AgentOptions {
quiet: cli.quiet,
id_only: cli.id_only,
dry_run: cli.dry_run,
yes: cli.yes,
};
output::set_quiet_mode(
cli.quiet || matches!(cli.output, OutputFormat::Json | OutputFormat::Ndjson),
);
set_yes_mode(cli.yes);
if cli.schema {
let schema = serde_json::json!({
"schema_version": "1.0",
"schema_file": "docs/json/schema.json",
});
if matches!(cli.output, OutputFormat::Ndjson) || cli.compact {
println!("{}", serde_json::to_string(&schema)?);
} else {
println!("{}", serde_json::to_string_pretty(&schema)?);
}
std::process::exit(0);
}
let _pager_guard = if should_use_pager(cli.no_pager, &cli.output, cli.quiet) {
setup_pager()
} else {
None
};
let result = run_command(cli.command, &output, agent_opts, cli.retry).await;
match result {
Ok(()) => std::process::exit(0),
Err(e) => {
if output.is_json() {
if let Some(cli_error) = e.downcast_ref::<CliError>() {
let error_json = serde_json::json!({
"error": true,
"message": cli_error.message,
"code": cli_error.code(),
"details": cli_error.details,
"retry_after": cli_error.retry_after,
});
eprintln!(
"{}",
serde_json::to_string(&error_json).unwrap_or_else(|_| e.to_string())
);
} else {
let error_json = serde_json::json!({
"error": true,
"message": e.to_string(),
"code": categorize_error(&e),
"details": null,
"retry_after": null,
});
eprintln!(
"{}",
serde_json::to_string(&error_json).unwrap_or_else(|_| e.to_string())
);
}
} else {
eprintln!("Error: {}", e);
}
std::process::exit(categorize_error(&e) as i32);
}
}
#[allow(unreachable_code)]
Ok(())
}
fn categorize_error(e: &anyhow::Error) -> u8 {
if let Some(cli_error) = e.downcast_ref::<CliError>() {
return cli_error.code();
}
let msg = e.to_string().to_lowercase();
if msg.contains("not found") || msg.contains("does not exist") {
2
} else if msg.contains("unauthorized")
|| msg.contains("api key")
|| msg.contains("authentication")
{
3
} else if msg.contains("rate limit") || msg.contains("too many requests") {
4
} else {
1
}
}
async fn run_command(
command: Commands,
output: &OutputOptions,
agent_opts: AgentOptions,
retry: u32,
) -> Result<()> {
match command {
Commands::Common => {
println!("Common tasks:");
println!(" linear issues list -t ENG");
println!(" linear issues get LIN-123");
println!(" linear issues create \"Title\" -t ENG");
println!(" linear issues update LIN-123 -s Done");
println!(" linear projects list");
println!(" linear teams list");
println!(" linear git checkout LIN-123");
println!(" linear git pr LIN-123 --draft");
println!(" linear interactive --team ENG");
println!();
println!("Tips:");
println!(" Use --help after any command for more options.");
println!(" Use --output json or --output ndjson for scripting/LLMs.");
println!(" Use --no-color for logs/CI.");
println!(" Use --limit/--page-size/--all for pagination.");
}
Commands::Agent => {
println!("Agent harness:");
println!(" Use --output json or --output ndjson for machine-readable output.");
println!(" Use --compact and --fields to reduce tokens.");
println!(" Use --sort/--order to stabilize list outputs.");
println!(" Use --filter to reduce list results.");
println!(" Use --id-only for chaining create/update commands.");
println!(" Use --data - for JSON input on issue create/update.");
println!(" Use --yes to auto-confirm all prompts (deletes, destructive ops).");
println!(" Use 'linear done' to mark current branch's issue as Done.");
println!();
println!("Examples:");
println!(" linear issues list --output json --compact --fields identifier,title");
println!(" linear issues list --output ndjson --filter state.name=In\\ Progress");
println!(" linear issues get LIN-123 --output json");
println!(" linear issues update LIN-123 --data - --dry-run");
println!(" linear context --output json --id-only");
println!();
println!("Schemas:");
println!(" See docs/json/ for sample outputs.");
println!(" Use --schema to print the current schema version.");
}
Commands::Projects { action } => projects::handle(action, output).await?,
Commands::ProjectUpdates { action } => project_updates::handle(action, output).await?,
Commands::Issues { action } => issues::handle(action, output, agent_opts).await?,
Commands::Attachments { action } => attachments::handle(action, output).await?,
Commands::Labels { action } => labels::handle(action, output).await?,
Commands::Teams { action } => teams::handle(action, output).await?,
Commands::Users { action } => users::handle(action, output).await?,
Commands::Cycles { action } => cycles::handle(action, output).await?,
Commands::Comments { action } => comments::handle(action, output).await?,
Commands::Documents { action } => documents::handle(action, output).await?,
Commands::Search { action } => search::handle(action, output).await?,
Commands::Sync { action } => sync::handle(action, output).await?,
Commands::Statuses { action } => statuses::handle(action, output).await?,
Commands::Git { action } => git::handle(action).await?,
Commands::Bulk { action } => bulk::handle(action, output).await?,
Commands::Cache { action } => commands::cache::handle(action).await?,
Commands::Notifications { action } => notifications::handle(action, output).await?,
Commands::Templates { action } => templates::handle(action, output).await?,
Commands::Time { action } => time::handle(action, output).await?,
Commands::Uploads { action } => uploads::handle(action).await?,
Commands::Interactive { team } => interactive::run(team).await?,
Commands::Context => handle_context(output, agent_opts, retry).await?,
Commands::Favorites { action } => favorites::handle(action, output).await?,
Commands::Roadmaps { action } => {
roadmaps::handle(action, output, &output.pagination).await?
}
Commands::Initiatives { action } => {
initiatives::handle(action, output, &output.pagination).await?
}
Commands::Triage { action } => triage::handle(action, output).await?,
Commands::Metrics { action } => metrics::handle(action, output).await?,
Commands::Milestones { action } => commands::milestones::handle(action, output).await?,
Commands::Export { action } => export::handle(action, output).await?,
Commands::Import { action } => import::handle(action, output).await?,
Commands::History { action } => history::handle(action, output).await?,
Commands::Views { action } => views::handle(action, output).await?,
Commands::Webhooks { action } => webhooks::handle(action, output).await?,
Commands::Watch { action } => match action {
WatchCommands::Issue { id, interval } => {
watch::watch_issue(&id, interval, output).await?
}
WatchCommands::Project { id, interval } => {
watch::watch_project(&id, interval, output).await?
}
WatchCommands::Team { team, interval } => {
watch::watch_team(&team, interval, output).await?
}
},
Commands::Relations { action } => relations::handle(action, output).await?,
Commands::Whoami => users::handle(users::UserCommands::Me, output).await?,
Commands::Done { status } => handle_done(&status, output, agent_opts, retry).await?,
Commands::Setup => handle_setup(output).await?,
Commands::Sprint { action } => sprint::handle(action, output).await?,
Commands::Completions { action } => match action {
CompletionCommands::Static { shell } => {
let mut cmd = Cli::command();
generate(shell, &mut cmd, "linear-cli", &mut std::io::stdout());
}
CompletionCommands::Dynamic { shell } => {
print_dynamic_completion_script(shell);
}
},
Commands::Complete {
type_,
prefix,
team,
} => handle_complete(&type_, &prefix, team.as_deref()).await?,
Commands::Auth { action } => auth::handle(action, output).await?,
Commands::Api { action } => commands::api::handle(action, output).await?,
Commands::Doctor { check_api, fix } => doctor::run(output, check_api, fix).await?,
Commands::Config { action } => match action {
ConfigCommands::SetKey { key } => {
config::set_api_key(&key)?;
if !agent_opts.quiet {
println!("API key saved successfully!");
}
}
ConfigCommands::Get { key, raw } => {
config::config_get(&key, raw)?;
}
ConfigCommands::Set { key, value } => {
config::config_set(&key, &value)?;
}
ConfigCommands::Show => {
config::show_config()?;
}
ConfigCommands::Completions { shell } => {
let mut cmd = Cli::command();
generate(shell, &mut cmd, "linear-cli", &mut std::io::stdout());
}
ConfigCommands::WorkspaceAdd { name, api_key } => {
config::workspace_add(&name, &api_key)?;
}
ConfigCommands::WorkspaceList => {
config::workspace_list()?;
}
ConfigCommands::WorkspaceSwitch { name } => {
config::workspace_switch(&name)?;
}
ConfigCommands::WorkspaceCurrent => {
config::workspace_current()?;
}
ConfigCommands::WorkspaceRemove { name } => {
config::workspace_remove(&name)?;
}
},
}
Ok(())
}
fn should_use_pager(no_pager: bool, format: &OutputFormat, quiet: bool) -> bool {
if no_pager || quiet {
return false;
}
if !matches!(format, OutputFormat::Table) {
return false;
}
std::io::stdout().is_terminal()
}
fn setup_pager() -> Option<PagerGuard> {
if std::env::var("LESS").is_err() {
std::env::set_var("LESS", "-R -X -F");
}
let pager_cmd = std::env::var("PAGER").unwrap_or_else(|_| "less".to_string());
if pager_cmd == "cat" || pager_cmd.is_empty() {
return None;
}
let mut parts = pager_cmd.split_whitespace();
let program = parts.next()?;
let args: Vec<&str> = parts.collect();
let mut child = match std::process::Command::new(program)
.args(&args)
.stdin(std::process::Stdio::piped())
.spawn()
{
Ok(child) => child,
Err(_) => return None, };
let child_stdin = child.stdin.take()?;
#[cfg(unix)]
{
use std::os::unix::io::AsRawFd;
let pager_fd = child_stdin.as_raw_fd();
unsafe { libc::dup2(pager_fd, 1); }
Some(PagerGuard {
child,
_stdin: Some(child_stdin),
})
}
#[cfg(not(unix))]
{
drop(child_stdin);
let _ = child.kill();
None
}
}
struct PagerGuard {
child: std::process::Child,
_stdin: Option<std::process::ChildStdin>,
}
impl Drop for PagerGuard {
fn drop(&mut self) {
self._stdin.take();
let _ = self.child.wait();
}
}
async fn handle_context(
output: &OutputOptions,
agent_opts: AgentOptions,
retry: u32,
) -> Result<()> {
let branch_output = std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output();
let branch = match branch_output {
Ok(output) if output.status.success() => {
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
_ => {
anyhow::bail!("Not in a git repository or git not available");
}
};
static ISSUE_RE: OnceLock<regex::Regex> = OnceLock::new();
let re = ISSUE_RE.get_or_init(|| regex::Regex::new(r"(?i)([a-z]+-\d+)").unwrap());
let issue_id = re
.find(&branch)
.map(|m| m.as_str().to_uppercase())
.ok_or_else(|| anyhow::anyhow!("No Linear issue ID found in branch: {}", branch))?;
if output.is_json() || output.has_template() {
if agent_opts.id_only {
print_json_owned(serde_json::json!(issue_id), output)?;
return Ok(());
}
let client = api::LinearClient::new_with_retry(retry)?;
let query = r#"
query($id: String!) {
issue(id: $id) {
id
identifier
title
state { name }
assignee { name }
priority
url
}
}
"#;
let result = client
.query(query, Some(serde_json::json!({ "id": issue_id })))
.await;
match result {
Ok(data) => {
let issue = &data["data"]["issue"];
if issue.is_null() {
print_json_owned(
serde_json::json!({
"branch": branch,
"issue_id": issue_id,
"found": false,
}),
output,
)?;
} else {
print_json_owned(
serde_json::json!({
"branch": branch,
"issue_id": issue_id,
"found": true,
"issue": issue,
}),
output,
)?;
}
}
Err(_) => {
print_json_owned(
serde_json::json!({
"branch": branch,
"issue_id": issue_id,
"found": false,
}),
output,
)?;
}
}
} else {
println!("{}", issue_id);
}
Ok(())
}
async fn handle_done(
status: &str,
output: &OutputOptions,
agent_opts: AgentOptions,
retry: u32,
) -> Result<()> {
let branch_output = std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output();
let branch = match branch_output {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
_ => {
anyhow::bail!("Not in a git repository or git not available");
}
};
static DONE_ISSUE_RE: OnceLock<regex::Regex> = OnceLock::new();
let re = DONE_ISSUE_RE.get_or_init(|| regex::Regex::new(r"(?i)([a-z]+-\d+)").unwrap());
let issue_id = re
.find(&branch)
.map(|m| m.as_str().to_uppercase())
.ok_or_else(|| anyhow::anyhow!("No Linear issue ID found in branch: {}", branch))?;
if output.dry_run {
if output.is_json() || output.has_template() {
print_json_owned(
serde_json::json!({
"dry_run": true,
"would_update": {
"issue_id": issue_id,
"status": status,
}
}),
output,
)?;
} else {
println!("[DRY RUN] Would set {} to status: {}", issue_id, status);
}
return Ok(());
}
let client = api::LinearClient::new_with_retry(retry)?;
let query = r#"
query($id: String!) {
issue(id: $id) {
id
identifier
title
team { id }
}
}
"#;
let result = client
.query(query, Some(serde_json::json!({ "id": issue_id })))
.await?;
let issue = &result["data"]["issue"];
if issue.is_null() {
anyhow::bail!("Issue not found: {}", issue_id);
}
let team_id = issue["team"]["id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Could not determine team for {}", issue_id))?;
let state_id = api::resolve_state_id(&client, team_id, status).await?;
let mutation = r#"
mutation($id: String!, $stateId: String!) {
issueUpdate(id: $id, input: { stateId: $stateId }) {
success
issue {
id
identifier
title
state { name }
}
}
}
"#;
let result = client
.mutate(
mutation,
Some(serde_json::json!({ "id": issue_id, "stateId": state_id })),
)
.await?;
let success = result["data"]["issueUpdate"]["success"]
.as_bool()
.unwrap_or(false);
if !success {
anyhow::bail!("Failed to update issue {}", issue_id);
}
if output.is_json() || output.has_template() {
let updated = &result["data"]["issueUpdate"]["issue"];
print_json_owned(
serde_json::json!({
"issue_id": issue_id,
"status": status,
"updated": updated,
}),
output,
)?;
} else if !agent_opts.quiet {
let identifier = result["data"]["issueUpdate"]["issue"]["identifier"]
.as_str()
.unwrap_or(&issue_id);
let new_state = result["data"]["issueUpdate"]["issue"]["state"]["name"]
.as_str()
.unwrap_or(status);
println!("+ {} -> {}", identifier, new_state);
}
Ok(())
}
async fn handle_setup(output: &OutputOptions) -> Result<()> {
use std::io::{self, Write};
println!("Linear CLI Setup");
println!("{}", "-".repeat(40));
println!();
println!("Step 1: Authentication");
println!(" Get your API key from: https://linear.app/settings/api");
println!();
print!(" Enter your Linear API key: ");
io::stdout().flush()?;
let mut api_key = String::new();
io::stdin().read_line(&mut api_key)?;
let api_key = api_key.trim().to_string();
if api_key.is_empty() {
anyhow::bail!("API key cannot be empty");
}
config::set_api_key(&api_key)?;
println!(" API key saved.");
println!();
println!("Step 2: Default Team");
let client = api::LinearClient::new()?;
let teams_query = r#"
query {
teams {
nodes {
id
name
key
}
}
}
"#;
match client.query(teams_query, None).await {
Ok(data) => {
let teams = &data["data"]["teams"]["nodes"];
if let Some(teams_arr) = teams.as_array() {
if teams_arr.is_empty() {
println!(" No teams found. Skipping default team.");
} else {
println!(" Available teams:");
for (i, team) in teams_arr.iter().enumerate() {
let key = team["key"].as_str().unwrap_or("?");
let name = team["name"].as_str().unwrap_or("?");
println!(" {}. {} ({})", i + 1, name, key);
}
println!();
print!(" Select team number (or press Enter to skip): ");
io::stdout().flush()?;
let mut choice = String::new();
io::stdin().read_line(&mut choice)?;
let choice = choice.trim();
if !choice.is_empty() {
if let Ok(num) = choice.parse::<usize>() {
if num >= 1 && num <= teams_arr.len() {
let team = &teams_arr[num - 1];
let key = team["key"].as_str().unwrap_or("?");
println!(" Default team: {}", key);
println!(" Tip: Use -t {} or set LINEAR_CLI_TEAM={}", key, key);
} else {
println!(" Invalid selection, skipping.");
}
} else {
println!(" Invalid input, skipping.");
}
}
}
}
}
Err(e) => {
println!(" Could not fetch teams (API key may be invalid): {}", e);
println!(" Run 'linear doctor --check-api' to diagnose.");
}
}
println!();
println!("Step 3: Output Format");
println!(" 1. table (default, human-readable)");
println!(" 2. json (machine-readable, for scripts/agents)");
println!();
print!(" Select format [1]: ");
io::stdout().flush()?;
let mut format_choice = String::new();
io::stdin().read_line(&mut format_choice)?;
let format_choice = format_choice.trim();
match format_choice {
"2" | "json" => {
println!(" Output format: json");
println!(" Tip: Set LINEAR_CLI_OUTPUT=json in your shell profile.");
}
_ => {
println!(" Output format: table (default)");
}
}
println!();
println!("Setup complete!");
if output.is_json() || output.has_template() {
print_json_owned(
serde_json::json!({
"setup": true,
"api_key_saved": true,
}),
output,
)?;
}
Ok(())
}
async fn handle_complete(type_: &str, prefix: &str, team: Option<&str>) -> Result<()> {
let cache = cache::Cache::new().ok();
match type_ {
"teams" => complete_teams(cache.as_ref(), prefix).await,
"projects" => complete_projects(cache.as_ref(), prefix).await,
"issues" => complete_issues(prefix).await,
"statuses" => complete_statuses(cache.as_ref(), prefix, team).await,
"users" => complete_users(cache.as_ref(), prefix).await,
"labels" => complete_labels(cache.as_ref(), prefix).await,
_ => Ok(()), }
}
async fn complete_teams(cache: Option<&cache::Cache>, prefix: &str) -> Result<()> {
let teams = if let Some(data) = cache.and_then(|c| c.get(cache::CacheType::Teams)) {
data.as_array().cloned().unwrap_or_default()
} else {
match fetch_teams_for_completion().await {
Ok(t) => t,
Err(_) => return Ok(()),
}
};
let prefix_lower = prefix.to_lowercase();
for team in &teams {
let key = team["key"].as_str().unwrap_or("");
let name = team["name"].as_str().unwrap_or("");
if prefix.is_empty()
|| key.to_lowercase().starts_with(&prefix_lower)
|| name.to_lowercase().starts_with(&prefix_lower)
{
println!("{}\t{}", key, name);
}
}
Ok(())
}
async fn complete_projects(cache: Option<&cache::Cache>, prefix: &str) -> Result<()> {
let projects = if let Some(data) = cache.and_then(|c| c.get(cache::CacheType::Projects)) {
data.as_array().cloned().unwrap_or_default()
} else {
match fetch_projects_for_completion().await {
Ok(p) => p,
Err(_) => return Ok(()),
}
};
let prefix_lower = prefix.to_lowercase();
for project in &projects {
let name = project["name"].as_str().unwrap_or("");
let state = project["state"].as_str().unwrap_or("");
if prefix.is_empty() || name.to_lowercase().starts_with(&prefix_lower) {
println!("{}\t{}", name, state);
}
}
Ok(())
}
async fn complete_issues(prefix: &str) -> Result<()> {
let client = match api::LinearClient::new() {
Ok(c) => c,
Err(_) => return Ok(()),
};
let query = r#"
query($first: Int) {
issues(first: $first, orderBy: updatedAt) {
nodes {
identifier
title
}
}
}
"#;
let result = match client
.query(query, Some(serde_json::json!({ "first": 50 })))
.await
{
Ok(r) => r,
Err(_) => return Ok(()),
};
let empty = vec![];
let issues = result["data"]["issues"]["nodes"]
.as_array()
.unwrap_or(&empty);
let prefix_upper = prefix.to_uppercase();
for issue in issues {
let id = issue["identifier"].as_str().unwrap_or("");
let title = issue["title"].as_str().unwrap_or("");
if prefix.is_empty() || id.to_uppercase().starts_with(&prefix_upper) {
println!("{}\t{}", id, title);
}
}
Ok(())
}
async fn complete_statuses(
cache: Option<&cache::Cache>,
prefix: &str,
team: Option<&str>,
) -> Result<()> {
let client = match api::LinearClient::new() {
Ok(c) => c,
Err(_) => return Ok(()),
};
let team_id = if let Some(t) = team {
match api::resolve_team_id(
&client,
t,
&cache::CacheOptions {
ttl_seconds: None,
no_cache: false,
},
)
.await
{
Ok(id) => id,
Err(_) => return Ok(()),
}
} else {
let teams = if let Some(data) = cache.and_then(|c| c.get(cache::CacheType::Teams)) {
data.as_array().cloned().unwrap_or_default()
} else {
match fetch_teams_for_completion().await {
Ok(t) => t,
Err(_) => return Ok(()),
}
};
match teams.first().and_then(|t| t["id"].as_str()) {
Some(id) => id.to_string(),
None => return Ok(()),
}
};
let states =
if let Some(cached) = cache.and_then(|c| c.get_keyed(cache::CacheType::Statuses, &team_id))
{
cached["states"].as_array().cloned().unwrap_or_default()
} else {
let query = r#"
query($teamId: String!) {
team(id: $teamId) {
states {
nodes {
id
name
type
}
}
}
}
"#;
match client
.query(query, Some(serde_json::json!({ "teamId": team_id })))
.await
{
Ok(result) => result["data"]["team"]["states"]["nodes"]
.as_array()
.cloned()
.unwrap_or_default(),
Err(_) => return Ok(()),
}
};
let prefix_lower = prefix.to_lowercase();
for state in &states {
let name = state["name"].as_str().unwrap_or("");
let type_ = state["type"].as_str().unwrap_or("");
if prefix.is_empty() || name.to_lowercase().starts_with(&prefix_lower) {
println!("{}\t{}", name, type_);
}
}
Ok(())
}
async fn complete_users(cache: Option<&cache::Cache>, prefix: &str) -> Result<()> {
let users = if let Some(data) = cache.and_then(|c| c.get(cache::CacheType::Users)) {
data.as_array().cloned().unwrap_or_default()
} else {
match fetch_users_for_completion().await {
Ok(u) => u,
Err(_) => return Ok(()),
}
};
let prefix_lower = prefix.to_lowercase();
for user in &users {
let name = user["name"].as_str().unwrap_or("");
let email = user["email"].as_str().unwrap_or("");
let display = if !email.is_empty() { email } else { name };
if prefix.is_empty()
|| name.to_lowercase().starts_with(&prefix_lower)
|| email.to_lowercase().starts_with(&prefix_lower)
{
println!("{}\t{}", display, name);
}
}
Ok(())
}
async fn complete_labels(cache: Option<&cache::Cache>, prefix: &str) -> Result<()> {
let labels = if let Some(data) = cache.and_then(|c| c.get(cache::CacheType::Labels)) {
data.as_array().cloned().unwrap_or_default()
} else {
match fetch_labels_for_completion().await {
Ok(l) => l,
Err(_) => return Ok(()),
}
};
let prefix_lower = prefix.to_lowercase();
for label in &labels {
let name = label["name"].as_str().unwrap_or("");
let color = label["color"].as_str().unwrap_or("");
if prefix.is_empty() || name.to_lowercase().starts_with(&prefix_lower) {
println!("{}\t{}", name, color);
}
}
Ok(())
}
async fn fetch_teams_for_completion() -> Result<Vec<serde_json::Value>> {
let client = api::LinearClient::new()?;
let query = r#"
query {
teams(first: 50) {
nodes { id name key }
}
}
"#;
let result = client.query(query, None).await?;
Ok(result["data"]["teams"]["nodes"]
.as_array()
.cloned()
.unwrap_or_default())
}
async fn fetch_projects_for_completion() -> Result<Vec<serde_json::Value>> {
let client = api::LinearClient::new()?;
let query = r#"
query {
projects(first: 50, orderBy: updatedAt) {
nodes { id name state }
}
}
"#;
let result = client.query(query, None).await?;
Ok(result["data"]["projects"]["nodes"]
.as_array()
.cloned()
.unwrap_or_default())
}
async fn fetch_users_for_completion() -> Result<Vec<serde_json::Value>> {
let client = api::LinearClient::new()?;
let query = r#"
query {
users(first: 50) {
nodes { id name email }
}
}
"#;
let result = client.query(query, None).await?;
Ok(result["data"]["users"]["nodes"]
.as_array()
.cloned()
.unwrap_or_default())
}
async fn fetch_labels_for_completion() -> Result<Vec<serde_json::Value>> {
let client = api::LinearClient::new()?;
let query = r#"
query {
issueLabels(first: 50) {
nodes { id name color }
}
}
"#;
let result = client.query(query, None).await?;
Ok(result["data"]["issueLabels"]["nodes"]
.as_array()
.cloned()
.unwrap_or_default())
}
fn print_dynamic_completion_script(shell: Shell) {
match shell {
Shell::Bash => print!("{}", BASH_DYNAMIC_COMPLETIONS),
Shell::Zsh => print!("{}", ZSH_DYNAMIC_COMPLETIONS),
Shell::Fish => print!("{}", FISH_DYNAMIC_COMPLETIONS),
Shell::PowerShell => print!("{}", POWERSHELL_DYNAMIC_COMPLETIONS),
_ => {
eprintln!("Dynamic completions not supported for this shell. Supported: bash, zsh, fish, powershell");
}
}
}
const BASH_DYNAMIC_COMPLETIONS: &str = r#"# Dynamic completions for linear-cli (bash)
# Source this file or add to ~/.bashrc:
# eval "$(linear-cli completions dynamic bash)"
_linear_cli_dynamic() {
local cur prev
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
# Find --team value in command line for scoped completions
local team_val=""
local i
for (( i=1; i < COMP_CWORD; i++ )); do
if [[ "${COMP_WORDS[i]}" == "-t" || "${COMP_WORDS[i]}" == "--team" ]]; then
team_val="${COMP_WORDS[i+1]}"
break
fi
done
case "$prev" in
-t|--team)
local IFS=$'\n'
COMPREPLY=($(compgen -W "$(linear-cli _complete --type teams --prefix "$cur" 2>/dev/null | cut -f1)" -- "$cur"))
return 0
;;
-s|--status)
local team_arg=""
if [[ -n "$team_val" ]]; then
team_arg="--team $team_val"
fi
local IFS=$'\n'
COMPREPLY=($(compgen -W "$(linear-cli _complete --type statuses --prefix "$cur" $team_arg 2>/dev/null | cut -f1)" -- "$cur"))
return 0
;;
--project)
local IFS=$'\n'
COMPREPLY=($(compgen -W "$(linear-cli _complete --type projects --prefix "$cur" 2>/dev/null | cut -f1)" -- "$cur"))
return 0
;;
--label|-l)
local IFS=$'\n'
COMPREPLY=($(compgen -W "$(linear-cli _complete --type labels --prefix "$cur" 2>/dev/null | cut -f1)" -- "$cur"))
return 0
;;
--assignee|--user)
local IFS=$'\n'
COMPREPLY=($(compgen -W "$(linear-cli _complete --type users --prefix "$cur" 2>/dev/null | cut -f1)" -- "$cur"))
return 0
;;
esac
# Complete issue IDs when the previous word is a subcommand that takes an issue
local subcmds_taking_issue="get update start close done archive unarchive comment link assign move transfer open"
for subcmd in $subcmds_taking_issue; do
if [[ "$prev" == "$subcmd" ]]; then
local IFS=$'\n'
COMPREPLY=($(compgen -W "$(linear-cli _complete --type issues --prefix "$cur" 2>/dev/null | cut -f1)" -- "$cur"))
return 0
fi
done
}
# Register the dynamic completion function
complete -o default -F _linear_cli_dynamic linear-cli
"#;
const ZSH_DYNAMIC_COMPLETIONS: &str = r#"# Dynamic completions for linear-cli (zsh)
# Source this file or add to ~/.zshrc:
# eval "$(linear-cli completions dynamic zsh)"
_linear_cli_dynamic() {
local -a completions
local team_val=""
# Extract --team value from command line
local -i i
for (( i=2; i < CURRENT; i++ )); do
if [[ "$words[$i]" == "-t" || "$words[$i]" == "--team" ]]; then
team_val="$words[$((i+1))]"
break
fi
done
case "$words[$((CURRENT-1))]" in
-t|--team)
completions=(${(f)"$(linear-cli _complete --type teams --prefix "$words[$CURRENT]" 2>/dev/null)"})
_describe 'team' completions
return
;;
-s|--status)
local team_arg=""
if [[ -n "$team_val" ]]; then
team_arg="--team $team_val"
fi
completions=(${(f)"$(linear-cli _complete --type statuses --prefix "$words[$CURRENT]" ${=team_arg} 2>/dev/null)"})
_describe 'status' completions
return
;;
--project)
completions=(${(f)"$(linear-cli _complete --type projects --prefix "$words[$CURRENT]" 2>/dev/null)"})
_describe 'project' completions
return
;;
--label|-l)
completions=(${(f)"$(linear-cli _complete --type labels --prefix "$words[$CURRENT]" 2>/dev/null)"})
_describe 'label' completions
return
;;
--assignee|--user)
completions=(${(f)"$(linear-cli _complete --type users --prefix "$words[$CURRENT]" 2>/dev/null)"})
_describe 'user' completions
return
;;
get|update|start|close|done|archive|unarchive|comment|link|assign|move|transfer|open)
completions=(${(f)"$(linear-cli _complete --type issues --prefix "$words[$CURRENT]" 2>/dev/null)"})
_describe 'issue' completions
return
;;
esac
}
compdef _linear_cli_dynamic linear-cli
"#;
const FISH_DYNAMIC_COMPLETIONS: &str = r#"# Dynamic completions for linear-cli (fish)
# Source this file or save to ~/.config/fish/completions/linear-cli-dynamic.fish:
# linear-cli completions dynamic fish > ~/.config/fish/completions/linear-cli-dynamic.fish
# Team completions
complete -c linear-cli -l team -s t -x -a '(linear-cli _complete --type teams 2>/dev/null | string replace \t "\t")'
# Status completions (tries to pick up --team from current command line)
complete -c linear-cli -l status -s s -x -a '(linear-cli _complete --type statuses 2>/dev/null | string replace \t "\t")'
# Project completions
complete -c linear-cli -l project -x -a '(linear-cli _complete --type projects 2>/dev/null | string replace \t "\t")'
# Label completions
complete -c linear-cli -l label -s l -x -a '(linear-cli _complete --type labels 2>/dev/null | string replace \t "\t")'
# User completions
complete -c linear-cli -l assignee -x -a '(linear-cli _complete --type users 2>/dev/null | string replace \t "\t")'
complete -c linear-cli -l user -x -a '(linear-cli _complete --type users 2>/dev/null | string replace \t "\t")'
# Issue ID completions for subcommands that take an issue
for subcmd in get update start close done archive unarchive comment link assign move transfer open
complete -c linear-cli -n "__fish_seen_subcommand_from $subcmd" -x -a '(linear-cli _complete --type issues 2>/dev/null | string replace \t "\t")'
end
"#;
const POWERSHELL_DYNAMIC_COMPLETIONS: &str = r#"# Dynamic completions for linear-cli (PowerShell)
# Source this file or add to your $PROFILE:
# linear-cli completions dynamic powershell | Invoke-Expression
Register-ArgumentCompleter -CommandName linear-cli -ScriptBlock {
param($commandName, $wordToComplete, $cursorPosition)
$tokens = $wordToComplete -split '\s+'
$prev = if ($tokens.Length -gt 1) { $tokens[-2] } else { '' }
$current = $tokens[-1]
# Find --team value in tokens
$teamVal = ''
for ($i = 0; $i -lt $tokens.Length; $i++) {
if ($tokens[$i] -eq '-t' -or $tokens[$i] -eq '--team') {
if ($i + 1 -lt $tokens.Length) { $teamVal = $tokens[$i + 1] }
break
}
}
$type = switch ($prev) {
{ $_ -in '-t', '--team' } { 'teams' }
{ $_ -in '-s', '--status' } { 'statuses' }
'--project' { 'projects' }
{ $_ -in '-l', '--label' } { 'labels' }
{ $_ -in '--assignee', '--user' } { 'users' }
{ $_ -in 'get', 'update', 'start', 'close', 'done', 'archive', 'unarchive', 'comment', 'link', 'assign', 'move', 'transfer', 'open' } { 'issues' }
default { $null }
}
if ($type) {
$teamArg = if ($teamVal -and $type -eq 'statuses') { "--team $teamVal" } else { '' }
$results = linear-cli _complete --type $type --prefix $current $teamArg 2>$null
if ($results) {
$results | ForEach-Object {
$parts = $_ -split '\t', 2
$value = $parts[0]
$desc = if ($parts.Length -gt 1) { $parts[1] } else { '' }
[System.Management.Automation.CompletionResult]::new($value, $value, 'ParameterValue', $desc)
}
}
}
}
"#;