Add show_page and show_file, and let file tools reach any path
This commit is contained in:
+109
-44
@@ -4,9 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -62,13 +60,13 @@ func (m *Manager) clientTools(l *live) []mcp.Tool {
|
||||
},
|
||||
{
|
||||
Name: "client_upload",
|
||||
Description: "Copy a file or directory from your worktree to a client Mac, replacing whatever is at dest there. " +
|
||||
Description: "Copy a file or directory from the Mac byre runs on 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(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"client": {"type": "string", "description": "The client's name from list_clients, or its id when several are connected from that Mac."},
|
||||
"path": {"type": "string", "description": "The file or directory, relative to your worktree."},
|
||||
"path": {"type": "string", "description": "The file or directory, relative to your worktree, absolute, or starting with ~/."},
|
||||
"dest": {"type": "string", "description": "Its new path on the client, absolute or starting with ~/, e.g. ~/Applications/Byre.app."}
|
||||
},
|
||||
"required": ["client", "path", "dest"]
|
||||
@@ -79,13 +77,14 @@ func (m *Manager) clientTools(l *live) []mcp.Tool {
|
||||
},
|
||||
{
|
||||
Name: "client_open",
|
||||
Description: "Show the human a file from your worktree on a client Mac: copy it into a fresh folder under ~/Library/Caches/byre/open/ there and open it with macOS's `open`, in that Mac's default app for its type (Preview for images and PDFs, the browser for HTML). " +
|
||||
"Use it when the human asks to see a file on their Mac, or for a file the Files tab can't show; it brings an app forward on their screen.",
|
||||
Description: "Show the human a file from the Mac byre runs on on a client Mac: copy it into a fresh folder under ~/Library/Caches/byre/open/ there and open it with macOS's `open`, in that Mac's default app for its type (Preview for images and PDFs, the browser for HTML). " +
|
||||
"Use it when the human asks to see a file on their Mac, or for a file the Files tab can't show; it brings an app forward on their screen. " +
|
||||
"Only the one file is copied, so an HTML page loses the stylesheets, scripts and images beside it; to show the human a file in byre's app, use show_page or show_file.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"client": {"type": "string", "description": "The client's name from list_clients, or its id when several are connected from that Mac."},
|
||||
"path": {"type": "string", "description": "The file or directory, relative to your worktree."}
|
||||
"path": {"type": "string", "description": "The file or directory, relative to your worktree, absolute, or starting with ~/."}
|
||||
},
|
||||
"required": ["client", "path"]
|
||||
}`),
|
||||
@@ -93,16 +92,48 @@ func (m *Manager) clientTools(l *live) []mcp.Tool {
|
||||
return m.clientOpen(ctx, l, raw)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "show_page",
|
||||
Description: "Show the human a file from the Mac byre runs on in byre's app on a client Mac, rendered in the page view beside this task's transcript, as a browser would show it: an HTML page (loading the stylesheets, scripts and images beside it), a PDF, an image. " +
|
||||
"Use it when the human asks to see a mockup, a preview or a document as it looks.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"client": {"type": "string", "description": "The client's name from list_clients, or its id when several are connected from that Mac."},
|
||||
"path": {"type": "string", "description": "The file, relative to your worktree, absolute, or starting with ~/."}
|
||||
},
|
||||
"required": ["client", "path"]
|
||||
}`),
|
||||
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
return m.show(ctx, l, raw, "show_page", api.OpenPage)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "show_file",
|
||||
Description: "Show the human a file or directory from the Mac byre runs on in byre's app on a client Mac, in this task's Files tab beside its transcript: a file's text (Markdown rendered, images drawn), or a directory's listing. " +
|
||||
"Use it when the human asks to see a file's contents or a directory.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"client": {"type": "string", "description": "The client's name from list_clients, or its id when several are connected from that Mac."},
|
||||
"path": {"type": "string", "description": "The file or directory, relative to your worktree, absolute, or starting with ~/."}
|
||||
},
|
||||
"required": ["client", "path"]
|
||||
}`),
|
||||
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
return m.show(ctx, l, raw, "show_file", api.OpenFile)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "client_download",
|
||||
Description: "Copy a file or directory from a client Mac into your worktree, replacing whatever is at dest there. " +
|
||||
Description: "Copy a file or directory from a client Mac to the Mac byre runs on, 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, or its id when several are connected from that Mac."},
|
||||
"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."}
|
||||
"dest": {"type": "string", "description": "Its new path, relative to your worktree, absolute, or starting with ~/; parent directories are created."}
|
||||
},
|
||||
"required": ["client", "path", "dest"]
|
||||
}`),
|
||||
@@ -296,36 +327,30 @@ func (m *Manager) clientUpload(ctx context.Context, l *live, raw json.RawMessage
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
clean, err := m.upload(ctx, l, args.Client, args.Path, args.Dest)
|
||||
full, err := l.resolve(args.Path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Copied %s to %s on %s.", clean, args.Dest, args.Client), nil
|
||||
err = m.upload(ctx, args.Client, filepath.Clean(full), args.Dest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Copied %s to %s on %s.", args.Path, args.Dest, args.Client), nil
|
||||
}
|
||||
|
||||
func (m *Manager) upload(ctx context.Context, l *live, client, path, dest string) (string, error) {
|
||||
clean := filepath.Clean(path)
|
||||
if filepath.IsAbs(clean) {
|
||||
return "", fmt.Errorf("path %q must be relative to your worktree", path)
|
||||
}
|
||||
root, err := os.OpenRoot(l.dir)
|
||||
func (m *Manager) upload(ctx context.Context, client, full, dest string) error {
|
||||
_, err := os.Lstat(full)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, err = root.Lstat(clean)
|
||||
root.Close()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("path %q: %w", path, err)
|
||||
return err
|
||||
}
|
||||
|
||||
full := filepath.Join(l.dir, clean)
|
||||
id := m.clients.AddTransfer(clients.Transfer{Dir: filepath.Dir(full), Name: filepath.Base(full)})
|
||||
defer m.clients.RemoveTransfer(id)
|
||||
|
||||
cctx, cancel := context.WithTimeout(ctx, copyTimeout)
|
||||
defer cancel()
|
||||
_, err = m.clients.Call(cctx, client, api.ClientRequest{Op: api.Upload, Transfer: &id, Dest: &dest})
|
||||
return clean, err
|
||||
return err
|
||||
}
|
||||
|
||||
type clientOpenArgs struct {
|
||||
@@ -339,13 +364,16 @@ func (m *Manager) clientOpen(ctx context.Context, l *live, raw json.RawMessage)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("malformed client_open call: %w", err)
|
||||
}
|
||||
dir := "~/Library/Caches/byre/open/" + rand.Text()
|
||||
name := filepath.Base(filepath.Clean(args.Path))
|
||||
if name == "." {
|
||||
return "", errors.New("path names your worktree, not a file in it")
|
||||
full, err := l.resolve(args.Path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
full = filepath.Clean(full)
|
||||
dir := "~/Library/Caches/byre/open/" + rand.Text()
|
||||
name := filepath.Base(full)
|
||||
dest := dir + "/" + name
|
||||
clean, err := m.upload(ctx, l, args.Client, args.Path, dest)
|
||||
clean := args.Path
|
||||
err = m.upload(ctx, args.Client, full, dest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -361,6 +389,45 @@ func (m *Manager) clientOpen(ctx context.Context, l *live, raw json.RawMessage)
|
||||
return fmt.Sprintf("Opened %s on %s, copied to %s.", clean, args.Client, dest), nil
|
||||
}
|
||||
|
||||
func (m *Manager) show(ctx context.Context, l *live, raw json.RawMessage, tool string, op api.ClientRequestOp) (string, error) {
|
||||
args := clientOpenArgs{}
|
||||
err := json.Unmarshal(raw, &args)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("malformed %s call: %w", tool, err)
|
||||
}
|
||||
full, err := l.resolve(args.Path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
full = filepath.Clean(full)
|
||||
fi, err := os.Stat(full)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if op == api.OpenPage && fi.IsDir() {
|
||||
return "", fmt.Errorf("%s is a directory; the page view shows a file", args.Path)
|
||||
}
|
||||
|
||||
shown := full
|
||||
rel, err := filepath.Rel(l.dir, full)
|
||||
if err == nil && filepath.IsLocal(rel) {
|
||||
shown = rel
|
||||
}
|
||||
shown = filepath.ToSlash(shown)
|
||||
if shown == "." {
|
||||
shown = ""
|
||||
} else if fi.IsDir() {
|
||||
shown += "/"
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, fileTimeout)
|
||||
defer cancel()
|
||||
_, err = m.clients.Call(cctx, args.Client, api.ClientRequest{Op: op, Task: &l.taskID, Path: &shown})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Showing %s on %s.", shown, args.Client), nil
|
||||
}
|
||||
|
||||
func (m *Manager) clientDownload(ctx context.Context, l *live, raw json.RawMessage) (string, error) {
|
||||
args := clientTransferArgs{}
|
||||
err := json.Unmarshal(raw, &args)
|
||||
@@ -371,18 +438,16 @@ func (m *Manager) clientDownload(ctx context.Context, l *live, raw json.RawMessa
|
||||
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)
|
||||
dest, err := l.resolve(args.Dest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer root.Close()
|
||||
dest = filepath.Clean(dest)
|
||||
if dest == filepath.Clean(l.dir) {
|
||||
return "", fmt.Errorf("dest %q is your worktree itself", args.Dest)
|
||||
}
|
||||
parent := filepath.Dir(dest)
|
||||
err = root.MkdirAll(parent, 0o755)
|
||||
err = os.MkdirAll(parent, 0o755)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("dest %q: %w", args.Dest, err)
|
||||
}
|
||||
@@ -403,27 +468,27 @@ func (m *Manager) clientDownload(ctx context.Context, l *live, raw json.RawMessa
|
||||
}
|
||||
|
||||
stage := filepath.Join(parent, ".byre-download-"+id)
|
||||
err = root.Mkdir(stage, 0o700)
|
||||
err = os.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()
|
||||
defer os.RemoveAll(stage)
|
||||
out, err := exec.CommandContext(ctx, "tar", "-xzf", filepath.Join(tmp, "archive.tgz"), "-C", stage).CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unpack: %w: %s", err, out)
|
||||
}
|
||||
entries, err := fs.ReadDir(root.FS(), stage)
|
||||
entries, err := os.ReadDir(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)
|
||||
err = os.RemoveAll(dest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
err = root.Rename(filepath.Join(stage, entries[0].Name()), dest)
|
||||
err = os.Rename(filepath.Join(stage, entries[0].Name()), dest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user