Initial commit

This commit is contained in:
2026-03-05 01:13:19 -06:00
commit 1ae223a1dc
21 changed files with 2404 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
package cmd
import (
"github.com/spf13/cobra"
)
// osrsCmd and rs3Cmd act as game-scoped parents.
// All real subcommands are registered under both via factory functions.
var osrsCmd = &cobra.Command{
Use: "osrs",
Short: "Query the Old School RuneScape Wiki",
PersistentPreRun: func(cmd *cobra.Command, args []string) {
game = "osrs"
},
}
var rs3Cmd = &cobra.Command{
Use: "rs3",
Short: "Query the RuneScape 3 Wiki",
PersistentPreRun: func(cmd *cobra.Command, args []string) {
game = "rs3"
},
}
func init() {
rootCmd.AddCommand(osrsCmd)
rootCmd.AddCommand(rs3Cmd)
}
// commandFactories holds functions that create fresh command instances.
// Each subcommand registers a factory at init time.
var commandFactories []func() *cobra.Command
// RegisterCommand adds a command factory. Both osrs and rs3 will get
// independent instances of the command.
func RegisterCommand(factory func() *cobra.Command) {
commandFactories = append(commandFactories, factory)
}
// wireCommands creates and attaches all subcommands to both game parents.
// Called from root.go's init after all subcommand init()s have run.
func wireCommands() {
for _, factory := range commandFactories {
osrsCmd.AddCommand(factory())
rs3Cmd.AddCommand(factory())
}
}

View File

