Skip to content

Commit b815323

Browse files
authored
fix(cli): allow positional name in any position for mcp add (#17)
Go's stdlib flag parser stops at the first non-flag argument, so `sluice mcp add github --command X` would stop at `github` and never see `--command`. This contradicted the usage message, which documented `<name>` as the first positional. Add reorderFlagsBeforePositional helper that moves flag args before positional args, respecting both `--flag value` and `--flag=value` forms, bool flags that don't consume the next arg, and the `--` terminator. handleMCPAdd now calls it before fs.Parse so the upstream name can appear at any position.
1 parent e22f2ee commit b815323

4 files changed

Lines changed: 221 additions & 1 deletion

File tree

cmd/sluice/flagutil.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package main
2+
3+
import (
4+
"flag"
5+
"strings"
6+
)
7+
8+
// reorderFlagsBeforePositional returns a copy of args with all flag
9+
// arguments moved before any positional arguments, so that Go's stdlib
10+
// flag parser (which stops at the first non-flag) still sees every flag.
11+
//
12+
// The FlagSet is consulted to determine which flags take a value and
13+
// therefore consume the following arg. "--flag=value" form is left as
14+
// a single token. "--" is treated as a terminator; everything after it
15+
// is positional, preserving the stdlib convention.
16+
//
17+
// Example: ["github", "--command", "https://x", "--timeout", "60"]
18+
// becomes ["--command", "https://x", "--timeout", "60", "github"]
19+
//
20+
// Flags defined as bool do not consume the following arg. Everything
21+
// else (string, int, Func, Var) is assumed to.
22+
func reorderFlagsBeforePositional(args []string, fs *flag.FlagSet) []string {
23+
var flagArgs, positional []string
24+
i := 0
25+
for i < len(args) {
26+
a := args[i]
27+
if a == "--" {
28+
// Terminator: everything after is positional, flag parsing
29+
// should still see "--" to stop.
30+
flagArgs = append(flagArgs, a)
31+
positional = append(positional, args[i+1:]...)
32+
break
33+
}
34+
if !strings.HasPrefix(a, "-") || a == "-" {
35+
positional = append(positional, a)
36+
i++
37+
continue
38+
}
39+
flagArgs = append(flagArgs, a)
40+
// --flag=value form: value is in the same arg.
41+
if strings.Contains(a, "=") {
42+
i++
43+
continue
44+
}
45+
// Otherwise the next arg is the value for non-bool flags.
46+
name := strings.TrimLeft(a, "-")
47+
if isValueFlag(fs, name) && i+1 < len(args) {
48+
flagArgs = append(flagArgs, args[i+1])
49+
i += 2
50+
continue
51+
}
52+
i++
53+
}
54+
return append(flagArgs, positional...)
55+
}
56+
57+
// isValueFlag reports whether the named flag consumes the next argument
58+
// as its value. Bool flags do not; everything else does.
59+
func isValueFlag(fs *flag.FlagSet, name string) bool {
60+
f := fs.Lookup(name)
61+
if f == nil {
62+
// Unknown flag. Assume it takes a value so we don't accidentally
63+
// slurp something that might be a positional arg. fs.Parse will
64+
// then surface the real error.
65+
return true
66+
}
67+
// The stdlib flag package exposes bool flags via an IsBoolFlag method
68+
// on the Value. Non-bool flags don't implement this.
69+
if bf, ok := f.Value.(interface{ IsBoolFlag() bool }); ok && bf.IsBoolFlag() {
70+
return false
71+
}
72+
return true
73+
}

cmd/sluice/flagutil_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package main
2+
3+
import (
4+
"flag"
5+
"reflect"
6+
"testing"
7+
)
8+
9+
func newTestFlagSet() *flag.FlagSet {
10+
fs := flag.NewFlagSet("test", flag.ContinueOnError)
11+
fs.String("command", "", "")
12+
fs.String("transport", "stdio", "")
13+
fs.Int("timeout", 120, "")
14+
fs.Bool("verbose", false, "")
15+
fs.Func("header", "", func(string) error { return nil })
16+
return fs
17+
}
18+
19+
func TestReorderFlagsBeforePositional_NameFirst(t *testing.T) {
20+
fs := newTestFlagSet()
21+
in := []string{"github", "--command", "https://x", "--transport", "http"}
22+
want := []string{"--command", "https://x", "--transport", "http", "github"}
23+
got := reorderFlagsBeforePositional(in, fs)
24+
if !reflect.DeepEqual(got, want) {
25+
t.Errorf("got %v, want %v", got, want)
26+
}
27+
}
28+
29+
func TestReorderFlagsBeforePositional_NameLast(t *testing.T) {
30+
// Already in canonical order: should be unchanged.
31+
fs := newTestFlagSet()
32+
in := []string{"--command", "https://x", "github"}
33+
want := []string{"--command", "https://x", "github"}
34+
got := reorderFlagsBeforePositional(in, fs)
35+
if !reflect.DeepEqual(got, want) {
36+
t.Errorf("got %v, want %v", got, want)
37+
}
38+
}
39+
40+
func TestReorderFlagsBeforePositional_NameInMiddle(t *testing.T) {
41+
fs := newTestFlagSet()
42+
in := []string{"--command", "https://x", "github", "--transport", "http"}
43+
want := []string{"--command", "https://x", "--transport", "http", "github"}
44+
got := reorderFlagsBeforePositional(in, fs)
45+
if !reflect.DeepEqual(got, want) {
46+
t.Errorf("got %v, want %v", got, want)
47+
}
48+
}
49+
50+
func TestReorderFlagsBeforePositional_EqualsForm(t *testing.T) {
51+
fs := newTestFlagSet()
52+
in := []string{"github", "--command=https://x", "--timeout=60"}
53+
want := []string{"--command=https://x", "--timeout=60", "github"}
54+
got := reorderFlagsBeforePositional(in, fs)
55+
if !reflect.DeepEqual(got, want) {
56+
t.Errorf("got %v, want %v", got, want)
57+
}
58+
}
59+
60+
func TestReorderFlagsBeforePositional_BoolFlag(t *testing.T) {
61+
// --verbose is a bool flag and does not consume the next arg.
62+
fs := newTestFlagSet()
63+
in := []string{"github", "--verbose", "--command", "https://x"}
64+
want := []string{"--verbose", "--command", "https://x", "github"}
65+
got := reorderFlagsBeforePositional(in, fs)
66+
if !reflect.DeepEqual(got, want) {
67+
t.Errorf("got %v, want %v", got, want)
68+
}
69+
}
70+
71+
func TestReorderFlagsBeforePositional_RepeatableFunc(t *testing.T) {
72+
fs := newTestFlagSet()
73+
in := []string{"github", "--header", "A=1", "--header", "B=2"}
74+
want := []string{"--header", "A=1", "--header", "B=2", "github"}
75+
got := reorderFlagsBeforePositional(in, fs)
76+
if !reflect.DeepEqual(got, want) {
77+
t.Errorf("got %v, want %v", got, want)
78+
}
79+
}
80+
81+
func TestReorderFlagsBeforePositional_Terminator(t *testing.T) {
82+
// Everything after -- is positional, even if it looks like a flag.
83+
fs := newTestFlagSet()
84+
in := []string{"--command", "X", "--", "--not-a-flag", "github"}
85+
want := []string{"--command", "X", "--", "--not-a-flag", "github"}
86+
got := reorderFlagsBeforePositional(in, fs)
87+
if !reflect.DeepEqual(got, want) {
88+
t.Errorf("got %v, want %v", got, want)
89+
}
90+
}
91+
92+
func TestReorderFlagsBeforePositional_Empty(t *testing.T) {
93+
fs := newTestFlagSet()
94+
got := reorderFlagsBeforePositional(nil, fs)
95+
if len(got) != 0 {
96+
t.Errorf("expected empty, got %v", got)
97+
}
98+
}

cmd/sluice/mcp.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,9 @@ func handleMCPAdd(args []string) error {
264264
headers[parts[0]] = parts[1]
265265
return nil
266266
})
267-
if err := fs.Parse(args); err != nil {
267+
// Go's stdlib flag parser stops at the first non-flag argument. Reorder
268+
// so positional args (upstream name) can come before flags too.
269+
if err := fs.Parse(reorderFlagsBeforePositional(args, fs)); err != nil {
268270
return err
269271
}
270272

cmd/sluice/mcp_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,53 @@ func TestHandleMCPAddAndList(t *testing.T) {
253253
}
254254
}
255255

