Skip to content

Commit 7d97c4f

Browse files
gwplclaude
andcommitted
feat(plugin): integrate plugins into help output
Display discovered plugins in a "Plugins:" section after "Commands:". Features: * Lazy discovery: only scans PATH when help is requested * Aligned output: plugin descriptions line up for readability * Colorized: uses same styling as built-in commands * Root help only: plugins not shown in subcommand help Example output: Plugins: docs headings List document headings with hierarchy and URLs hello Say hello (example plugin) Part of #188 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 27bfeb7 commit 7d97c4f

1 file changed

Lines changed: 94 additions & 0 deletions

File tree

internal/cmd/help_printer.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ func helpPrinter(options kong.HelpOptions, ctx *kong.Context) error {
4747

4848
out := rewriteCommandSummaries(buf.String(), ctx.Selected())
4949
out = injectBuildLine(out)
50+
out = injectExternalPlugins(out, ctx.Selected())
5051
out = colorizeHelp(out, helpProfile(origStdout, helpColorMode(ctx.Args)))
5152
_, err := io.WriteString(origStdout, out)
5253
return err
@@ -138,10 +139,16 @@ func colorizeHelp(out string, profile termenv.Profile) string {
138139
}
139140

140141
inCommands := false
142+
inPlugins := false
141143
lines := strings.Split(out, "\n")
142144
for i, line := range lines {
143145
if line == "Commands:" {
144146
inCommands = true
147+
inPlugins = false
148+
}
149+
if line == "Plugins:" {
150+
inPlugins = true
151+
inCommands = false
145152
}
146153
switch {
147154
case strings.HasPrefix(line, "Usage:"):
@@ -150,6 +157,8 @@ func colorizeHelp(out string, profile termenv.Profile) string {
150157
lines[i] = section(line)
151158
case line == "Commands:":
152159
lines[i] = section(line)
160+
case line == "Plugins:":
161+
lines[i] = section(line)
153162
case line == "Arguments:":
154163
lines[i] = section(line)
155164
case strings.HasPrefix(line, "Build:") || line == "Config:":
@@ -160,6 +169,8 @@ func colorizeHelp(out string, profile termenv.Profile) string {
160169
lines[i] = colorizeCommandSummaryLine(line, cmdName, dim)
161170
case inCommands && strings.HasPrefix(line, " ") && strings.TrimSpace(line) != "":
162171
lines[i] = " " + dim(strings.TrimPrefix(line, " "))
172+
case inPlugins && strings.HasPrefix(line, " ") && strings.TrimSpace(line) != "":
173+
lines[i] = colorizePluginLine(line, cmdName, dim)
163174
}
164175
}
165176
return strings.Join(lines, "\n")
@@ -190,6 +201,33 @@ func colorizeCommandSummaryLine(line string, cmdName func(string) string, dim fu
190201
return " " + styled + " " + tail
191202
}
192203

204+
// colorizePluginLine colorizes a plugin line in the Plugins: section.
205+
// Plugin lines look like: " docs headings List document headings"
206+
func colorizePluginLine(line string, cmdName func(string) string, dim func(string) string) string {
207+
if !strings.HasPrefix(line, " ") {
208+
return line
209+
}
210+
rest := strings.TrimPrefix(line, " ")
211+
if rest == "" {
212+
return line
213+
}
214+
215+
// Find where the command name ends (two or more spaces indicate description start)
216+
parts := strings.SplitN(rest, " ", 2)
217+
name := parts[0]
218+
if name == "" {
219+
return line
220+
}
221+
222+
styled := cmdName(name)
223+
if len(parts) == 1 {
224+
return " " + styled
225+
}
226+
227+
// Description is dimmed
228+
return " " + styled + " " + dim(parts[1])
229+
}
230+
193231
func rewriteCommandSummaries(out string, selected *kong.Node) string {
194232
if selected == nil {
195233
return out
@@ -223,3 +261,59 @@ func guessColumns(w io.Writer) int {
223261
}
224262
return 80
225263
}
264+
265+
// injectExternalPlugins adds discovered external plugins to help output.
266+
// Plugins are displayed in a separate "Plugins:" section after "Commands:".
267+
//
268+
// Discovery is lazy: only scans PATH when help is requested.
269+
// The --help-oneliner protocol is used to get plugin descriptions.
270+
// Plugins that don't respond within 100ms get no description.
271+
func injectExternalPlugins(out string, selected *kong.Node) string {
272+
// Only show plugins in root help (not subcommand help)
273+
if selected != nil && selected.Parent != nil {
274+
return out
275+
}
276+
277+
plugins := DiscoverExternalPlugins()
278+
if len(plugins) == 0 {
279+
return out
280+
}
281+
282+
// Fetch oneliners (with timeout)
283+
plugins = FetchOneliners(plugins)
284+
285+
// Build plugins section
286+
var sb strings.Builder
287+
sb.WriteString("\nPlugins:\n")
288+
289+
// Find max command name length for alignment
290+
maxLen := 0
291+
for _, p := range plugins {
292+
if len(p.CommandName()) > maxLen {
293+
maxLen = len(p.CommandName())
294+
}
295+
}
296+
297+
for _, p := range plugins {
298+
cmdName := p.CommandName()
299+
padding := strings.Repeat(" ", maxLen-len(cmdName)+2)
300+
if p.Oneliner != "" {
301+
sb.WriteString(fmt.Sprintf(" %s%s%s\n", cmdName, padding, p.Oneliner))
302+
} else {
303+
sb.WriteString(fmt.Sprintf(" %s\n", cmdName))
304+
}
305+
}
306+
307+
// Insert before "Run ... --help" line or at end
308+
lines := strings.Split(out, "\n")
309+
for i, line := range lines {
310+
if strings.HasPrefix(line, "Run \"") && strings.HasSuffix(line, " --help\" for more information on a command.") {
311+
before := strings.Join(lines[:i], "\n")
312+
after := strings.Join(lines[i:], "\n")
313+
return before + sb.String() + after
314+
}
315+
}
316+
317+
// Append at end if no "Run ... --help" line found
318+
return out + sb.String()
319+
}

0 commit comments

Comments
 (0)