@@ -0,0 +1,237 @@
package cmd
import (
"fmt"
"strings"
"github.com/runescape-wiki/rsw/internal/extract"
"github.com/runescape-wiki/rsw/internal/prices"
"github.com/runescape-wiki/rsw/internal/render"
"github.com/runescape-wiki/rsw/internal/wiki"
"github.com/spf13/cobra"
)
func newItemCmd() *cobra.Command {
return &cobra.Command{
Use: "item <name>",
Short: "Look up an item — sources, stats, and price",
Long: `Searches for an item on the wiki and displays its details:
infobox stats, drop sources, acquisition methods, and GE price.
With --ironman, hides GE price and emphasizes self-sufficient acquisition
(drops, shops, crafting).
Examples:
rsw osrs item "abyssal whip"
rsw rs3 item "dragon bones" --ironman`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
wikiClient := wiki.NewClient(GameBaseURL())
results, err := wikiClient.Search(name, 5)
if err != nil {
return fmt.Errorf("search failed: %w", err)
}
if len(results) == 0 {
return fmt.Errorf("no wiki page found for %q", name)
}
pageTitle := results[0].Title
page, err := wikiClient.GetPage(pageTitle)
if err != nil {
return fmt.Errorf("failed to fetch page: %w", err)
}
if Raw() {
fmt.Println(page.Wikitext)
return nil
}
templates := extract.ParseTemplates(page.Wikitext)
md := render.New()
md.H1(pageTitle)
// Extract infobox
infobox := findItemInfobox(templates)
if infobox != nil {
md.H2("Item Details")
md.KV("Examine", infobox.Params["examine"])
md.KV("Members", infobox.Params["members"])
md.KV("Tradeable", infobox.Params["tradeable"])
md.KV("Quest item", infobox.Params["quest"])
md.KV("Weight", infobox.Params["weight"])
md.KV("High Alch", formatAlchValue(infobox.Params["highalch"]))
md.KV("Low Alch", formatAlchValue(infobox.Params["lowalch"]))
md.KV("Destroy", infobox.Params["destroy"])
md.KV("Release", infobox.Params["release"])
md.Newline()
}
// Equipment bonuses
bonuses := extract.FindTemplate(templates, "Infobox Bonuses")
if bonuses != nil {
md.H2("Equipment Bonuses")
headers := []string{"Stat", "Value"}
var rows [][]string
for _, stat := range []string{"astab", "aslash", "acrush", "amagic", "arange",
"dstab", "dslash", "dcrush", "dmagic", "drange",
"str", "rstr", "mdmg", "prayer"} {
if v, ok := bonuses.Params[stat]; ok && v != "" && v != "0" {
rows = append(rows, []string{statName(stat), v})
}
}
if len(rows) > 0 {
md.Table(headers, rows)
}
}
// GE Price vs Ironman
if !Ironman() {
renderGEPrice(md, name, pageTitle)
} else {
md.H2("Ironman Acquisition")
md.P("*GE prices hidden — showing self-sufficient acquisition info.*")
if infobox != nil {
md.KV("High Alch", formatAlchValue(infobox.Params["highalch"]))
md.KV("Low Alch", formatAlchValue(infobox.Params["lowalch"]))
md.KV("Store price", infobox.Params["store"])
}
md.Newline()
}
// Drop sources
renderDropSources(md, templates)
// Acquisition section from the page
renderSourcesSection(md, page, wikiClient, pageTitle)
fmt.Print(md.String())
return nil
},
}
}
func findItemInfobox(templates []extract.Infobox) *extract.Infobox {
for _, name := range []string{"Infobox Item", "Infobox item", "Infobox Bonuses", "Infobox bonuses"} {
if box := extract.FindTemplate(templates, name); box != nil {
return box
}
}
return nil
}
func renderGEPrice(md *render.Builder, name, pageTitle string) {
priceClient := prices.NewClient(GamePriceBaseURL())
mc := prices.NewMappingCache(priceClient)
if err := mc.Load(); err != nil {
return
}
item := mc.LookupByName(name)
if item == nil {
item = mc.LookupByName(pageTitle)
}
if item == nil {
return
}
latest, err := priceClient.GetLatestForItem(item.ID)
if err != nil {
return
}
md.H2("Grand Exchange")
if latest.High != nil {
md.KV("Buy price", render.FormatGP(*latest.High))
}
if latest.Low != nil {
md.KV("Sell price", render.FormatGP(*latest.Low))
}
if item.Limit != nil {
md.KV("Buy limit", render.FormatNumber(*item.Limit))
}
md.Newline()
}
func renderDropSources(md *render.Builder, templates []extract.Infobox) {
drops := extract.FindAllTemplates(templates, "DropsLine")
if len(drops) == 0 {
drops = extract.FindAllTemplates(templates, "DropLine")
}
if len(drops) == 0 {
return
}
md.H2("Drop Sources")
headers := []string{"Monster", "Quantity", "Rarity"}
var rows [][]string
for _, d := range drops {
monster := firstNonEmpty(d.Params["name"], d.Params["Name"], d.Params["monster"], d.Params["Monster"])
qty := firstNonEmpty(d.Params["quantity"], d.Params["Quantity"])
rarity := firstNonEmpty(d.Params["rarity"], d.Params["Rarity"])
if monster != "" {
rows = append(rows, []string{monster, qty, rarity})
}
}
if len(rows) > 0 {
md.Table(headers, rows)
}
}
func renderSourcesSection(md *render.Builder, page *wiki.ParsedPage, client *wiki.Client, title string) {
for _, s := range page.Sections {
lower := strings.ToLower(s.Line)
if lower == "item sources" || lower == "sources" || lower == "obtaining" || lower == "acquisition" {
idx := 0
fmt.Sscanf(s.Index, "%d", &idx)
if idx > 0 {
sectionPage, err := client.GetPageSection(title, idx)
if err == nil {
plain := extract.ExtractPlainText(sectionPage.Wikitext)
cleaned := strings.TrimSpace(plain)
// Skip if only whitespace/asterisks remain after cleanup
stripped := strings.NewReplacer("*", "", " ", "").Replace(cleaned)
if stripped != "" {
md.H2("Sources")
md.P(cleaned)
}
}
}
return
}
}
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
func formatAlchValue(s string) string {
if s == "" {
return ""
}
return s + " gp"
}
func statName(s string) string {
names := map[string]string{
"astab": "Stab Attack", "aslash": "Slash Attack", "acrush": "Crush Attack",
"amagic": "Magic Attack", "arange": "Ranged Attack",
"dstab": "Stab Defence", "dslash": "Slash Defence", "dcrush": "Crush Defence",
"dmagic": "Magic Defence", "drange": "Ranged Defence",
"str": "Melee Strength", "rstr": "Ranged Strength",
"mdmg": "Magic Damage", "prayer": "Prayer",
}
if n, ok := names[s]; ok {
return n
}
return s
}
func init() {
RegisterCommand(newItemCmd)
}

View File

@@ -0,0 +1,105 @@
package cmd
import (
"fmt"
"strings"
"github.com/runescape-wiki/rsw/internal/extract"
"github.com/runescape-wiki/rsw/internal/render"
"github.com/runescape-wiki/rsw/internal/wiki"
"github.com/spf13/cobra"
)
func newPageCmd() *cobra.Command {
var pageSection string
cmd := &cobra.Command{
Use: "page <title>",
Short: "Fetch and display a wiki page",
Long: `Fetches a wiki page and renders it as markdown. Optionally filter
to a specific section.
Examples:
rsw osrs page "Dragon scimitar"
rsw rs3 page "Mining" --section "Training"`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
title := args[0]
client := wiki.NewClient(GameBaseURL())
page, err := client.GetPage(title)
if err != nil {
return fmt.Errorf("failed to fetch page: %w", err)
}
wikitext := page.Wikitext
if pageSection != "" {
idx := wiki.FindSectionIndex(page.Sections, pageSection)
if idx == -1 {
for _, s := range page.Sections {
if strings.EqualFold(s.Line, pageSection) {
i := 0
fmt.Sscanf(s.Index, "%d", &i)
idx = i
break
}
}
}
if idx == -1 {
return fmt.Errorf("section %q not found. Available sections: %s",
pageSection, listSections(page.Sections))
}
sectionPage, err := client.GetPageSection(title, idx)
if err != nil {
return fmt.Errorf("failed to fetch section: %w", err)
}
wikitext = sectionPage.Wikitext
}
if Raw() {
fmt.Println(wikitext)
return nil
}
md := render.New()
md.H1(page.Title)
if pageSection == "" && len(page.Sections) > 0 {
md.H2("Sections")
for _, s := range page.Sections {
indent := ""
if s.Level == "3" {
indent = " "
} else if s.Level == "4" {
indent = " "
}
md.Line(fmt.Sprintf("%s- %s", indent, s.Line))
}
md.Newline()
md.HR()
}
plain := extract.ExtractPlainText(wikitext)
md.P(plain)
fmt.Print(md.String())
return nil
},
}
cmd.Flags().StringVar(&pageSection, "section", "", "Fetch only the named section")
return cmd
}
func listSections(sections []wiki.Section) string {
names := make([]string, len(sections))
for i, s := range sections {
names[i] = s.Line
}
return strings.Join(names, ", ")
}
func init() {
RegisterCommand(newPageCmd)
}

