From b5c74275200b912b527ba2386cc51f9c8fe652b7 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 21 Jul 2026 13:21:01 +0700 Subject: [PATCH] fix(commands): render examples conditionally with inline help code --- AGENTS.md | 4 ++++ README.md | 15 ++++++------ cmd/server/command_menu_test.go | 15 ++++++++---- docs/deploy-coolify-selfhosted.md | 6 +++-- .../260721-1011-telegram-command-discovery.md | 22 ++++++++++------- internal/modules/coin/handlers.go | 8 +++---- internal/modules/command_presentation.go | 14 +++++------ internal/modules/command_presentation_test.go | 10 ++++---- internal/modules/gold/handlers.go | 6 ++--- internal/modules/module.go | 2 +- internal/modules/stock/handlers.go | 14 +++++------ internal/modules/util/handlers_test.go | 4 ++-- internal/modules/util/help.go | 8 ++++--- internal/modules/util/help_test.go | 14 ++++++++--- internal/modules/validate.go | 12 +++++++++- internal/modules/validate_test.go | 24 ++++++++++++++++++- 16 files changed, 118 insertions(+), 60 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3b6233a..06881d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,10 @@ Include units or currencies when meaningful (for example, ``), use `[...]` for optional input, append `...` for remaining free text, and use parentheses to document structured input (for example, ``). Keep metadata, usage errors, examples, and tests exact. +Public commands with parameters must provide an explicit copyable example; +commands without parameters must omit it. In both Telegram's native menu and +`/help`, append `Eg: ` inline; `/help` must wrap only the invocation +in Telegram HTML `` formatting. ## Stats Compatibility diff --git a/README.md b/README.md index 7fb7163..a7f69f7 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,13 @@ field supports only single-line plain text—no copyable code block. For `/stock_buy`, the bot therefore sends this description: ```text - . Buy VN stock at market price. Example: /stock_buy 100 TCB + . Buy VN stock at market price. Eg: /stock_buy 100 TCB ``` -`/help` combines the command and parameter syntax, adds the same summary, then -puts the example on the next line in a copyable code block. When `Example` is -omitted, both surfaces use the bare command (for example, `/ping`). +`/help` combines the command syntax and summary, then appends `Eg:` and the +example on the same line. It wraps only the invocation in Telegram HTML +`` formatting so it is easy to copy. Public commands with parameters must +register an explicit example; commands without parameters omit it entirely. For future commands, use lowercase descriptive parameter names. Include units or currencies when they affect meaning (``, ``), use @@ -46,9 +47,9 @@ metadata, handler usage text, examples, tests, and this documentation aligned. Stock dividends are manual portfolio adjustments: -- `/stock_cash_dividend ` credits a positive whole-VND amount for each pre-event share held. Example: `/stock_cash_dividend 1500 TCB`. -- `/stock_share_dividend ` adds `floor(pre_event_shares × new / owned)` whole shares. Example: `/stock_share_dividend 100:10 TCB`. -- `/stock_dividend ` applies both parts from the same pre-event holding and saves them together. Example: `/stock_dividend 1500 100:10 TCB`. +- `/stock_cash_dividend ` credits a positive whole-VND amount for each pre-event share held. Eg: `/stock_cash_dividend 1500 TCB`. +- `/stock_share_dividend ` adds `floor(pre_event_shares × new / owned)` whole shares. Eg: `/stock_share_dividend 100:10 TCB`. +- `/stock_dividend ` applies both parts from the same pre-event holding and saves them together. Eg: `/stock_dividend 1500 100:10 TCB`. Ratios use `owned:new` exactly as written in the issuer notice. Equivalent unreduced ratios are accepted and the entered ratio is preserved in the reply. diff --git a/cmd/server/command_menu_test.go b/cmd/server/command_menu_test.go index 11e6e3f..107570b 100644 --- a/cmd/server/command_menu_test.go +++ b/cmd/server/command_menu_test.go @@ -38,8 +38,8 @@ func TestBotCommandMenu_UsesLoadedPublicCommandsInModuleOrder(t *testing.T) { got := botCommandMenu(reg) want := []models.BotCommand{ - {Command: "beta_public", Description: ". Beta public. Example: /beta_public demo"}, - {Command: "alpha_public", Description: "Alpha public. Example: /alpha_public"}, + {Command: "beta_public", Description: ". Beta public. Eg: /beta_public demo"}, + {Command: "alpha_public", Description: "Alpha public."}, } if len(got) != len(want) { t.Fatalf("commands = %v, want %v", got, want) @@ -90,8 +90,13 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) { if got := command.Parameters; got != expectedParameters[command.Name] { t.Errorf("/%s parameters = %q, want %q", command.Name, got, expectedParameters[command.Name]) } - if !strings.HasPrefix(command.ExampleInvocation(), "/"+command.Name) { - t.Errorf("/%s example = %q", command.Name, command.ExampleInvocation()) + example := command.ExampleInvocation() + if command.Parameters == "" { + if example != "" || strings.Contains(command.TelegramMenuDescription(), "Eg:") { + t.Errorf("/%s without parameters has example %q", command.Name, example) + } + } else if !strings.HasPrefix(example, "/"+command.Name) { + t.Errorf("/%s example = %q", command.Name, example) } description := command.TelegramMenuDescription() if strings.ContainsAny(description, "\r\n") { @@ -164,7 +169,7 @@ func TestRegisterCommandMenu_CallsTelegramSetMyCommands(t *testing.T) { if err := json.Unmarshal([]byte(call.Form["commands"]), &cmds); err != nil { t.Fatalf("decode commands form field: %v; raw=%q", err, call.Form["commands"]) } - if len(cmds) != 1 || cmds[0].Command != "demo" || cmds[0].Description != "Demo command. Example: /demo" { + if len(cmds) != 1 || cmds[0].Command != "demo" || cmds[0].Description != "Demo command." { t.Fatalf("commands payload = %+v, want demo command", cmds) } } diff --git a/docs/deploy-coolify-selfhosted.md b/docs/deploy-coolify-selfhosted.md index 1cf9029..956a6ac 100644 --- a/docs/deploy-coolify-selfhosted.md +++ b/docs/deploy-coolify-selfhosted.md @@ -139,8 +139,10 @@ every startup. The Go module registry is the single source of truth; no separate command-menu file or manual registration step is required. A command's description plus optional `Parameters` and `Example` metadata feed both surfaces. Telegram renders the command name separately and accepts only a -single-line plain-text description, while `/help` can put the example in a -copyable code block. An omitted example defaults to the bare command. +single-line plain-text description, so parameterized commands append +`Eg: ` inline. `/help` uses the same inline layout and wraps only +the invocation in Telegram HTML `` formatting. Commands without +parameters omit examples. ## Operations diff --git a/docs/journals/260721-1011-telegram-command-discovery.md b/docs/journals/260721-1011-telegram-command-discovery.md index d575011..caaac72 100644 --- a/docs/journals/260721-1011-telegram-command-discovery.md +++ b/docs/journals/260721-1011-telegram-command-discovery.md @@ -22,12 +22,16 @@ source documentation. ` `, and ` ` for cash, share, and combined commands. Examples and parsing remain unchanged. -- Native menu descriptions now combine parameters, summary, and example on one - plain-text line. Registration validation rejects multiline metadata, examples - for another command, and public descriptions over Telegram's 256-character - limit. -- `/help` now renders each invocation and summary together, followed by a - copyable HTML `
` example. Dynamic command fields are HTML-escaped.
+- Native menu descriptions show only the summary for no-parameter commands.
+  Parameterized commands append an explicit example with the short `Eg:` label
+  on the same plain-text line.
+- `/help` renders each invocation and summary together. Parameterized commands
+  append `Eg: invocation` on the same line, with only the copyable
+  invocation inside Telegram's HTML `` formatting. No-parameter commands
+  omit the label and example. Dynamic command fields are HTML-escaped.
+- Registration validation rejects multiline metadata, examples for another
+  command, public commands that provide only one of parameters or example, and
+  public descriptions over Telegram's 256-character limit.
 - Updated user and deployment documentation for the shared registry behavior.
 
 ## Reflection
@@ -42,13 +46,15 @@ supports without sacrificing safe HTML rendering.
 - Existing command names, handlers, parsers, and persisted data remain
   unchanged; normalization is presentation-only.
 - No command was added, renamed, or deleted, so no stats migration is needed.
-- Commands without parameters default their example to the command invocation.
+- Public commands with parameters require an explicit example; commands
+  without parameters omit it.
 - The complete `/help` output remains within Telegram's 4,096-character limit.
 
 ## Verification
 
 - Passed: command presentation, validation, menu, and `/help` tests for all 40
-  public commands.
+  public commands, including inline `` rendering, no-parameter omission,
+  and both parameter/example validation branches.
 - Passed: `go test ./...`, including real MongoDB Testcontainers suites.
 - Passed: `go vet ./...`
 - Passed: `go build ./...`
diff --git a/internal/modules/coin/handlers.go b/internal/modules/coin/handlers.go
index e338585..756aa63 100644
--- a/internal/modules/coin/handlers.go
+++ b/internal/modules/coin/handlers.go
@@ -20,7 +20,7 @@ var (
 func (s *state) handlePrice(ctx context.Context, b *bot.Bot, update *models.Update) error {
 	args := argsAfterCommand(update.Message.Text)
 	if len(args) != 1 {
-		return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_price \nExample: /coin_price BTC")
+		return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_price \nEg: /coin_price BTC")
 	}
 	coin, err := ResolveCoinSymbol(args[0])
 	if err != nil {
@@ -42,7 +42,7 @@ func (s *state) handleTopup(ctx context.Context, b *bot.Bot, update *models.Upda
 	}
 	args := argsAfterCommand(update.Message.Text)
 	if len(args) != 1 {
-		return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_topup \nExample: /coin_topup 1000")
+		return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_topup \nEg: /coin_topup 1000")
 	}
 	amount, ok := parsePositiveFinite(args[0])
 	if !ok || !isSafeUSD(amount) {
@@ -70,7 +70,7 @@ func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update
 	}
 	args := argsAfterCommand(update.Message.Text)
 	if len(args) != 2 {
-		return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_buy  \nSpend USD to buy coin.\nAlternative: /coin_buy  \nExample: /coin_buy BTC 10")
+		return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_buy  \nSpend USD to buy coin.\nAlternative: /coin_buy  \nEg: /coin_buy BTC 10")
 	}
 	parsed, err := parseCoinValueArgs(args, isSafeUSD, errInvalidUSDAmount)
 	if errors.Is(err, errInvalidUSDAmount) {
@@ -124,7 +124,7 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat
 	}
 	args := argsAfterCommand(update.Message.Text)
 	if len(args) != 2 {
-		return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_sell  \nSell enough coin to receive USD.\nAlternative: /coin_sell  \nExample: /coin_sell BTC 10")
+		return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_sell  \nSell enough coin to receive USD.\nAlternative: /coin_sell  \nEg: /coin_sell BTC 10")
 	}
 	parsed, err := parseCoinValueArgs(args, isSafeUSD, errInvalidUSDAmount)
 	if errors.Is(err, errInvalidUSDAmount) {
diff --git a/internal/modules/command_presentation.go b/internal/modules/command_presentation.go
index 083f178..e655ef7 100644
--- a/internal/modules/command_presentation.go
+++ b/internal/modules/command_presentation.go
@@ -17,13 +17,9 @@ func (c Command) InvocationSentence() string {
 	return withTerminalPunctuation(c.Invocation())
 }
 
-// ExampleInvocation returns the copyable example, defaulting to the command
-// itself for commands that take no parameters.
+// ExampleInvocation returns the explicitly registered copyable example.
 func (c Command) ExampleInvocation() string {
-	if example := strings.TrimSpace(c.Example); example != "" {
-		return example
-	}
-	return "/" + c.Name
+	return strings.TrimSpace(c.Example)
 }
 
 // SummarySentence normalizes a command summary to a sentence without
@@ -42,8 +38,10 @@ func (c Command) TelegramMenuDescription() string {
 		sb.WriteByte(' ')
 	}
 	sb.WriteString(c.SummarySentence())
-	sb.WriteString(" Example: ")
-	sb.WriteString(c.ExampleInvocation())
+	if example := c.ExampleInvocation(); example != "" {
+		sb.WriteString(" Eg: ")
+		sb.WriteString(example)
+	}
 	return sb.String()
 }
 
diff --git a/internal/modules/command_presentation_test.go b/internal/modules/command_presentation_test.go
index 75ce883..1b8274d 100644
--- a/internal/modules/command_presentation_test.go
+++ b/internal/modules/command_presentation_test.go
@@ -20,14 +20,14 @@ func TestCommandPresentation(t *testing.T) {
 			},
 			invocation: "/stock_buy  ",
 			example:    "/stock_buy 100 TCB",
-			menu:       " . Buy VN stock at market price. Example: /stock_buy 100 TCB",
+			menu:       " . Buy VN stock at market price. Eg: /stock_buy 100 TCB",
 		},
 		{
-			name:       "no parameters uses command as example",
+			name:       "no parameters omits example",
 			command:    Command{Name: "ping", Description: "Health check!"},
 			invocation: "/ping",
-			example:    "/ping",
-			menu:       "Health check! Example: /ping",
+			example:    "",
+			menu:       "Health check!",
 		},
 		{
 			name: "variadic parameters keep ellipsis",
@@ -39,7 +39,7 @@ func TestCommandPresentation(t *testing.T) {
 			},
 			invocation: "/random ",
 			example:    "/random pizza, sushi",
-			menu:       ". Pick one option. Example: /random pizza, sushi",
+			menu:       ". Pick one option. Eg: /random pizza, sushi",
 		},
 	}
 
diff --git a/internal/modules/gold/handlers.go b/internal/modules/gold/handlers.go
index df44988..3f3de94 100644
--- a/internal/modules/gold/handlers.go
+++ b/internal/modules/gold/handlers.go
@@ -52,7 +52,7 @@ func (s *state) handleTopup(ctx context.Context, b *bot.Bot, update *models.Upda
 	}
 	args := argsAfterCommand(update.Message.Text)
 	if len(args) != 1 {
-		return chathelper.Reply(ctx, b, update.Message, "Usage: /gold_topup \nExample: /gold_topup 5000000")
+		return chathelper.Reply(ctx, b, update.Message, "Usage: /gold_topup \nEg: /gold_topup 5000000")
 	}
 	amount, ok := parsePositiveFinite(args[0])
 	if !ok || !isSafeVND(amount) {
@@ -81,7 +81,7 @@ func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update
 	}
 	args := argsAfterCommand(update.Message.Text)
 	if len(args) != 1 {
-		return chathelper.Reply(ctx, b, update.Message, "Usage: /gold_buy \nExample: /gold_buy 1")
+		return chathelper.Reply(ctx, b, update.Message, "Usage: /gold_buy \nEg: /gold_buy 1")
 	}
 	qty, ok := parsePositiveFinite(args[0])
 	if !ok {
@@ -128,7 +128,7 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat
 	}
 	args := argsAfterCommand(update.Message.Text)
 	if len(args) != 1 {
-		return chathelper.Reply(ctx, b, update.Message, "Usage: /gold_sell \nExample: /gold_sell 0.5")
+		return chathelper.Reply(ctx, b, update.Message, "Usage: /gold_sell \nEg: /gold_sell 0.5")
 	}
 	qty, ok := parsePositiveFinite(args[0])
 	if !ok {
diff --git a/internal/modules/module.go b/internal/modules/module.go
index f02f352..58692cf 100644
--- a/internal/modules/module.go
+++ b/internal/modules/module.go
@@ -40,7 +40,7 @@ type Command struct {
 	Visibility  Visibility     // public/protected/private
 	Description string         // concise summary shown in command discovery (required, non-empty)
 	Parameters  string         // optional syntax after the command, e.g. " "
-	Example     string         // optional full invocation; defaults to "/" + Name
+	Example     string         // full invocation required with public Parameters; omitted without them
 	Handler     CommandHandler // required
 }
 
diff --git a/internal/modules/stock/handlers.go b/internal/modules/stock/handlers.go
index d06056b..a0299cd 100644
--- a/internal/modules/stock/handlers.go
+++ b/internal/modules/stock/handlers.go
@@ -79,7 +79,7 @@ func parsePositiveFinite(raw string) (float64, bool) {
 func (s *state) handlePrice(ctx context.Context, b *bot.Bot, update *models.Update) error {
 	args := argsAfterCommand(update.Message.Text)
 	if len(args) != 1 {
-		return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_price \nExample: /stock_price TCB")
+		return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_price \nEg: /stock_price TCB")
 	}
 	symbol, err := normalizeStockSymbol(args[0])
 	if err != nil {
@@ -109,7 +109,7 @@ func (s *state) handleTopup(ctx context.Context, b *bot.Bot, update *models.Upda
 	}
 	args := argsAfterCommand(update.Message.Text)
 	if len(args) != 1 {
-		return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_topup \nExample: /stock_topup 5000000")
+		return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_topup \nEg: /stock_topup 5000000")
 	}
 	amount, ok := parsePositiveFinite(args[0])
 	if !ok {
@@ -141,7 +141,7 @@ func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update
 	}
 	args := argsAfterCommand(update.Message.Text)
 	if len(args) != 2 {
-		return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_buy  \nExample: /stock_buy 100 TCB")
+		return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_buy  \nEg: /stock_buy 100 TCB")
 	}
 	qty, err := strconv.ParseInt(args[0], 10, 64)
 	if err != nil || qty <= 0 {
@@ -198,7 +198,7 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat
 	}
 	args := argsAfterCommand(update.Message.Text)
 	if len(args) != 2 {
-		return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_sell  \nExample: /stock_sell 100 TCB")
+		return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_sell  \nEg: /stock_sell 100 TCB")
 	}
 	qty, err := strconv.ParseInt(args[0], 10, 64)
 	if err != nil || qty <= 0 {
@@ -258,7 +258,7 @@ func (s *state) handleCashDividend(ctx context.Context, b *bot.Bot, update *mode
 	args := argsAfterCommand(update.Message.Text)
 	if len(args) != 2 {
 		return chathelper.Reply(ctx, b, update.Message,
-			"Usage: /stock_cash_dividend  \nExample: /stock_cash_dividend 1500 TCB")
+			"Usage: /stock_cash_dividend  \nEg: /stock_cash_dividend 1500 TCB")
 	}
 	vndPerShare, ok := parsePositiveWhole(args[0])
 	if !ok {
@@ -314,7 +314,7 @@ func (s *state) handleShareDividend(ctx context.Context, b *bot.Bot, update *mod
 	args := argsAfterCommand(update.Message.Text)
 	if len(args) != 2 {
 		return chathelper.Reply(ctx, b, update.Message,
-			"Usage: /stock_share_dividend  \nExample: /stock_share_dividend 100:10 TCB")
+			"Usage: /stock_share_dividend  \nEg: /stock_share_dividend 100:10 TCB")
 	}
 	ratio, ok := parseShareRatio(args[0])
 	if !ok {
@@ -374,7 +374,7 @@ func (s *state) handleDividend(ctx context.Context, b *bot.Bot, update *models.U
 	args := argsAfterCommand(update.Message.Text)
 	if len(args) != 3 {
 		return chathelper.Reply(ctx, b, update.Message,
-			"Usage: /stock_dividend   \nExample: /stock_dividend 1500 100:10 TCB")
+			"Usage: /stock_dividend   \nEg: /stock_dividend 1500 100:10 TCB")
 	}
 	vndPerShare, ok := parsePositiveWhole(args[0])
 	if !ok {
diff --git a/internal/modules/util/handlers_test.go b/internal/modules/util/handlers_test.go
index 6ede62a..5916a0f 100644
--- a/internal/modules/util/handlers_test.go
+++ b/internal/modules/util/handlers_test.go
@@ -90,8 +90,8 @@ func TestHelp_RendersHTML(t *testing.T) {
 	if !strings.Contains(got.Text(), "/help. Show all available commands.") {
 		t.Errorf("/help body missing formatted command summary; got %q", got.Text())
 	}
-	if !strings.Contains(got.Text(), "
/help
") { - t.Errorf("/help body missing copyable example; got %q", got.Text()) + if strings.Contains(got.Text(), "/help") || strings.Contains(got.Text(), "Eg:") { + t.Errorf("/help command has no parameters and should not show an example; got %q", got.Text()) } } diff --git a/internal/modules/util/help.go b/internal/modules/util/help.go index a608073..d30e9ed 100644 --- a/internal/modules/util/help.go +++ b/internal/modules/util/help.go @@ -47,10 +47,12 @@ func RenderHelp(reg *modules.Registry) string { var sb strings.Builder fmt.Fprintf(&sb, "%s", html.EscapeString(mod.Name)) for _, command := range es { - fmt.Fprintf(&sb, "\n%s %s\n
%s
", + fmt.Fprintf(&sb, "\n%s %s", html.EscapeString(command.InvocationSentence()), - html.EscapeString(command.SummarySentence()), - html.EscapeString(command.ExampleInvocation())) + html.EscapeString(command.SummarySentence())) + if example := command.ExampleInvocation(); example != "" { + fmt.Fprintf(&sb, " Eg: %s", html.EscapeString(example)) + } } sections = append(sections, sb.String()) } diff --git a/internal/modules/util/help_test.go b/internal/modules/util/help_test.go index b61f828..04666ef 100644 --- a/internal/modules/util/help_test.go +++ b/internal/modules/util/help_test.go @@ -52,7 +52,6 @@ func TestRenderHelp_GroupsByModuleAndSkipsNonPublic(t *testing.T) { "alpha", "beta", "/a_pub. alpha public.", - "
/a_pub
", // HTML in user descriptions must be escaped. "beta <i>desc</i>", // Locks html.EscapeString contract: & → &, " → ". @@ -70,6 +69,9 @@ func TestRenderHelp_GroupsByModuleAndSkipsNonPublic(t *testing.T) { if strings.Contains(out, "a_prot") { t.Errorf("output leaked protected command\n---output---\n%s", out) } + if strings.Contains(out, "
/a_pub
") || strings.Contains(out, "Eg:") { + t.Errorf("commands without parameters should not render examples\n---output---\n%s", out) + } } func TestRenderHelp_ShowsParametersAndCopyableExample(t *testing.T) { @@ -78,7 +80,7 @@ func TestRenderHelp_ShowsParametersAndCopyableExample(t *testing.T) { Visibility: modules.VisibilityPublic, Description: "Buy & hold", Parameters: " ", - Example: "/buy 100 TCB", + Example: "/buy 100 TCB & hold", Handler: helpTestNoop, } reg, err := modules.Build( @@ -95,9 +97,15 @@ func TestRenderHelp_ShowsParametersAndCopyableExample(t *testing.T) { if !strings.Contains(out, "/buy <quantity> <ticker>. Buy & hold.") { t.Fatalf("help missing formatted invocation and summary:\n%s", out) } - if !strings.Contains(out, "
/buy 100 TCB
") { + if !strings.Contains(out, "/buy 100 TCB & hold") { t.Fatalf("help missing copyable example:\n%s", out) } + if !strings.Contains(out, "Buy & hold. Eg: /buy 100 TCB & hold") { + t.Fatalf("help missing short example label:\n%s", out) + } + if strings.Contains(out, "
") {
+		t.Fatalf("help example should use inline code, not a pre block:\n%s", out)
+	}
 }
 
 func TestRenderHelp_ModuleOrderMatchesEnvOrder(t *testing.T) {
diff --git a/internal/modules/validate.go b/internal/modules/validate.go
index 0b0881f..413d12f 100644
--- a/internal/modules/validate.go
+++ b/internal/modules/validate.go
@@ -32,7 +32,17 @@ func validateCommand(c Command) error {
 	if strings.ContainsAny(c.Example, "\r\n") {
 		return fmt.Errorf("command %q: example must be single-line", c.Name)
 	}
-	if example := strings.TrimSpace(c.Example); example != "" {
+	parameters := strings.TrimSpace(c.Parameters)
+	example := strings.TrimSpace(c.Example)
+	if c.Visibility == VisibilityPublic {
+		if parameters == "" && example != "" {
+			return fmt.Errorf("command %q: example requires parameters", c.Name)
+		}
+		if parameters != "" && example == "" {
+			return fmt.Errorf("command %q: example is required when parameters are present", c.Name)
+		}
+	}
+	if example != "" {
 		prefix := "/" + c.Name
 		if example != prefix && !strings.HasPrefix(example, prefix+" ") {
 			return fmt.Errorf("command %q: example must invoke %s", c.Name, prefix)
diff --git a/internal/modules/validate_test.go b/internal/modules/validate_test.go
index e89fef4..ba5d525 100644
--- a/internal/modules/validate_test.go
+++ b/internal/modules/validate_test.go
@@ -55,9 +55,17 @@ func TestValidateCommand_RejectsInvalidPresentationMetadata(t *testing.T) {
 			name:    "example invokes another command",
 			command: Command{Name: "ok", Visibility: VisibilityPublic, Description: "d", Example: "/other", Handler: okHandler},
 		},
+		{
+			name:    "example without parameters",
+			command: Command{Name: "ok", Visibility: VisibilityPublic, Description: "d", Example: "/ok", Handler: okHandler},
+		},
+		{
+			name:    "parameters without example",
+			command: Command{Name: "ok", Visibility: VisibilityPublic, Description: "d", Parameters: "", Handler: okHandler},
+		},
 		{
 			name:    "native description too long",
-			command: Command{Name: "ok", Visibility: VisibilityPublic, Description: strings.Repeat("x", 250), Handler: okHandler},
+			command: Command{Name: "ok", Visibility: VisibilityPublic, Description: strings.Repeat("x", 257), Handler: okHandler},
 		},
 	}
 
@@ -78,6 +86,20 @@ func TestValidateCommand_AcceptsLegalNames(t *testing.T) {
 	}
 }
 
+func TestValidateCommand_AcceptsMatchedParametersAndExample(t *testing.T) {
+	command := Command{
+		Name:        "ok",
+		Visibility:  VisibilityPublic,
+		Description: "Do it",
+		Parameters:  "",
+		Example:     "/ok demo",
+		Handler:     okHandler,
+	}
+	if err := validateCommand(command); err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+}
+
 func TestValidateCommand_RequiresDescriptionAndHandler(t *testing.T) {
 	if err := validateCommand(Command{Name: "ok", Visibility: VisibilityPublic, Description: "", Handler: okHandler}); err == nil {
 		t.Error("expected error for empty description")