Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions internal/apiform/form_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package apiform

import (
"bytes"
"io"
"mime/multipart"
"strings"
"testing"
)

Expand Down Expand Up @@ -111,3 +113,116 @@ func TestEncode(t *testing.T) {
})
}
}

// namedReader wraps an io.Reader with a Name() method to simulate os.File.
type namedReader struct {
io.Reader
name string
}

func (r *namedReader) Name() string { return r.name }

// filenameReader wraps an io.Reader with a Filename() method.
// The encoder prefers Filename() over Name(), so this simulates the
// namedFile wrapper used by the CLI for skill uploads.
type filenameReader struct {
io.Reader
filename string
}

func (r *filenameReader) Filename() string { return r.filename }

func TestEncodeFileUpload(t *testing.T) {
t.Parallel()

t.Run("single file uses filename in Content-Disposition", func(t *testing.T) {
t.Parallel()
buf := bytes.NewBuffer(nil)
writer := multipart.NewWriter(buf)
writer.SetBoundary("xxx")

file := &namedReader{Reader: strings.NewReader("file content"), name: "test.txt"}
form := map[string]any{"file": file}
if err := MarshalWithSettings(form, writer, FormatBrackets); err != nil {
t.Fatal(err)
}
writer.Close()
result := buf.String()
if !strings.Contains(result, `name="file"`) {
t.Errorf("expected field name=\"file\", got:\n%s", result)
}
if !strings.Contains(result, `filename="test.txt"`) {
t.Errorf("expected filename=\"test.txt\", got:\n%s", result)
}
if !strings.Contains(result, "file content") {
t.Errorf("expected file content in body, got:\n%s", result)
}
})

t.Run("array of files uses brackets notation with Filename", func(t *testing.T) {
t.Parallel()
buf := bytes.NewBuffer(nil)
writer := multipart.NewWriter(buf)
writer.SetBoundary("xxx")

// Use filenameReader (Filename() method) to simulate how the CLI
// wraps files for skill uploads with explicit relative paths.
files := []any{
&filenameReader{Reader: strings.NewReader("content A"), filename: "skill-dir/SKILL.md"},
&filenameReader{Reader: strings.NewReader("content B"), filename: "skill-dir/ref/doc.md"},
}
form := map[string]any{"files": files}
if err := MarshalWithSettings(form, writer, FormatBrackets); err != nil {
t.Fatal(err)
}
writer.Close()
result := buf.String()
if strings.Count(result, `name="files[]"`) != 2 {
t.Errorf("expected 2 fields with name=\"files[]\", got:\n%s", result)
}
if !strings.Contains(result, `filename="skill-dir/SKILL.md"`) {
t.Errorf("expected relative path in Filename(), got:\n%s", result)
}
if !strings.Contains(result, `filename="skill-dir/ref/doc.md"`) {
t.Errorf("expected relative path in Filename(), got:\n%s", result)
}
})

t.Run("Name uses path.Base for safety", func(t *testing.T) {
t.Parallel()
buf := bytes.NewBuffer(nil)
writer := multipart.NewWriter(buf)
writer.SetBoundary("xxx")

// Name() (e.g., os.File) goes through path.Base -- safe default
file := &namedReader{Reader: strings.NewReader("hello"), name: "/tmp/upload.txt"}
form := map[string]any{"file": file}
if err := MarshalWithSettings(form, writer, FormatBrackets); err != nil {
t.Fatal(err)
}
writer.Close()
result := buf.String()
if !strings.Contains(result, `filename="upload.txt"`) {
t.Errorf("expected path.Base for Name(), got:\n%s", result)
}
})

t.Run("Filename preferred over Name", func(t *testing.T) {
t.Parallel()
buf := bytes.NewBuffer(nil)
writer := multipart.NewWriter(buf)
writer.SetBoundary("xxx")

// Filename() takes precedence and preserves the full value
file := &filenameReader{Reader: strings.NewReader("hello"), filename: "my-skill/SKILL.md"}
form := map[string]any{"file": file}
if err := MarshalWithSettings(form, writer, FormatBrackets); err != nil {
t.Fatal(err)
}
writer.Close()
result := buf.String()
if !strings.Contains(result, `filename="my-skill/SKILL.md"`) {
t.Errorf("expected Filename() value preserved, got:\n%s", result)
}
})
}
19 changes: 19 additions & 0 deletions pkg/cmd/betafile.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"context"
"fmt"
"os"
"strings"

"github.com/anthropics/anthropic-cli/internal/apiquery"
"github.com/anthropics/anthropic-cli/internal/requestflag"
Expand All @@ -15,6 +16,21 @@ import (
"github.com/urfave/cli/v3"
)

const managedAgentsBeta = "managed-agents-2026-04-01"

