Add show_page and show_file, and let file tools reach any path

This commit is contained in:
Ian Gulliver
2026-10-21 18:29:01 -07:00
parent 585c8ba5f8
commit 524bc0252e
11 changed files with 540 additions and 496 deletions
+109 -44
View File
@@ -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
}