View File

@@ -0,0 +1,145 @@
package cmd
import (
"fmt"
"time"
"github.com/runescape-wiki/rsw/internal/prices"
"github.com/runescape-wiki/rsw/internal/render"
"github.com/spf13/cobra"
)
func newPriceCmd() *cobra.Command {
return &cobra.Command{
Use: "price <item name>",
Short: "Look up Grand Exchange prices for an item",
Long: `Fetches real-time GE price data for an item: current buy/sell prices,
trade volume, and recent trend.
With --ironman, shows alch values and vendor prices instead of GE data.
Examples:
rsw osrs price "abyssal whip"
rsw rs3 price "blue partyhat"
rsw osrs price "dragon bones" --ironman`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
priceClient := prices.NewClient(GamePriceBaseURL())
mc := prices.NewMappingCache(priceClient)
if err := mc.Load(); err != nil {
return fmt.Errorf("failed to load item mapping: %w", err)
}
item := mc.LookupByName(name)
if item == nil {
matches := mc.SearchByName(name)
if len(matches) == 0 {
return fmt.Errorf("no item found matching %q", name)
}
if len(matches) > 1 {
md := render.New()
md.H2("Multiple items found")
md.P("Did you mean one of these?")
shown := 0
for _, m := range matches {
md.Bullet(m.Name)
shown++
if shown >= 15 {
md.P(fmt.Sprintf("...and %d more", len(matches)-15))
break
}
}
fmt.Print(md.String())
return nil
}
item = matches[0]
}
md := render.New()
md.H1(item.Name)
md.KV("Examine", item.Examine)
md.KV("Members", fmt.Sprintf("%v", item.Members))
md.KV("Store value", render.FormatGP(item.Value))
if item.HighAlch != nil {
md.KV("High Alch", render.FormatGP(*item.HighAlch))
}
if item.LowAlch != nil {
md.KV("Low Alch", render.FormatGP(*item.LowAlch))
}
md.Newline()
if Ironman() {
md.H2("Ironman Info")
md.P("*GE trading unavailable — showing self-sufficient values.*")
md.KV("Store value", render.FormatGP(item.Value))
if item.HighAlch != nil {
md.KV("High Alch value", render.FormatGP(*item.HighAlch))
}
md.P("Use `rsw item` for drop sources and shop locations.")
} else {
latest, err := priceClient.GetLatestForItem(item.ID)
if err != nil {
md.P("*Price data unavailable.*")
} else {
md.H2("Grand Exchange Price")
if latest.High != nil {
md.KV("Instant buy", render.FormatGP(*latest.High))
if latest.HighTime != nil {
md.KV("Last buy", timeAgo(*latest.HighTime))
}
}
if latest.Low != nil {
md.KV("Instant sell", render.FormatGP(*latest.Low))
if latest.LowTime != nil {
md.KV("Last sell", timeAgo(*latest.LowTime))
}
}
if item.Limit != nil {
md.KV("Buy limit", render.FormatNumber(*item.Limit))
}
md.Newline()
}
hourly, err := priceClient.Get1Hour(item.ID)
if err == nil && len(hourly.Data) > 0 {
recent := hourly.Data[len(hourly.Data)-1]
md.H2("Recent Activity (1h)")
if recent.AvgHighPrice != nil {
md.KV("Avg buy price", render.FormatGP(*recent.AvgHighPrice))
}
md.KV("Buy volume", render.FormatNumber(recent.HighVolume))
if recent.AvgLowPrice != nil {
md.KV("Avg sell price", render.FormatGP(*recent.AvgLowPrice))
}
md.KV("Sell volume", render.FormatNumber(recent.LowVolume))
md.Newline()
}
}
fmt.Print(md.String())
return nil
},
}
}
func timeAgo(unixTime int64) string {
t := time.Unix(unixTime, 0)
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%d min ago", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%d hours ago", int(d.Hours()))
default:
return fmt.Sprintf("%d days ago", int(d.Hours()/24))
}
}
func init() {
RegisterCommand(newPriceCmd)
}

