Skip to content

Commit faf8fb3

Browse files
refactor(cli): split non-issue command families into focused modules
Extract whoami/version, team, project, and profile command implementations from the monolithic LinearCli.CLI.Commands into four focused modules: - LinearCli.CLI.Commands.System (whoami, version) - LinearCli.CLI.Commands.Teams (team list/favorite/unfavorite) - LinearCli.CLI.Commands.Projects (project list/favorite/unfavorite/update) - LinearCli.CLI.Commands.Profiles (profile create/list/use/show/clear/delete) Rewire CLI dispatch clauses directly to the new modules. No forwarding facade retained in Commands. The filter_favorites/4 private helper is intentionally duplicated into Teams and Projects rather than shared, per the phase-18 plan guidance that private helpers stay with their command family. The Commands module now only owns issue commands, pending EXT-54. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c22e879 commit faf8fb3

7 files changed

Lines changed: 314 additions & 258 deletions

File tree

‎app/lib/linear_cli/cli.ex‎

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ defmodule LinearCli.CLI do
88
"""
99

1010
alias LinearCli.CLI.Commands
11+
alias LinearCli.CLI.Commands.{Profiles, Projects, Teams}
12+
alias LinearCli.CLI.Commands.System, as: SystemCmds
1113

1214
@workflow_state_types ~w(triage backlog unstarted started completed canceled duplicate)
1315

@@ -196,39 +198,39 @@ defmodule LinearCli.CLI do
196198
end
197199
end
198200

199-
defp dispatch([:whoami], result, halt), do: run(&Commands.whoami/1, result, halt)
200-
defp dispatch([:version], result, halt), do: run(&Commands.version/1, result, halt)
201-
defp dispatch([:team, :list], result, halt), do: run(&Commands.team_list/1, result, halt)
201+
defp dispatch([:whoami], result, halt), do: run(&SystemCmds.whoami/1, result, halt)
202+
defp dispatch([:version], result, halt), do: run(&SystemCmds.version/1, result, halt)
203+
defp dispatch([:team, :list], result, halt), do: run(&Teams.team_list/1, result, halt)
202204

203205
defp dispatch([:team, :favorite], result, halt),
204-
do: run(&Commands.team_favorite/1, result, halt)
206+
do: run(&Teams.team_favorite/1, result, halt)
205207

206208
defp dispatch([:team, :unfavorite], result, halt),
207-
do: run(&Commands.team_unfavorite/1, result, halt)
209+
do: run(&Teams.team_unfavorite/1, result, halt)
208210

209-
defp dispatch([:project, :list], result, halt), do: run(&Commands.project_list/1, result, halt)
211+
defp dispatch([:project, :list], result, halt), do: run(&Projects.project_list/1, result, halt)
210212

211213
defp dispatch([:project, :favorite], result, halt),
212-
do: run(&Commands.project_favorite/1, result, halt)
214+
do: run(&Projects.project_favorite/1, result, halt)
213215

214216
defp dispatch([:project, :unfavorite], result, halt),
215-
do: run(&Commands.project_unfavorite/1, result, halt)
217+
do: run(&Projects.project_unfavorite/1, result, halt)
216218

217219
defp dispatch([:project, :update], result, halt),
218-
do: run(&Commands.project_update/1, result, halt)
220+
do: run(&Projects.project_update/1, result, halt)
219221

220222
defp dispatch([:profile, :create], result, halt),
221-
do: run(&Commands.profile_create/1, result, halt)
223+
do: run(&Profiles.profile_create/1, result, halt)
222224

223-
defp dispatch([:profile, :list], result, halt), do: run(&Commands.profile_list/1, result, halt)
224-
defp dispatch([:profile, :use], result, halt), do: run(&Commands.profile_use/1, result, halt)
225-
defp dispatch([:profile, :show], result, halt), do: run(&Commands.profile_show/1, result, halt)
225+
defp dispatch([:profile, :list], result, halt), do: run(&Profiles.profile_list/1, result, halt)
226+
defp dispatch([:profile, :use], result, halt), do: run(&Profiles.profile_use/1, result, halt)
227+
defp dispatch([:profile, :show], result, halt), do: run(&Profiles.profile_show/1, result, halt)
226228

227229
defp dispatch([:profile, :delete], result, halt),
228-
do: run(&Commands.profile_delete/1, result, halt)
230+
do: run(&Profiles.profile_delete/1, result, halt)
229231

230232
defp dispatch([:profile, :clear], result, halt),
231-
do: run(&Commands.profile_clear/1, result, halt)
233+
do: run(&Profiles.profile_clear/1, result, halt)
232234

233235
defp dispatch([:issue, :list], result, halt), do: run(&Commands.issue_list/1, result, halt)
234236
defp dispatch([:issue, :view], result, halt), do: run(&Commands.issue_view/1, result, halt)

‎app/lib/linear_cli/cli/commands.ex‎

Lines changed: 7 additions & 241 deletions
Original file line numberDiff line numberDiff line change
@@ -1,254 +1,20 @@
11
defmodule LinearCli.CLI.Commands do
22
@moduledoc """
3-
The logic behind each subcommand: fetch via `LinearCli.Linear`, display the
4-
result. Ported from vendor/ruby-linear-cli/lib/linear/commands/**.
3+
Issue command implementations: fetch via `LinearCli.Linear`, display the
4+
result. Ported from vendor/ruby-linear-cli/lib/linear/commands/issue/**.
5+
6+
Non-issue command families live in their own focused modules:
7+
`LinearCli.CLI.Commands.System`, `LinearCli.CLI.Commands.Teams`,
8+
`LinearCli.CLI.Commands.Projects`, and `LinearCli.CLI.Commands.Profiles`.
59
"""
610

711
alias LinearCli.Browser
812
alias LinearCli.CLI.{Display, IssueHelpers, Projects, Prompt, WhatFor}
913
alias LinearCli.CLI.Issue.{Actions, Assignment, Creation, Identifiers}
10-
alias LinearCli.{Favorites, Git, Linear, Profiles}
14+
alias LinearCli.{Git, Linear, Profiles}
1115

1216
@max_concurrent_issue_updates 20
1317

14-
@doc "Ported from commands/whoami.rb."
15-
def whoami(%{flags: flags, options: options}) do
16-
with {:ok, user} <- Linear.me() do
17-
Display.show(user, %{output: options.output, teams: flags.teams})
18-
:ok
19-
end
20-
end
21-
22-
@doc """
23-
Ported from commands/version.rb, extended to respect the global
24-
`--output json` option like every other command does - previously
25-
ignored it and always printed plain text. The hidden Markdown renders make
26-
this command a complete release smoke test for the MDEx and Syntect NIFs,
27-
Marcli's syntax-highlighting integration, and the application boot path.
28-
"""
29-
def version(%{options: options}) do
30-
verify_markdown_runtime!()
31-
version = to_string(Application.spec(:linear_cli, :vsn))
32-
33-
if options.output == "json" do
34-
IO.puts(Jason.encode!(%{version: version}))
35-
else
36-
IO.puts(version)
37-
end
38-
39-
:ok
40-
end
41-
42-
defp verify_markdown_runtime! do
43-
theme = Marcli.Theme.default()
44-
elixir = Marcli.render("```elixir\ndef smoke, do: :ok\n```")
45-
ruby = Marcli.render("```ruby\ndef smoke; :ok; end\n```")
46-
47-
elixir_keyword = theme.syntax.keyword_declaration <> "def" <> theme.reset
48-
ruby_keyword = theme.syntax.keyword_type <> "def" <> theme.reset
49-
50-
unless String.contains?(elixir, elixir_keyword) and String.contains?(ruby, ruby_keyword) do
51-
raise "Markdown syntax-highlighting runtime is unavailable"
52-
end
53-
54-
:ok
55-
end
56-
57-
@doc "Ported from commands/team/list.rb. Ruby's `--mine` defaults true."
58-
def team_list(%{flags: flags, options: options}) do
59-
result = if flags.no_mine, do: Linear.teams(), else: Linear.my_teams()
60-
61-
with {:ok, teams} <- result do
62-
Display.show(filter_favorites(teams, flags.all, "team", & &1.key), %{
63-
output: options.output
64-
})
65-
66-
:ok
67-
end
68-
end
69-
70-
@doc "Ported from commands/project/list.rb. Ruby's `--mine` defaults false."
71-
def project_list(%{flags: flags, options: options}) do
72-
with {:ok, projects} <- projects_for(flags, options) do
73-
Display.show(filter_favorites(projects, flags.all, "project", & &1.id), %{
74-
output: options.output
75-
})
76-
77-
:ok
78-
end
79-
end
80-
81-
defp projects_for(_flags, %{team: team_key}) when is_binary(team_key) do
82-
with {:ok, team} <- Linear.find_team(team_key) do
83-
Linear.projects_by_team(team.id)
84-
end
85-
end
86-
87-
defp projects_for(%{mine: true}, _options), do: Linear.my_projects()
88-
defp projects_for(_flags, _options), do: Linear.projects()
89-
90-
@doc """
91-
New in this port - Ruby has no equivalent. Favorites a team
92-
(`LinearCli.Favorites`) - once any team is favorited, `team list`
93-
defaults to showing just favorites (`--all` overrides).
94-
"""
95-
def team_favorite(%{args: %{team: key}}) do
96-
with {:ok, team} <- Linear.find_team(key) do
97-
Favorites.add("team", team.key)
98-
Prompt.ok("Favorited team #{team.key}")
99-
:ok
100-
end
101-
end
102-
103-
@doc "New in this port - Ruby has no equivalent. Un-favorites a team."
104-
def team_unfavorite(%{args: %{team: key}}) do
105-
with {:ok, team} <- Linear.find_team(key) do
106-
Favorites.remove("team", team.key)
107-
Prompt.ok("Un-favorited team #{team.key}")
108-
:ok
109-
end
110-
end
111-
112-
@doc """
113-
New in this port - Ruby has no equivalent. Favorites a project
114-
(`LinearCli.Favorites`), resolved against the active team's projects,
115-
prompting if ambiguous. Team is resolved via `--team`, the active
116-
profile, or an interactive prompt. Once any project is favorited,
117-
`project list` defaults to showing just favorites (`--all` overrides).
118-
"""
119-
def project_favorite(%{args: %{project: search}, options: options}) do
120-
team = WhatFor.team_for(options.team || Profiles.default_team())
121-
122-
with {:ok, projects} <- Linear.projects_by_team(team.id, %{search: search}),
123-
project when not is_nil(project) <- Projects.project_for(projects, search) do
124-
Favorites.add("project", project.id)
125-
Prompt.ok("Favorited project #{project.name}")
126-
:ok
127-
else
128-
nil -> {:error, {:smells_bad, "No project found matching #{search}"}}
129-
{:error, reason} -> {:error, reason}
130-
end
131-
end
132-
133-
@doc "New in this port - Ruby has no equivalent. Un-favorites a project."
134-
def project_unfavorite(%{args: %{project: search}, options: options}) do
135-
team = WhatFor.team_for(options.team || Profiles.default_team())
136-
137-
with {:ok, projects} <- Linear.projects_by_team(team.id, %{search: search}),
138-
project when not is_nil(project) <- Projects.project_for(projects, search) do
139-
Favorites.remove("project", project.id)
140-
Prompt.ok("Un-favorited project #{project.name}")
141-
:ok
142-
else
143-
nil -> {:error, {:smells_bad, "No project found matching #{search}"}}
144-
{:error, reason} -> {:error, reason}
145-
end
146-
end
147-
148-
# Once any favorite of `kind` exists, narrows `records` down to just
149-
# those (matched via `key_fun`) - invisible to anyone who's never
150-
# favorited anything, since an empty favorites list leaves `records`
151-
# untouched. `all?` (the new `--all` flag) always shows everything,
152-
# bypassing the favorites lookup entirely.
153-
defp filter_favorites(records, true, _kind, _key_fun), do: records
154-
155-
defp filter_favorites(records, _all?, kind, key_fun) do
156-
case Favorites.list(kind) do
157-
[] -> records
158-
favorite_values -> Enum.filter(records, &(key_fun.(&1) in favorite_values))
159-
end
160-
end
161-
162-
@doc """
163-
New in this port - Ruby has no equivalent. Posts a status update
164-
(Linear's own "Project Update" feature - a journal-style status post,
165-
not an edit to the project's own fields) via the projectUpdateCreate
166-
mutation. `PROJECT` is resolved against the active team's projects,
167-
prompting if ambiguous. Team is resolved via `--team`, the active
168-
profile, or an interactive prompt.
169-
"""
170-
def project_update(%{args: %{project: search}, options: options}) do
171-
team = WhatFor.team_for(options.team || Profiles.default_team())
172-
173-
with {:ok, projects} <- Linear.projects_by_team(team.id, %{search: search}),
174-
project when not is_nil(project) <- Projects.project_for(projects, search),
175-
{:ok, update} <-
176-
Linear.post_project_update(project.id, options.body, %{health: options.health}) do
177-
Display.show(update, %{output: options.output})
178-
:ok
179-
else
180-
nil -> {:error, {:smells_bad, "No project found matching #{search}"}}
181-
{:error, reason} -> {:error, reason}
182-
end
183-
end
184-
185-
@doc """
186-
New in this port - Ruby has no equivalent. Saves a new named
187-
team/project bundle (`LinearCli.Profiles.create/2`) that `profile use`
188-
can later switch to.
189-
"""
190-
def profile_create(%{args: %{name: name}, options: options}) do
191-
case Profiles.create(name, team: options.team, project: options.project) do
192-
{:ok, profile} ->
193-
Display.show(profile, %{output: options.output})
194-
:ok
195-
196-
{:error, reason} ->
197-
{:error, reason}
198-
end
199-
end
200-
201-
@doc "New in this port - Ruby has no equivalent. Lists every saved profile."
202-
def profile_list(%{options: options}) do
203-
Display.show(Profiles.list(), %{output: options.output})
204-
:ok
205-
end
206-
207-
@doc """
208-
New in this port - Ruby has no equivalent. Switches the active profile -
209-
its team/project become the defaults `issue create`/`issue list` fall
210-
back to when `--team`/`--project` are omitted.
211-
"""
212-
def profile_use(%{args: %{name: name}}) do
213-
case Profiles.activate(name) do
214-
:ok ->
215-
Prompt.ok("Switched to profile #{name}")
216-
:ok
217-
218-
{:error, :not_found} ->
219-
{:error, {:smells_bad, "No profile named #{name}"}}
220-
end
221-
end
222-
223-
@doc "New in this port - Ruby has no equivalent. Shows the active profile, if any."
224-
def profile_show(%{options: options}) do
225-
case Profiles.active() do
226-
nil -> Prompt.warn("No active profile")
227-
profile -> Display.show(profile, %{output: options.output})
228-
end
229-
230-
:ok
231-
end
232-
233-
@doc "New in this port - Ruby has no equivalent. Deactivates the active profile without deleting it."
234-
def profile_clear(_result) do
235-
Profiles.clear()
236-
Prompt.ok("Cleared active profile")
237-
:ok
238-
end
239-
240-
@doc "New in this port - Ruby has no equivalent. Deletes a saved profile."
241-
def profile_delete(%{args: %{name: name}}) do
242-
case Profiles.delete(name) do
243-
:ok ->
244-
Prompt.ok("Deleted profile #{name}")
245-
:ok
246-
247-
{:error, :not_found} ->
248-
{:error, {:smells_bad, "No profile named #{name}"}}
249-
end
250-
end
251-
25218
@doc """
25319
Ported from commands/issue/list.rb + operations/issue/list.rb.
25420

0 commit comments

Comments
 (0)