From 65b7a6df7296b9ed1e762c4aa39fdeee07f324cf Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Sun, 9 Aug 2026 16:36:27 -0400 Subject: [PATCH] feat(cli): support Ruby's short subcommand aliases Optimus's subcommand spec has no alias mechanism (verified: no `alias` support in vendor/optimus/lib/optimus/subcommand.ex), so `lc i dev CRY-37` failed - only canonical names worked. Ruby registers these via each command module's ALIASES constant (cli.rb's register/register_sub!); sourced the full table from there rather than the Readme's incomplete prose list, which was missing several (new/add/me/who/teams/projects/ issues/t/p/v). Same pre-parse-rewrite pattern as normalize_aliases/1 (flag aliases): expand the leading 1-2 tokens to their canonical form via a plain map lookup before Optimus.parse!/3 ever sees them. Has to run before normalize_help/1, since Optimus's `help ` only recognizes canonical subcommand names, not aliases. completion (dry-cli-specific) and console/pry (Ruby dev tooling) have no equivalent here, so they're intentionally not included. `issue take` has no alias in Ruby either, so none was invented for it here. Closes #14. Co-Authored-By: Claude Sonnet 5 --- Readme.adoc | 32 ++++++++++++++++ app/lib/linear_cli/cli.ex | 64 +++++++++++++++++++++++++++++++- app/test/linear_cli/cli_test.exs | 48 ++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 1 deletion(-) diff --git a/Readme.adoc b/Readme.adoc index 0b6508c..564a5e5 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -85,6 +85,38 @@ You can find your API key in your https://linear.app/settings/api[Linear Setting === Commands +==== Command Aliases + +Short aliases are available for the top-level commands and some subcommands, +so you don't have to type the full name every time: + +[cols="1,2"] +|=== +|Command |Aliases + +|`whoami` |`me`, `w`, `who`, `whodat` +|`version` |`v` +|`issue` |`i`, `issues` +|`team` |`t`, `teams` +|`project` |`p`, `projects` +|`issue create` |`c`, `new`, `add` +|`issue develop` |`d`, `dev` +|`issue list` |`l`, `ls` +|`issue update` |`u` +|`issue pr` |`pull-request` +|`team list` |`l`, `ls` +|`project list` |`l`, `ls` +|=== + +[source,sh] +---- +$ lc w --teams +$ lc i ls +$ lc i dev CRY-1234 +---- + +`issue take` has no alias. + ==== Help You can get help/usage for any command or subcommand by using the `--help` flag. diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index 145d34b..ad9253d 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -10,7 +10,12 @@ defmodule LinearCli.CLI do alias LinearCli.CLI.Commands def main(argv, halt \\ &System.halt/1) do - argv = argv |> normalize_aliases() |> normalize_help() |> default_to_issue_list() + argv = + argv + |> normalize_aliases() + |> normalize_subcommand_aliases() + |> normalize_help() + |> default_to_issue_list() # Optimus.parse!/3 returns *either* {subcommand_path, parse_result} # (a subcommand matched) *or* a bare %Optimus.ParseResult{} (nothing @@ -58,6 +63,63 @@ defmodule LinearCli.CLI do Enum.map(argv, &Map.get(@flag_aliases, &1, &1)) end + # Optimus's subcommand spec has no alias mechanism either (verified: + # vendor/optimus/lib/optimus/subcommand.ex), but Ruby registers every one + # of these via each command module's `ALIASES` constant (cli.rb's + # `register`/`register_sub!`) - real, user-facing shortcuts people type + # (`lc i dev CRY-37`). Sourced from those constants directly, not the + # Readme's prose list, which was missing several (`new`/`add`/`me`/`who`/ + # `teams`/`projects`/`issues`/`t`/`p`/`v`). `completion` (dry-cli-specific) + # and `console`/`pry` (Ruby dev tooling) have no equivalent here, so + # they're intentionally not included. + @command_aliases %{ + "me" => "whoami", + "w" => "whoami", + "who" => "whoami", + "whodat" => "whoami", + "v" => "version", + "i" => "issue", + "issues" => "issue", + "t" => "team", + "teams" => "team", + "p" => "project", + "projects" => "project" + } + + # `take` has no alias in Ruby either - every issue subcommand not listed + # here (just `take`) is only ever reachable by its canonical name there + # too, so this port doesn't need to invent one. + @subcommand_aliases %{ + "issue" => %{ + "c" => "create", + "new" => "create", + "add" => "create", + "d" => "develop", + "dev" => "develop", + "l" => "list", + "ls" => "list", + "u" => "update", + "pull-request" => "pr" + }, + "team" => %{"l" => "list", "ls" => "list"}, + "project" => %{"l" => "list", "ls" => "list"} + } + + @doc false + def normalize_subcommand_aliases([first | rest]) do + canonical_first = Map.get(@command_aliases, first, first) + + case {Map.fetch(@subcommand_aliases, canonical_first), rest} do + {{:ok, sub_aliases}, [second | more]} -> + [canonical_first, Map.get(sub_aliases, second, second) | more] + + _ -> + [canonical_first | rest] + end + end + + def normalize_subcommand_aliases(argv), do: argv + # Ported from exe/scripts/lc.sh's own `[ "$#" -eq 0 ]` branch exactly # (including its stderr text) - a bare `lc` invocation defaults to # `issue list` rather than dumping top-level help. diff --git a/app/test/linear_cli/cli_test.exs b/app/test/linear_cli/cli_test.exs index 441c0e9..ac44247 100644 --- a/app/test/linear_cli/cli_test.exs +++ b/app/test/linear_cli/cli_test.exs @@ -189,6 +189,54 @@ defmodule LinearCli.CLITest do assert LinearCli.CLI.normalize_aliases(["whoami"]) == ["whoami"] end + test "subcommand aliases (#14) rewrite to their canonical top-level/subcommand names" do + # Sourced from Ruby's own ALIASES constants (cli.rb/commands/*.rb), not + # the Readme's prose list. Direct unit tests on the pure rewrite, same + # rationale as the flag-alias test above. + assert LinearCli.CLI.normalize_subcommand_aliases(["i", "dev", "CRY-37"]) == + ["issue", "develop", "CRY-37"] + + assert LinearCli.CLI.normalize_subcommand_aliases(["issues", "l"]) == ["issue", "list"] + assert LinearCli.CLI.normalize_subcommand_aliases(["t", "ls"]) == ["team", "list"] + assert LinearCli.CLI.normalize_subcommand_aliases(["p", "l"]) == ["project", "list"] + assert LinearCli.CLI.normalize_subcommand_aliases(["w", "-t"]) == ["whoami", "-t"] + assert LinearCli.CLI.normalize_subcommand_aliases(["v"]) == ["version"] + + assert LinearCli.CLI.normalize_subcommand_aliases(["i", "pull-request", "CRY-1"]) == + ["issue", "pr", "CRY-1"] + + # `take` has no alias in Ruby either - unaliased subcommands pass through. + assert LinearCli.CLI.normalize_subcommand_aliases(["issue", "take", "CRY-1"]) == + ["issue", "take", "CRY-1"] + + assert LinearCli.CLI.normalize_subcommand_aliases([]) == [] + end + + test "aliased subcommands actually dispatch end to end" do + assert capture_io(fn -> LinearCli.CLI.main(["w"]) end) =~ "Ada" + assert capture_io(fn -> LinearCli.CLI.main(["i", "ls"]) end) =~ "CRY-1" + assert capture_io(fn -> LinearCli.CLI.main(["t", "l"]) end) =~ "Engineering" + assert capture_io(fn -> LinearCli.CLI.main(["p", "ls"]) end) =~ "Manhattan" + end + + test "an aliased subcommand composes correctly with --help" do + # normalize_subcommand_aliases/1 has to run before normalize_help/1 - + # Optimus's `help ` only recognizes canonical subcommand names, + # not aliases, so "i dev --help" must become "help issue develop", not + # a broken "help i dev". Same fake-halt/CaseClauseError artifact as the + # "issue list --help" test above. + output = + capture_io(fn -> + try do + LinearCli.CLI.main(["i", "dev", "--help"], fn _code -> :ok end) + rescue + CaseClauseError -> :ok + end + end) + + assert output =~ "Start or update development status of an issue" + end + test "a catch-all error halts with exit code 88" do # A malformed API response (neither "data" nor "errors") makes # LinearCli.Api return {:error, {:unexpected_response, body}}, which Ash