View File

@@ -0,0 +1,156 @@
package cmd
import (
"fmt"
"strings"
"github.com/runescape-wiki/rsw/internal/extract"
"github.com/runescape-wiki/rsw/internal/render"
"github.com/runescape-wiki/rsw/internal/wiki"
"github.com/spf13/cobra"
)
func newQuestCmd() *cobra.Command {
return &cobra.Command{
Use: "quest <name>",
Short: "Look up quest requirements, items needed, and rewards",
Long: `Searches for a quest and displays its details: skill requirements,
quest prerequisites, items needed, enemies to defeat, and rewards.
With --ironman, flags items that need self-sufficient acquisition and
notes which combat encounters might be dangerous for HCIM.
Examples:
rsw osrs quest "Monkey Madness I"
rsw rs3 quest "Plague's End" --ironman`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
wikiClient := wiki.NewClient(GameBaseURL())
// Try fetching the page directly first (exact title match)
page, err := wikiClient.GetPage(name)
if err != nil {
// Fall back to search
results, err := wikiClient.Search(name, 5)
if err != nil {
return fmt.Errorf("search failed: %w", err)
}
if len(results) == 0 {
return fmt.Errorf("no wiki page found for quest %q", name)
}
page, err = wikiClient.GetPage(results[0].Title)
if err != nil {
return fmt.Errorf("failed to fetch page: %w", err)
}
}
if Raw() {
fmt.Println(page.Wikitext)
return nil
}
templates := extract.ParseTemplates(page.Wikitext)
md := render.New()
md.H1(page.Title)
// Extract from Infobox Quest
questBox := extract.FindTemplate(templates, "Infobox Quest")
if questBox == nil {
questBox = extract.FindTemplate(templates, "Infobox quest")
}
if questBox != nil {
md.H2("Overview")
md.KV("Members", questBox.Params["members"])
md.KV("Difficulty", questBox.Params["difficulty"])
md.KV("Length", questBox.Params["length"])
md.KV("Series", questBox.Params["series"])
md.KV("Age", questBox.Params["age"])
md.KV("Release", questBox.Params["release"])
md.Newline()
}
// Extract from Quest details template (OSRS stores requirements here)
details := extract.FindTemplate(templates, "Quest details")
if details == nil {
details = extract.FindTemplate(templates, "Quest Details")
}
if details != nil {
renderQuestTemplateField(md, details, "difficulty", "Difficulty")
renderQuestTemplateField(md, details, "length", "Length")
renderQuestTemplateField(md, details, "requirements", "Requirements")
renderQuestTemplateField(md, details, "items", "Items Required")
renderQuestTemplateField(md, details, "recommended", "Recommended")
renderQuestTemplateField(md, details, "kills", "Enemies to Defeat")
}
// Also try section-based extraction as fallback
renderQuestSection(md, page, wikiClient, page.Title, "Requirements",
[]string{"requirements", "skill requirements"})
renderQuestSection(md, page, wikiClient, page.Title, "Items Required",
[]string{"items required", "items needed", "required items"})
renderQuestSection(md, page, wikiClient, page.Title, "Enemies to Defeat",
[]string{"enemies to defeat", "enemies"})
renderQuestSection(md, page, wikiClient, page.Title, "Rewards",
[]string{"rewards"})
if Ironman() {
md.HR()
md.H2("Ironman Notes")
md.P("*Consider the following for self-sufficient play:*")
md.Bullet("All required items must be obtained without the GE")
md.Bullet("Check drop sources and shop availability for each required item")
md.Bullet("Boss/enemy encounters may be dangerous for HCIM — review combat levels and mechanics")
md.Newline()
}
fmt.Print(md.String())
return nil
},
}
}
func renderQuestTemplateField(md *render.Builder, details *extract.Infobox, field, heading string) {
val := details.Params[field]
if val == "" {
return
}
cleaned := extract.CleanWikitext(val)
cleaned = strings.TrimSpace(cleaned)
if cleaned == "" {
return
}
md.H2(heading)
md.P(cleaned)
}
func renderQuestSection(md *render.Builder, page *wiki.ParsedPage, client *wiki.Client,
title string, heading string, sectionNames []string) {
for _, s := range page.Sections {
lower := strings.ToLower(s.Line)
for _, target := range sectionNames {
if lower == target || strings.Contains(lower, target) {
idx := 0
fmt.Sscanf(s.Index, "%d", &idx)
if idx > 0 {
sectionPage, err := client.GetPageSection(title, idx)
if err == nil {
plain := extract.ExtractPlainText(sectionPage.Wikitext)
if strings.TrimSpace(plain) != "" {
md.H2(heading)
md.P(plain)
}
}
}
return
}
}
}
}
func init() {
RegisterCommand(newQuestCmd)
}

