hachem.wtf
aboutsummaryrefslogtreecommitdiff
path: root/internal/cli
diff options
context:
space:
mode:
authorhachem <im@hachem.wtf>2026-08-29 18:24:42 +0200
committerhachem <im@hachem.wtf>2026-08-29 18:24:42 +0200
commitfcbbeccaa80c7e120933682b330272a1490c1cf5 (patch)
tree11de4610f01a3861f7bbea94da63dd2d070f420a /internal/cli
parent4806ee7ef95f0f04079856f7753399b7b7aa6330 (diff)
refactor: unify mods command with the search
Diffstat (limited to 'internal/cli')
-rw-r--r--internal/cli/check.go17
-rw-r--r--internal/cli/cli.go23
-rw-r--r--internal/cli/commands.go26
-rw-r--r--internal/cli/generate.go21
-rw-r--r--internal/cli/mods.go318
5 files changed, 368 insertions, 37 deletions
diff --git a/internal/cli/check.go b/internal/cli/check.go
index 1df5137..6d3df5f 100644
--- a/internal/cli/check.go
+++ b/internal/cli/check.go
@@ -33,16 +33,21 @@ func checkReport(cfg *config.Config) string {
client := modrinth.NewClient()
var res checkResult
- for _, slug := range cfg.ModrinthMods {
- versions, err := client.GetVersions(slug)
+ for _, mod := range cfg.ModrinthMods {
+ versions, err := client.GetVersions(mod.Slug)
if err != nil {
- res.failed = append(res.failed, slug)
+ res.failed = append(res.failed, mod.Slug)
continue
}
- match := modrinth.CompatibleVersion(versions, cfg.MinecraftVersion, "fabric")
+ var match *modrinth.Version
+ if mod.Pinned() {
+ match = modrinth.FindVersion(versions, mod.Version)
+ } else {
+ match = modrinth.CompatibleVersion(versions, cfg.MinecraftVersion, "fabric")
+ }
if match == nil {
- res.incompatible = append(res.incompatible, slug)
+ res.incompatible = append(res.incompatible, mod.Slug)
continue
}
@@ -50,7 +55,7 @@ func checkReport(cfg *config.Config) string {
if len(match.Files) > 0 {
filename = match.Files[0].Filename
}
- res.compatible = append(res.compatible, compatMod{slug: slug, filename: filename})
+ res.compatible = append(res.compatible, compatMod{slug: mod.Slug, filename: filename})
}
return renderCheck(cfg, res)
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index 4f98b6d..243dc80 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -8,6 +8,7 @@ import (
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"github.com/hachem-wtf/benson/internal/config"
@@ -37,7 +38,14 @@ type model struct {
quitting bool
events chan tea.Msg
- width int
+ form *huh.Form
+ pickerStage int
+ pickerAction string
+ pickerQuery string
+ pickerSlug string
+ pickerVersion string
+
+ width, height int
}
func Run(version string) error {
@@ -88,9 +96,13 @@ func (m *model) Init() tea.Cmd {
}
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ if m.form != nil {
+ return m.updatePicker(msg)
+ }
+
switch msg := msg.(type) {
case tea.WindowSizeMsg:
- m.width = msg.Width
+ m.width, m.height = msg.Width, msg.Height
m.input.Width = max(msg.Width-12, 20)
return m, nil
@@ -116,6 +128,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.busy = false
return m, tea.Println(msg.text)
+ case modManagerMsg:
+ m.busy = false
+ return m, m.handleModManager(msg)
+
case streamMsg:
return m, tea.Sequence(tea.Println(msg.text), waitForEvent(m.events))
@@ -145,6 +161,9 @@ func (m *model) View() string {
if m.quitting {
return ""
}
+ if m.form != nil {
+ return "\n" + m.form.View() + "\n"
+ }
return "\n" + m.suggestionRow() + "\n" + m.promptLine() + "\n"
}
diff --git a/internal/cli/commands.go b/internal/cli/commands.go
index f50036c..d76402d 100644
--- a/internal/cli/commands.go
+++ b/internal/cli/commands.go
@@ -1,7 +1,6 @@
package cli
import (
- "fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
@@ -13,11 +12,9 @@ import (
func defaultCommands() []command {
return []command{
{
- name: "list-mods",
- desc: "List the configured mods",
- run: func(m *model) tea.Cmd {
- return tea.Println(renderModList(m.cfg))
- },
+ name: "mods",
+ desc: "Manage mods — list, search, add, pin versions, remove",
+ run: runMods,
},
{
name: "check",
@@ -76,23 +73,6 @@ func helpBlock(cmds []command) string {
return lipgloss.JoinVertical(lipgloss.Left, rows...)
}
-func renderModList(cfg *config.Config) string {
- rows := []string{
- headingStyle.Render(fmt.Sprintf("Mods for MC %s", cfg.MinecraftVersion)),
- "",
- }
-
- if len(cfg.ModrinthMods) == 0 {
- rows = append(rows, mutedStyle.Render(" (none configured)"))
- }
- for _, slug := range cfg.ModrinthMods {
- rows = append(rows, " "+bulletStyle.Render("◆ ")+slug)
- }
-
- rows = append(rows, "", mutedStyle.Render(fmt.Sprintf("Total: %d mods", len(cfg.ModrinthMods))))
- return lipgloss.JoinVertical(lipgloss.Left, rows...)
-}
-
func padRight(s string, n int) string {
if pad := n - len([]rune(s)); pad > 0 {
return s + strings.Repeat(" ", pad)
diff --git a/internal/cli/generate.go b/internal/cli/generate.go
index 352b7b4..3e7db04 100644
--- a/internal/cli/generate.go
+++ b/internal/cli/generate.go
@@ -67,25 +67,34 @@ func generateWorker(cfg *config.Config, ch chan<- tea.Msg) {
client := modrinth.NewClient()
downloaded, failed := 0, 0
- for _, slug := range cfg.ModrinthMods {
- versions, err := client.GetVersions(slug)
+ for _, mod := range cfg.ModrinthMods {
+ versions, err := client.GetVersions(mod.Slug)
if err != nil {
failed++
- emit(" " + errorStyle.Render("✗ ") + slug + mutedStyle.Render(" — "+err.Error()))
+ emit(" " + errorStyle.Render("✗ ") + mod.Slug + mutedStyle.Render(" — "+err.Error()))
continue
}
- match := modrinth.CompatibleVersion(versions, cfg.MinecraftVersion, "fabric")
+ var match *modrinth.Version
+ if mod.Pinned() {
+ match = modrinth.FindVersion(versions, mod.Version)
+ } else {
+ match = modrinth.CompatibleVersion(versions, cfg.MinecraftVersion, "fabric")
+ }
if match == nil || len(match.Files) == 0 {
failed++
- emit(" " + errorStyle.Render("✗ ") + slug + mutedStyle.Render(" — no fabric build for MC "+cfg.MinecraftVersion))
+ reason := "no fabric build for MC " + cfg.MinecraftVersion
+ if mod.Pinned() {
+ reason = "version " + mod.Version + " not found"
+ }
+ emit(" " + errorStyle.Render("✗ ") + mod.Slug + mutedStyle.Render(" — "+reason))
continue
}
file := match.Files[0]
if err := client.Download(file.URL, filepath.Join(modsDir, file.Filename)); err != nil {
failed++
- emit(" " + errorStyle.Render("✗ ") + slug + mutedStyle.Render(" — "+err.Error()))
+ emit(" " + errorStyle.Render("✗ ") + mod.Slug + mutedStyle.Render(" — "+err.Error()))
continue
}
diff --git a/internal/cli/mods.go b/internal/cli/mods.go
new file mode 100644
index 0000000..b5ff93e
--- /dev/null
+++ b/internal/cli/mods.go
@@ -0,0 +1,318 @@
+package cli
+
+import (
+ "fmt"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/huh"
+ "github.com/charmbracelet/lipgloss"
+
+ "github.com/hachem-wtf/benson/internal/config"
+ "github.com/hachem-wtf/benson/internal/modrinth"
+)
+
+const (
+ stageMenu = iota
+ stageQuery
+ stageResults
+ stageVersion
+ stageRemove
+)
+
+type modManagerMsg struct {
+ kind string
+ hits []modrinth.SearchHit
+ versions []modrinth.Version
+ query string
+ err error
+}
+
+func runMods(m *model) tea.Cmd {
+ m.openMenu()
+ return tea.Batch(m.form.Init(), sizeCmd(m.width, m.height))
+}
+
+func (m *model) updatePicker(msg tea.Msg) (tea.Model, tea.Cmd) {
+ if size, ok := msg.(tea.WindowSizeMsg); ok {
+ m.width, m.height = size.Width, size.Height
+ }
+
+ form, cmd := m.form.Update(msg)
+ if f, ok := form.(*huh.Form); ok {
+ m.form = f
+ }
+
+ switch m.form.State {
+ case huh.StateAborted:
+ if m.pickerStage == stageMenu {
+ m.form = nil
+ return m, nil
+ }
+ return m, m.reopenMenu()
+ case huh.StateCompleted:
+ return m, m.advance()
+ }
+ return m, cmd
+}
+
+func (m *model) advance() tea.Cmd {
+ switch m.pickerStage {
+ case stageMenu:
+ switch m.pickerAction {
+ case "add":
+ m.openQuery()
+ return tea.Batch(m.form.Init(), sizeCmd(m.width, m.height))
+ case "remove":
+ m.openRemove()
+ return tea.Batch(m.form.Init(), sizeCmd(m.width, m.height))
+ default:
+ m.form = nil
+ return tea.Println(renderModList(m.cfg))
+ }
+
+ case stageQuery:
+ query := strings.TrimSpace(m.pickerQuery)
+ m.form = nil
+ if query == "" {
+ return m.reopenMenu()
+ }
+ return m.searchCmd(query)
+
+ case stageResults:
+ slug := m.pickerSlug
+ m.form = nil
+ return m.versionsCmd(slug)
+
+ case stageVersion:
+ m.cfg.ModrinthMods = upsertMod(m.cfg.ModrinthMods, config.Mod{Slug: m.pickerSlug, Version: m.pickerVersion})
+ return m.saveThenMenu(successStyle.Render("Added ") + m.pickerSlug + mutedStyle.Render(" ("+versionTag(m.pickerVersion)+")"))
+
+ case stageRemove:
+ m.cfg.ModrinthMods = removeMod(m.cfg.ModrinthMods, m.pickerSlug)
+ return m.saveThenMenu(successStyle.Render("Removed ") + m.pickerSlug)
+ }
+ return nil
+}
+
+func (m *model) searchCmd(query string) tea.Cmd {
+ m.busy = true
+ mc := m.cfg.MinecraftVersion
+ return tea.Batch(m.spin.Tick, func() tea.Msg {
+ hits, err := modrinth.NewClient().Search(query, mc, "fabric")
+ return modManagerMsg{kind: "search", hits: hits, query: query, err: err}
+ })
+}
+
+func (m *model) versionsCmd(slug string) tea.Cmd {
+ m.busy = true
+ return tea.Batch(m.spin.Tick, func() tea.Msg {
+ versions, err := modrinth.NewClient().GetVersions(slug)
+ return modManagerMsg{kind: "versions", versions: versions, err: err}
+ })
+}
+
+func (m *model) handleModManager(msg modManagerMsg) tea.Cmd {
+ if msg.err != nil {
+ return tea.Sequence(tea.Println(errorStyle.Render("Error: "+msg.err.Error())), m.reopenMenu())
+ }
+
+ switch msg.kind {
+ case "search":
+ if len(msg.hits) == 0 {
+ return tea.Sequence(tea.Println(mutedStyle.Render("No results for “"+msg.query+"”.")), m.reopenMenu())
+ }
+ m.openResults(msg.hits)
+ case "versions":
+ m.openVersion(msg.versions)
+ }
+ return tea.Batch(m.form.Init(), sizeCmd(m.width, m.height))
+}
+
+func (m *model) saveThenMenu(notice string) tea.Cmd {
+ if err := config.Save(m.cfg); err != nil {
+ return tea.Sequence(tea.Println(errorStyle.Render("Failed to save config: "+err.Error())), m.reopenMenu())
+ }
+ return tea.Sequence(tea.Println(notice), m.reopenMenu())
+}
+
+func (m *model) reopenMenu() tea.Cmd {
+ m.openMenu()
+ return tea.Batch(m.form.Init(), sizeCmd(m.width, m.height))
+}
+
+func (m *model) openMenu() {
+ m.pickerStage = stageMenu
+ m.pickerAction = "add"
+
+ options := []huh.Option[string]{huh.NewOption("Search & add a mod", "add")}
+ if len(m.cfg.ModrinthMods) > 0 {
+ options = append(options, huh.NewOption("Remove a mod", "remove"))
+ }
+ options = append(options, huh.NewOption("Done", "done"))
+
+ m.form = huh.NewForm(huh.NewGroup(
+ huh.NewSelect[string]().
+ Title(fmt.Sprintf("Mods · %d configured", len(m.cfg.ModrinthMods))).
+ Description(modsSummary(m.cfg.ModrinthMods)).
+ Options(options...).
+ Value(&m.pickerAction),
+ )).WithTheme(pickerTheme())
+}
+
+func (m *model) openQuery() {
+ m.pickerStage = stageQuery
+ m.pickerQuery = ""
+ m.form = huh.NewForm(huh.NewGroup(
+ huh.NewInput().
+ Title("Search Modrinth").
+ Description("Mod name or keyword · esc to go back").
+ Value(&m.pickerQuery),
+ )).WithTheme(pickerTheme())
+}
+
+func (m *model) openResults(hits []modrinth.SearchHit) {
+ m.pickerStage = stageResults
+ m.pickerSlug = hits[0].Slug
+
+ options := make([]huh.Option[string], len(hits))
+ for i, h := range hits {
+ options[i] = huh.NewOption(h.Title+" ("+h.Slug+")", h.Slug)
+ }
+
+ m.form = huh.NewForm(huh.NewGroup(
+ huh.NewSelect[string]().
+ Title("Add a mod").
+ Description("Pick a search result · esc to go back").
+ Options(options...).
+ Height(selectHeight(len(options))).
+ Value(&m.pickerSlug),
+ )).WithTheme(pickerTheme())
+}
+
+func (m *model) openVersion(versions []modrinth.Version) {
+ m.pickerStage = stageVersion
+ m.pickerVersion = ""
+
+ options := []huh.Option[string]{huh.NewOption("Latest compatible · recommended", "")}
+ for _, v := range versions {
+ if containsStr(v.Loaders, "fabric") && containsStr(v.GameVersions, m.cfg.MinecraftVersion) {
+ options = append(options, huh.NewOption(v.VersionNumber, v.VersionNumber))
+ }
+ }
+
+ m.form = huh.NewForm(huh.NewGroup(
+ huh.NewSelect[string]().
+ Title("Version for " + m.pickerSlug).
+ Description("Keep it on latest, or pin one").
+ Options(options...).
+ Height(selectHeight(len(options))).
+ Value(&m.pickerVersion),
+ )).WithTheme(pickerTheme())
+}
+
+func (m *model) openRemove() {
+ m.pickerStage = stageRemove
+ m.pickerSlug = m.cfg.ModrinthMods[0].Slug
+
+ options := make([]huh.Option[string], len(m.cfg.ModrinthMods))
+ for i, mod := range m.cfg.ModrinthMods {
+ options[i] = huh.NewOption(modLabel(mod), mod.Slug)
+ }
+
+ m.form = huh.NewForm(huh.NewGroup(
+ huh.NewSelect[string]().
+ Title("Remove which mod?").
+ Description("esc to go back").
+ Options(options...).
+ Height(selectHeight(len(options))).
+ Value(&m.pickerSlug),
+ )).WithTheme(pickerTheme())
+}
+
+func renderModList(cfg *config.Config) string {
+ rows := []string{headingStyle.Render(fmt.Sprintf("Mods for MC %s", cfg.MinecraftVersion)), ""}
+ if len(cfg.ModrinthMods) == 0 {
+ rows = append(rows, mutedStyle.Render(" (none configured)"))
+ }
+ for _, mod := range cfg.ModrinthMods {
+ tag := mutedStyle.Render(" latest")
+ if mod.Pinned() {
+ tag = warnStyle.Render(" pinned " + mod.Version)
+ }
+ rows = append(rows, " "+bulletStyle.Render("◆ ")+mod.Slug+tag)
+ }
+ rows = append(rows, "", mutedStyle.Render(fmt.Sprintf("Total: %d mods", len(cfg.ModrinthMods))))
+ return lipgloss.JoinVertical(lipgloss.Left, rows...)
+}
+
+func modsSummary(mods []config.Mod) string {
+ if len(mods) == 0 {
+ return "No mods yet — search Modrinth to add some."
+ }
+ var b strings.Builder
+ for _, mod := range mods {
+ fmt.Fprintf(&b, "• %s\n", modLabel(mod))
+ }
+ return strings.TrimRight(b.String(), "\n")
+}
+
+func modLabel(mod config.Mod) string {
+ if mod.Pinned() {
+ return mod.Slug + " (" + mod.Version + ")"
+ }
+ return mod.Slug + " (latest)"
+}
+
+func versionTag(version string) string {
+ if version == "" {
+ return "latest"
+ }
+ return "pinned " + version
+}
+
+func upsertMod(mods []config.Mod, mod config.Mod) []config.Mod {
+ for i := range mods {
+ if mods[i].Slug == mod.Slug {
+ mods[i] = mod
+ return mods
+ }
+ }
+ return append(mods, mod)
+}
+
+func removeMod(mods []config.Mod, slug string) []config.Mod {
+ kept := make([]config.Mod, 0, len(mods))
+ for _, mod := range mods {
+ if mod.Slug != slug {
+ kept = append(kept, mod)
+ }
+ }
+ return kept
+}
+
+func selectHeight(n int) int {
+ return min(10, n) + 2
+}
+
+func containsStr(list []string, s string) bool {
+ for _, v := range list {
+ if v == s {
+ return true
+ }
+ }
+ return false
+}
+
+func pickerTheme() *huh.Theme {
+ theme := huh.ThemeCharm()
+ theme.Focused.Title = theme.Focused.Title.Foreground(colAccent).Bold(true)
+ theme.Focused.Base = theme.Focused.Base.BorderForeground(colPrimary)
+ return theme
+}
+
+func sizeCmd(w, h int) tea.Cmd {
+ return func() tea.Msg {
+ return tea.WindowSizeMsg{Width: w, Height: h}
+ }
+}