From 1605c9f423e47cfa5b9e0bee444c6f4ebf814747 Mon Sep 17 00:00:00 2001 From: Dan Rassi <129646+drassi@users.noreply.github.com> Date: Sat, 7 Mar 2026 14:05:46 -0500 Subject: [PATCH] feat: add attach and download commands, register attachable entity Co-Authored-By: Claude Opus 4.6 --- internal/api/attachments.go | 116 ++++++++++++++++++++++++++++++ internal/api/entities.go | 1 + internal/cmd/attach.go | 109 ++++++++++++++++++++++++++++ internal/cmd/download.go | 72 +++++++++++++++++++ internal/cmd/root.go | 2 + internal/cmd/schema.go | 12 ++++ skills/qbo/SKILL.md | 10 ++- skills/qbo/references/COMMANDS.md | 16 ++++- 8 files changed, 336 insertions(+), 2 deletions(-) create mode 100644 internal/api/attachments.go create mode 100644 internal/cmd/attach.go create mode 100644 internal/cmd/download.go diff --git a/internal/api/attachments.go b/internal/api/attachments.go new file mode 100644 index 0000000..b51f47e --- /dev/null +++ b/internal/api/attachments.go @@ -0,0 +1,116 @@ +package api + +import ( + "bytes" + "context" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "strings" + + "github.com/voska/qbo-cli/internal/errfmt" +) + +func (c *Client) Upload(ctx context.Context, metadata []byte, filename, contentType string, fileContent io.Reader) (map[string]any, error) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + metaHeader := make(textproto.MIMEHeader) + metaHeader.Set("Content-Disposition", `form-data; name="file_metadata_01"; filename="metadata.json"`) + metaHeader.Set("Content-Type", "application/json") + metaPart, err := writer.CreatePart(metaHeader) + if err != nil { + return nil, errfmt.Wrap(errfmt.ExitError, "cannot create metadata part", err) + } + if _, err := metaPart.Write(metadata); err != nil { + return nil, errfmt.Wrap(errfmt.ExitError, "cannot write metadata", err) + } + + fileHeader := make(textproto.MIMEHeader) + fileHeader.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file_content_01"; filename="%s"`, filename)) + if contentType != "" { + fileHeader.Set("Content-Type", contentType) + } + filePart, err := writer.CreatePart(fileHeader) + if err != nil { + return nil, errfmt.Wrap(errfmt.ExitError, "cannot create file part", err) + } + if _, err := io.Copy(filePart, fileContent); err != nil { + return nil, errfmt.Wrap(errfmt.ExitError, "cannot write file content", err) + } + + if err := writer.Close(); err != nil { + return nil, errfmt.Wrap(errfmt.ExitError, "cannot close multipart writer", err) + } + + endpoint := c.url("upload") + endpoint = c.addMinorVersion(endpoint) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, &body) + if err != nil { + return nil, errfmt.Wrap(errfmt.ExitError, "cannot build request", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", writer.FormDataContentType()) + + result, err := c.do(req) + if err != nil { + return nil, err + } + + ar, _ := result["AttachableResponse"].([]any) + if len(ar) == 0 { + return nil, errfmt.New(errfmt.ExitError, "unexpected upload response: missing AttachableResponse") + } + first, _ := ar[0].(map[string]any) + att, _ := first["Attachable"].(map[string]any) + if att == nil { + return nil, errfmt.New(errfmt.ExitError, "unexpected upload response: missing Attachable") + } + return att, nil +} + +func (c *Client) FetchDownloadURL(ctx context.Context, attachableID string) (string, error) { + endpoint := c.url("download/" + attachableID) + endpoint = c.addMinorVersion(endpoint) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return "", errfmt.Wrap(errfmt.ExitError, "cannot build request", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", errfmt.Wrap(errfmt.ExitRetryable, "request failed", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", errfmt.Wrap(errfmt.ExitError, "cannot read response", err) + } + + if resp.StatusCode >= 400 { + return "", mapHTTPError(resp.StatusCode, respBody) + } + + return strings.TrimSpace(string(respBody)), nil +} + +func (c *Client) Download(ctx context.Context, attachableID string) (io.ReadCloser, error) { + presignedQBOAttachableURL, err := c.FetchDownloadURL(ctx, attachableID) + if err != nil { + return nil, err + } + resp, err := http.Get(presignedQBOAttachableURL) //nolint:gosec // pre-signed download URL from QBO API + if err != nil { + return nil, errfmt.Wrap(errfmt.ExitRetryable, "download failed", err) + } + if resp.StatusCode >= 400 { + _ = resp.Body.Close() + return nil, errfmt.New(errfmt.ExitError, fmt.Sprintf("download failed (%d)", resp.StatusCode)) + } + return resp.Body, nil +} diff --git a/internal/api/entities.go b/internal/api/entities.go index d002018..6a7664d 100644 --- a/internal/api/entities.go +++ b/internal/api/entities.go @@ -14,6 +14,7 @@ type EntityInfo struct { var entities = map[string]EntityInfo{ "account": {Name: "Account", Endpoint: "account", Queryable: true, Creatable: true, Updatable: true}, + "attachable": {Name: "Attachable", Endpoint: "attachable", Queryable: true, Creatable: true, Updatable: true, Deletable: true}, "bill": {Name: "Bill", Endpoint: "bill", Queryable: true, Creatable: true, Updatable: true, Deletable: true}, "billpayment": {Name: "BillPayment", Endpoint: "billpayment", Queryable: true, Creatable: true, Updatable: true, Deletable: true}, "budget": {Name: "Budget", Endpoint: "budget", Queryable: true}, diff --git a/internal/cmd/attach.go b/internal/cmd/attach.go new file mode 100644 index 0000000..c6278b5 --- /dev/null +++ b/internal/cmd/attach.go @@ -0,0 +1,109 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "mime" + "os" + "path/filepath" + + "github.com/voska/qbo-cli/internal/api" + "github.com/voska/qbo-cli/internal/errfmt" + "github.com/voska/qbo-cli/internal/output" +) + +type AttachCmd struct { + Args []string `arg:"" optional:"" help:"[entity-type entity-id] file-path"` + Note string `name:"note" help:"Note text to include with the attachment."` + IncludeOnSend bool `name:"include-on-send" help:"Include attachment when sending entity."` +} + +const maxUploadSize = 100 * 1024 * 1024 // 100 MB + +func (c *AttachCmd) Run(g *Globals) error { + var entityType, entityID, filePath string + + nargs := len(c.Args) + if nargs != 1 && nargs != 3 { + return errfmt.Usage("usage: qbo attach [entity-type entity-id] ") + } + if nargs == 3 { + entityType, entityID, filePath = c.Args[0], c.Args[1], c.Args[2] + } else { + filePath = c.Args[0] + } + + var entityName string + if entityType != "" { + entity, ok := api.LookupEntity(entityType) + if !ok { + return errfmt.Usage("unknown entity type: " + entityType) + } + entityName = entity.Name + } + + fi, err := os.Stat(filePath) + if err != nil { + return errfmt.Wrap(errfmt.ExitError, "cannot access file", err) + } + if fi.Size() > maxUploadSize { + return errfmt.New(errfmt.ExitError, fmt.Sprintf("file too large (%d bytes, max %d)", fi.Size(), maxUploadSize)) + } + + fileName := fi.Name() + contentType := mime.TypeByExtension(filepath.Ext(filePath)) + + metadata := buildMetadata(fileName, contentType, c.Note, entityName, entityID, c.IncludeOnSend) + metaJSON, err := json.Marshal(metadata) + if err != nil { + return errfmt.Wrap(errfmt.ExitError, "cannot marshal metadata", err) + } + + if g.CLI.DryRun { + output.Hint("[dry-run] POST /v3/company/{id}/upload") + output.Hint("File: %s (%d bytes)", fileName, fi.Size()) + DryRunLog(g.CLI, "POST", "upload", metaJSON) + return nil + } + + client, _, err := g.NewAPIClient() + if err != nil { + return err + } + + file, err := os.Open(filePath) + if err != nil { + return errfmt.Wrap(errfmt.ExitError, "cannot open file", err) + } + defer func() { _ = file.Close() }() + + result, err := client.Upload(g.Ctx, metaJSON, fileName, contentType, file) + if err != nil { + return err + } + return WriteOutput(g.Ctx, result) +} + +func buildMetadata(fileName, contentType, note, entityName, entityID string, includeOnSend bool) map[string]any { + meta := map[string]any{ + "FileName": fileName, + } + if contentType != "" { + meta["ContentType"] = contentType + } + if note != "" { + meta["Note"] = note + } + if entityName != "" && entityID != "" { + meta["AttachableRef"] = []map[string]any{ + { + "EntityRef": map[string]any{ + "type": entityName, + "value": entityID, + }, + "IncludeOnSend": includeOnSend, + }, + } + } + return meta +} diff --git a/internal/cmd/download.go b/internal/cmd/download.go new file mode 100644 index 0000000..52a2649 --- /dev/null +++ b/internal/cmd/download.go @@ -0,0 +1,72 @@ +package cmd + +import ( + "io" + "os" + "path/filepath" + + "github.com/voska/qbo-cli/internal/errfmt" + "github.com/voska/qbo-cli/internal/output" +) + +type DownloadCmd struct { + ID string `arg:"" help:"Attachable ID to download."` + Output string `name:"output" short:"o" help:"Save to this file path instead of original filename."` + URL bool `name:"url" help:"Print the temporary download URL instead of saving the file."` +} + +func (c *DownloadCmd) Run(g *Globals) error { + if g.CLI.DryRun { + output.Hint("[dry-run] GET /v3/company/{id}/download/%s", c.ID) + return nil + } + + client, _, err := g.NewAPIClient() + if err != nil { + return err + } + + if c.URL { + url, err := client.FetchDownloadURL(g.Ctx, c.ID) + if err != nil { + return err + } + return WriteOutput(g.Ctx, map[string]any{"url": url}) + } + + savePath := c.Output + if savePath == "" { + meta, err := client.Read(g.Ctx, "attachable", c.ID) + if err != nil { + return err + } + savePath = extractFileName(meta) + } + + body, err := client.Download(g.Ctx, c.ID) + if err != nil { + return err + } + defer func() { _ = body.Close() }() + + f, err := os.Create(savePath) + if err != nil { + return errfmt.Wrap(errfmt.ExitError, "cannot create file", err) + } + + _, err = io.Copy(f, body) + _ = f.Close() + if err != nil { + _ = os.Remove(savePath) + return errfmt.Wrap(errfmt.ExitError, "cannot write file", err) + } + + output.Hint("saved %s", savePath) + return nil +} + +func extractFileName(meta map[string]any) string { + att, _ := meta["Attachable"].(map[string]any) + name, _ := att["FileName"].(string) + return filepath.Base(name) +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 9ee50cf..2edc5e4 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -35,6 +35,8 @@ type CLI struct { CDC CDCCmd `cmd:"" name:"cdc" help:"Change data capture polling."` Report ReportCmd `cmd:"" help:"Run a QBO report."` Company CompanyCmd `cmd:"" help:"Company info and switching."` + Attach AttachCmd `cmd:"" help:"Upload a file attachment to an entity."` + Download DownloadCmd `cmd:"" help:"Download an attachment file."` Schema SchemaCmd `cmd:"" help:"Dump CLI schema as JSON for agent introspection."` ExitCodes ExitCodesCmd `cmd:"" name:"exit-codes" help:"Print exit code table."` } diff --git a/internal/cmd/schema.go b/internal/cmd/schema.go index d83b36a..0ce7520 100644 --- a/internal/cmd/schema.go +++ b/internal/cmd/schema.go @@ -111,6 +111,18 @@ func fullSchema(version string) map[string]any { {"name": "switch", "help": "Set default company", "args": []string{"realm-id"}}, }, }, + { + "name": "attach", + "help": "Upload a file attachment to an entity", + "args": []string{"entity-type?", "entity-id?", "file"}, + "flags": []string{"--note", "--include-on-send"}, + }, + { + "name": "download", + "help": "Download an attachment file", + "args": []string{"id"}, + "flags": []string{"--output", "--url"}, + }, { "name": "schema", "help": "Dump CLI schema as JSON for agent introspection", diff --git a/skills/qbo/SKILL.md b/skills/qbo/SKILL.md index 216e609..89c66b7 100644 --- a/skills/qbo/SKILL.md +++ b/skills/qbo/SKILL.md @@ -1,6 +1,6 @@ --- name: qbo -description: Queries, creates, updates, and manages QuickBooks Online data via the qbo CLI. Use when working with QBO entities (invoices, customers, bills, payments, vendors, accounts, items, estimates), running reports, or setting up install, auth, sandbox, and company switching. +description: Queries, creates, updates, and manages QuickBooks Online data via the qbo CLI. Use when working with QBO entities (invoices, customers, bills, payments, vendors, accounts, items, estimates, attachables), running reports, or setting up install, auth, sandbox, and company switching. allowed-tools: Bash(qbo *) --- @@ -149,6 +149,14 @@ echo '{"CustomerRef":{"value":"CUSTOMER_ID"},"TotalAmt":1500,"Line":[{"Amount":1 | qbo create payment -f - --sandbox --json ``` +## Attachments + +```bash +qbo attach invoice 123 receipt.pdf --json +qbo list attachable --where "AttachableRef.EntityRef.value = '123'" --json --results-only +qbo download +``` + ## Agent Introspection ```bash diff --git a/skills/qbo/references/COMMANDS.md b/skills/qbo/references/COMMANDS.md index ad485bd..2f893d6 100644 --- a/skills/qbo/references/COMMANDS.md +++ b/skills/qbo/references/COMMANDS.md @@ -144,6 +144,20 @@ qbo company list # List configured companies qbo company switch # Set default company ``` +### attach + +```bash +qbo attach [entity-type entity-id] [--note TEXT] [--include-on-send] +``` + +### download + +```bash +qbo download [-o path] [--url] +``` + +`--url` prints the temporary download URL instead of saving the file. + ### schema ```bash @@ -159,7 +173,7 @@ qbo exit-codes --json ## Supported Entities -Account, Bill, BillPayment, Budget, Class, CompanyInfo, CreditMemo, Customer, Department, Deposit, Employee, Estimate, Invoice, Item, JournalEntry, Payment, PaymentMethod, Preferences, Purchase, PurchaseOrder, RefundReceipt, SalesReceipt, TaxCode, TaxRate, Term, TimeActivity, Transfer, Vendor, VendorCredit. +Account, Attachable, Bill, BillPayment, Budget, Class, CompanyInfo, CreditMemo, Customer, Department, Deposit, Employee, Estimate, Invoice, Item, JournalEntry, Payment, PaymentMethod, Preferences, Purchase, PurchaseOrder, RefundReceipt, SalesReceipt, TaxCode, TaxRate, Term, TimeActivity, Transfer, Vendor, VendorCredit. ## Environment Variables