View File

@@ -0,0 +1,91 @@
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
var (
ironman bool
raw bool
game string
)
var rootCmd = &cobra.Command{
Use: "rsw <game> <command>",
Short: "RuneScape Wiki CLI — query the RS3 and OSRS wikis from your terminal",
Long: `rsw is a command-line tool for querying the RuneScape Wiki.
It supports both RS3 (runescape.wiki) and Old School RuneScape (oldschool.runescape.wiki).
The first argument must be the game: "osrs" or "rs3".
Examples:
rsw osrs item "dragon scimitar" --ironman
rsw rs3 quest "Plague's End"
rsw osrs skill mining --level 50-70 --ironman
rsw rs3 price "blue partyhat"`,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
if game != "osrs" && game != "rs3" {
return fmt.Errorf("game must be 'osrs' or 'rs3', got %q", game)
}
return nil
},
}
func Execute() error {
wireCommands()
return rootCmd.Execute()
}
func init() {
// Game is set by the wrapper commands, not directly by the user.
// See game.go for how osrs/rs3 subcommands inject this.
rootCmd.PersistentFlags().BoolVarP(&ironman, "ironman", "i", false, "Ironman mode: emphasize self-sufficient acquisition, hide GE prices")
rootCmd.PersistentFlags().BoolVar(&raw, "raw", false, "Output raw wikitext instead of rendered markdown")
// Suppress the default completion command
rootCmd.CompletionOptions.DisableDefaultCmd = true
}
// GameBaseURL returns the MediaWiki API base URL for the selected game.
func GameBaseURL() string {
switch game {
case "osrs":
return "https://oldschool.runescape.wiki/api.php"
case "rs3":
return "https://runescape.wiki/api.php"
default:
fmt.Fprintf(os.Stderr, "invalid game %q\n", game)
os.Exit(1)
return ""
}
}
// GamePriceBaseURL returns the real-time price API base URL.
func GamePriceBaseURL() string {
switch game {
case "osrs":
return "https://prices.runescape.wiki/api/v1/osrs"
case "rs3":
return "https://prices.runescape.wiki/api/v1/rs"
default:
return ""
}
}
// Game returns the current game selection.
func Game() string {
return game
}
// Ironman returns whether ironman mode is active.
func Ironman() bool {
return ironman
}
// Raw returns whether raw output mode is active.
func Raw() bool {
return raw
}