256+
// TestHandleMCPAddNameBeforeFlags verifies that the upstream name can appear
257+
// before the flags, matching what the usage string documents. The stdlib
258+
// flag parser normally stops at the first non-flag arg; handleMCPAdd
259+
// reorders args via reorderFlagsBeforePositional to handle both orders.
260+
func TestHandleMCPAddNameBeforeFlags(t *testing.T) {
261+
dir := t.TempDir()
262+
dbPath := filepath.Join(dir, "test.db")
263+
264+
err := handleMCPAdd([]string{
265+
"github", // name first
266+
"--db", dbPath,
267+
"--transport", "http",
268+
"--command", "https://api.githubcopilot.com/mcp/",
269+
"--header", "Authorization=Bearer token123",
270+
})
271+
if err != nil {
272+
t.Fatalf("mcp add: %v", err)
273+
}
274+
275+
db, err := store.New(dbPath)
276+
if err != nil {
277+
t.Fatal(err)
278+
}
279+
defer func() { _ = db.Close() }()
280+
281+
upstreams, err := db.ListMCPUpstreams()
282+
if err != nil {
283+
t.Fatal(err)
284+
}
285+
if len(upstreams) != 1 {
286+
t.Fatalf("expected 1 upstream, got %d", len(upstreams))
287+
}
288+
u := upstreams[0]
289+
if u.Name != "github" {
290+
t.Errorf("expected name github, got %q", u.Name)
291+
}
292+
if u.Transport != "http" {
293+
t.Errorf("expected transport http, got %q", u.Transport)
294+
}
295+
if u.Command != "https://api.githubcopilot.com/mcp/" {
296+
t.Errorf("expected command URL, got %q", u.Command)
297+
}
298+
if u.Headers["Authorization"] != "Bearer token123" {
299+
t.Errorf("expected Authorization header, got %v", u.Headers)
300+
}
301+
}
302+
256303
// TestHandleMCPAddWithEnv verifies that environment variables are parsed correctly.
257304
func TestHandleMCPAddWithEnv(t *testing.T) {
258305
dir := t.TempDir()

0 commit comments

Comments
 (0)