// addManagedAgentsBetaForFiles unconditionally appends the managed-agents
// beta header. Session-scoped files require both the files-api and
// managed-agents beta headers. Adding managed-agents to non-session file
// requests is harmless.
func addManagedAgentsBetaForFiles(cmd *cli.Command, options []option.RequestOption) []option.RequestOption {
if cmd.IsSet("scope-id") {
if scopeID, ok := cmd.Value("scope-id").(string); ok && strings.HasPrefix(scopeID, "sesn_") {
return append(options, option.WithHeaderAdd("anthropic-beta", managedAgentsBeta))
}
}
return options
}

var betaFilesList = cli.Command{
Name: "list",
Usage: "List Files",
Expand Down Expand Up @@ -162,6 +178,7 @@ func handleBetaFilesList(ctx context.Context, cmd *cli.Command) error {
if err != nil {
return err
}
options = addManagedAgentsBetaForFiles(cmd, options)

format := cmd.Root().String("format")
explicitFormat := cmd.Root().IsSet("format")
Expand Down Expand Up @@ -269,6 +286,7 @@ func handleBetaFilesDownload(ctx context.Context, cmd *cli.Command) error {
if err != nil {
return err
}
options = append(options, option.WithHeaderAdd("anthropic-beta", managedAgentsBeta))

response, err := client.Beta.Files.Download(
ctx,
Expand Down Expand Up @@ -309,6 +327,7 @@ func handleBetaFilesRetrieveMetadata(ctx context.Context, cmd *cli.Command) erro
if err != nil {
return err
}
options = append(options, option.WithHeaderAdd("anthropic-beta", managedAgentsBeta))

var res []byte
options = append(options, option.WithResponseBodyInto(&res))
Expand Down
2 changes: 1 addition & 1 deletion pkg/cmd/betaskill.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ var betaSkillsCreate = cli.Command{
Usage: "Display title for the skill.\n\nThis is a human-readable label that is not included in the prompt sent to the model.",
BodyPath: "display_title",
},
&requestflag.Flag[any]{
&requestflag.Flag[[]any]{
Name: "file",
Usage: "Files to upload for the skill.\n\nAll files must be in the same top-level directory and must include a SKILL.md file at the root of that directory.",
BodyPath: "files",
Expand Down
2 changes: 1 addition & 1 deletion pkg/cmd/betaskillversion.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ var betaSkillsVersionsCreate = cli.Command{
Usage: "Unique identifier for the skill.\n\nThe format and length of IDs may change over time.",
Required: true,
},
&requestflag.Flag[any]{
&requestflag.Flag[[]any]{
Name: "file",
Usage: "Files to upload for the skill.\n\nAll files must be in the same top-level directory and must include a SKILL.md file at the root of that directory.",
BodyPath: "files",
Expand Down
39 changes: 37 additions & 2 deletions pkg/cmd/flagoptions.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"mime/multipart"
"net/http"
"os"
"path/filepath"
"reflect"
"strings"
"unicode/utf8"
Expand Down Expand Up @@ -76,6 +77,17 @@ func isStdinPath(s string) bool {
return false
}

// namedFile wraps an io.ReadCloser with an explicit Filename() method.
// The apiform encoder prefers Filename() over Name() when setting the
// Content-Disposition filename in multipart uploads, so this lets callers
// control the filename independently of the underlying file path.
type namedFile struct {
io.ReadCloser
filename string
}

func (f *namedFile) Filename() string { return f.filename }

func embedFiles(obj any, embedStyle FileEmbedStyle, stdin *onceStdinReader) (any, error) {
if obj == nil {
return obj, nil
Expand Down Expand Up @@ -135,13 +147,36 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle, stdin *onceStdi

case reflect.String:
// FilePathValue is always treated as a file path without needing the "@" prefix.
// These only appear on binary upload parameters (multipart/octet-stream), which
// always use EmbedIOReader.
// These appear on binary upload parameters (multipart/octet-stream).
if v.Type() == reflect.TypeOf(FilePathValue("")) {
s := v.String()
if s == "" {
return v, nil
}
if embedStyle == EmbedIOReader {
if isStdinPath(s) {
r, err := stdin.read()
if err != nil {
return v, err
}
return reflect.ValueOf(io.NopCloser(r)), nil
}
file, err := os.Open(s)
if err != nil {
return v, err
}
// Normalize the filename for multipart uploads:
// - Absolute paths use basename only (safe for general uploads)
// - Relative paths preserve directory structure (needed for skills)
// - Strip "./" prefix, clean redundant slashes/dots
name := filepath.Clean(s)
if filepath.IsAbs(name) {
name = filepath.Base(name)
} else {
name = strings.TrimPrefix(name, "./")
}
return reflect.ValueOf(&namedFile{ReadCloser: file, filename: name}), nil
}
if isStdinPath(s) {
content, err := stdin.readAll()
if err != nil {
Expand Down