Skip to content

Commit d5cb67e

Browse files
authored
Add -c flag to ls command for executable command output (#16)
- New -c/--command flag shows NAME and COMMAND columns only - Environment variables are expanded and prepended inline to commands - Values are shell-quoted when containing special characters - Unexpanded variables remain as ${VAR} for clarity - Useful for AI agents to get copy-paste ready commands WARNING: may expose sensitive data such as API keys and secrets
1 parent 269c654 commit d5cb67e

1 file changed

Lines changed: 118 additions & 24 deletions

File tree

cmd/list.go

Lines changed: 118 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@ import (
1313
)
1414

1515
var (
16-
allServers bool
17-
longFormat bool
18-
showStatus bool
19-
toolFilter string
20-
allTools bool
16+
allServers bool
17+
longFormat bool
18+
showStatus bool
19+
toolFilter string
20+
allTools bool
21+
commandFormat bool
2122
)
2223

2324
// listCmd represents the list command
@@ -30,7 +31,8 @@ Without arguments, it lists all default servers.
3031
With a profile argument, it lists all servers with that profile.
3132
With the -a flag, it lists all servers.
3233
With the -l flag, it shows detailed information including command and environment variables.
33-
With the -s flag, it shows deployment status across configured tools.`,
34+
With the -s flag, it shows deployment status across configured tools.
35+
With the -c flag, it shows the executable command with environment variables expanded and inline.`,
3436
Run: func(cmd *cobra.Command, args []string) {
3537
config, err := loadComposeFile(composeFile)
3638
if err != nil {
@@ -62,6 +64,7 @@ func init() {
6264
listCmd.Flags().BoolVarP(&showStatus, "status", "s", false, "Show deployment status across configured tools")
6365
listCmd.Flags().StringVarP(&toolFilter, "tool", "t", "", "Show status for specific tool only (q-cli, claude-desktop, cursor, kiro)")
6466
listCmd.Flags().BoolVar(&allTools, "all-tools", false, "Show status across all supported tools")
67+
listCmd.Flags().BoolVarP(&commandFormat, "command", "c", false, "Show executable command with environment variables expanded inline. WARNING: may expose sensitive data such as API keys and secrets")
6568
}
6669

6770
func displayServers(servers map[string]Service) {
@@ -70,12 +73,26 @@ func displayServers(servers map[string]Service) {
7073
return
7174
}
7275

76+
// Load environment variables if command format flag is set
77+
var envVars map[string]string
78+
if commandFormat {
79+
var err error
80+
envVars, err = loadEnvVars(composeFile)
81+
if err != nil {
82+
fmt.Fprintf(os.Stderr, "Warning: error loading environment variables: %v\n", err)
83+
envVars = make(map[string]string)
84+
}
85+
}
86+
7387
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
7488

7589
// Display headers based on format
76-
if longFormat {
77-
fmt.Fprintln(w, "NAME\tPROFILES\tCOMMAND (TYPE)\tENVVARS")
78-
fmt.Fprintln(w, "----\t--------\t--------------\t-------")
90+
if commandFormat {
91+
fmt.Fprintln(w, "NAME\tCOMMAND")
92+
fmt.Fprintln(w, "----\t-------")
93+
} else if longFormat {
94+
fmt.Fprintln(w, "NAME\tPROFILES\tCOMMAND\tENVVARS")
95+
fmt.Fprintln(w, "----\t--------\t-------\t-------")
7996
} else {
8097
fmt.Fprintln(w, "NAME\tPROFILES")
8198
fmt.Fprintln(w, "----\t--------")
@@ -86,7 +103,7 @@ func displayServers(servers map[string]Service) {
86103
if err != nil {
87104
// If we can't load the file again, just use the map order
88105
for name, service := range servers {
89-
printServerRow(w, name, service)
106+
printServerRow(w, name, service, envVars)
90107
}
91108
} else {
92109
// Create two lists: one for default servers and one for non-default servers
@@ -127,20 +144,33 @@ func displayServers(servers map[string]Service) {
127144

128145
// Print default servers first (alphabetically sorted)
129146
for _, name := range defaultServers {
130-
printServerRow(w, name, servers[name])
147+
printServerRow(w, name, servers[name], envVars)
131148
}
132149

133150
// Then print other servers (alphabetically sorted)
134151
for _, name := range otherServers {
135-
printServerRow(w, name, servers[name])
152+
printServerRow(w, name, servers[name], envVars)
136153
}
137154
}
138155

139156
w.Flush()
140157
}
141158

159+
// shellQuote quotes a string for safe use in shell commands
160+
func shellQuote(s string) string {
161+
// If the string contains no special characters, return as-is
162+
if !strings.ContainsAny(s, " \t\n\"'\\`!") {
163+
return s
164+
}
165+
// Use double quotes and escape special characters (but not $, to preserve unexpanded vars)
166+
escaped := strings.ReplaceAll(s, "\\", "\\\\")
167+
escaped = strings.ReplaceAll(escaped, "\"", "\\\"")
168+
escaped = strings.ReplaceAll(escaped, "`", "\\`")
169+
return "\"" + escaped + "\""
170+
}
171+
142172
// Helper function to print a single server row
143-
func printServerRow(w *tabwriter.Writer, name string, service Service) {
173+
func printServerRow(w *tabwriter.Writer, name string, service Service, envVars map[string]string) {
144174
// Get profiles
145175
var profiles []string
146176
if profilesStr, ok := service.Labels["mcp.profile"]; ok {
@@ -154,15 +184,81 @@ func printServerRow(w *tabwriter.Writer, name string, service Service) {
154184
}
155185
profilesStr := strings.Join(profiles, ", ")
156186

157-
if longFormat {
187+
if commandFormat {
188+
// Command format: NAME + executable command with env vars inline
158189
var commandStr string
159-
var serverType string
160190

161191
// Check if this is a remote server
162192
if IsRemoteServer(service) {
163-
// For remote servers, show the URL and indicate it's remote
193+
// For remote servers, just show the URL
194+
commandStr = service.Command
195+
} else {
196+
// Build env var prefix for the command
197+
var envPrefix string
198+
if !IsRemoteServer(service) && len(service.Environment) > 0 {
199+
var envParts []string
200+
// Sort keys for consistent output
201+
var keys []string
202+
for key := range service.Environment {
203+
keys = append(keys, key)
204+
}
205+
sort.Strings(keys)
206+
for _, key := range keys {
207+
value := service.Environment[key]
208+
expandedValue := expandEnvVars(value, envVars)
209+
envParts = append(envParts, fmt.Sprintf("%s=%s", key, shellQuote(expandedValue)))
210+
}
211+
envPrefix = strings.Join(envParts, " ") + " "
212+
}
213+
214+
// Get the container tool from config, default to "docker"
215+
containerTool := "docker"
216+
configDir := getConfigDir()
217+
configPath := filepath.Join(configDir, "config.json")
218+
219+
if _, err := os.Stat(configPath); err == nil {
220+
data, err := os.ReadFile(configPath)
221+
if err == nil {
222+
var config CLIConfig
223+
if err := json.Unmarshal(data, &config); err == nil && config.ContainerTool != "" {
224+
containerTool = config.ContainerTool
225+
}
226+
}
227+
}
228+
229+
if service.Image != "" {
230+
// For image-based servers, show the container run command format
231+
commandStr = fmt.Sprintf("%s run -i --rm", containerTool)
232+
233+
// Add environment variables as -e flags
234+
var keys []string
235+
for key := range service.Environment {
236+
keys = append(keys, key)
237+
}
238+
sort.Strings(keys)
239+
for _, key := range keys {
240+
value := service.Environment[key]
241+
expandedValue := expandEnvVars(value, envVars)
242+
commandStr += fmt.Sprintf(" -e %s=%s", key, shellQuote(expandedValue))
243+
}
244+
245+
// Add the image name
246+
commandStr += fmt.Sprintf(" %s", service.Image)
247+
} else {
248+
// For command-based servers, prepend env vars and expand command
249+
expandedCommand := expandEnvVars(service.Command, envVars)
250+
commandStr = envPrefix + expandedCommand
251+
}
252+
}
253+
254+
fmt.Fprintf(w, "%s\t%s\n", name, commandStr)
255+
} else if longFormat {
256+
var commandStr string
257+
258+
// Check if this is a remote server
259+
if IsRemoteServer(service) {
260+
// For remote servers, show the URL
164261
commandStr = service.Command
165-
serverType = "remote"
166262
} else {
167263
// Get the container tool from config, default to "docker"
168264
containerTool := "docker"
@@ -190,25 +286,23 @@ func printServerRow(w *tabwriter.Writer, name string, service Service) {
190286

191287
// Add the image name
192288
commandStr += fmt.Sprintf(" %s", service.Image)
193-
serverType = "container"
194289
} else {
195290
// For command-based servers, show the command
196291
commandStr = service.Command
197-
serverType = "local"
198292
}
199293
}
200294

201295
// Get environment variables (only for local servers, remote servers use OAuth)
202-
var envVars []string
296+
var envVarsDisplay []string
203297
if !IsRemoteServer(service) {
204298
for key := range service.Environment {
205-
envVars = append(envVars, key)
299+
envVarsDisplay = append(envVarsDisplay, key)
206300
}
207301
}
208-
envVarsStr := strings.Join(envVars, ", ")
302+
sort.Strings(envVarsDisplay)
303+
envVarsStr := strings.Join(envVarsDisplay, ", ")
209304

210-
// Include server type in the output to distinguish remote vs local servers
211-
fmt.Fprintf(w, "%s\t%s\t%s (%s)\t%s\n", name, profilesStr, commandStr, serverType, envVarsStr)
305+
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, profilesStr, commandStr, envVarsStr)
212306
} else {
213307
// Simple format with just name and profiles
214308
fmt.Fprintf(w, "%s\t%s\n", name, profilesStr)

0 commit comments

Comments
 (0)