View File

@@ -0,0 +1,60 @@
package cmd
import (
"fmt"
"github.com/runescape-wiki/rsw/internal/render"
"github.com/runescape-wiki/rsw/internal/wiki"
"github.com/spf13/cobra"
)
func newSearchCmd() *cobra.Command {
var searchLimit int
cmd := &cobra.Command{
Use: "search <query>",
Short: "Search the wiki for pages matching a query",
Long: `Search performs a full-text search across the wiki and returns
matching page titles with short snippets.
Examples:
rsw osrs search "dragon scimitar"
rsw rs3 search "mining training"`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
query := args[0]
client := wiki.NewClient(GameBaseURL())
results, err := client.Search(query, searchLimit)
if err != nil {
return fmt.Errorf("search failed: %w", err)
}
if len(results) == 0 {
fmt.Println("No results found.")
return nil
}
md := render.New()
md.H2(fmt.Sprintf("Search results for \"%s\"", query))
for i, r := range results {
md.NumberedItem(i+1, fmt.Sprintf("**%s**", r.Title))
if r.Snippet != "" {
md.Line(fmt.Sprintf(" %s", r.Snippet))
}
}
md.Newline()
fmt.Print(md.String())
return nil
},
}
cmd.Flags().IntVar(&searchLimit, "limit", 10, "Maximum number of results")
return cmd
}
func init() {
RegisterCommand(newSearchCmd)
}

