Add client_read, client_write, client_edit and client_download
This commit is contained in:
+312
-9
@@ -2,14 +2,18 @@ package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/clients"
|
||||
@@ -21,7 +25,9 @@ const (
|
||||
maxBashTimeout = 10 * time.Minute
|
||||
replyGrace = 30 * time.Second
|
||||
copyTimeout = 10 * time.Minute
|
||||
fileTimeout = 2 * time.Minute
|
||||
handsTimeout = 11 * time.Minute
|
||||
defaultReadLimit = 2000
|
||||
)
|
||||
|
||||
func (m *Manager) clientTools(l *live) []mcp.Tool {
|
||||
@@ -55,7 +61,7 @@ func (m *Manager) clientTools(l *live) []mcp.Tool {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "client_copy",
|
||||
Name: "client_upload",
|
||||
Description: "Copy a file or directory from your worktree to a client Mac, replacing whatever is at dest there. " +
|
||||
"Directories, app bundles included, are copied whole with their permissions and symlinks.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
@@ -68,7 +74,76 @@ func (m *Manager) clientTools(l *live) []mcp.Tool {
|
||||
"required": ["client", "path", "dest"]
|
||||
}`),
|
||||
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
return m.clientCopy(ctx, l, raw)
|
||||
return m.clientUpload(ctx, l, raw)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "client_download",
|
||||
Description: "Copy a file or directory from a client Mac into your worktree, replacing whatever is at dest there. " +
|
||||
"Directories are copied whole with their permissions and symlinks.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"client": {"type": "string", "description": "The client's name from list_clients."},
|
||||
"path": {"type": "string", "description": "The file or directory on the client, absolute or starting with ~/."},
|
||||
"dest": {"type": "string", "description": "Its new path, relative to your worktree; parent directories are created."}
|
||||
},
|
||||
"required": ["client", "path", "dest"]
|
||||
}`),
|
||||
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
return m.clientDownload(ctx, l, raw)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "client_read",
|
||||
Description: "Read a file on a client Mac. Text comes back with line numbers, starting at line 1 (or offset), at most limit lines. " +
|
||||
"A PNG, JPEG, GIF or WebP image comes back as the image. Other binary files fail; use client_download for them.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"client": {"type": "string", "description": "The client's name from list_clients."},
|
||||
"path": {"type": "string", "description": "The file, absolute or starting with ~/."},
|
||||
"offset": {"type": "integer", "description": "The line number to start from (default 1)."},
|
||||
"limit": {"type": "integer", "description": "The most lines to return (default 2000)."}
|
||||
},
|
||||
"required": ["client", "path"]
|
||||
}`),
|
||||
Rich: func(ctx context.Context, raw json.RawMessage) mcp.Result {
|
||||
return m.clientRead(ctx, raw)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "client_write",
|
||||
Description: "Write a file on a client Mac, replacing its contents, creating it and its parent directories when missing.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"client": {"type": "string", "description": "The client's name from list_clients."},
|
||||
"path": {"type": "string", "description": "The file, absolute or starting with ~/."},
|
||||
"content": {"type": "string"}
|
||||
},
|
||||
"required": ["client", "path", "content"]
|
||||
}`),
|
||||
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
return m.clientWrite(ctx, raw)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "client_edit",
|
||||
Description: "Replace old_string with new_string in a text file on a client Mac. " +
|
||||
"old_string must match the file exactly, whitespace included, and appear exactly once; otherwise the file is left as it was.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"client": {"type": "string", "description": "The client's name from list_clients."},
|
||||
"path": {"type": "string", "description": "The file, absolute or starting with ~/."},
|
||||
"old_string": {"type": "string"},
|
||||
"new_string": {"type": "string"}
|
||||
},
|
||||
"required": ["client", "path", "old_string", "new_string"]
|
||||
}`),
|
||||
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
return m.clientEdit(ctx, raw)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -178,20 +253,28 @@ func (m *Manager) clientBash(ctx context.Context, raw json.RawMessage) (string,
|
||||
return status + "\n" + *reply.Output, nil
|
||||
}
|
||||
|
||||
type clientCopyArgs struct {
|
||||
type clientTransferArgs struct {
|
||||
Client string `json:"client"`
|
||||
Path string `json:"path"`
|
||||
Dest string `json:"dest"`
|
||||
}
|
||||
|
||||
func (m *Manager) clientCopy(ctx context.Context, l *live, raw json.RawMessage) (string, error) {
|
||||
args := clientCopyArgs{}
|
||||
func clientPath(name, p string) error {
|
||||
if !filepath.IsAbs(p) && !strings.HasPrefix(p, "~/") {
|
||||
return fmt.Errorf("%s %q must be absolute or start with ~/", name, p)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) clientUpload(ctx context.Context, l *live, raw json.RawMessage) (string, error) {
|
||||
args := clientTransferArgs{}
|
||||
err := json.Unmarshal(raw, &args)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("malformed client_copy call: %w", err)
|
||||
return "", fmt.Errorf("malformed client_upload call: %w", err)
|
||||
}
|
||||
if !filepath.IsAbs(args.Dest) && !strings.HasPrefix(args.Dest, "~/") {
|
||||
return "", fmt.Errorf("dest %q must be absolute or start with ~/", args.Dest)
|
||||
err = clientPath("dest", args.Dest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
clean := filepath.Clean(args.Path)
|
||||
@@ -214,13 +297,233 @@ func (m *Manager) clientCopy(ctx context.Context, l *live, raw json.RawMessage)
|
||||
|
||||
cctx, cancel := context.WithTimeout(ctx, copyTimeout)
|
||||
defer cancel()
|
||||
_, err = m.clients.Call(cctx, args.Client, api.ClientRequest{Op: api.Copy, Transfer: &id, Dest: &args.Dest})
|
||||
_, err = m.clients.Call(cctx, args.Client, api.ClientRequest{Op: api.Upload, Transfer: &id, Dest: &args.Dest})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Copied %s to %s on %s.", clean, args.Dest, args.Client), nil
|
||||
}
|
||||
|
||||
func (m *Manager) clientDownload(ctx context.Context, l *live, raw json.RawMessage) (string, error) {
|
||||
args := clientTransferArgs{}
|
||||
err := json.Unmarshal(raw, &args)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("malformed client_download call: %w", err)
|
||||
}
|
||||
err = clientPath("path", args.Path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dest := filepath.Clean(args.Dest)
|
||||
if filepath.IsAbs(dest) || dest == "." {
|
||||
return "", fmt.Errorf("dest %q must be a path inside your worktree, relative to it", args.Dest)
|
||||
}
|
||||
|
||||
root, err := os.OpenRoot(l.dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer root.Close()
|
||||
parent := filepath.Dir(dest)
|
||||
err = root.MkdirAll(parent, 0o755)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("dest %q: %w", args.Dest, err)
|
||||
}
|
||||
|
||||
tmp, err := os.MkdirTemp("", "byre-download-")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.RemoveAll(tmp)
|
||||
id := m.clients.AddTransfer(clients.Transfer{Dir: tmp, Name: "archive.tgz", Receive: true})
|
||||
defer m.clients.RemoveTransfer(id)
|
||||
|
||||
cctx, cancel := context.WithTimeout(ctx, copyTimeout)
|
||||
defer cancel()
|
||||
_, err = m.clients.Call(cctx, args.Client, api.ClientRequest{Op: api.Download, Path: &args.Path, Transfer: &id})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
stage := filepath.Join(parent, ".byre-download-"+id)
|
||||
err = root.Mkdir(stage, 0o700)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer root.RemoveAll(stage)
|
||||
out, err := exec.CommandContext(ctx, "tar", "-xzf", filepath.Join(tmp, "archive.tgz"), "-C", filepath.Join(l.dir, stage)).CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unpack: %w: %s", err, out)
|
||||
}
|
||||
entries, err := fs.ReadDir(root.FS(), stage)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
return "", fmt.Errorf("unpack: the archive holds %d entries, not 1", len(entries))
|
||||
}
|
||||
err = root.RemoveAll(dest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
err = root.Rename(filepath.Join(stage, entries[0].Name()), dest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Copied %s on %s to %s.", args.Path, args.Client, dest), nil
|
||||
}
|
||||
|
||||
func (m *Manager) readClientFile(ctx context.Context, client, path string) ([]byte, error) {
|
||||
err := clientPath("path", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, fileTimeout)
|
||||
defer cancel()
|
||||
reply, err := m.clients.Call(cctx, client, api.ClientRequest{Op: api.ReadFile, Path: &path})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if reply.Data == nil {
|
||||
return nil, fmt.Errorf("%s's reply has no data", client)
|
||||
}
|
||||
return *reply.Data, nil
|
||||
}
|
||||
|
||||
func (m *Manager) writeClientFile(ctx context.Context, client, path string, data []byte) error {
|
||||
err := clientPath("path", path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, fileTimeout)
|
||||
defer cancel()
|
||||
_, err = m.clients.Call(cctx, client, api.ClientRequest{Op: api.WriteFile, Path: &path, Data: &data})
|
||||
return err
|
||||
}
|
||||
|
||||
var imageTypes = map[string]string{".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp"}
|
||||
|
||||
type clientReadArgs struct {
|
||||
Client string `json:"client"`
|
||||
Path string `json:"path"`
|
||||
Offset *int `json:"offset"`
|
||||
Limit *int `json:"limit"`
|
||||
}
|
||||
|
||||
func (m *Manager) clientRead(ctx context.Context, raw json.RawMessage) mcp.Result {
|
||||
args := clientReadArgs{}
|
||||
err := json.Unmarshal(raw, &args)
|
||||
if err != nil {
|
||||
return mcp.TextResult(fmt.Sprintf("malformed client_read call: %s", err), true)
|
||||
}
|
||||
data, err := m.readClientFile(ctx, args.Client, args.Path)
|
||||
if err != nil {
|
||||
return mcp.TextResult(err.Error(), true)
|
||||
}
|
||||
mime, ok := imageTypes[strings.ToLower(filepath.Ext(args.Path))]
|
||||
if ok {
|
||||
return mcp.Result{Content: []mcp.Content{{Type: "image", Data: base64.StdEncoding.EncodeToString(data), MimeType: mime}}}
|
||||
}
|
||||
text, err := numberLines(args.Path, data, args.Offset, args.Limit)
|
||||
if err != nil {
|
||||
return mcp.TextResult(err.Error(), true)
|
||||
}
|
||||
return mcp.TextResult(text, false)
|
||||
}
|
||||
|
||||
func numberLines(path string, data []byte, offset, limit *int) (string, error) {
|
||||
if !utf8.Valid(data) {
|
||||
return "", fmt.Errorf("%s isn't text; use client_download to copy it into your worktree", path)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return fmt.Sprintf("%s is empty.", path), nil
|
||||
}
|
||||
start := 1
|
||||
if offset != nil {
|
||||
start = *offset
|
||||
}
|
||||
n := defaultReadLimit
|
||||
if limit != nil {
|
||||
n = *limit
|
||||
}
|
||||
if start < 1 || n < 1 {
|
||||
return "", errors.New("offset and limit must be at least 1")
|
||||
}
|
||||
lines := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n")
|
||||
if start > len(lines) {
|
||||
return "", fmt.Errorf("%s has %d lines; offset %d is past the end", path, len(lines), start)
|
||||
}
|
||||
end := min(start-1+n, len(lines))
|
||||
b := &strings.Builder{}
|
||||
for i := start - 1; i < end; i++ {
|
||||
fmt.Fprintf(b, "%6d\t%s\n", i+1, lines[i])
|
||||
}
|
||||
if end < len(lines) {
|
||||
fmt.Fprintf(b, "(%d more lines; read on with offset %d)\n", len(lines)-end, end+1)
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
type clientWriteArgs struct {
|
||||
Client string `json:"client"`
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func (m *Manager) clientWrite(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
args := clientWriteArgs{}
|
||||
err := json.Unmarshal(raw, &args)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("malformed client_write call: %w", err)
|
||||
}
|
||||
err = m.writeClientFile(ctx, args.Client, args.Path, []byte(args.Content))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Wrote %d bytes to %s on %s.", len(args.Content), args.Path, args.Client), nil
|
||||
}
|
||||
|
||||
type clientEditArgs struct {
|
||||
Client string `json:"client"`
|
||||
Path string `json:"path"`
|
||||
OldString string `json:"old_string"`
|
||||
NewString string `json:"new_string"`
|
||||
}
|
||||
|
||||
func (m *Manager) clientEdit(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
args := clientEditArgs{}
|
||||
err := json.Unmarshal(raw, &args)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("malformed client_edit call: %w", err)
|
||||
}
|
||||
if args.OldString == "" {
|
||||
return "", errors.New("old_string is empty")
|
||||
}
|
||||
if args.OldString == args.NewString {
|
||||
return "", errors.New("old_string and new_string are the same")
|
||||
}
|
||||
data, err := m.readClientFile(ctx, args.Client, args.Path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !utf8.Valid(data) {
|
||||
return "", fmt.Errorf("%s isn't text", args.Path)
|
||||
}
|
||||
text := string(data)
|
||||
n := strings.Count(text, args.OldString)
|
||||
if n == 0 {
|
||||
return "", fmt.Errorf("old_string isn't in %s", args.Path)
|
||||
}
|
||||
if n > 1 {
|
||||
return "", fmt.Errorf("old_string appears %d times in %s; include more of the text around it so it appears once", n, args.Path)
|
||||
}
|
||||
err = m.writeClientFile(ctx, args.Client, args.Path, []byte(strings.Replace(text, args.OldString, args.NewString, 1)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Edited %s on %s.", args.Path, args.Client), nil
|
||||
}
|
||||
|
||||
type clientHandsArgs struct {
|
||||
Client string `json:"client"`
|
||||
Tool string `json:"tool"`
|
||||
|
||||
Reference in New Issue
Block a user