okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawntui: skills, instructions and mcp on listView with global/session toggles and a pane without enter96704f7

main
10.8 KiB373 linesraw
1package tui
2
3import (
4	"albedo/cli/internal/config"
5	"albedo/cli/internal/daemon"
6	"cmp"
7	"errors"
8	"fmt"
9	"net/url"
10	"path"
11	"regexp"
12	"slices"
13	"strings"
14
15	tea "charm.land/bubbletea/v2"
16)
17
18// mcpForm adds or edits one MCP server on a single screen. Nothing is written
19// until the whole form is saved, so a server that needs credentials gets them
20// before its first connection attempt.
21type mcpForm struct {
22	form
23	// Editing names the server being edited; empty for a new server.
24	Editing   string
25	Transport string // "http" or "stdio"
26	// Stored names the secrets the daemon already holds for this server.
27	Stored daemon.MCPSecretNames
28	// Base keeps fields the form does not edit (enabled, tools, timeouts).
29	Base config.MCPServer
30	// NameTouched stops the name following the URL or command once typed.
31	NameTouched bool
32}
33
34const (
35	fieldTransport = "transport"
36	fieldURL       = "url"
37	fieldCommand   = "command"
38	fieldName      = "name"
39	fieldToken     = "token"
40	fieldHeader    = "header"
41	fieldValue     = "value"
42	fieldEnv       = "env"
43)
44
45// mcpFieldLabels renames the fields whose key is not the words a person
46// reads; the rest label themselves.
47var mcpFieldLabels = map[string]string{
48	fieldToken: "bearer token",
49	fieldValue: "header value",
50}
51
52func newMCPForm(editing string, server config.MCPServer, stored daemon.MCPSecretNames) *mcpForm {
53	transport := "http"
54	if server.Type == "stdio" {
55		transport = "stdio"
56	}
57	keys := []string{fieldURL, fieldCommand, fieldName, fieldToken, fieldHeader, fieldValue, fieldEnv}
58	f := &mcpForm{Editing: editing, Transport: transport, Base: server, Stored: stored, form: newForm(keys, fieldToken, fieldValue, fieldEnv)}
59	f.Inputs[fieldURL].SetValue(server.URL)
60	f.Inputs[fieldCommand].SetValue(joinCommand(server.Command, server.Args))
61	f.Inputs[fieldName].SetValue(editing)
62	f.NameTouched = editing != ""
63	if stored.BearerToken {
64		f.Inputs[fieldToken].Placeholder = "stored · leave blank to keep it, or enter - to remove it"
65	}
66	if len(stored.Headers) > 0 {
67		f.Inputs[fieldHeader].Placeholder = strings.Join(stored.Headers, ", ") + " stored · add or replace one"
68	}
69	f.Inputs[fieldEnv].Placeholder = "KEY=value KEY2=value (optional)"
70	if len(stored.Env) > 0 {
71		f.Inputs[fieldEnv].Placeholder = strings.Join(stored.Env, ", ") + " stored · KEY=value adds or replaces"
72	}
73	f.Focus = 1 // the address: transport already defaults to http
74	f.focus(f.current())
75	return f
76}
77
78// fields lists the rows for the current transport, in order.
79func (f *mcpForm) fields() []string {
80	if f.Transport == "stdio" {
81		return []string{fieldTransport, fieldCommand, fieldName, fieldEnv}
82	}
83	return []string{fieldTransport, fieldURL, fieldName, fieldToken, fieldHeader, fieldValue}
84}
85
86func (f *mcpForm) current() string {
87	fields := f.fields()
88	return fields[min(f.Focus, len(fields)-1)]
89}
90
91// update handles one key or paste; submit reports that the form should be saved.
92func (f *mcpForm) update(msg tea.Msg) (submit bool, cmd tea.Cmd) {
93	if submit, handled := f.key(msg, f.fields(), 0); handled {
94		return submit, nil
95	}
96	if f.current() == fieldTransport {
97		if key, ok := msg.(tea.KeyPressMsg); ok && f.Editing == "" {
98			switch key.String() {
99			case "left", "right", "space":
100				if f.Transport == "http" {
101					f.Transport = "stdio"
102				} else {
103					f.Transport = "http"
104				}
105				f.suggestName()
106			}
107		}
108		return false, nil
109	}
110	if f.current() == fieldName && f.Editing != "" {
111		return false, nil // renaming would orphan stored credentials
112	}
113	input := f.Inputs[f.current()]
114	before := input.Value()
115	next, cmd := input.Update(msg)
116	*input = next
117	if input.Value() != before {
118		switch f.current() {
119		case fieldName:
120			f.NameTouched = true
121		case fieldURL, fieldCommand:
122			f.suggestName()
123		}
124	}
125	return false, cmd
126}
127
128func (f *mcpForm) suggestName() {
129	if f.NameTouched {
130		return
131	}
132	name := ""
133	if f.Transport == "http" {
134		name = nameFromURL(f.Inputs[fieldURL].Value())
135	} else if argv, err := splitCommand(f.Inputs[fieldCommand].Value()); err == nil {
136		name = nameFromCommand(argv)
137	}
138	f.Inputs[fieldName].SetValue(name)
139}
140
141// mcpSubmission is a validated form: the server entry and the change to its
142// saved credentials.
143type mcpSubmission struct {
144	Secrets daemon.MCPSecretsPatch
145	Name    string
146	Server  config.MCPServer
147}
148
149func (f *mcpForm) submission(existing []capabilityItem) (mcpSubmission, error) {
150	name := f.value(fieldName)
151	if !mcpName.MatchString(name) {
152		return mcpSubmission{}, errors.New("name: 1–64 letters, digits, _ or -")
153	}
154	if f.Editing == "" && slices.ContainsFunc(existing, func(item capabilityItem) bool { return item.ID == name }) {
155		return mcpSubmission{}, fmt.Errorf("a server called %s already exists; choose another name", name)
156	}
157	server := f.Base
158	server.Type = f.Transport
159	secrets := daemon.MCPSecretsPatch{}
160	if f.Transport == "http" {
161		raw := f.value(fieldURL)
162		parsed, err := url.Parse(raw)
163		if raw == "" || err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
164			return mcpSubmission{}, errors.New("enter a server URL starting with http:// or https://")
165		}
166		server.URL, server.Command, server.Args, server.CWD = raw, "", nil, ""
167		if token := f.value(fieldToken); token == "-" {
168			secrets.RemoveBearerToken = true
169		} else if token != "" {
170			secrets.BearerToken = &token
171		}
172		header, value := f.value(fieldHeader), f.value(fieldValue)
173		switch {
174		case header == "" && value == "":
175		case !mcpHeaderName.MatchString(header):
176			return mcpSubmission{}, errors.New("enter a valid HTTP header name")
177		case value == "-":
178			secrets.Headers = map[string]*string{header: nil}
179		case value == "":
180			return mcpSubmission{}, fmt.Errorf("enter a value for %s, or - to remove it", header)
181		default:
182			secrets.Headers = map[string]*string{header: &value}
183		}
184		secrets.ClearEnv = true
185	} else {
186		argv, err := splitCommand(f.Inputs[fieldCommand].Value())
187		if err != nil || len(argv) == 0 {
188			return mcpSubmission{}, errors.New("enter the command that starts the server")
189		}
190		server.Command, server.Args, server.URL = argv[0], argv[1:], ""
191		entries, err := splitCommand(f.Inputs[fieldEnv].Value())
192		if err != nil {
193			return mcpSubmission{}, fmt.Errorf("check environment variables: %w", err)
194		}
195		var env map[string]*string
196		for _, entry := range entries {
197			key, value, ok := strings.Cut(entry, "=")
198			if !ok || !mcpEnvName.MatchString(key) {
199				return mcpSubmission{}, errors.New("enter environment variables as KEY=value pairs separated by spaces")
200			}
201			if env == nil {
202				env = map[string]*string{}
203			}
204			if value == "-" {
205				env[key] = nil
206			} else {
207				env[key] = &value
208			}
209		}
210		if env != nil {
211			secrets.Env = env
212		}
213		secrets.RemoveBearerToken, secrets.ClearHeaders = true, true
214	}
215	return mcpSubmission{Name: name, Server: server, Secrets: secrets}, nil
216}
217
218// view is the form's rows; footer is its keys, shown in the screen's footer.
219func (f *mcpForm) view(width int) []string {
220	f.fit(width - 16)
221	title := "add MCP server"
222	if f.Editing != "" {
223		title = "edit " + f.Editing
224	}
225	rows := []string{DefaultStyles.Bold.Render(title)}
226	for i, key := range f.fields() {
227		value := f.Transport
228		if key != fieldTransport {
229			value = f.Inputs[key].View()
230		} else if f.Editing == "" {
231			value = " http  ‹stdio›"
232			if f.Transport == "http" {
233				value = "‹http›  stdio "
234			}
235		}
236		label := cmp.Or(mcpFieldLabels[key], key)
237		rows = append(rows, formRow(i == f.Focus, label, 13, value, width))
238	}
239	return rows
240}
241
242// footer explains the focused field and lists the form's keys.
243func (f *mcpForm) footer(width int) string {
244	var own []hint
245	note := ""
246	switch f.current() {
247	case fieldTransport:
248		if f.Editing == "" {
249			own = []hint{{"←→", "http or stdio"}}
250		}
251	case fieldToken:
252		note = "optional · sent as an Authorization: Bearer header"
253	case fieldHeader, fieldValue:
254		note = "optional custom header, stored privately"
255	case fieldEnv:
256		note = "optional, stored privately"
257	}
258	return formFooter(note, width, own...)
259}
260
261var nameUnsafe = regexp.MustCompile(`[^A-Za-z0-9_-]+`)
262
263// nameFromURL suggests a server name from its host: mcp.linear.app → linear.
264func nameFromURL(raw string) string {
265	parsed, err := url.Parse(strings.TrimSpace(raw))
266	if err != nil || parsed.Hostname() == "" {
267		return ""
268	}
269	labels := strings.Split(parsed.Hostname(), ".")
270	for len(labels) > 1 && (labels[0] == "mcp" || labels[0] == "api" || labels[0] == "www") {
271		labels = labels[1:]
272	}
273	if len(labels) == 4 && strings.Trim(parsed.Hostname(), "0123456789.") == "" {
274		return cleanName("mcp-" + strings.ReplaceAll(parsed.Hostname(), ".", "-"))
275	}
276	return cleanName(labels[0])
277}
278
279// nameFromCommand suggests a name from the package or program a command
280// runs: npx -y @modelcontextprotocol/server-filesystem → filesystem.
281func nameFromCommand(argv []string) string {
282	if len(argv) == 0 {
283		return ""
284	}
285	target := path.Base(argv[0])
286	switch target {
287	case "npx", "uvx", "bunx", "pnpx", "pipx", "node", "python", "python3", "uv", "docker":
288		for _, arg := range argv[1:] {
289			if !strings.HasPrefix(arg, "-") && arg != "run" && arg != "tool" {
290				target = arg
291				break
292			}
293		}
294	}
295	target = path.Base(target)
296	if at := strings.LastIndex(target, "@"); at > 0 {
297		target = target[:at]
298	}
299	for _, affix := range []string{"server-", "mcp-server-", "mcp-"} {
300		target = strings.TrimPrefix(target, affix)
301	}
302	for _, affix := range []string{"-mcp-server", "-mcp", "-server"} {
303		target = strings.TrimSuffix(target, affix)
304	}
305	return cleanName(target)
306}
307
308func cleanName(name string) string {
309	name = strings.Trim(nameUnsafe.ReplaceAllString(strings.ToLower(name), "-"), "-_")
310	if len(name) > 64 {
311		name = name[:64]
312	}
313	return name
314}
315
316// splitCommand splits a command line on spaces, honouring single and double
317// quotes and backslash escapes, without invoking a shell.
318func splitCommand(line string) ([]string, error) {
319	var args []string
320	var current strings.Builder
321	inArg, quote, escaped := false, rune(0), false
322	for _, r := range line {
323		switch {
324		case escaped:
325			current.WriteRune(r)
326			escaped = false
327		case r == '\\' && quote != '\'':
328			escaped, inArg = true, true
329		case quote != 0:
330			if r == quote {
331				quote = 0
332			} else {
333				current.WriteRune(r)
334			}
335		case r == '\'' || r == '"':
336			quote, inArg = r, true
337		case r == ' ' || r == '\t':
338			if inArg {
339				args = append(args, current.String())
340				current.Reset()
341				inArg = false
342			}
343		default:
344			current.WriteRune(r)
345			inArg = true
346		}
347	}
348	if quote != 0 || escaped {
349		return nil, errors.New("unterminated quote")
350	}
351	if inArg {
352		args = append(args, current.String())
353	}
354	return args, nil
355}
356
357func joinCommand(command string, args []string) string {
358	if command == "" {
359		return ""
360	}
361	parts := []string{quoteArg(command)}
362	for _, arg := range args {
363		parts = append(parts, quoteArg(arg))
364	}
365	return strings.Join(parts, " ")
366}
367
368func quoteArg(arg string) string {
369	if arg != "" && !strings.ContainsAny(arg, " \t'\"\\") {
370		return arg
371	}
372	return "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'"
373}