• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

astronomer / astro-cli / 97311093-15da-4cd8-9b87-9fa959aa3162

22 Dec 2025 05:59PM UTC coverage: 33.209% (+0.08%) from 33.132%
97311093-15da-4cd8-9b87-9fa959aa3162

Pull #1993

circleci

schnie
Adds initial plugin system
Pull Request #1993: Adds initial plugin system

101 of 161 new or added lines in 4 files covered. (62.73%)

20945 of 63071 relevant lines covered (33.21%)

8.53 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.94
/pkg/plugin/plugin.go
1
package plugin
2

3
import (
4
        "errors"
5
        "fmt"
6
        "os"
7
        "os/exec"
8
        "path/filepath"
9
        "strings"
10
)
11

12
const (
13
        // PluginPrefix is the prefix for plugin binaries in PATH
14
        PluginPrefix = "astro-"
15
)
16

17
var (
18
        // ErrPluginNotFound is returned when no plugin is found for the given command
19
        ErrPluginNotFound = errors.New("plugin not found")
20
        // ErrPluginNotExecutable is returned when the plugin file is not executable
21
        ErrPluginNotExecutable = errors.New("plugin is not executable")
22
)
23

24
// Plugin represents a discovered plugin
25
type Plugin struct {
26
        Name       string // The plugin command name (e.g., "ai" from "astro-ai")
27
        BinaryName string // The full binary name (e.g., "astro-ai")
28
        Path       string // The full path to the plugin binary
29
}
30

31
// FindPlugin searches for a plugin using the longest-match algorithm.
32
// For a command like "astro ai foo bar", it will try to find:
33
// 1. astro-ai-foo-bar
34
// 2. astro-ai-foo
35
// 3. astro-ai
36
// Returns the plugin path and the remaining arguments to pass to the plugin.
37
func FindPlugin(args []string) (pluginPath string, pluginArgs []string, err error) {
9✔
38
        if len(args) == 0 {
10✔
39
                return "", nil, ErrPluginNotFound
1✔
40
        }
1✔
41

42
        // Try longest match first, working backwards
43
        for i := len(args); i > 0; i-- {
26✔
44
                pluginName := PluginPrefix + strings.Join(args[:i], "-")
18✔
45
                path, lookupErr := exec.LookPath(pluginName)
18✔
46
                if lookupErr == nil {
24✔
47
                        // Found the plugin, return it with remaining args
6✔
48
                        remainingArgs := args[i:]
6✔
49
                        return path, remainingArgs, nil
6✔
50
                }
6✔
51
        }
52

53
        return "", nil, ErrPluginNotFound
2✔
54
}
55

56
// ExecutePlugin executes the plugin binary with the given arguments.
57
// It connects stdin, stdout, and stderr to the current process and
58
// preserves the plugin's exit code.
59
func ExecutePlugin(pluginPath string, args []string) error {
2✔
60
        // Verify the plugin file is executable
2✔
61
        if !isExecutable(pluginPath) {
3✔
62
                return fmt.Errorf("%w: %s", ErrPluginNotExecutable, pluginPath)
1✔
63
        }
1✔
64

65
        cmd := exec.Command(pluginPath, args...) //nolint:gosec
1✔
66
        cmd.Stdin = os.Stdin
1✔
67
        cmd.Stdout = os.Stdout
1✔
68
        cmd.Stderr = os.Stderr
1✔
69
        cmd.Env = os.Environ()
1✔
70

1✔
71
        // Run and preserve exit code
1✔
72
        if err := cmd.Run(); err != nil {
1✔
NEW
73
                if exitErr, ok := err.(*exec.ExitError); ok {
×
NEW
74
                        // Preserve the plugin's exit code
×
NEW
75
                        os.Exit(exitErr.ExitCode())
×
NEW
76
                }
×
NEW
77
                return fmt.Errorf("failed to execute plugin: %w", err)
×
78
        }
79

80
        return nil
1✔
81
}
82

83
// ListPlugins discovers all plugins in the system PATH.
84
// It searches for executables that start with the PluginPrefix.
85
func ListPlugins() ([]Plugin, error) {
3✔
86
        pathEnv := os.Getenv("PATH")
3✔
87
        if pathEnv == "" {
4✔
88
                return nil, nil
1✔
89
        }
1✔
90

91
        paths := filepath.SplitList(pathEnv)
2✔
92
        pluginMap := make(map[string]Plugin) // Use map to deduplicate
2✔
93

2✔
94
        for _, dir := range paths {
14✔
95
                entries, err := os.ReadDir(dir)
12✔
96
                if err != nil {
13✔
97
                        // Skip directories we can't read
1✔
98
                        continue
1✔
99
                }
100

101
                for _, entry := range entries {
534✔
102
                        name := entry.Name()
523✔
103
                        if !strings.HasPrefix(name, PluginPrefix) {
1,041✔
104
                                continue
518✔
105
                        }
106

107
                        // Skip if we've already found this plugin in an earlier PATH entry
108
                        if _, exists := pluginMap[name]; exists {
6✔
109
                                continue
1✔
110
                        }
111

112
                        fullPath := filepath.Join(dir, name)
4✔
113

4✔
114
                        // Only include executable files
4✔
115
                        if !isExecutable(fullPath) {
4✔
NEW
116
                                continue
×
117
                        }
118

119
                        // Extract command name by removing prefix
120
                        commandName := strings.TrimPrefix(name, PluginPrefix)
4✔
121

4✔
122
                        pluginMap[name] = Plugin{
4✔
123
                                Name:       commandName,
4✔
124
                                BinaryName: name,
4✔
125
                                Path:       fullPath,
4✔
126
                        }
4✔
127
                }
128
        }
129

130
        // Convert map to slice
131
        plugins := make([]Plugin, 0, len(pluginMap))
2✔
132
        for _, plugin := range pluginMap {
6✔
133
                plugins = append(plugins, plugin)
4✔
134
        }
4✔
135

136
        return plugins, nil
2✔
137
}
138

139
// IsPluginCommand checks if a command name matches the plugin naming pattern
140
func IsPluginCommand(name string) bool {
5✔
141
        return strings.HasPrefix(name, PluginPrefix)
5✔
142
}
5✔
143

144
// isExecutable checks if a file is executable
145
func isExecutable(path string) bool {
9✔
146
        info, err := os.Stat(path)
9✔
147
        if err != nil {
10✔
148
                return false
1✔
149
        }
1✔
150

151
        // Check if it's a regular file and has execute permissions
152
        mode := info.Mode()
8✔
153
        return mode.IsRegular() && (mode.Perm()&0111 != 0)
8✔
154
}
155

156
// CommandNameFromPlugin converts a plugin binary name to a command name
157
// Example: "astro-ai-chat" -> "ai chat"
158
func CommandNameFromPlugin(binaryName string) string {
4✔
159
        name := strings.TrimPrefix(binaryName, PluginPrefix)
4✔
160
        return strings.ReplaceAll(name, "-", " ")
4✔
161
}
4✔
162

163
// PluginNameFromCommand converts a command to a plugin binary name
164
// Example: []string{"ai", "chat"} -> "astro-ai-chat"
165
func PluginNameFromCommand(args []string) string {
4✔
166
        return PluginPrefix + strings.Join(args, "-")
4✔
167
}
4✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc