* fix(agent): instruct agents to use download_url for attachments
Agents were not aware of the signed vs unsigned URL distinction in
attachments, causing failures when trying to read images. Added an
Attachments section to the generated CLAUDE.md/AGENTS.md template that
tells agents to always use `download_url`. Also increased signed URL
expiry from 5 to 30 minutes to better accommodate agent processing time.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(cli): add `multica attachment download` command
Adds a dedicated CLI command for downloading attachments by ID. The
command fetches attachment metadata from the API (which returns a fresh
signed URL), downloads the file, and saves it locally. This eliminates
the need for agents to understand signed vs unsigned URLs.
Changes:
- New `multica attachment download <id>` CLI command
- New `GET /api/attachments/{id}` backend endpoint
- `DownloadFile` helper on APIClient
- Updated CLAUDE.md template to document the command
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): sanitize filename and add download size limit
- Use filepath.Base on attachment filename to prevent path traversal
- Add 100MB size limit to DownloadFile (matches upload limit)
- Include response body in download error messages for debugging
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
87 lines
2.2 KiB
Go
87 lines
2.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/multica-ai/multica/server/internal/cli"
|
|
)
|
|
|
|
var attachmentCmd = &cobra.Command{
|
|
Use: "attachment",
|
|
Short: "Manage attachments",
|
|
}
|
|
|
|
var attachmentDownloadCmd = &cobra.Command{
|
|
Use: "download <attachment-id>",
|
|
Short: "Download an attachment to a local file",
|
|
Long: "Fetches the attachment metadata from the API, then downloads the file using its signed URL. Prints the local file path on success.",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runAttachmentDownload,
|
|
}
|
|
|
|
func init() {
|
|
attachmentCmd.AddCommand(attachmentDownloadCmd)
|
|
|
|
attachmentDownloadCmd.Flags().StringP("output-dir", "o", ".", "Directory to save the downloaded file")
|
|
}
|
|
|
|
func runAttachmentDownload(cmd *cobra.Command, args []string) error {
|
|
client, err := newAPIClient(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
defer cancel()
|
|
|
|
// Fetch attachment metadata (includes signed download_url).
|
|
var att map[string]any
|
|
if err := client.GetJSON(ctx, "/api/attachments/"+args[0], &att); err != nil {
|
|
return fmt.Errorf("get attachment: %w", err)
|
|
}
|
|
|
|
downloadURL := strVal(att, "download_url")
|
|
if downloadURL == "" {
|
|
return fmt.Errorf("attachment has no download URL")
|
|
}
|
|
|
|
filename := filepath.Base(strVal(att, "filename"))
|
|
if filename == "" || filename == "." {
|
|
filename = args[0]
|
|
}
|
|
|
|
// Download the file content.
|
|
data, err := client.DownloadFile(ctx, downloadURL)
|
|
if err != nil {
|
|
return fmt.Errorf("download file: %w", err)
|
|
}
|
|
|
|
// Write to the output directory.
|
|
outputDir, _ := cmd.Flags().GetString("output-dir")
|
|
destPath := filepath.Join(outputDir, filename)
|
|
|
|
if err := os.WriteFile(destPath, data, 0o644); err != nil {
|
|
return fmt.Errorf("write file: %w", err)
|
|
}
|
|
|
|
// Print the absolute path so agents can reference the file.
|
|
abs, err := filepath.Abs(destPath)
|
|
if err != nil {
|
|
abs = destPath
|
|
}
|
|
fmt.Fprintln(os.Stderr, "Downloaded:", abs)
|
|
|
|
// Also print as JSON for --output json compatibility.
|
|
return cli.PrintJSON(os.Stdout, map[string]any{
|
|
"id": strVal(att, "id"),
|
|
"filename": filename,
|
|
"path": abs,
|
|
"size": strVal(att, "size_bytes"),
|
|
})
|
|
}
|