diff --git a/cmd/cmsctl/commands.go b/cmd/cmsctl/commands.go index 4c9b605..30a52be 100644 --- a/cmd/cmsctl/commands.go +++ b/cmd/cmsctl/commands.go @@ -13,6 +13,27 @@ import ( "strings" ) +func runCommerce(ctx context.Context, client *Client, args []string, stdin io.Reader, stdout io.Writer) error { + if len(args) == 0 || args[0] == "help" || args[0] == "--help" { + _, _ = io.WriteString(stdout, "Usage: cmsctl commerce promotions {list|get|upsert|status|delete} ...\n cmsctl commerce value-codes definitions {get|replace} ...\n cmsctl commerce carts {get|add|set|remove} ...\n cmsctl commerce checkout create --country SE --terms accepted\n cmsctl commerce orders {get|value-codes} ...\n") + return nil + } + switch args[0] { + case "carts": + return runCommerceCarts(ctx, client, args[1:], stdin, stdout) + case "checkout": + return runCommerceCheckout(ctx, client, args[1:], stdin, stdout) + case "orders": + return runCommerceOrders(ctx, client, args[1:], stdout) + case "value-codes": + return runCommerceValueCodes(ctx, client, args[1:], stdin, stdout) + case "promotions": + return runCommercePromotions(ctx, client, args[1:], stdin, stdout) + default: + return fmt.Errorf("unknown commerce command %q", args[0]) + } +} + func runCMS(ctx context.Context, client *Client, args []string, stdin io.Reader, stdout io.Writer) error { if len(args) == 0 || args[0] == "help" || args[0] == "--help" { _, _ = io.WriteString(stdout, "Usage: cmsctl cms nodes {list|get|create|update|patch|delete|retype} ...\n cmsctl cms edges {get|create|delete|reorder} ...\n cmsctl cms schemas|templates|blueprints|tree|nav|graph|publish|regenerate|script ...\n cmsctl cms search --q text\n cmsctl cms types\n cmsctl cms import [file|-]\n") diff --git a/cmd/cmsctl/commerce_commands.go b/cmd/cmsctl/commerce_commands.go new file mode 100644 index 0000000..b1ca419 --- /dev/null +++ b/cmd/cmsctl/commerce_commands.go @@ -0,0 +1,290 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/url" + "strconv" +) + +func commerceURL(c *Client, route string) string { return joinURL(effectiveBaseURL(c.cfg), route) } + +func runCommerceCarts(ctx context.Context, c *Client, args []string, stdin io.Reader, stdout io.Writer) error { + if len(args) == 0 || args[0] == "help" { + _, _ = io.WriteString(stdout, "Usage: carts get [id]\n carts add --sku SKU [--quantity N]\n carts set --input FILE|-\n carts remove --item-id ID\n") + return nil + } + switch args[0] { + case "get": + if len(args) > 1 { + body, err := c.get(ctx, commerceURL(c, "/cart/byid/"+escapePathSegment(args[1]))) + if err != nil { + return err + } + return writeRaw(stdout, body) + } + body, err := c.get(ctx, commerceURL(c, "/cart")) + if err != nil { + return err + } + return writeRaw(stdout, body) + case "add": + id, err := requireID(args[1:], "commerce carts add requires a cart id") + if err != nil { + return err + } + fs := flag.NewFlagSet("commerce carts add", flag.ContinueOnError) + fs.SetOutput(io.Discard) + sku := fs.String("sku", "", "SKU") + qty := fs.Int("quantity", 1, "quantity") + if err := fs.Parse(args[2:]); err != nil { + return err + } + if *sku == "" { + return errors.New("carts add requires --sku") + } + body, err := c.postJSON(ctx, commerceURL(c, "/cart/byid/"+escapePathSegment(id)), map[string]any{"sku": *sku, "quantity": *qty}) + if err != nil { + return err + } + return writeRaw(stdout, body) + case "set": + id, err := requireID(args[1:], "commerce carts set requires a cart id") + if err != nil { + return err + } + body, err := readRequiredBody(stdin, args[2:], "cart") + if err != nil { + return err + } + if !json.Valid(body) { + return errors.New("cart input is not valid JSON") + } + response, err := c.postBody(ctx, commerceURL(c, "/cart/byid/"+escapePathSegment(id)+"/set"), body, "application/json") + if err != nil { + return err + } + return writeRaw(stdout, response) + case "remove": + id, err := requireID(args[1:], "commerce carts remove requires a cart id") + if err != nil { + return err + } + fs := flag.NewFlagSet("commerce carts remove", flag.ContinueOnError) + fs.SetOutput(io.Discard) + itemID := fs.Int64("item-id", -1, "cart line item id") + if err := fs.Parse(args[2:]); err != nil { + return err + } + if *itemID < 0 { + return errors.New("carts remove requires --item-id") + } + body, err := c.delete(ctx, commerceURL(c, "/cart/byid/"+escapePathSegment(id)+"/"+strconv.FormatInt(*itemID, 10))) + if err != nil { + return err + } + return writeRaw(stdout, body) + default: + return fmt.Errorf("unknown commerce carts command %q", args[0]) + } +} + +func runCommerceCheckout(ctx context.Context, c *Client, args []string, stdin io.Reader, stdout io.Writer) error { + if len(args) == 0 || args[0] == "help" { + _, _ = io.WriteString(stdout, "Usage: checkout create --country SE --terms accepted\n") + return nil + } + if args[0] != "create" { + return fmt.Errorf("unknown commerce checkout command %q", args[0]) + } + id, err := requireID(args[1:], "commerce checkout create requires a cart id") + if err != nil { + return err + } + fs := flag.NewFlagSet("commerce checkout create", flag.ContinueOnError) + fs.SetOutput(io.Discard) + country := fs.String("country", "SE", "country code") + terms := fs.String("terms", "accepted", "terms value") + if err := fs.Parse(args[2:]); err != nil { + return err + } + body, err := c.postJSON(ctx, commerceURL(c, "/cart/byid/"+escapePathSegment(id)+"/checkout-order"), map[string]string{"country": *country, "terms": *terms}) + if err != nil { + return err + } + return writeRaw(stdout, body) +} + +func runCommerceOrders(ctx context.Context, c *Client, args []string, stdout io.Writer) error { + if len(args) == 0 || args[0] == "help" { + _, _ = io.WriteString(stdout, "Usage: orders get \n orders value-codes [--status STATUS --provider PROVIDER --limit N --offset N]\n") + return nil + } + switch args[0] { + case "get": + id, err := requireID(args[1:], "commerce orders get requires an order id") + if err != nil { + return err + } + body, err := c.get(ctx, commerceURL(c, "/api/orders/"+escapePathSegment(id))) + if err != nil { + return err + } + return writeRaw(stdout, body) + case "value-codes": + fs := flag.NewFlagSet("commerce orders value-codes", flag.ContinueOnError) + fs.SetOutput(io.Discard) + status := fs.String("status", "", "status") + provider := fs.String("provider", "", "provider") + q := fs.String("q", "", "search") + limit := fs.Int("limit", 100, "page size") + offset := fs.Int("offset", 0, "offset") + if err := fs.Parse(args[1:]); err != nil { + return err + } + query := url.Values{"limit": {strconv.Itoa(*limit)}, "offset": {strconv.Itoa(*offset)}} + if *status != "" { + query.Set("status", *status) + } + if *provider != "" { + query.Set("provider", *provider) + } + if *q != "" { + query.Set("q", *q) + } + body, err := c.get(ctx, commerceURL(c, "/api/orders/value-codes")+"?"+query.Encode()) + if err != nil { + return err + } + return writeRaw(stdout, body) + default: + return fmt.Errorf("unknown commerce orders command %q", args[0]) + } +} + +func runCommerceValueCodes(ctx context.Context, c *Client, args []string, stdin io.Reader, stdout io.Writer) error { + if len(args) == 0 || args[0] == "help" { + _, _ = io.WriteString(stdout, "Usage: value-codes definitions get\n value-codes definitions replace --input FILE|-\n") + return nil + } + if args[0] != "definitions" { + return fmt.Errorf("unknown value-codes command %q", args[0]) + } + if len(args) < 2 || args[1] == "get" { + body, err := c.get(ctx, commerceURL(c, "/commerce/valuecodes")) + if err != nil { + return err + } + return writeRaw(stdout, body) + } + if args[1] != "replace" { + return fmt.Errorf("unknown definitions command %q", args[1]) + } + body, err := readRequiredBody(stdin, args[2:], "value-code definitions") + if err != nil { + return err + } + if !json.Valid(body) { + return errors.New("value-code definitions input is not valid JSON") + } + response, err := c.postBody(ctx, commerceURL(c, "/commerce/valuecodes"), body, "application/json") + if err != nil { + return err + } + return writeRaw(stdout, response) +} + +func runCommercePromotions(ctx context.Context, c *Client, args []string, stdin io.Reader, stdout io.Writer) error { + if len(args) == 0 || args[0] == "help" { + _, _ = io.WriteString(stdout, "Usage: promotions list [--status STATUS]\n promotions get \n promotions upsert --input FILE|-\n promotions status --status STATUS\n promotions delete --yes\n") + return nil + } + switch args[0] { + case "list": + fs := flag.NewFlagSet("commerce promotions list", flag.ContinueOnError) + fs.SetOutput(io.Discard) + status := fs.String("status", "", "status") + if err := fs.Parse(args[1:]); err != nil { + return err + } + endpoint := commerceURL(c, "/mcp") + if *status != "" { + endpoint += "?status=" + url.QueryEscape(*status) + } + return mcpCall(ctx, c, endpoint, "list_promotions", map[string]any{"status": *status}, stdout) + case "get": + id, err := requireID(args[1:], "commerce promotions get requires an id") + if err != nil { + return err + } + return mcpCall(ctx, c, commerceURL(c, "/mcp"), "get_promotion", map[string]string{"id": id}, stdout) + case "upsert": + body, err := readRequiredBody(stdin, args[1:], "promotion") + if err != nil { + return err + } + if !json.Valid(body) { + return errors.New("promotion input is not valid JSON") + } + return mcpCall(ctx, c, commerceURL(c, "/mcp"), "upsert_promotion", map[string]json.RawMessage{"promotion": body}, stdout) + case "status": + id, err := requireID(args[1:], "commerce promotions status requires an id") + if err != nil { + return err + } + fs := flag.NewFlagSet("commerce promotions status", flag.ContinueOnError) + fs.SetOutput(io.Discard) + status := fs.String("status", "", "status") + if err := fs.Parse(args[2:]); err != nil { + return err + } + if *status == "" { + return errors.New("promotions status requires --status") + } + return mcpCall(ctx, c, commerceURL(c, "/mcp"), "set_promotion_status", map[string]string{"id": id, "status": *status}, stdout) + case "delete": + id, err := requireID(args[1:], "commerce promotions delete requires an id") + if err != nil { + return err + } + fs := flag.NewFlagSet("commerce promotions delete", flag.ContinueOnError) + fs.SetOutput(io.Discard) + yes := fs.Bool("yes", false, "confirm deletion") + if err := fs.Parse(args[2:]); err != nil { + return err + } + if !*yes { + return errors.New("commerce promotions delete requires --yes") + } + return mcpCall(ctx, c, commerceURL(c, "/mcp"), "delete_promotion", map[string]string{"id": id}, stdout) + default: + return fmt.Errorf("unknown commerce promotions command %q", args[0]) + } +} + +func mcpCall(ctx context.Context, c *Client, endpoint, name string, arguments any, stdout io.Writer) error { + initBody, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": map[string]any{"protocolVersion": "2025-06-18", "capabilities": map[string]any{}, "clientInfo": map[string]string{"name": "cmsctl", "version": "dev"}}}) + if err != nil { + return err + } + if _, err = c.postBody(ctx, endpoint, initBody, "application/json"); err != nil { + return err + } + notification := []byte(`{"jsonrpc":"2.0","method":"notifications/initialized"}`) + if _, err = c.postBody(ctx, endpoint, notification, "application/json"); err != nil { + return err + } + body, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": "cmsctl", "method": "tools/call", "params": map[string]any{"name": name, "arguments": arguments}}) + if err != nil { + return err + } + response, err := c.postBody(ctx, endpoint, body, "application/json") + if err != nil { + return err + } + return writeRaw(stdout, response) +} diff --git a/cmd/cmsctl/commerce_commands_test.go b/cmd/cmsctl/commerce_commands_test.go new file mode 100644 index 0000000..a379650 --- /dev/null +++ b/cmd/cmsctl/commerce_commands_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestCommerceCartAddUsesBaseURLAndPayload(t *testing.T) { + var gotMethod, gotPath, gotBody string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{ "id": "cart-1", "items": [] }`) + })) + defer server.Close() + + client := newClient(Config{BaseURL: server.URL}, "token", "", 0) + var out strings.Builder + if err := runCommerceCarts(context.Background(), client, []string{"add", "cart-1", "--sku", "ND10200818HB0001", "--quantity", "2"}, strings.NewReader(""), &out); err != nil { + t.Fatal(err) + } + if gotMethod != http.MethodPost || gotPath != "/cart/byid/cart-1" { + t.Fatalf("request = %s %s, want POST /cart/byid/cart-1", gotMethod, gotPath) + } + if gotBody != `{"quantity":2,"sku":"ND10200818HB0001"}` { + t.Fatalf("body = %s", gotBody) + } +} + +func TestCommercePromotionUpsertUsesMCPHandshakeAndToolCall(t *testing.T) { + var methods []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + methods = append(methods, string(body)) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"jsonrpc":"2.0","id":1,"result":{}}`) + })) + defer server.Close() + + client := newClient(Config{BaseURL: server.URL}, "token", "", 0) + promotion := `{"id":"promo-test","name":"Test","status":"active","conditions":[],"actions":[]}` + var out strings.Builder + if err := runCommercePromotions(context.Background(), client, []string{"upsert", "--input", "-"}, strings.NewReader(promotion), &out); err != nil { + t.Fatal(err) + } + if len(methods) != 3 { + t.Fatalf("MCP request count = %d, want 3", len(methods)) + } + if !strings.Contains(methods[0], `"method":"initialize"`) || !strings.Contains(methods[1], `"method":"notifications/initialized"`) || !strings.Contains(methods[2], `"method":"tools/call"`) || !strings.Contains(methods[2], `"name":"upsert_promotion"`) { + t.Fatalf("unexpected MCP requests: %v", methods) + } +} diff --git a/cmd/cmsctl/main.go b/cmd/cmsctl/main.go index 87fa9d5..c611db0 100644 --- a/cmd/cmsctl/main.go +++ b/cmd/cmsctl/main.go @@ -69,6 +69,12 @@ Usage: cmsctl finder save-trigger Ask reader to persist state cmsctl finder import [file|-] Import inventory items (JSON/JSONL) + cmsctl commerce promotions {list|get|upsert|status|delete} ... + cmsctl commerce value-codes definitions {get|replace} ... + cmsctl commerce carts {get|add|set|remove} ... + cmsctl commerce checkout create ... + cmsctl commerce orders {get|value-codes} ... + All commands accept --config, --base-url, --finder-url, --api-key, --token, and --timeout before the command. A missing --finder-url uses /find for reads. Finder administration always uses /catalog/admin. @@ -153,6 +159,9 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) erro case "finder", "inventory": client := newClient(cfg, global.token, global.apiKey, global.timeout) return runFinder(ctx, client, rest[1:], stdin, stdout) + case "commerce": + client := newClient(cfg, global.token, global.apiKey, global.timeout) + return runCommerce(ctx, client, rest[1:], stdin, stdout) default: return fmt.Errorf("unknown command %q (use --help)", rest[0]) }