Skip to content

Commit 21e36c0

Browse files
authored
Integrate extensions into help (#894)
<!--- Note to EXTERNAL Contributors --> <!-- Thanks for opening a PR! If it is a significant code change, please **make sure there is an open issue** for this. We work best with you when we have accepted the idea first before you code. --> <!--- For ALL Contributors 👇 --> ## What was changed <!-- Describe what has changed in this PR --> Implemented ["Discovery and Help Text" from the CLI extension proposal](https://github.com/temporalio/proposals/blob/master/cli/cli-extensions.md#discovery-and-help-text). ## Checklist <!--- add/delete as needed ---> 1. Closes <!-- add issue number here --> 2. How was this tested: <!--- Please describe how you tested your changes/how we can test them --> 3. Any docs updates needed? <!--- update README if applicable or point out where to update docs.temporal.io -->
1 parent 168728b commit 21e36c0

File tree

4 files changed

+297
-8
lines changed

4 files changed

+297
-8
lines changed

internal/temporalcli/commands.extension.go

Lines changed: 100 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ package temporalcli
33
import (
44
"context"
55
"fmt"
6+
"os"
67
"os/exec"
8+
"path/filepath"
9+
"runtime"
710
"slices"
811
"strings"
912

@@ -42,7 +45,7 @@ func tryExecuteExtension(cctx *CommandContext, tcmd *TemporalCommand) (error, bo
4245
cliParseArgs, cliPassArgs, extArgs := groupArgs(foundCmd, remainingArgs)
4346

4447
// Search for an extension executable.
45-
cmdPrefix := strings.Split(foundCmd.CommandPath(), " ")[1:]
48+
cmdPrefix := strings.Fields(foundCmd.CommandPath())
4649
extPath, extArgs := lookupExtension(cmdPrefix, extArgs)
4750

4851
// Parse CLI args that need validation.
@@ -159,21 +162,110 @@ func lookupExtension(cmdPrefix, extArgs []string) (string, []string) {
159162
if !isPosArg(arg) {
160163
break
161164
}
162-
// Dashes are converted to underscores so "foo bar-baz" finds "temporal-foo-bar_baz".
163-
posArgs = append(posArgs, strings.ReplaceAll(arg, extensionSeparator, argDashReplacement))
165+
posArgs = append(posArgs, arg)
164166
}
165167

166168
// Try most-specific to least-specific.
167169
parts := append(cmdPrefix, posArgs...)
168170
for n := len(parts); n > len(cmdPrefix); n-- {
169-
path, err := exec.LookPath(extensionPrefix + strings.Join(parts[:n], extensionSeparator))
171+
binName := extensionCommandToBinary(parts[:n])
172+
if fullPath, _ := isExecutable(binName); fullPath != "" {
173+
// Remove matched positionals from extArgs (they come first).
174+
matched := n - len(cmdPrefix)
175+
return fullPath, extArgs[matched:]
176+
}
177+
}
178+
179+
return "", extArgs
180+
}
181+
182+
// discoverExtensions scans the PATH for executables with the "temporal-" prefix
183+
// and returns their command parts (without the prefix).
184+
func discoverExtensions() [][]string {
185+
var extensions [][]string
186+
seen := make(map[string]bool)
187+
188+
for _, dir := range filepath.SplitList(os.Getenv("PATH")) {
189+
if dir == "" {
190+
continue
191+
}
192+
193+
entries, err := os.ReadDir(dir)
170194
if err != nil {
171195
continue
172196
}
173-
// Remove matched positionals from extArgs (they come first).
174-
matched := n - len(cmdPrefix)
175-
return path, extArgs[matched:]
197+
198+
for _, entry := range entries {
199+
name := entry.Name()
200+
201+
// Look for extensions.
202+
if !strings.HasPrefix(name, extensionPrefix) {
203+
continue
204+
}
205+
206+
// Check if the file is executable.
207+
fullPath, baseName := isExecutable(filepath.Join(dir, name))
208+
if fullPath == "" {
209+
continue
210+
}
211+
212+
path := extensionBinaryToCommandPath(baseName)
213+
key := strings.Join(path, "/")
214+
if seen[key] {
215+
continue
216+
}
217+
218+
seen[key] = true
219+
extensions = append(extensions, path)
220+
}
176221
}
222+
return extensions
223+
}
177224

178-
return "", extArgs
225+
// isExecutable checks if a file or command is executable.
226+
// On Windows, it validates PATHEXT suffix and strips it from the base name.
227+
// Returns the full path and base name (without Windows extension suffix).
228+
func isExecutable(name string) (fullPath, baseName string) {
229+
path, err := exec.LookPath(name)
230+
if err != nil {
231+
return "", ""
232+
}
233+
234+
base := filepath.Base(path)
235+
if runtime.GOOS == "windows" {
236+
pathext := os.Getenv("PATHEXT")
237+
if pathext == "" {
238+
pathext = ".exe;.bat"
239+
}
240+
lower := strings.ToLower(base)
241+
for ext := range strings.SplitSeq(strings.ToLower(pathext), ";") {
242+
if ext != "" && strings.HasSuffix(lower, ext) {
243+
return path, base[:len(base)-len(ext)]
244+
}
245+
}
246+
return "", ""
247+
}
248+
return path, base
249+
}
250+
251+
// extensionBinaryToCommandPath converts a binary name to command path.
252+
// Underscores are converted to dashes.
253+
// For example: "temporal-foo-bar_baz" -> ["temporal", "foo", "bar-baz"]
254+
func extensionBinaryToCommandPath(binary string) []string {
255+
path := strings.Split(binary, extensionSeparator)
256+
for i, p := range path {
257+
path[i] = strings.ReplaceAll(p, argDashReplacement, extensionSeparator)
258+
}
259+
return path
260+
}
261+
262+
// extensionCommandToBinary converts command path to a binary name.
263+
// Dashes in path are converted to underscores.
264+
// For example: ["temporal", "foo", "bar-baz"] -> "temporal-foo-bar_baz"
265+
func extensionCommandToBinary(path []string) string {
266+
converted := make([]string, len(path))
267+
for i, p := range path {
268+
converted[i] = strings.ReplaceAll(p, extensionSeparator, argDashReplacement)
269+
}
270+
return strings.Join(converted, extensionSeparator)
179271
}

internal/temporalcli/commands.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,9 @@ func (c *TemporalCommand) initCommand(cctx *CommandContext) {
443443
// Set custom usage template with proper flag wrapping
444444
c.Command.SetUsageTemplate(getUsageTemplate())
445445

446+
// Customize the built-in help command to support --all/-a for listing extensions
447+
customizeHelpCommand(&c.Command)
448+
446449
// Unfortunately color is a global option, so we can set in pre-run but we
447450
// must unset in post-run
448451
origNoColor := color.NoColor
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package temporalcli
2+
3+
import (
4+
"slices"
5+
"strings"
6+
7+
"github.com/spf13/cobra"
8+
)
9+
10+
// customizeHelpCommand adds the --all/-a flag to Cobra's built-in help command
11+
// and customizes its behavior to include extensions when the flag is set.
12+
func customizeHelpCommand(rootCmd *cobra.Command) {
13+
// Ensure the default help command is initialized
14+
rootCmd.InitDefaultHelpCmd()
15+
16+
// Find the help command
17+
var helpCmd *cobra.Command
18+
for _, c := range rootCmd.Commands() {
19+
if c.Name() == "help" {
20+
helpCmd = c
21+
break
22+
}
23+
}
24+
if helpCmd == nil {
25+
return
26+
}
27+
28+
// Add --all/-a flag
29+
var showAll bool
30+
helpCmd.Flags().BoolVarP(&showAll, "all", "a", false, "Show all commands including extensions found in PATH.")
31+
32+
// Store the original help function
33+
originalRun := helpCmd.Run
34+
35+
// Override the run function
36+
helpCmd.Run = func(cmd *cobra.Command, args []string) {
37+
// Find target command
38+
targetCmd := rootCmd
39+
if len(args) > 0 {
40+
if found, _, err := rootCmd.Find(args); err == nil {
41+
targetCmd = found
42+
}
43+
}
44+
45+
// If --all is set, register extensions as commands before showing help
46+
if showAll {
47+
registerExtensionCommands(targetCmd)
48+
}
49+
50+
// Run original help
51+
originalRun(cmd, args)
52+
}
53+
}
54+
55+
// registerExtensionCommands adds discovered extensions as placeholder commands
56+
// so they appear in the default help output. It filters extensions based on
57+
// the current command's path in the hierarchy.
58+
func registerExtensionCommands(cmd *cobra.Command) {
59+
cmdPath := strings.Fields(cmd.CommandPath())
60+
seen := make(map[string]bool)
61+
62+
for _, ext := range discoverExtensions() {
63+
// Extension must be deeper than current command and share the same prefix
64+
if len(ext) <= len(cmdPath) || !slices.Equal(ext[:len(cmdPath)], cmdPath) {
65+
continue
66+
}
67+
68+
// Get the next level command name
69+
nextPart := ext[len(cmdPath)]
70+
71+
// Skip if already added
72+
if seen[nextPart] {
73+
continue
74+
}
75+
76+
// Skip if a built-in command exists
77+
if found, _, _ := cmd.Find([]string{nextPart}); found != cmd {
78+
continue
79+
}
80+
81+
seen[nextPart] = true
82+
cmd.AddCommand(&cobra.Command{
83+
Use: nextPart,
84+
DisableFlagParsing: true,
85+
Run: func(*cobra.Command, []string) {},
86+
})
87+
}
88+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package temporalcli_test
2+
3+
import (
4+
"os"
5+
"runtime"
6+
"strings"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestHelp_Root(t *testing.T) {
14+
h := NewCommandHarness(t)
15+
16+
res := h.Execute("help")
17+
18+
assert.Contains(t, res.Stdout.String(), "Available Commands:")
19+
assert.Contains(t, res.Stdout.String(), "workflow")
20+
assert.NoError(t, res.Err)
21+
}
22+
23+
func TestHelp_Subcommand(t *testing.T) {
24+
h := NewCommandHarness(t)
25+
26+
res := h.Execute("help", "workflow")
27+
28+
assert.Contains(t, res.Stdout.String(), "Workflow commands")
29+
assert.NoError(t, res.Err)
30+
}
31+
32+
func TestHelp_HelpShowsAllFlag(t *testing.T) {
33+
h := NewCommandHarness(t)
34+
res := h.Execute("help", "--help")
35+
36+
assert.Contains(t, res.Stdout.String(), "-a, --all")
37+
assert.Contains(t, res.Stdout.String(), "extensions found in PATH")
38+
}
39+
40+
func TestHelp_AllFlag_ShowsExtensions(t *testing.T) {
41+
h := newExtensionHarness(t)
42+
fooPath := h.createExtension("temporal-foo", codeEchoArgs)
43+
fooBarPath := h.createExtension("temporal-foo-bar", codeEchoArgs)
44+
h.createExtension("temporal-workflow-bar_baz", codeEchoArgs)
45+
46+
// Without --all, no extensions are shown
47+
res := h.Execute("help")
48+
assert.NotContains(t, res.Stdout.String(), "foo")
49+
assert.NotContains(t, res.Stdout.String(), "bar-baz")
50+
assert.NoError(t, res.Err)
51+
52+
// With --all, extensions on root level are shown in Available Commands (not Additional help topics)
53+
res = h.Execute("help", "--all")
54+
out := res.Stdout.String()
55+
assert.Contains(t, out, "foo") // shown now!
56+
assert.NotContains(t, out, "bar-baz") // is under workflow
57+
58+
// Verify foo appears in Available Commands section (between "Available Commands:" and "Flags:")
59+
availableIdx := strings.Index(out, "Available Commands:")
60+
fooIdx := strings.Index(out, "foo")
61+
flagsIdx := strings.Index(out, "Flags:")
62+
assert.Greater(t, fooIdx, availableIdx, "foo should appear after Available Commands:")
63+
assert.Less(t, fooIdx, flagsIdx, "foo should appear before Flags:")
64+
assert.NoError(t, res.Err)
65+
66+
// Non-executable extensions are skipped
67+
// On Unix, remove executable permission; on Windows, rename to .bak extension
68+
if runtime.GOOS == "windows" {
69+
require.NoError(t, os.Rename(fooPath, fooPath+".bak"))
70+
require.NoError(t, os.Rename(fooBarPath, fooBarPath+".bak"))
71+
} else {
72+
require.NoError(t, os.Chmod(fooPath, 0644))
73+
require.NoError(t, os.Chmod(fooBarPath, 0644))
74+
}
75+
res = h.Execute("help", "--all")
76+
assert.NotContains(t, res.Stdout.String(), "foo")
77+
assert.NoError(t, res.Err)
78+
79+
// With --all on built-in subcommand, shows nested extensions
80+
res = h.Execute("help", "workflow", "--all")
81+
assert.Contains(t, res.Stdout.String(), "bar-baz")
82+
assert.NoError(t, res.Err)
83+
}
84+
85+
func TestHelp_AllFlag_FirstInPathWins(t *testing.T) {
86+
h := newExtensionHarness(t)
87+
binDir1 := h.binDir
88+
binDir2 := t.TempDir()
89+
90+
// Set PATH with binDir1 before binDir2
91+
oldPath := os.Getenv("PATH")
92+
os.Setenv("PATH", binDir1+string(os.PathListSeparator)+binDir2+string(os.PathListSeparator)+oldPath)
93+
t.Cleanup(func() { os.Setenv("PATH", oldPath) })
94+
95+
// Create extension in binDir1 that outputs "first"
96+
h.createExtension("temporal-foo", `fmt.Println("first")`)
97+
98+
// Create extension in binDir2 that outputs "second"
99+
h.binDir = binDir2
100+
h.createExtension("temporal-foo", `fmt.Println("second")`)
101+
102+
// Should use the first one found in PATH
103+
res := h.Execute("foo")
104+
assert.Equal(t, "first\n", res.Stdout.String())
105+
assert.NoError(t, res.Err)
106+
}

0 commit comments

Comments
 (0)