diff --git a/README.md b/README.md index c2f7c0f..ed8c9f4 100644 --- a/README.md +++ b/README.md @@ -449,8 +449,9 @@ Config is stored at `~/.config/linear-cli/config.toml` (Linux/macOS) or `%APPDAT ```bash linear-cli config show # Show current config -linear-cli config get default_team # Get a value -linear-cli config set default_team ENG # Set a value +linear-cli config get default-team # Get default team +linear-cli config set default-team ENG # Set default team +# also accepted: default_team, team # Multiple workspaces linear-cli config workspace-add work # Add workspace profile diff --git a/src/api.rs b/src/api.rs index 4eae4b1..0f092e0 100644 --- a/src/api.rs +++ b/src/api.rs @@ -238,6 +238,32 @@ pub async fn resolve_team_id( resolve_id(client, team, cache_opts, &config, find_team_id).await } +/// Fetch every team the viewer can see (cursor-paginated). +pub async fn fetch_all_teams(client: &LinearClient) -> Result> { + let query = r#" + query($first: Int, $after: String) { + teams(first: $first, after: $after) { + nodes { id name key } + pageInfo { hasNextPage endCursor } + } + } + "#; + paginate_nodes( + client, + query, + serde_json::Map::new(), + &["data", "teams", "nodes"], + &["data", "teams", "pageInfo"], + &PaginationOptions { + all: true, + page_size: Some(100), + ..Default::default() + }, + 100, + ) + .await +} + /// Resolve a user identifier to a UUID. /// Handles "me", UUIDs, names, and emails. pub async fn resolve_user_id( diff --git a/src/commands/cycles.rs b/src/commands/cycles.rs index 6b0a9ee..2f0a895 100644 --- a/src/commands/cycles.rs +++ b/src/commands/cycles.rs @@ -19,9 +19,9 @@ pub enum CycleCommands { /// List cycles for a team #[command(alias = "ls")] List { - /// Team ID or name + /// Team ID or name (falls back to default-team / LINEAR_CLI_TEAM) #[arg(short, long)] - team: String, + team: Option, /// Include completed cycles #[arg(short, long)] all: bool, @@ -33,15 +33,15 @@ pub enum CycleCommands { }, /// Show the current active cycle Current { - /// Team ID or name + /// Team ID or name (falls back to default-team / LINEAR_CLI_TEAM) #[arg(short, long)] - team: String, + team: Option, }, /// Create a new cycle Create { - /// Team key, name, or ID + /// Team key, name, or ID (falls back to default-team / LINEAR_CLI_TEAM) #[arg(short, long)] - team: String, + team: Option, /// Cycle name #[arg(short, long)] name: Option, @@ -110,16 +110,25 @@ struct CycleRow { pub async fn handle(cmd: CycleCommands, output: &OutputOptions) -> Result<()> { match cmd { - CycleCommands::List { team, all } => list_cycles(&team, all, output).await, + CycleCommands::List { team, all } => { + let team = crate::config::require_team(team)?; + list_cycles(&team, all, output).await + } CycleCommands::Get { id } => get_cycle(&id, output).await, - CycleCommands::Current { team } => current_cycle(&team, output).await, + CycleCommands::Current { team } => { + let team = crate::config::require_team(team)?; + current_cycle(&team, output).await + } CycleCommands::Create { team, name, description, starts_at, ends_at, - } => create_cycle(&team, name, description, starts_at, ends_at, output).await, + } => { + let team = crate::config::require_team(team)?; + create_cycle(&team, name, description, starts_at, ends_at, output).await + } CycleCommands::Update { id, name, diff --git a/src/commands/interactive.rs b/src/commands/interactive.rs index d6b72e6..4225070 100644 --- a/src/commands/interactive.rs +++ b/src/commands/interactive.rs @@ -149,23 +149,7 @@ pub async fn run(default_team: Option) -> Result<()> { } async fn fetch_teams(client: &LinearClient) -> Result> { - let query = r#" - query { - teams(first: 100) { - nodes { - id - name - key - } - } - } - "#; - - let result = client.query(query, None).await?; - let empty = vec![]; - let teams_json = result["data"]["teams"]["nodes"] - .as_array() - .unwrap_or(&empty); + let teams_json = crate::api::fetch_all_teams(client).await?; let teams: Vec = teams_json .iter() diff --git a/src/commands/issues.rs b/src/commands/issues.rs index b092ecc..8527275 100644 --- a/src/commands/issues.rs +++ b/src/commands/issues.rs @@ -383,8 +383,11 @@ pub async fn handle( .or(tpl.team.clone()) .or(data_team) .or(data_team_id) + .or_else(|| crate::config::resolve_team_arg(None)) .ok_or_else(|| { - anyhow::anyhow!("--team is required (or use a template with a default team)") + anyhow::anyhow!( + "--team is required (or set a default: linear config set default-team TEAM, or use a template with a default team)" + ) })?; // Build title with optional prefix from template diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 3bac020..ee84cfd 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -24,6 +24,7 @@ pub mod projects; pub mod relations; pub mod roadmaps; pub mod search; +pub mod setup; pub mod sprint; pub mod statuses; pub mod sync; diff --git a/src/commands/relations.rs b/src/commands/relations.rs index fdf9f12..1b38717 100644 --- a/src/commands/relations.rs +++ b/src/commands/relations.rs @@ -23,16 +23,46 @@ pub enum RelationType { } impl RelationType { + /// Linear's `IssueRelationType` only has `blocks`, `related`, and `duplicate`. + /// There is no `blockedBy` — "A is blocked by B" is stored as "B blocks A". fn to_api_string(self) -> &'static str { + match self { + RelationType::Blocks | RelationType::BlockedBy => "blocks", + RelationType::Related => "related", + RelationType::Duplicate => "duplicate", + } + } + + /// Whether this CLI relation is the inverse of the Linear API direction. + fn is_inverted(self) -> bool { + matches!(self, RelationType::BlockedBy) + } + + fn display_name(self) -> &'static str { match self { RelationType::Blocks => "blocks", - RelationType::BlockedBy => "blockedBy", + RelationType::BlockedBy => "blocked-by", RelationType::Related => "related", RelationType::Duplicate => "duplicate", } } } +/// Resolve issue endpoints and API type for `issueRelationCreate`. +/// +/// `blocked-by` is sugar for inverted `blocks`: `A blocked-by B` → `B blocks A`. +fn resolve_relation_endpoints<'a>( + from: &'a str, + relation: RelationType, + to: &'a str, +) -> (&'a str, &'a str, &'static str) { + if relation.is_inverted() { + (to, from, "blocks") + } else { + (from, to, relation.to_api_string()) + } +} + #[derive(Subcommand, Debug)] pub enum RelationCommands { /// List issue relationships @@ -294,6 +324,7 @@ async fn add_relation( output: &OutputOptions, ) -> Result<()> { let client = LinearClient::new()?; + let (issue_id, related_issue_id, api_type) = resolve_relation_endpoints(from, relation, to); let mutation = r#" mutation($issueId: String!, $relatedIssueId: String!, $type: IssueRelationType!) { @@ -317,9 +348,9 @@ async fn add_relation( .mutate( mutation, Some(json!({ - "issueId": from, - "relatedIssueId": to, - "type": relation.to_api_string() + "issueId": issue_id, + "relatedIssueId": related_issue_id, + "type": api_type })), ) .await?; @@ -327,12 +358,12 @@ async fn add_relation( if output.is_json() { print_json(&result["data"]["issueRelationCreate"], output)?; } else { - let rel = &result["data"]["issueRelationCreate"]["issueRelation"]; + // Keep the user's argument order; blocked-by is sugar over inverted blocks. println!( "Created relation: {} {} {}", - rel["issue"]["identifier"].as_str().unwrap_or(from), - relation.to_api_string(), - rel["relatedIssue"]["identifier"].as_str().unwrap_or(to) + from, + relation.display_name(), + to ); } @@ -433,11 +464,16 @@ mod tests { #[test] fn test_relation_type_blocks() { assert_eq!(RelationType::Blocks.to_api_string(), "blocks"); + assert!(!RelationType::Blocks.is_inverted()); + assert_eq!(RelationType::Blocks.display_name(), "blocks"); } #[test] - fn test_relation_type_blocked_by() { - assert_eq!(RelationType::BlockedBy.to_api_string(), "blockedBy"); + fn test_relation_type_blocked_by_maps_to_blocks() { + // Linear has no blockedBy enum; CLI maps it to inverted blocks. + assert_eq!(RelationType::BlockedBy.to_api_string(), "blocks"); + assert!(RelationType::BlockedBy.is_inverted()); + assert_eq!(RelationType::BlockedBy.display_name(), "blocked-by"); } #[test] @@ -450,6 +486,25 @@ mod tests { assert_eq!(RelationType::Duplicate.to_api_string(), "duplicate"); } + #[test] + fn test_resolve_relation_endpoints_blocks() { + let (issue_id, related_id, api_type) = + resolve_relation_endpoints("A", RelationType::Blocks, "B"); + assert_eq!(issue_id, "A"); + assert_eq!(related_id, "B"); + assert_eq!(api_type, "blocks"); + } + + #[test] + fn test_resolve_relation_endpoints_blocked_by_swaps() { + // A blocked-by B == B blocks A + let (issue_id, related_id, api_type) = + resolve_relation_endpoints("A", RelationType::BlockedBy, "B"); + assert_eq!(issue_id, "B"); + assert_eq!(related_id, "A"); + assert_eq!(api_type, "blocks"); + } + #[test] fn test_relation_node_deserializes_with_state_id() { use crate::types::IssueRelation; diff --git a/src/commands/setup.rs b/src/commands/setup.rs new file mode 100644 index 0000000..f064556 --- /dev/null +++ b/src/commands/setup.rs @@ -0,0 +1,183 @@ +use anyhow::{Context, Result}; +use dialoguer::Password; +use std::io::{self, IsTerminal, Write}; + +use crate::api::{self, fetch_all_teams}; +use crate::config; +use crate::output::{print_json_owned, OutputOptions}; + +/// Guided onboarding wizard: API key, default team, output-format tip. +pub async fn handle(output: &OutputOptions) -> Result<()> { + println!("Linear CLI Setup"); + println!("{}", "-".repeat(40)); + println!(); + + // Step 1: API Key + println!("Step 1: Authentication"); + println!(" Get your API key from: https://linear.app/settings/api"); + println!(); + let api_key = if io::stdin().is_terminal() { + Password::new() + .with_prompt(" Enter your Linear API key") + .allow_empty_password(false) + .interact() + .context("Failed to read Linear API key")? + .trim() + .to_string() + } else { + print!(" Enter your Linear API key: "); + io::stdout().flush()?; + let mut api_key = String::new(); + io::stdin().read_line(&mut api_key)?; + api_key.trim().to_string() + }; + + if api_key.is_empty() { + anyhow::bail!("API key cannot be empty"); + } + + println!(" Validating API key..."); + println!(); + + // Step 2: Validate the key and pick default team + println!("Step 2: Default Team"); + let client = api::LinearClient::with_api_key(api_key.clone())?; + + // Paginate all teams — Linear pages connections; large workspaces need this (#34). + let teams_arr = fetch_all_teams(&client) + .await + .context("Could not validate API key or fetch teams")?; + + config::set_api_key(&api_key)?; + println!(" API key validated and saved."); + + let mut saved_team: Option = None; + + if teams_arr.is_empty() { + println!(" No teams found. Skipping default team."); + } else { + println!(" Available teams ({}):", teams_arr.len()); + 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, key, or name (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() { + saved_team = select_team_key(&teams_arr, choice); + if let Some(ref key) = saved_team { + config::set_default_team(Some(key))?; + println!(" Default team saved: {}", key); + println!( + " Tip: Override with -t {} or LINEAR_CLI_TEAM={}", + key, key + ); + } else { + println!(" Invalid selection, skipping."); + } + } + } + + println!(); + + // Step 3: Output format (tip only — not persisted in config) + println!("Step 3: Output Format (shell tip; not written to config)"); + 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 let Some(ref team) = saved_team { + println!(" Default team: {} (persisted in config)", team); + println!(" Change later: linear config set default-team "); + } else { + println!(" Default team: not set"); + println!(" Set later: linear config set default-team "); + } + + if output.is_json() || output.has_template() { + print_json_owned( + serde_json::json!({ + "setup": true, + "api_key_saved": true, + "default_team": saved_team, + }), + output, + )?; + } + + Ok(()) +} + +fn select_team_key(teams: &[serde_json::Value], choice: &str) -> Option { + if let Ok(num) = choice.parse::() { + if num >= 1 && num <= teams.len() { + return teams[num - 1]["key"].as_str().map(|s| s.to_string()); + } + return None; + } + + teams + .iter() + .find(|t| { + t["key"] + .as_str() + .is_some_and(|k| k.eq_ignore_ascii_case(choice)) + || t["name"] + .as_str() + .is_some_and(|n| n.eq_ignore_ascii_case(choice)) + }) + .and_then(|t| t["key"].as_str().map(|s| s.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn select_team_by_number() { + let teams = vec![ + json!({"key": "ENG", "name": "Engineering"}), + json!({"key": "OPS", "name": "Operations"}), + ]; + assert_eq!(select_team_key(&teams, "2").as_deref(), Some("OPS")); + } + + #[test] + fn select_team_by_key() { + let teams = vec![json!({"key": "ENG", "name": "Engineering"})]; + assert_eq!(select_team_key(&teams, "eng").as_deref(), Some("ENG")); + } + + #[test] + fn select_team_invalid() { + let teams = vec![json!({"key": "ENG", "name": "Engineering"})]; + assert!(select_team_key(&teams, "99").is_none()); + assert!(select_team_key(&teams, "nope").is_none()); + } +} diff --git a/src/commands/sprint.rs b/src/commands/sprint.rs index 0d1c31a..8bf80d6 100644 --- a/src/commands/sprint.rs +++ b/src/commands/sprint.rs @@ -12,36 +12,36 @@ use crate::output::{print_json, print_json_owned, OutputOptions}; pub enum SprintCommands { /// Show current sprint status and progress Status { - /// Team key, name, or ID + /// Team key, name, or ID (falls back to default-team / LINEAR_CLI_TEAM) #[arg(short, long)] - team: String, + team: Option, }, /// Show sprint progress (completion %) Progress { - /// Team key, name, or ID + /// Team key, name, or ID (falls back to default-team / LINEAR_CLI_TEAM) #[arg(short, long)] - team: String, + team: Option, }, /// List issues planned for next cycle Plan { - /// Team key, name, or ID + /// Team key, name, or ID (falls back to default-team / LINEAR_CLI_TEAM) #[arg(short, long)] - team: String, + team: Option, }, /// Move incomplete issues from current cycle to next CarryOver { - /// Team key, name, or ID + /// Team key, name, or ID (falls back to default-team / LINEAR_CLI_TEAM) #[arg(short, long)] - team: String, + team: Option, /// Skip confirmation #[arg(short, long)] force: bool, }, /// Show ASCII burndown chart for current sprint Burndown { - /// Team key, name, or ID + /// Team key, name, or ID (falls back to default-team / LINEAR_CLI_TEAM) #[arg(short, long)] - team: String, + team: Option, /// Chart width in characters (default: 60) #[arg(long, default_value = "60")] width: usize, @@ -51,9 +51,9 @@ pub enum SprintCommands { }, /// Show sprint velocity across recent cycles Velocity { - /// Team key, name, or ID + /// Team key, name, or ID (falls back to default-team / LINEAR_CLI_TEAM) #[arg(short, long)] - team: String, + team: Option, /// Number of past cycles to analyze (default: 6) #[arg(short = 'n', long, default_value = "6")] count: usize, @@ -62,16 +62,34 @@ pub enum SprintCommands { pub async fn handle(cmd: SprintCommands, output: &OutputOptions) -> Result<()> { match cmd { - SprintCommands::Status { team } => sprint_status(&team, output).await, - SprintCommands::Progress { team } => sprint_progress(&team, output).await, - SprintCommands::Plan { team } => sprint_plan(&team, output).await, - SprintCommands::CarryOver { team, force } => sprint_carry_over(&team, force, output).await, + SprintCommands::Status { team } => { + let team = crate::config::require_team(team)?; + sprint_status(&team, output).await + } + SprintCommands::Progress { team } => { + let team = crate::config::require_team(team)?; + sprint_progress(&team, output).await + } + SprintCommands::Plan { team } => { + let team = crate::config::require_team(team)?; + sprint_plan(&team, output).await + } + SprintCommands::CarryOver { team, force } => { + let team = crate::config::require_team(team)?; + sprint_carry_over(&team, force, output).await + } SprintCommands::Burndown { team, width, height, - } => burndown(&team, width, height, output).await, - SprintCommands::Velocity { team, count } => velocity(&team, count, output).await, + } => { + let team = crate::config::require_team(team)?; + burndown(&team, width, height, output).await + } + SprintCommands::Velocity { team, count } => { + let team = crate::config::require_team(team)?; + velocity(&team, count, output).await + } } } diff --git a/src/commands/statuses.rs b/src/commands/statuses.rs index e5579d8..01e25bf 100644 --- a/src/commands/statuses.rs +++ b/src/commands/statuses.rs @@ -23,25 +23,25 @@ pub enum StatusCommands { /// List all issue statuses for a team #[command(alias = "ls")] List { - /// Team name or ID + /// Team name or ID (falls back to default-team / LINEAR_CLI_TEAM) #[arg(short, long)] - team: String, + team: Option, }, /// Get details of a specific status Get { /// Status name(s) or ID(s). Use "-" to read from stdin. ids: Vec, - /// Team name or ID + /// Team name or ID (falls back to default-team / LINEAR_CLI_TEAM) #[arg(short, long)] - team: String, + team: Option, }, /// Update a workflow state Update { /// Status name or ID id: String, - /// Team name or ID + /// Team name or ID (falls back to default-team / LINEAR_CLI_TEAM) #[arg(short, long)] - team: String, + team: Option, /// New name #[arg(short, long)] name: Option, @@ -73,8 +73,12 @@ struct StatusRow { pub async fn handle(cmd: StatusCommands, output: &OutputOptions) -> Result<()> { match cmd { - StatusCommands::List { team } => list_statuses(&team, output).await, + StatusCommands::List { team } => { + let team = crate::config::require_team(team)?; + list_statuses(&team, output).await + } StatusCommands::Get { ids, team } => { + let team = crate::config::require_team(team)?; let final_ids = read_ids_from_stdin(ids); if final_ids.is_empty() { anyhow::bail!( @@ -91,6 +95,7 @@ pub async fn handle(cmd: StatusCommands, output: &OutputOptions) -> Result<()> { description, dry_run, } => { + let team = crate::config::require_team(team)?; let dry_run = dry_run || output.dry_run; update_status(&id, &team, name, color, description, dry_run, output).await } diff --git a/src/commands/triage.rs b/src/commands/triage.rs index 921d3cb..7ab2916 100644 --- a/src/commands/triage.rs +++ b/src/commands/triage.rs @@ -45,7 +45,9 @@ struct TriageRow { pub async fn handle(cmd: TriageCommands, output: &OutputOptions) -> Result<()> { match cmd { - TriageCommands::List { team } => list_triage(team, output).await, + TriageCommands::List { team } => { + list_triage(crate::config::resolve_team_arg(team), output).await + } TriageCommands::Claim { id } => claim_issue(&id, output).await, TriageCommands::Snooze { id, duration } => snooze_issue(&id, &duration, output).await, } diff --git a/src/config.rs b/src/config.rs index 10ff0ef..0745cec 100644 --- a/src/config.rs +++ b/src/config.rs @@ -26,6 +26,19 @@ pub struct Workspace { pub api_key: String, #[serde(skip_serializing_if = "Option::is_none")] pub oauth: Option, + /// Default team key/name for commands that accept `--team`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_team: Option, +} + +impl Workspace { + pub fn with_api_key(api_key: impl Into) -> Self { + Self { + api_key: api_key.into(), + oauth: None, + default_team: None, + } + } } #[derive(Debug, Serialize, Deserialize, Default)] @@ -56,17 +69,12 @@ pub fn load_config() -> Result { // Migrate legacy api_key to workspaces if needed if let Some(legacy_key) = config.api_key.take() { if !config.workspaces.contains_key("default") { - config.workspaces.insert( - "default".to_string(), - Workspace { - api_key: legacy_key, - oauth: None, - }, - ); + config + .workspaces + .insert("default".to_string(), Workspace::with_api_key(legacy_key)); if config.current.is_none() { config.current = Some("default".to_string()); } - // Save migrated config save_config(&config)?; } } @@ -109,32 +117,95 @@ pub fn save_config(config: &Config) -> Result<()> { Ok(()) } -pub fn set_api_key(key: &str) -> Result<()> { - let mut config = load_config()?; - let profile = std::env::var("LINEAR_CLI_PROFILE") +/// Resolve profile name for read/write without the process-lifetime cache used by +/// [`current_profile`]. Prefer this when the config may still be mid-setup. +fn resolve_profile_name(config: &Config) -> String { + std::env::var("LINEAR_CLI_PROFILE") .ok() - .filter(|p| !p.is_empty()); - let workspace_name = profile + .filter(|p| !p.is_empty()) .or_else(|| config.current.clone()) - .unwrap_or_else(|| "default".to_string()); - let existing_oauth = config + .unwrap_or_else(|| "default".to_string()) +} + +pub fn set_api_key(key: &str) -> Result<()> { + let mut config = load_config()?; + let workspace_name = resolve_profile_name(&config); + let workspace = config .workspaces - .get(&workspace_name) - .and_then(|w| w.oauth.clone()); - config.workspaces.insert( - workspace_name.clone(), - Workspace { - api_key: key.to_string(), - oauth: existing_oauth, - }, - ); + .entry(workspace_name.clone()) + .or_insert_with(|| Workspace::with_api_key("")); + workspace.api_key = key.to_string(); if config.current.is_none() { - config.current = Some(workspace_name.clone()); + config.current = Some(workspace_name); } save_config(&config)?; Ok(()) } +/// Stored default team for the active workspace (config file only; no env). +pub fn get_stored_default_team() -> Option { + let config = load_config().ok()?; + let profile = resolve_profile_name(&config); + config + .workspaces + .get(&profile) + .and_then(|w| w.default_team.clone()) + .filter(|t| !t.trim().is_empty()) +} + +/// Effective default team: `LINEAR_CLI_TEAM` env, then stored workspace default. +pub fn get_default_team() -> Option { + if let Ok(team) = std::env::var("LINEAR_CLI_TEAM") { + let team = team.trim().to_string(); + if !team.is_empty() { + return Some(team); + } + } + get_stored_default_team() +} + +/// CLI `--team` / positional override, then effective default team. +pub fn resolve_team_arg(cli: Option) -> Option { + cli.map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .or_else(get_default_team) +} + +/// Like [`resolve_team_arg`], but errors when nothing is configured. +pub fn require_team(cli: Option) -> Result { + resolve_team_arg(cli).ok_or_else(|| { + anyhow::anyhow!( + "--team is required (or set a default: linear config set default-team TEAM)" + ) + }) +} + +/// Set the default team for the current workspace. Requires an existing workspace. +pub fn set_default_team(team: Option<&str>) -> Result<()> { + let mut config = load_config()?; + let profile = resolve_profile_name(&config); + let workspace = config.workspaces.get_mut(&profile).with_context(|| { + format!( + "Workspace '{}' not found. Run setup or: linear config set-key first.", + profile + ) + })?; + workspace.default_team = team + .map(str::trim) + .filter(|t| !t.is_empty()) + .map(|t| t.to_string()); + if config.current.is_none() { + config.current = Some(profile); + } + save_config(&config)?; + Ok(()) +} + +/// Known configuration keys for get/set (for help and error messages). +pub fn known_config_keys() -> &'static [&'static str] { + &["api-key", "profile", "default-team"] +} + fn read_secret_from_stdin_or_prompt(prompt: &str) -> Result { if std::io::stdin().is_terminal() { let secret = Password::new() @@ -233,14 +304,11 @@ pub fn current_profile() -> Result { pub fn set_workspace_key(name: &str, api_key: &str) -> Result<()> { let mut config = load_config()?; - let existing_oauth = config.workspaces.get(name).and_then(|w| w.oauth.clone()); - config.workspaces.insert( - name.to_string(), - Workspace { - api_key: api_key.to_string(), - oauth: existing_oauth, - }, - ); + let workspace = config + .workspaces + .entry(name.to_string()) + .or_insert_with(|| Workspace::with_api_key("")); + workspace.api_key = api_key.to_string(); if config.current.is_none() { config.current = Some(name.to_string()); } @@ -262,7 +330,20 @@ pub fn config_get(key: &str, raw: bool) -> Result<()> { let profile = current_profile()?; println!("{}", profile); } - _ => anyhow::bail!("Unknown config key: {}", key), + // Stored value only — env override is separate (see show / get_default_team). + "default-team" | "default_team" | "team" => match get_stored_default_team() { + Some(team) => println!("{}", team), + None => { + if !raw { + println!("(not set)"); + } + } + }, + _ => anyhow::bail!( + "Unknown config key: {}. Known keys: {}", + key, + known_config_keys().join(", ") + ), } Ok(()) } @@ -273,7 +354,16 @@ pub fn config_set(key: &str, value: &str) -> Result<()> { "Setting api-key via argv is disabled. Use `linear config set-key` and provide the key via stdin or the hidden prompt." ), "profile" => workspace_switch(value), - _ => anyhow::bail!("Unknown config key: {}", key), + "default-team" | "default_team" | "team" => { + set_default_team(Some(value))?; + println!("Default team set to '{}'", value.trim()); + Ok(()) + } + _ => anyhow::bail!( + "Unknown config key: {}. Known keys: {}", + key, + known_config_keys().join(", ") + ), } } @@ -283,16 +373,28 @@ pub fn show_config() -> Result<()> { println!("Config file: {}", path.display()); println!(); + println!("Known keys: {}", known_config_keys().join(", ")); + println!(); if let Some(current) = &config.current { println!("Current workspace: {}", current); if let Some(workspace) = config.workspaces.get(current) { println!("API Key: {}", mask_api_key_for_display(&workspace.api_key)); + match &workspace.default_team { + Some(team) if !team.is_empty() => println!("Default team: {}", team), + _ => println!("Default team: (not set)"), + } } } else { println!("No workspace configured. Run: linear workspace add "); } + if let Ok(env_team) = std::env::var("LINEAR_CLI_TEAM") { + if !env_team.trim().is_empty() { + println!("LINEAR_CLI_TEAM (overrides config): {}", env_team.trim()); + } + } + Ok(()) } @@ -308,13 +410,9 @@ pub fn workspace_add(name: &str, api_key: &str) -> Result<()> { ); } - config.workspaces.insert( - name.to_string(), - Workspace { - api_key: api_key.to_string(), - oauth: None, - }, - ); + config + .workspaces + .insert(name.to_string(), Workspace::with_api_key(api_key)); // If this is the first workspace, make it current if config.current.is_none() { @@ -422,10 +520,7 @@ pub fn save_oauth_config(profile: &str, oauth_config: &OAuthConfig) -> Result<() let workspace = config .workspaces .entry(profile.to_string()) - .or_insert_with(|| Workspace { - api_key: String::new(), - oauth: None, - }); + .or_insert_with(|| Workspace::with_api_key("")); workspace.oauth = Some(oauth_config.clone()); if config.current.is_none() { config.current = Some(profile.to_string()); @@ -552,18 +647,11 @@ mod tests { }; config.workspaces.insert( "prod".to_string(), - Workspace { - api_key: "lin_api_prod123".to_string(), - oauth: None, - }, - ); - config.workspaces.insert( - "staging".to_string(), - Workspace { - api_key: "lin_api_staging456".to_string(), - oauth: None, - }, + Workspace::with_api_key("lin_api_prod123"), ); + let mut staging = Workspace::with_api_key("lin_api_staging456"); + staging.default_team = Some("ENG".to_string()); + config.workspaces.insert("staging".to_string(), staging); let toml_str = toml::to_string_pretty(&config).unwrap(); let parsed: Config = toml::from_str(&toml_str).unwrap(); @@ -572,6 +660,11 @@ mod tests { assert_eq!(parsed.workspaces.len(), 2); assert_eq!(parsed.workspaces["prod"].api_key, "lin_api_prod123"); assert_eq!(parsed.workspaces["staging"].api_key, "lin_api_staging456"); + assert_eq!( + parsed.workspaces["staging"].default_team.as_deref(), + Some("ENG") + ); + assert!(parsed.workspaces["prod"].default_team.is_none()); } #[test] @@ -704,20 +797,18 @@ mod tests { current: Some("oauth-test".to_string()), ..Default::default() }; - config.workspaces.insert( - "oauth-test".to_string(), - Workspace { - api_key: String::new(), - oauth: Some(OAuthConfig { - client_id: "my-app".to_string(), - access_token: "lin_oauth_token123".to_string(), - refresh_token: Some("lin_refresh_abc".to_string()), - expires_at: Some(1700000000), - token_type: "Bearer".to_string(), - scopes: vec!["read".to_string(), "write".to_string()], - }), - }, - ); + let mut oauth_ws = Workspace::with_api_key(""); + oauth_ws.oauth = Some(OAuthConfig { + client_id: "my-app".to_string(), + access_token: "lin_oauth_token123".to_string(), + refresh_token: Some("lin_refresh_abc".to_string()), + expires_at: Some(1700000000), + token_type: "Bearer".to_string(), + scopes: vec!["read".to_string(), "write".to_string()], + }); + config + .workspaces + .insert("oauth-test".to_string(), oauth_ws); let toml_str = toml::to_string_pretty(&config).unwrap(); let parsed: Config = toml::from_str(&toml_str).unwrap(); @@ -738,13 +829,9 @@ mod tests { current: Some("default".to_string()), ..Default::default() }; - config.workspaces.insert( - "default".to_string(), - Workspace { - api_key: "lin_api_key".to_string(), - oauth: None, - }, - ); + config + .workspaces + .insert("default".to_string(), Workspace::with_api_key("lin_api_key")); let toml_str = toml::to_string_pretty(&config).unwrap(); assert!( @@ -844,4 +931,59 @@ mod tests { .to_string() .contains("Setting api-key via argv is disabled")); } + + #[test] + fn test_config_get_unknown_key_lists_known_keys() { + let err = config_get("nope", false).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("Unknown config key")); + assert!(msg.contains("default-team")); + assert!(msg.contains("api-key")); + assert!(msg.contains("profile")); + } + + #[test] + fn test_default_team_roundtrip_toml() { + let mut config = Config { + current: Some("default".to_string()), + ..Default::default() + }; + let mut ws = Workspace::with_api_key("lin_api_key"); + ws.default_team = Some("ENG".to_string()); + config.workspaces.insert("default".to_string(), ws); + + let toml_str = toml::to_string_pretty(&config).unwrap(); + assert!(toml_str.contains("default_team")); + let parsed: Config = toml::from_str(&toml_str).unwrap(); + assert_eq!( + parsed.workspaces["default"].default_team.as_deref(), + Some("ENG") + ); + } + + #[test] + fn test_known_config_keys() { + let keys = known_config_keys(); + assert!(keys.contains(&"api-key")); + assert!(keys.contains(&"profile")); + assert!(keys.contains(&"default-team")); + } + + #[test] + fn test_resolve_team_arg_prefers_cli() { + assert_eq!( + resolve_team_arg(Some("CLI".to_string())).as_deref(), + Some("CLI") + ); + } + + #[test] + fn test_resolve_team_arg_empty_cli_falls_through() { + // Without env/config this is None; empty CLI must not win. + let result = resolve_team_arg(Some(" ".to_string())); + // May be Some if env is set in the process; never equal to whitespace. + if let Some(t) = result { + assert_ne!(t.trim(), ""); + } + } } diff --git a/src/main.rs b/src/main.rs index 63a34f7..d0a29bc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,14 +18,14 @@ mod text; mod types; mod vcs; -use anyhow::{Context, Result}; +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, - update, uploads, users, views, watch, webhooks, + projects, relations, roadmaps, search, setup, sprint, statuses, sync, teams, templates, time, + triage, update, uploads, users, views, watch, webhooks, }; use error::CliError; use output::print_json_owned; @@ -749,14 +749,26 @@ Walks you through: #[arg(long)] team: Option, }, - /// Configure CLI settings - API keys and workspaces - #[command(after_help = r#"EXAMPLES: + /// Configure CLI settings - API keys, default team, and workspaces + #[command(after_help = r#"KNOWN KEYS: + api-key API key (get only; set via `config set-key`) + profile Active workspace profile + default-team Default team key/name (also: default_team, team) + +EXAMPLES: printf '%s\n' "$LINEAR_API_KEY" | linear config set-key linear config get api-key # Get API key (masked) + linear config set default-team ENG # Persist default team + linear config get default-team # Show default team linear config set profile work # Switch profile linear config show # Show configuration printf '%s\n' "$LINEAR_API_KEY" | linear config workspace-add work - linear config workspace-switch work # Switch workspace"#)] + linear config workspace-switch work # Switch workspace + +ENV OVERRIDES: + LINEAR_CLI_TEAM Overrides default-team for this process + LINEAR_CLI_PROFILE Overrides active profile + LINEAR_CLI_OUTPUT Default output format"#)] Config { #[command(subcommand)] action: ConfigCommands, @@ -771,7 +783,7 @@ enum ConfigCommands { SetKey, /// Get a configuration value Get { - /// Config key to retrieve (api-key, profile) + /// Config key: api-key, profile, default-team key: ConfigGetKey, /// Output raw value without masking #[arg(long)] @@ -779,7 +791,7 @@ enum ConfigCommands { }, /// Set a configuration value Set { - /// Config key to set + /// Config key: profile, default-team key: ConfigSetKey, /// Value to set value: String, @@ -832,6 +844,8 @@ enum ConfigGetKey { #[value(alias = "api_key")] ApiKey, Profile, + #[value(alias = "default_team", alias = "team")] + DefaultTeam, } impl std::fmt::Display for ConfigGetKey { @@ -839,6 +853,7 @@ impl std::fmt::Display for ConfigGetKey { match self { Self::ApiKey => write!(f, "api-key"), Self::Profile => write!(f, "profile"), + Self::DefaultTeam => write!(f, "default-team"), } } } @@ -846,12 +861,15 @@ impl std::fmt::Display for ConfigGetKey { #[derive(clap::ValueEnum, Clone, Debug)] enum ConfigSetKey { Profile, + #[value(alias = "default_team", alias = "team")] + DefaultTeam, } impl std::fmt::Display for ConfigSetKey { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Profile => write!(f, "profile"), + Self::DefaultTeam => write!(f, "default-team"), } } } @@ -995,7 +1013,14 @@ async fn async_main() -> Result { let exit_code = { // Keep the pager guard scoped so cleanup runs before main exits. - let _pager_guard = if should_use_pager(cli.no_pager, &cli.output, cli.quiet) { + // Never page interactive commands (setup/auth/TUI) — less captures keys + // and can leave the terminal in a bad state on macOS (see issue #34). + let _pager_guard = if should_use_pager( + cli.no_pager, + &cli.output, + cli.quiet, + command_disables_pager(&cli.command), + ) { setup_pager() } else { None @@ -1137,7 +1162,9 @@ async fn run_command( 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::Interactive { team } => { + interactive::run(config::resolve_team_arg(team)).await? + } Commands::Context => handle_context(output, agent_opts, retry).await?, Commands::Favorites { action } => favorites::handle(action, output).await?, Commands::Roadmaps { action } => { @@ -1168,7 +1195,7 @@ async fn run_command( 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::Setup => setup::handle(output).await?, Commands::Sprint { action } => sprint::handle(action, output).await?, Commands::Completions { action } => match action { CompletionCommands::Static { shell } => { @@ -1228,9 +1255,24 @@ async fn run_command( Ok(()) } +/// Commands that own the terminal (prompts, password entry, full-screen TUI). +/// Paging these breaks keyboard input and can leave the tty raw after exit. +fn command_disables_pager(command: &Commands) -> bool { + // Terminal owners only (password prompts / TUI). Doctor is output-oriented. + matches!( + command, + Commands::Setup | Commands::Interactive { .. } | Commands::Auth { .. } + ) +} + /// Determine if pager should be used -fn should_use_pager(no_pager: bool, format: &OutputFormat, quiet: bool) -> bool { - if no_pager || quiet { +fn should_use_pager( + no_pager: bool, + format: &OutputFormat, + quiet: bool, + interactive_command: bool, +) -> bool { + if no_pager || quiet || interactive_command { return false; } // Only page table output, not JSON/NDJSON (those are for scripts) @@ -1419,6 +1461,7 @@ impl Drop for StdoutRedirectGuard { #[allow(clippy::items_after_test_module)] mod tests { use super::*; + #[cfg(unix)] use std::sync::{Mutex, OnceLock}; #[cfg(unix)] @@ -1547,6 +1590,41 @@ mod tests { ); } + #[test] + fn test_should_use_pager_disabled_for_interactive_commands() { + assert!(!should_use_pager( + false, + &OutputFormat::Table, + false, + true + )); + // Non-interactive table on TTY would still depend on is_terminal; quiet/json off. + assert!(!should_use_pager( + true, + &OutputFormat::Table, + false, + false + )); + assert!(!should_use_pager( + false, + &OutputFormat::Json, + false, + false + )); + assert!(!should_use_pager( + false, + &OutputFormat::Table, + true, + false + )); + } + + #[test] + fn test_command_disables_pager_for_setup() { + assert!(command_disables_pager(&Commands::Setup)); + assert!(!command_disables_pager(&Commands::Common)); + } + #[test] fn test_sanitize_completion_value_strips_shell_metacharacters() { let value = "$(touch /tmp/pwn); hello|world && rm -rf /"; @@ -1570,19 +1648,29 @@ mod tests { } #[test] - fn test_resolve_pager_command_rejects_absolute_path_bypass() { - let (program, args) = resolve_pager_command("/tmp/evil/less --steal", false); + fn test_resolve_pager_command_rejects_relative_path_bypass() { + let (program, args) = resolve_pager_command("./less", false); assert_eq!(program, "less"); assert!(args.is_empty()); } #[test] - fn test_resolve_pager_command_rejects_relative_path_bypass() { - let (program, args) = resolve_pager_command("./less", false); + fn test_resolve_pager_command_rejects_relative_path_even_when_trusted() { + let (program, args) = resolve_pager_command("./less -R", true); assert_eq!(program, "less"); assert!(args.is_empty()); } + // Unix path roots are absolute only on Unix; Windows uses drive-letter paths. + #[cfg(unix)] + #[test] + fn test_resolve_pager_command_rejects_absolute_path_bypass() { + let (program, args) = resolve_pager_command("/tmp/evil/less --steal", false); + assert_eq!(program, "less"); + assert!(args.is_empty()); + } + + #[cfg(unix)] #[test] fn test_resolve_pager_command_allows_absolute_path_when_trusted() { let (program, args) = resolve_pager_command("/usr/bin/less -R -F", true); @@ -1590,11 +1678,12 @@ mod tests { assert_eq!(args, vec!["-R", "-F"]); } + #[cfg(windows)] #[test] - fn test_resolve_pager_command_rejects_relative_path_even_when_trusted() { - let (program, args) = resolve_pager_command("./less -R", true); - assert_eq!(program, "less"); - assert!(args.is_empty()); + fn test_resolve_pager_command_allows_windows_absolute_path_when_trusted() { + let (program, args) = resolve_pager_command(r"C:\bin\less -R -F", true); + assert_eq!(program, r"C:\bin\less"); + assert_eq!(args, vec!["-R", "-F"]); } } @@ -1844,142 +1933,6 @@ async fn handle_done( Ok(()) } -/// Handle the `setup` command — guided onboarding wizard -async fn handle_setup(output: &OutputOptions) -> Result<()> { - use dialoguer::Password; - use std::io::{self, IsTerminal, Write}; - - println!("Linear CLI Setup"); - println!("{}", "-".repeat(40)); - println!(); - - // Step 1: API Key - println!("Step 1: Authentication"); - println!(" Get your API key from: https://linear.app/settings/api"); - println!(); - let api_key = if io::stdin().is_terminal() { - Password::new() - .with_prompt(" Enter your Linear API key") - .allow_empty_password(false) - .interact() - .context("Failed to read Linear API key")? - .trim() - .to_string() - } else { - print!(" Enter your Linear API key: "); - io::stdout().flush()?; - let mut api_key = String::new(); - io::stdin().read_line(&mut api_key)?; - api_key.trim().to_string() - }; - - if api_key.is_empty() { - anyhow::bail!("API key cannot be empty"); - } - - println!(" Validating API key..."); - println!(); - - // Step 2: Validate the key and pick default team - println!("Step 2: Default Team"); - let client = api::LinearClient::with_api_key(api_key.clone())?; - - let teams_query = r#" - query { - teams { - nodes { - id - name - key - } - } - } - "#; - - let data = client - .query(teams_query, None) - .await - .context("Could not validate API key or fetch teams")?; - - config::set_api_key(&api_key)?; - println!(" API key validated and saved."); - - 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::() { - 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."); - } - } - } - } - - println!(); - - // Step 3: Output format - 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(()) -} - /// Handle the hidden `_complete` command -- output dynamic completion values async fn handle_complete(type_: &str, prefix: &str, team: Option<&str>) -> Result<()> { // Try to use cache first for fast completions; fall back to API diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index a10f033..ef5925c 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -99,6 +99,10 @@ fn test_config_help() { assert!(stdout.contains("show")); assert!(stdout.contains("workspace-add")); assert!(stdout.contains("workspace-list")); + assert!( + stdout.contains("default-team") || stdout.contains("default_team"), + "config help should document default-team key" + ); } #[test] @@ -109,12 +113,34 @@ fn test_top_level_help_does_not_expose_api_key_flag() { } #[test] -fn test_config_set_help_only_allows_profile() { +fn test_config_set_rejects_api_key_on_argv() { let (code, _stdout, stderr) = run_cli(&["config", "set", "api-key", "dummy"]); assert_ne!(code, 0); assert!(stderr.contains("invalid value") || stderr.contains("api-key")); } +#[test] +fn test_config_set_accepts_default_team() { + let (code, stdout, _stderr) = run_cli(&["config", "set", "--help"]); + assert_eq!(code, 0); + assert!( + stdout.contains("default-team") || stdout.contains("DefaultTeam"), + "config set should accept default-team: {}", + stdout + ); +} + +#[test] +fn test_config_get_accepts_default_team() { + let (code, stdout, _stderr) = run_cli(&["config", "get", "--help"]); + assert_eq!(code, 0); + assert!( + stdout.contains("default-team") || stdout.contains("DefaultTeam"), + "config get should accept default-team: {}", + stdout + ); +} + #[test] fn test_bulk_help() { let (code, stdout, _stderr) = run_cli(&["bulk", "--help"]); @@ -398,6 +424,18 @@ fn test_relations_help() { assert!(stdout.contains("remove")); } +#[test] +fn test_relations_add_help_lists_blocked_by() { + let (code, stdout, _stderr) = run_cli(&["relations", "add", "--help"]); + assert_eq!(code, 0); + assert!( + stdout.contains("blocked-by") || stdout.contains("blockedBy"), + "relations add should advertise blocked-by: {}", + stdout + ); + assert!(stdout.contains("blocks")); +} + #[test] fn test_favorites_help() { let (code, stdout, _stderr) = run_cli(&["favorites", "--help"]); @@ -1378,21 +1416,34 @@ fn test_sprint_alias() { #[test] fn test_sprint_status_requires_team() { + // Without default-team / LINEAR_CLI_TEAM, sprint status still fails. let (code, _stdout, stderr) = run_cli(&["sprint", "status"]); assert_ne!(code, 0); assert!( - stderr.contains("--team") || stderr.contains("required"), - "sprint status should require --team flag" + stderr.contains("--team") + || stderr.contains("required") + || stderr.contains("default-team") + || stderr.contains("default team"), + "sprint status should require team or default-team: {}", + stderr ); } +fn team_required_error(stderr: &str) -> bool { + stderr.contains("--team") + || stderr.contains("required") + || stderr.contains("default-team") + || stderr.contains("default team") +} + #[test] fn test_sprint_progress_requires_team() { let (code, _stdout, stderr) = run_cli(&["sprint", "progress"]); assert_ne!(code, 0); assert!( - stderr.contains("--team") || stderr.contains("required"), - "sprint progress should require --team flag" + team_required_error(&stderr), + "sprint progress should require team or default-team: {}", + stderr ); } @@ -1401,8 +1452,9 @@ fn test_sprint_plan_requires_team() { let (code, _stdout, stderr) = run_cli(&["sprint", "plan"]); assert_ne!(code, 0); assert!( - stderr.contains("--team") || stderr.contains("required"), - "sprint plan should require --team flag" + team_required_error(&stderr), + "sprint plan should require team or default-team: {}", + stderr ); } @@ -1411,8 +1463,9 @@ fn test_sprint_carry_over_requires_team() { let (code, _stdout, stderr) = run_cli(&["sprint", "carry-over"]); assert_ne!(code, 0); assert!( - stderr.contains("--team") || stderr.contains("required"), - "sprint carry-over should require --team flag" + team_required_error(&stderr), + "sprint carry-over should require team or default-team: {}", + stderr ); }