View File

@@ -0,0 +1,144 @@
package cmd
import (
"fmt"
"strings"
"github.com/runescape-wiki/rsw/internal/extract"
"github.com/runescape-wiki/rsw/internal/render"
"github.com/runescape-wiki/rsw/internal/wiki"
"github.com/spf13/cobra"
)
func newSkillCmd() *cobra.Command {
var levelRange string
cmd := &cobra.Command{
Use: "skill <name>",
Short: "Look up skill training methods",
Long: `Fetches training guide information for a skill. Optionally filter
to a specific level range.
With --ironman, emphasizes training methods viable without GE access.
Examples:
rsw osrs skill mining
rsw rs3 skill "prayer" --level 50-70
rsw osrs skill slayer --ironman`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
skillName := args[0]
wikiClient := wiki.NewClient(GameBaseURL())
trainingTitle := capitalizeFirst(strings.ToLower(skillName)) + " training"
page, err := wikiClient.GetPage(trainingTitle)
if err != nil {
page, err = wikiClient.GetPage(capitalizeFirst(strings.ToLower(skillName)))
if err != nil {
return fmt.Errorf("failed to fetch skill page: %w", err)
}
}
if Raw() {
fmt.Println(page.Wikitext)
return nil
}
md := render.New()
md.H1(fmt.Sprintf("%s Training Guide", page.Title))
if Ironman() {
md.P("*Showing methods suitable for ironman accounts (no GE access).*")
}
if len(page.Sections) > 0 {
md.H2("Contents")
for _, s := range page.Sections {
if s.Level == "2" {
md.Bullet(s.Line)
}
}
md.Newline()
}
if levelRange != "" {
found := false
for _, s := range page.Sections {
if strings.Contains(strings.ToLower(s.Line), strings.ToLower(levelRange)) ||
sectionMatchesLevelRange(s.Line, levelRange) {
idx := 0
fmt.Sscanf(s.Index, "%d", &idx)
if idx > 0 {
sectionPage, err := wikiClient.GetPageSection(page.Title, idx)
if err == nil {
plain := extract.ExtractPlainText(sectionPage.Wikitext)
if strings.TrimSpace(plain) != "" {
md.H2(s.Line)
md.P(plain)
found = true
}
}
}
}
}
if !found {
md.P(fmt.Sprintf("*No section found matching level range %q. Showing full guide.*", levelRange))
renderFullGuide(md, page, wikiClient)
}
} else {
renderFullGuide(md, page, wikiClient)
}
fmt.Print(md.String())
return nil
},
}
cmd.Flags().StringVar(&levelRange, "level", "", "Filter to a level range (e.g., '50-70')")
return cmd
}
func renderFullGuide(md *render.Builder, page *wiki.ParsedPage, client *wiki.Client) {
for _, s := range page.Sections {
if s.Level != "2" {
continue
}
idx := 0
fmt.Sscanf(s.Index, "%d", &idx)
if idx <= 0 {
continue
}
sectionPage, err := client.GetPageSection(page.Title, idx)
if err != nil {
continue
}
plain := extract.ExtractPlainText(sectionPage.Wikitext)
if strings.TrimSpace(plain) != "" {
md.H2(s.Line)
md.P(plain)
}
}
}
func sectionMatchesLevelRange(heading, levelRange string) bool {
h := strings.ToLower(heading)
h = strings.ReplaceAll(h, "", "-")
h = strings.ReplaceAll(h, "—", "-")
h = strings.ReplaceAll(h, " ", "")
lr := strings.ToLower(levelRange)
lr = strings.ReplaceAll(lr, " ", "")
return strings.Contains(h, lr)
}
func capitalizeFirst(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
func init() {
RegisterCommand(newSkillCmd)
}