hachem.wtf
aboutsummaryrefslogtreecommitdiff
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
parent4806ee7ef95f0f04079856f7753399b7b7aa6330 (diff)
refactor: unify mods command with the search
-rw-r--r--go.mod1
-rw-r--r--go.sum2
-rw-r--r--internal/bot/bot.go20
-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
-rw-r--r--internal/config/cache.go12
-rw-r--r--internal/config/config.go72
-rw-r--r--internal/modrinth/modrinth.go47
-rw-r--r--internal/setup/mods.go177
-rw-r--r--internal/setup/setup.go69
13 files changed, 711 insertions, 94 deletions
diff --git a/go.mod b/go.mod
index bae9ab1..da81140 100644
--- a/go.mod
+++ b/go.mod
@@ -7,6 +7,7 @@ require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/huh v1.0.0
+ github.com/charmbracelet/huh/spinner v0.0.0-20260223110133-9dc45e34a40b
github.com/charmbracelet/lipgloss v1.1.0
)
diff --git a/go.sum b/go.sum
index d6e3171..826df57 100644
--- a/go.sum
+++ b/go.sum
@@ -18,6 +18,8 @@ github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
+github.com/charmbracelet/huh/spinner v0.0.0-20260223110133-9dc45e34a40b h1:deQbW7eR/gYwkXonGX6a1now6H6f8v4kfv0OIKECu0I=
+github.com/charmbracelet/huh/spinner v0.0.0-20260223110133-9dc45e34a40b/go.mod h1:Y68nuKJuC/Q2lmiq18EkHWkVWi2VGLrwaOfOyPKLkkE=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
diff --git a/internal/bot/bot.go b/internal/bot/bot.go
index 48e395b..39fd00b 100644
--- a/internal/bot/bot.go
+++ b/internal/bot/bot.go
@@ -114,17 +114,21 @@ func checkMods(cfg *config.Config, client *modrinth.Client) report {
newCache := config.Cache{}
var rep report
- for _, slug := range cfg.ModrinthMods {
- versions, err := client.GetVersions(slug)
+ for _, mod := range cfg.ModrinthMods {
+ if mod.Pinned() {
+ continue
+ }
+
+ versions, err := client.GetVersions(mod.Slug)
if err != nil {
- log.Printf("[Modrinth] failed for %s: %v", slug, err)
+ log.Printf("[Modrinth] failed for %s: %v", mod.Slug, err)
continue
}
latest := modrinth.LatestVersion(versions)
compatible := modrinth.CompatibleVersion(versions, cfg.MinecraftVersion, "")
- cached := cache[slug]
+ cached := cache[mod.Slug]
state := config.ModState{}
if compatible != nil {
@@ -133,15 +137,15 @@ func checkMods(cfg *config.Config, client *modrinth.Client) report {
if latest != nil {
state.Latest = latest.ID
}
- newCache[slug] = state
+ newCache[mod.Slug] = state
switch {
case compatible == nil:
- rep.noCompatible = append(rep.noCompatible, slug)
+ rep.noCompatible = append(rep.noCompatible, mod.Slug)
case cached.Compatible == "":
- rep.newlyCompatible = append(rep.newlyCompatible, slug)
+ rep.newlyCompatible = append(rep.newlyCompatible, mod.Slug)
case latest != nil && latest.ID != cached.Latest:
- rep.stillCompatibleUpdate = append(rep.stillCompatibleUpdate, slug)
+ rep.stillCompatibleUpdate = append(rep.stillCompatibleUpdate, mod.Slug)
}
}
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}
+ }
+}
diff --git a/internal/config/cache.go b/internal/config/cache.go
index 825af7f..aaaa97b 100644
--- a/internal/config/cache.go
+++ b/internal/config/cache.go
@@ -23,6 +23,18 @@ func LoadAPI() (*API, error) {
return &a, nil
}
+func SaveAPI(api *API) error {
+ if err := os.MkdirAll(Dir, 0o755); err != nil {
+ return err
+ }
+
+ data, err := json.MarshalIndent(api, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(APIPath(), append(data, '\n'), 0o600)
+}
+
type ModState struct {
Compatible string `json:"compatible"`
Latest string `json:"latest"`
diff --git a/internal/config/config.go b/internal/config/config.go
index 35842a1..05d96ad 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -7,17 +7,55 @@ import (
)
type Config struct {
- ChannelID string `json:"channel_id"`
- CheckIntervalMinutes int `json:"check_interval_minutes"`
- MinecraftVersion string `json:"minecraft_version"`
- FabricLoaderVersion string `json:"fabric_loader_version"`
- LoaderMetaVersion string `json:"loader_meta_version"`
- JavaPath string `json:"java_path"`
- ServerLocation string `json:"server_location"`
- BackupLocation string `json:"backup_location"`
- MergePreCommand string `json:"merge_pre_command"`
- MergePostCommand string `json:"merge_post_command"`
- ModrinthMods []string `json:"modrinth_mods"`
+ ChannelID string `json:"channel_id"`
+ CheckIntervalMinutes int `json:"check_interval_minutes"`
+ MinecraftVersion string `json:"minecraft_version"`
+ FabricLoaderVersion string `json:"fabric_loader_version"`
+ LoaderMetaVersion string `json:"loader_meta_version"`
+ JavaPath string `json:"java_path"`
+ ServerLocation string `json:"server_location"`
+ BackupLocation string `json:"backup_location"`
+ MergePreCommand string `json:"merge_pre_command"`
+ MergePostCommand string `json:"merge_post_command"`
+ ModrinthMods []Mod `json:"modrinth_mods"`
+}
+
+type Mod struct {
+ Slug string
+ Version string
+}
+
+func (m Mod) Pinned() bool {
+ return m.Version != ""
+}
+
+func (m Mod) MarshalJSON() ([]byte, error) {
+ if m.Version == "" {
+ return json.Marshal(m.Slug)
+ }
+ return json.Marshal(modObject{Slug: m.Slug, Version: m.Version})
+}
+
+func (m *Mod) UnmarshalJSON(data []byte) error {
+ var slug string
+ if err := json.Unmarshal(data, &slug); err == nil {
+ m.Slug = slug
+ m.Version = ""
+ return nil
+ }
+
+ var obj modObject
+ if err := json.Unmarshal(data, &obj); err != nil {
+ return err
+ }
+ m.Slug = obj.Slug
+ m.Version = obj.Version
+ return nil
+}
+
+type modObject struct {
+ Slug string `json:"slug"`
+ Version string `json:"version"`
}
const Dir = "config"
@@ -43,3 +81,15 @@ func Load() (*Config, error) {
return &c, nil
}
+
+func Save(cfg *Config) error {
+ if err := os.MkdirAll(Dir, 0o755); err != nil {
+ return err
+ }
+
+ data, err := json.MarshalIndent(cfg, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(Path(), append(data, '\n'), 0o644)
+}
diff --git a/internal/modrinth/modrinth.go b/internal/modrinth/modrinth.go
index 8060f3b..a6ed9eb 100644
--- a/internal/modrinth/modrinth.go
+++ b/internal/modrinth/modrinth.go
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
+ "net/url"
"os"
"time"
)
@@ -37,6 +38,43 @@ func NewClient() *Client {
return &Client{http: &http.Client{Timeout: 30 * time.Second}}
}
+type SearchHit struct {
+ Slug string `json:"slug"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Downloads int `json:"downloads"`
+}
+
+func (c *Client) Search(query, mcVersion, loader string) ([]SearchHit, error) {
+ facets := fmt.Sprintf(`[["project_type:mod"],["categories:%s"],["versions:%s"]]`, loader, mcVersion)
+ endpoint := fmt.Sprintf("%s/search?limit=20&index=relevance&query=%s&facets=%s",
+ apiBase, url.QueryEscape(query), url.QueryEscape(facets))
+
+ req, err := http.NewRequest(http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("User-Agent", userAgent)
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("modrinth: search returned %s", resp.Status)
+ }
+
+ var result struct {
+ Hits []SearchHit `json:"hits"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return nil, err
+ }
+ return result.Hits, nil
+}
+
func (c *Client) GetVersions(slug string) ([]Version, error) {
url := fmt.Sprintf("%s/project/%s/version", apiBase, slug)
@@ -77,6 +115,15 @@ func LatestVersion(versions []Version) *Version {
return &versions[best]
}
+func FindVersion(versions []Version, versionNumber string) *Version {
+ for i := range versions {
+ if versions[i].VersionNumber == versionNumber {
+ return &versions[i]
+ }
+ }
+ return nil
+}
+
func CompatibleVersion(versions []Version, mcVersion, loader string) *Version {
var compatible []Version
for _, v := range versions {
diff --git a/internal/setup/mods.go b/internal/setup/mods.go
new file mode 100644
index 0000000..f8de840
--- /dev/null
+++ b/internal/setup/mods.go
@@ -0,0 +1,177 @@
+package setup
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/charmbracelet/huh"
+ "github.com/charmbracelet/huh/spinner"
+
+ "github.com/hachem-wtf/benson/internal/config"
+ "github.com/hachem-wtf/benson/internal/modrinth"
+)
+
+func pickMods(existing []config.Mod, mc, loader string) []config.Mod {
+ mods := append([]config.Mod{}, existing...)
+ client := modrinth.NewClient()
+
+ for {
+ options := []huh.Option[string]{huh.NewOption("Search & add a mod", "add")}
+ if len(mods) > 0 {
+ options = append(options, huh.NewOption("Remove a mod", "remove"))
+ }
+ options = append(options, huh.NewOption("Done", "done"))
+
+ var action string
+ menu := huh.NewSelect[string]().
+ Title(fmt.Sprintf("Mods · %d configured", len(mods))).
+ Description(modsSummary(mods)).
+ Options(options...).
+ Value(&action)
+
+ if err := runSub(menu); err != nil {
+ return mods
+ }
+
+ switch action {
+ case "done":
+ return mods
+ case "add":
+ mods = addMod(mods, client, mc, loader)
+ case "remove":
+ mods = removeMod(mods)
+ }
+ }
+}
+
+func addMod(mods []config.Mod, client *modrinth.Client, mc, loader string) []config.Mod {
+ var query string
+ if err := runSub(huh.NewInput().Title("Search Modrinth").Description("Mod name or keyword").Value(&query)); err != nil {
+ return mods
+ }
+ query = strings.TrimSpace(query)
+ if query == "" {
+ return mods
+ }
+
+ var hits []modrinth.SearchHit
+ _ = spinner.New().Title("Searching Modrinth…").Action(func() {
+ hits, _ = client.Search(query, mc, loader)
+ }).Run()
+
+ if len(hits) == 0 {
+ _ = runSub(huh.NewNote().Title("No results").Description("Nothing matched “" + query + "” for MC " + mc + "."))
+ return mods
+ }
+
+ options := make([]huh.Option[string], len(hits))
+ for i, h := range hits {
+ options[i] = huh.NewOption(fmt.Sprintf("%s (%s)", h.Title, h.Slug), h.Slug)
+ }
+
+ var slug string
+ if err := runSub(huh.NewSelect[string]().Title("Results for “" + query + "”").Options(options...).Height(selectHeight(len(options))).Value(&slug)); err != nil {
+ return mods
+ }
+ if slug == "" {
+ return mods
+ }
+
+ return upsertMod(mods, config.Mod{Slug: slug, Version: pickVersion(client, slug, mc, loader)})
+}
+
+func pickVersion(client *modrinth.Client, slug, mc, loader string) string {
+ var versions []modrinth.Version
+ _ = spinner.New().Title("Loading versions…").Action(func() {
+ versions, _ = client.GetVersions(slug)
+ }).Run()
+
+ options := []huh.Option[string]{huh.NewOption("Latest compatible · recommended", "")}
+ for _, v := range versions {
+ if compatibleWith(v, mc, loader) {
+ options = append(options, huh.NewOption(v.VersionNumber, v.VersionNumber))
+ }
+ }
+
+ var chosen string
+ _ = runSub(huh.NewSelect[string]().
+ Title("Version for " + slug).
+ Description("Keep it on latest, or pin a specific version").
+ Options(options...).
+ Height(selectHeight(len(options))).
+ Value(&chosen))
+ return chosen
+}
+
+func removeMod(mods []config.Mod) []config.Mod {
+ options := make([]huh.Option[string], len(mods))
+ for i, m := range mods {
+ options[i] = huh.NewOption(modLabel(m), m.Slug)
+ }
+
+ var slug string
+ if err := runSub(huh.NewSelect[string]().Title("Remove which mod?").Options(options...).Height(selectHeight(len(options))).Value(&slug)); err != nil {
+ return mods
+ }
+ if slug == "" {
+ return mods
+ }
+
+ kept := make([]config.Mod, 0, len(mods))
+ for _, m := range mods {
+ if m.Slug != slug {
+ kept = append(kept, m)
+ }
+ }
+ return kept
+}
+
+func runSub(field huh.Field) error {
+ return huh.NewForm(huh.NewGroup(field)).WithTheme(emberTheme()).Run()
+}
+
+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 compatibleWith(v modrinth.Version, mc, loader string) bool {
+ hasMC, hasLoader := false, false
+ for _, g := range v.GameVersions {
+ if g == mc {
+ hasMC = true
+ break
+ }
+ }
+ for _, l := range v.Loaders {
+ if l == loader {
+ hasLoader = true
+ break
+ }
+ }
+ return hasMC && hasLoader
+}
+
+func modLabel(m config.Mod) string {
+ if m.Pinned() {
+ return m.Slug + " (" + m.Version + ")"
+ }
+ return m.Slug + " (latest)"
+}
+
+func modsSummary(mods []config.Mod) string {
+ if len(mods) == 0 {
+ return "No mods yet — search Modrinth to add some."
+ }
+
+ var b strings.Builder
+ for _, m := range mods {
+ fmt.Fprintf(&b, "• %s\n", modLabel(m))
+ }
+ return strings.TrimRight(b.String(), "\n")
+}
diff --git a/internal/setup/setup.go b/internal/setup/setup.go
index c2f8c10..c3c957c 100644
--- a/internal/setup/setup.go
+++ b/internal/setup/setup.go
@@ -27,46 +27,49 @@ func Run() error {
backupLoc = cfg.BackupLocation
preCmd = cfg.MergePreCommand
postCmd = cfg.MergePostCommand
- modsText = strings.Join(cfg.ModrinthMods, "\n")
- confirm = true
)
games, loaders, installers := fetchFabricVersions()
form := huh.NewForm(
huh.NewGroup(
- huh.NewNote().
- Title("Benson setup").
- Description("Configure the server manager.\nThe Discord bot is optional — run `benson setup bot` for that."),
- ),
- huh.NewGroup(
- versionField("Minecraft version", "The Minecraft release your server runs — newest stable is recommended", games, &mcVersion, "1.21.10"),
+ versionField("Minecraft version", "Which release your server runs · newest recommended", games, &mcVersion, "1.21.10"),
),
huh.NewGroup(
- versionField("Fabric loader version", "The Fabric mod loader that boots your mods — newest is recommended", loaders, &fabricLoader, "0.17.3"),
+ versionField("Fabric loader version", "The mod loader that boots your mods · newest recommended", loaders, &fabricLoader, "0.17.3"),
),
huh.NewGroup(
- versionField("Fabric installer version", "Builds the server jar from the loader — newest is recommended", installers, &loaderMeta, "1.1.0"),
+ versionField("Fabric installer version", "Builds the server jar · newest recommended", installers, &loaderMeta, "1.1.0"),
),
huh.NewGroup(
- huh.NewInput().Title("Java path").Description("Leave blank to use 'java' from PATH").Value(&javaPath),
- huh.NewInput().Title("Server location").Description("Live server directory, merged into by 'merge'").Value(&serverLoc),
- huh.NewInput().Title("Backup location").Description("Where server backups are written").Value(&backupLoc),
- huh.NewInput().Title("Pre-merge command").Description("Optional shell command run before merging").Value(&preCmd),
- huh.NewInput().Title("Post-merge command").Description("Optional shell command run after merging").Value(&postCmd),
- ).Title("Paths & hooks (optional)"),
+ huh.NewInput().Title("Java path").Description("Blank = use 'java' from PATH").Value(&javaPath),
+ huh.NewInput().Title("Server location").Description("Live server dir, merged into by 'merge'").Value(&serverLoc),
+ huh.NewInput().Title("Backup location").Description("Where backups are written").Value(&backupLoc),
+ ).Title("Locations (optional)"),
huh.NewGroup(
- huh.NewText().Title("Modrinth mods").Description("One mod slug per line").Lines(8).Value(&modsText),
- ).Title("Mods"),
- huh.NewGroup(
- huh.NewConfirm().Title("Write config/config.json?").Affirmative("Write").Negative("Cancel").Value(&confirm),
- ),
+ huh.NewInput().Title("Pre-merge command").Description("Optional shell command before merging").Value(&preCmd),
+ huh.NewInput().Title("Post-merge command").Description("Optional shell command after merging").Value(&postCmd),
+ ).Title("Merge hooks (optional)"),
)
if done, err := runForm(form); err != nil || !done {
return err
}
+ mods := pickMods(cfg.ModrinthMods, strings.TrimSpace(mcVersion), "fabric")
+
+ confirm := true
+ confirmForm := huh.NewForm(huh.NewGroup(
+ huh.NewConfirm().Title("Write config/config.json?").Affirmative("Write").Negative("Cancel").Value(&confirm),
+ ))
+ if done, err := runForm(confirmForm); err != nil || !done {
+ return err
+ }
+ if !confirm {
+ fmt.Println("Setup cancelled — nothing written.")
+ return nil
+ }
+
cfg.MinecraftVersion = strings.TrimSpace(mcVersion)
cfg.FabricLoaderVersion = strings.TrimSpace(fabricLoader)
cfg.LoaderMetaVersion = strings.TrimSpace(loaderMeta)
@@ -75,7 +78,7 @@ func Run() error {
cfg.BackupLocation = strings.TrimSpace(backupLoc)
cfg.MergePreCommand = strings.TrimSpace(preCmd)
cfg.MergePostCommand = strings.TrimSpace(postCmd)
- cfg.ModrinthMods = parseMods(modsText)
+ cfg.ModrinthMods = mods
if err := writeConfigFile(cfg); err != nil {
return err
@@ -106,14 +109,10 @@ func RunBot() error {
huh.NewGroup(
huh.NewNote().
Title("Benson bot setup").
- Description("Configure the Discord bot that posts Modrinth compatibility reports."),
- ),
- huh.NewGroup(
+ Description("Posts Modrinth compatibility reports to a Discord channel."),
huh.NewInput().Title("Discord bot token").Description("Developer Portal → Bot → Reset/Copy Token").Password(true).Value(&token).Validate(required("token")),
huh.NewInput().Title("Channel ID").Description("Channel where reports are posted").Value(&channelID).Validate(required("channel ID")),
huh.NewInput().Title("Check interval (minutes)").Value(&intervalStr).Validate(positiveInt),
- ).Title("Discord"),
- huh.NewGroup(
huh.NewConfirm().Title("Write config/config.json and config/api.json?").Affirmative("Write").Negative("Cancel").Value(&confirm),
),
)
@@ -176,10 +175,14 @@ func versionField(title, desc string, options []string, value *string, placehold
Title(title).
Description(desc).
Options(recommendedOptions(options)...).
- Height(12).
+ Height(selectHeight(len(options))).
Value(value)
}
+func selectHeight(n int) int {
+ return min(10, n) + 2
+}
+
func recommendedOptions(versions []string) []huh.Option[string] {
options := make([]huh.Option[string], len(versions))
for i, v := range versions {
@@ -243,16 +246,6 @@ func positiveInt(s string) error {
return nil
}
-func parseMods(text string) []string {
- mods := []string{}
- for _, line := range strings.Split(text, "\n") {
- if slug := strings.TrimSpace(line); slug != "" {
- mods = append(mods, slug)
- }
- }
- return mods
-}
-
func check() string {
return lipgloss.NewStyle().Foreground(lipgloss.Color("#22C55E")).Render("✓")
}