Files
mrktplaats-cli/main.go
Joren 7470bf1baa Initial Marktplaats CLI scaffold
Create mrktplaats-cli from twdehands-cli, updating module/import names, command branding, and token paths while preserving command behavior and payload formats.
2026-04-15 23:45:49 +02:00

1712 lines
49 KiB
Go

// mrktplaats — command-line interface to the marktplaats.nl API
//
// Designed for use by both humans and LLM agents.
// All output is JSON on stdout. Errors go to stderr with exit code 1.
//
// Token resolution order:
// 1. MRKTPLAATS_TOKEN environment variable
// 2. ~/.config/mrktplaats/token (JSON: {accessToken, refreshToken, expiresIn})
// 3. ./.mrktplaats-token in the current directory
//
// After login the token is written to ~/.config/mrktplaats/token automatically.
// The token expires after ~12 hours; re-login when prompted.
package main
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"math"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/joren/mrktplaats"
)
// ── helpers ───────────────────────────────────────────────────────────────────
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, "error: "+format+"\n", args...)
os.Exit(1)
}
func out(v any) {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(v); err != nil {
fatalf("encoding output: %v", err)
}
}
func ctx() context.Context {
c, cancel := context.WithTimeout(context.Background(), 30*time.Second)
// cancel is intentionally not deferred here — the CLI calls one operation
// per invocation and exits immediately after, so the leak is benign.
_ = cancel
return c
}
// tokenPath returns the path to the persisted token file.
func tokenPath() string {
if cfg, err := os.UserConfigDir(); err == nil {
return filepath.Join(cfg, "mrktplaats", "token")
}
return filepath.Join(os.Getenv("HOME"), ".config", "mrktplaats", "token")
}
// savedToken is the JSON structure stored in the token file.
type savedToken struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpiresIn string `json:"expiresIn"`
}
// loadTokenData reads the token file and returns parsed token data.
// Supports both the legacy plain-text format (access token only) and the
// current JSON format.
func loadTokenData() *savedToken {
var data []byte
var err error
if t := os.Getenv("MRKTPLAATS_TOKEN"); t != "" {
return &savedToken{AccessToken: strings.TrimSpace(t)}
}
for _, p := range []string{tokenPath(), ".mrktplaats-token"} {
if data, err = os.ReadFile(p); err == nil {
break
}
}
if data == nil {
return nil
}
// Try JSON first (current format).
var tok savedToken
if err := json.Unmarshal(bytes.TrimSpace(data), &tok); err == nil && tok.AccessToken != "" {
return &tok
}
// Fall back to legacy plain-text format.
if t := strings.TrimSpace(string(data)); t != "" {
return &savedToken{AccessToken: t}
}
return nil
}
// saveToken persists the full token payload as JSON to the config file.
func saveToken(tok savedToken) error {
p := tokenPath()
if err := os.MkdirAll(filepath.Dir(p), 0700); err != nil {
return err
}
b, err := json.Marshal(tok)
if err != nil {
return err
}
return os.WriteFile(p, append(b, '\n'), 0600)
}
// pendingPath returns the path used to persist a pending 2FA session between
// the two login calls (first call triggers SMS; second call submits the code).
func pendingPath() string {
return filepath.Join(filepath.Dir(tokenPath()), "pending-2fa")
}
// pending2FA stores the requestId AND session parameters so the second CLI
// invocation (with --code) uses the exact same session as the first.
// The API requires matching session/gaClientId/magic_number/threatmetrix
// between the login and verify-code requests.
type pending2FA struct {
RequestID string `json:"requestId"`
Session string `json:"session"`
GAClientID string `json:"gaClientId"`
MagicNumber string `json:"magicNumber"`
ThreatMetrixSessionID string `json:"threatMetrixSessionId"`
}
func savePending2FA(p pending2FA) error {
path := pendingPath()
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err
}
b, err := json.Marshal(p)
if err != nil {
return err
}
return os.WriteFile(path, append(b, '\n'), 0600)
}
func loadPending2FA() *pending2FA {
data, err := os.ReadFile(pendingPath())
if err != nil {
return nil
}
data = bytes.TrimSpace(data)
if len(data) == 0 {
return nil
}
// Try JSON format (current).
var p pending2FA
if err := json.Unmarshal(data, &p); err == nil && p.RequestID != "" {
return &p
}
// Fall back to legacy plain-text format (requestId only).
if id := strings.TrimSpace(string(data)); id != "" {
return &pending2FA{RequestID: id}
}
return nil
}
func clearPending2FA() {
_ = os.Remove(pendingPath())
}
// authedClient returns a client with a valid token, auto-refreshing if expired.
func authedClient() *mrktplaats.Client {
tok := loadTokenData()
if tok == nil || tok.AccessToken == "" {
fatalf("no token found — run 'mrktplaats login --email E --password P' first,\n" +
"or set MRKTPLAATS_TOKEN in the environment")
}
// Check expiry and auto-refresh if we have a refresh token.
if tok.ExpiresIn != "" {
if exp, err := time.Parse(time.RFC3339, tok.ExpiresIn); err == nil && time.Now().After(exp) {
if tok.RefreshToken == "" {
fatalf("access token expired at %s — please re-login:\n"+
" mrktplaats login --email EMAIL --password PASS",
exp.Local().Format("2006-01-02 15:04:05"))
}
// Attempt silent refresh.
tmpClient := mrktplaats.NewClient()
newTok, err := tmpClient.Auth.RefreshToken(ctx(), tok.RefreshToken)
if err != nil {
fatalf("token refresh failed (please re-login): %v", err)
}
updated := savedToken{
AccessToken: newTok.AccessToken,
RefreshToken: newTok.RefreshToken,
ExpiresIn: newTok.ExpiresIn,
}
if err := saveToken(updated); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not save refreshed token: %v\n", err)
}
tok = &updated
}
}
return mrktplaats.NewClient(mrktplaats.WithAccessToken(tok.AccessToken))
}
// eurosToC converts a euro string ("20", "20.50") to euro cents.
func eurosToC(s string) int {
f, err := strconv.ParseFloat(s, 64)
if err != nil {
fatalf("invalid amount %q: must be a number in euros (e.g. 20 or 20.50)", s)
}
return int(math.Round(f * 100))
}
// ── commands ──────────────────────────────────────────────────────────────────
// login — authenticate and save token
//
// mrktplaats login --email EMAIL --password PASS
func cmdLogin(args []string) {
fs := flag.NewFlagSet("login", flag.ExitOnError)
email := fs.String("email", "", "account e-mail address (required)")
pass := fs.String("password", "", "account password (required)")
code := fs.String("code", "", "SMS 2FA code (only needed when 2FA is triggered)")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, `usage: mrktplaats login --email EMAIL --password PASS [--code 2FA_CODE]
Authenticate with marktplaats.nl and save the access token.
If the account has 2FA enabled the call will return with a "verification_required"
status. Re-run the command with --code CODE after receiving the SMS.
Flags:`)
fs.PrintDefaults()
}
_ = fs.Parse(args)
if *email == "" || *pass == "" {
fs.Usage()
os.Exit(1)
}
client := mrktplaats.NewClient()
// If the user provided --code, check whether we saved a pending 2FA session
// from the previous login call. If so, create the client with the SAME session
// parameters and go straight to VerifyCode — calling Login again would generate
// a new requestId, invalidating the SMS code the user just received.
if *code != "" {
pending := loadPending2FA()
if pending == nil || pending.RequestID == "" {
fatalf("no pending 2FA session — run login without --code first to trigger the SMS:\n" +
" mrktplaats login --email EMAIL --password PASS")
}
// Restore the session parameters from the login call so the API sees
// the same session/gaClientId/magic_number/threatmetrix as before.
var opts []mrktplaats.Option
if pending.Session != "" {
opts = append(opts, mrktplaats.WithSession(pending.Session))
}
if pending.GAClientID != "" {
opts = append(opts, mrktplaats.WithGAClientID(pending.GAClientID))
}
if pending.MagicNumber != "" {
opts = append(opts, mrktplaats.WithMagicNumber(pending.MagicNumber))
}
if pending.ThreatMetrixSessionID != "" {
opts = append(opts, mrktplaats.WithThreatMetrixSessionID(pending.ThreatMetrixSessionID))
}
client = mrktplaats.NewClient(opts...)
auth, err := client.Auth.VerifyCode(ctx(), pending.RequestID, *code)
if err != nil {
fatalf("2FA verification failed: %v", err)
}
clearPending2FA()
if err := saveToken(savedToken{AccessToken: auth.Auth.AccessToken, RefreshToken: auth.Auth.RefreshToken, ExpiresIn: auth.Auth.ExpiresIn}); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not save token: %v\n", err)
}
out(map[string]any{
"status": "ok",
"token_saved": tokenPath(),
"access_token": auth.Auth.AccessToken,
"expires_in": auth.Auth.ExpiresIn,
"user": auth.User,
})
return
}
login, err := client.Auth.Login(ctx(), *email, *pass)
if err != nil {
fatalf("login failed: %v", err)
}
// Direct auth — no 2FA
if login.Auth != nil {
clearPending2FA()
if err := saveToken(savedToken{AccessToken: login.Auth.AccessToken, RefreshToken: login.Auth.RefreshToken, ExpiresIn: login.Auth.ExpiresIn}); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not save token: %v\n", err)
}
out(map[string]any{
"status": "ok",
"token_saved": tokenPath(),
"access_token": login.Auth.AccessToken,
"expires_in": login.Auth.ExpiresIn,
})
return
}
// 2FA required
if login.Verification == nil {
fatalf("unexpected login response: no auth and no verification")
}
// Persist the requestId AND session parameters so the next invocation
// (with --code) can recreate the client with identical session values.
sp := client.SessionParams()
if err := savePending2FA(pending2FA{
RequestID: login.Verification.RequestID,
Session: sp.Session,
GAClientID: sp.GAClientID,
MagicNumber: sp.MagicNumber,
ThreatMetrixSessionID: sp.ThreatMetrixSessionID,
}); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not save pending 2FA state: %v\n", err)
}
out(map[string]any{
"status": "verification_required",
"method": login.Verification.Method,
"message": login.Verification.Message,
"next_step": "re-run with --code CODE after receiving the SMS",
})
}
// search — search listings
//
// mrktplaats search --query Q [--category ID] [--page N] [--size N] [--sort FIELD]
func cmdSearch(args []string) {
fs := flag.NewFlagSet("search", flag.ExitOnError)
query := fs.String("query", "", "search query (required)")
category := fs.Int("category", 0, "category ID filter (0 = all categories)")
page := fs.Int("page", 1, "page number (1-based)")
size := fs.Int("size", 20, "results per page (max 100)")
sort := fs.String("sort", "SORT_INDEX", "sort order: SORT_INDEX | DATE_DESC | PRICE_ASC | PRICE_DESC")
sellerID := fs.String("seller", "", "filter by seller ID")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, `usage: mrktplaats search --query Q [options]
Search listings. Does not require authentication.
Output: {"total": N, "listings": [{urn, title, price_cents, price_label, city, seller_name, url}]}
Flags:`)
fs.PrintDefaults()
}
_ = fs.Parse(args)
if *query == "" {
fs.Usage()
os.Exit(1)
}
client := mrktplaats.NewClient()
req := &mrktplaats.SearchRequest{
Query: *query,
Page: *page,
Size: *size,
SortBy: *sort,
AllowCorrection: true,
SearchOnTitleAndDescription: true,
ShowListings: true,
SupportsReservedFlag: true,
}
if *category != 0 {
req.CategoryID = category
}
if *sellerID != "" {
req.SellerID = *sellerID
}
resp, err := client.Search.Fetch(ctx(), req, nil)
if err != nil {
fatalf("search failed: %v", err)
}
type listingResult struct {
URN string `json:"urn"`
Title string `json:"title"`
PriceCents int `json:"price_cents"`
PriceLabel string `json:"price_label"`
PriceType string `json:"price_type"`
City string `json:"city"`
SellerName string `json:"seller_name"`
URL string `json:"url"`
}
seen := map[string]bool{}
var listings []listingResult
for _, item := range resp.Items {
l := item.Listing()
if l == nil || seen[l.AdCore.URN] {
continue
}
seen[l.AdCore.URN] = true
listings = append(listings, listingResult{
URN: l.AdCore.URN,
Title: l.AdCore.Title,
PriceCents: l.AdCore.Price.PriceAmount,
PriceLabel: l.AdCore.Price.PriceTypeLabel,
PriceType: l.AdCore.Price.PriceType,
City: l.AdCore.AdAddress.City,
SellerName: l.SellerInformation.Name,
URL: l.URL,
})
}
total := 0
if resp.SearchHistograms != nil {
total = resp.SearchHistograms.NumFound
}
out(map[string]any{
"total": total,
"page": *page,
"size": *size,
"listings": listings,
})
}
// listing — get full listing details
//
// mrktplaats listing --urn URN
func cmdListing(args []string) {
fs := flag.NewFlagSet("listing", flag.ExitOnError)
urn := fs.String("urn", "", "listing URN, e.g. m2372861012 (required)")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, `usage: mrktplaats listing --urn URN
Fetch full details for a listing: title, description, price, seller, pictures,
attributes, bids. Requires authentication (some fields are auth-only).
Output: full ListingDetail object
Flags:`)
fs.PrintDefaults()
}
_ = fs.Parse(args)
if *urn == "" {
fs.Usage()
os.Exit(1)
}
client := authedClient()
detail, err := client.Listings.Get(ctx(), *urn)
if err != nil {
fatalf("listing %s: %v", *urn, err)
}
out(detail)
}
// conversations — list all conversations
//
// mrktplaats conversations [--limit N]
func cmdConversations(args []string) {
fs := flag.NewFlagSet("conversations", flag.ExitOnError)
limit := fs.Int("limit", 50, "max conversations to return")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, `usage: mrktplaats conversations [--limit N]
List all messaging conversations for the authenticated user.
Output: {"total": N, "unread": N, "conversations": [{id, item_urn, title, other_party, unread, latest_message}]}
Flags:`)
fs.PrintDefaults()
}
_ = fs.Parse(args)
client := authedClient()
resp, err := client.Messaging.Conversations(ctx(), &mrktplaats.ConversationsOptions{Limit: *limit})
if err != nil {
fatalf("conversations: %v", err)
}
type convResult struct {
ID string `json:"id"`
ItemURN string `json:"item_urn"`
Title string `json:"title"`
OtherParty string `json:"other_party"`
OtherPartyID int `json:"other_party_id"`
Unread int `json:"unread"`
LatestMessage string `json:"latest_message"`
}
var convs []convResult
for _, c := range resp.Conversations {
latest := ""
if lm := c.LatestMessage(); lm != nil {
latest = lm.Text
}
convs = append(convs, convResult{
ID: c.ID,
ItemURN: c.ItemID,
Title: c.Title,
OtherParty: c.OtherParticipant.Name,
OtherPartyID: c.OtherParticipant.ID,
Unread: c.UnreadMessagesCount,
LatestMessage: latest,
})
}
out(map[string]any{
"total": resp.TotalCount,
"unread": resp.UnreadMessagesCount,
"conversations": convs,
})
}
// messages — read messages in a conversation
//
// mrktplaats messages --conv CONV_ID [--limit N] [--offset N]
func cmdMessages(args []string) {
fs := flag.NewFlagSet("messages", flag.ExitOnError)
convID := fs.String("conv", "", "conversation ID, e.g. 1cgx:5qgvx3h:2p1gxt95d (required)")
limit := fs.Int("limit", 50, "max messages to return")
offset := fs.Int("offset", 0, "skip first N messages")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, `usage: mrktplaats messages --conv CONV_ID [--limit N] [--offset N]
Read messages in a conversation. Use 'mrktplaats conversations' to find conversation IDs.
Output: {"total": N, "messages": [{id, sender_id, text, date, type, read}]}
Flags:`)
fs.PrintDefaults()
}
_ = fs.Parse(args)
if *convID == "" {
fs.Usage()
os.Exit(1)
}
client := authedClient()
resp, err := client.Messaging.Messages(ctx(), *convID, *offset, *limit)
if err != nil {
fatalf("messages: %v", err)
}
type msgResult struct {
ID string `json:"id"`
SenderID int `json:"sender_id"`
Text string `json:"text"`
Date time.Time `json:"date"`
Type string `json:"type"`
Read bool `json:"read"`
}
var msgs []msgResult
for _, m := range resp.Messages {
msgs = append(msgs, msgResult{
ID: m.ID,
SenderID: m.SenderID,
Text: m.Text,
Date: m.ReceivedDate,
Type: m.MessageType,
Read: m.IsRead,
})
}
out(map[string]any{
"total": resp.TotalCount,
"messages": msgs,
})
}
// send — start a new conversation about a listing
//
// mrktplaats send --urn URN --text TEXT
func cmdSend(args []string) {
fs := flag.NewFlagSet("send", flag.ExitOnError)
urn := fs.String("urn", "", "listing URN to message the seller about (required)")
text := fs.String("text", "", "message text (required)")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, `usage: mrktplaats send --urn URN --text TEXT
Start a new conversation with the seller of a listing.
To reply to an existing conversation use 'mrktplaats reply'.
Output: {"conversation_id": "...", "status": "sent"}
Flags:`)
fs.PrintDefaults()
}
_ = fs.Parse(args)
if *urn == "" || *text == "" {
fs.Usage()
os.Exit(1)
}
client := authedClient()
// Fetch the listing to get the seller ID required by the enquiry endpoint.
detail, err := client.Listings.Get(ctx(), *urn)
if err != nil {
fatalf("send: fetching listing %s: %v", *urn, err)
}
sellerID := fmt.Sprintf("%d", detail.SellerInformation.ID)
// Use the ASQ (Ask Seller a Question) enquiry endpoint.
// This works for both first contact and repeat messages — the API always returns 204.
if err := client.Enquiry.AskQuestion(ctx(), *urn, sellerID, *text); err != nil {
fatalf("send: %v", err)
}
out(map[string]any{
"status": "sent",
"item_urn": *urn,
"seller_id": sellerID,
"seller_name": detail.SellerInformation.Name,
})
}
// reply — send a message in an existing conversation
//
// mrktplaats reply --conv CONV_ID --text TEXT
func cmdReply(args []string) {
fs := flag.NewFlagSet("reply", flag.ExitOnError)
convID := fs.String("conv", "", "conversation ID (required)")
text := fs.String("text", "", "message text (required)")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, `usage: mrktplaats reply --conv CONV_ID --text TEXT
Send a message in an existing conversation.
Use 'mrktplaats conversations' to find conversation IDs.
Output: {"message_id": "...", "conversation_id": "...", "status": "sent"}
Flags:`)
fs.PrintDefaults()
}
_ = fs.Parse(args)
if *convID == "" || *text == "" {
fs.Usage()
os.Exit(1)
}
client := authedClient()
msgID, err := client.Messaging.SendMessage(ctx(), *convID, *text)
if err != nil {
fatalf("reply: %v", err)
}
out(map[string]any{
"message_id": msgID,
"conversation_id": *convID,
"status": "sent",
})
}
// bid — place a bid on a listing
//
// mrktplaats bid --urn URN --amount EUROS [--message TEXT]
func cmdBid(args []string) {
fs := flag.NewFlagSet("bid", flag.ExitOnError)
urn := fs.String("urn", "", "listing URN (required)")
amount := fs.String("amount", "", "bid amount in euros, e.g. 20 or 20.50 (required)")
message := fs.String("message", "", "optional personal message to the seller")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, `usage: mrktplaats bid --urn URN --amount EUROS [--message TEXT]
Place a bid on a listing. The listing must have priceType MIN_BID or FAST_BID.
Amount is in euros (e.g. --amount 20 for €20.00).
Output: {"bids": [...], "current_minimum_bid_cents": N, "current_minimum_bid_euros": N}
Flags:`)
fs.PrintDefaults()
}
_ = fs.Parse(args)
if *urn == "" || *amount == "" {
fs.Usage()
os.Exit(1)
}
cents := eurosToC(*amount)
client := authedClient()
resp, err := client.Enquiry.PlaceBid(ctx(), *urn, &mrktplaats.PlaceBidRequest{
Value: cents,
PersonalMessage: *message,
})
if err != nil {
fatalf("bid: %v", err)
}
out(map[string]any{
"bids": resp.Bids,
"current_minimum_bid_cents": resp.CurrentMinimumBid,
"current_minimum_bid_euros": float64(resp.CurrentMinimumBid) / 100,
})
}
// remove-bid — remove an existing bid
//
// mrktplaats remove-bid --id BID_ID
func cmdRemoveBid(args []string) {
fs := flag.NewFlagSet("remove-bid", flag.ExitOnError)
id := fs.Int("id", 0, "bid ID to remove (required; find it in the 'bids' array from 'bid' or 'listing')")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, `usage: mrktplaats remove-bid --id BID_ID
Remove a bid you previously placed.
Output: {"bids": [...], "current_minimum_bid_euros": N}
Flags:`)
fs.PrintDefaults()
}
_ = fs.Parse(args)
if *id == 0 {
fs.Usage()
os.Exit(1)
}
client := authedClient()
resp, err := client.Enquiry.RemoveBid(ctx(), *id)
if err != nil {
fatalf("remove-bid: %v", err)
}
out(map[string]any{
"bids": resp.Bids,
"current_minimum_bid_cents": resp.CurrentMinimumBid,
"current_minimum_bid_euros": float64(resp.CurrentMinimumBid) / 100,
})
}
// favorites — list saved listings
//
// mrktplaats favorites [--limit N]
func cmdFavorites(args []string) {
fs := flag.NewFlagSet("favorites", flag.ExitOnError)
limit := fs.Int("limit", 100, "max favorites to return")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, `usage: mrktplaats favorites [--limit N]
List the authenticated user's saved/favourite listings.
Output: {"total": N, "items": [{item_urn, saved_at}]}
Flags:`)
fs.PrintDefaults()
}
_ = fs.Parse(args)
client := authedClient()
resp, err := client.Favorites.List(ctx(), *limit)
if err != nil {
fatalf("favorites: %v", err)
}
type favResult struct {
ItemURN string `json:"item_urn"`
SavedAt string `json:"saved_at"`
}
var items []favResult
for _, f := range resp.Items {
items = append(items, favResult{ItemURN: f.ItemID, SavedAt: f.CreationDate})
}
out(map[string]any{
"total": resp.Total,
"more": resp.MoreItemsAvailable,
"items": items,
})
}
// add-favorite — save a listing to favourites
//
// mrktplaats add-favorite --urn URN
func cmdAddFavorite(args []string) {
fs := flag.NewFlagSet("add-favorite", flag.ExitOnError)
urn := fs.String("urn", "", "listing URN (required)")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, `usage: mrktplaats add-favorite --urn URN
Add a listing to the authenticated user's favourites.
Output: {"status": "added", "item_urn": "..."}
Flags:`)
fs.PrintDefaults()
}
_ = fs.Parse(args)
if *urn == "" {
fs.Usage()
os.Exit(1)
}
client := authedClient()
if _, err := client.Favorites.Add(ctx(), *urn); err != nil {
fatalf("add-favorite: %v", err)
}
out(map[string]any{"status": "added", "item_urn": *urn})
}
// remove-favorite — remove a listing from favourites
//
// mrktplaats remove-favorite --urn URN
func cmdRemoveFavorite(args []string) {
fs := flag.NewFlagSet("remove-favorite", flag.ExitOnError)
urn := fs.String("urn", "", "listing URN (required)")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, `usage: mrktplaats remove-favorite --urn URN
Remove a listing from the authenticated user's favourites.
Output: {"status": "removed", "item_urn": "..."}
Flags:`)
fs.PrintDefaults()
}
_ = fs.Parse(args)
if *urn == "" {
fs.Usage()
os.Exit(1)
}
client := authedClient()
if err := client.Favorites.Remove(ctx(), *urn); err != nil {
fatalf("remove-favorite: %v", err)
}
out(map[string]any{"status": "removed", "item_urn": *urn})
}
// me — show the authenticated user's profile
//
// mrktplaats me
func cmdMe(args []string) {
_ = args // no flags
client := authedClient()
user, err := client.Users.Me(ctx())
if err != nil {
fatalf("me: %v", err)
}
out(user)
}
// my-ads — list the authenticated user's own listings
//
// mrktplaats my-ads [--status active|inactive|sold] [--limit N]
func cmdMyAds(args []string) {
fs := flag.NewFlagSet("my-ads", flag.ExitOnError)
status := fs.String("status", "active", "filter by status: active | inactive | sold")
limit := fs.Int("limit", 50, "max listings to return")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, `usage: mrktplaats my-ads [--status STATUS] [--limit N]
List the authenticated user's own listings.
Output: {"total": N, "ads": [{item_id, title, status, price_cents, price_euros}]}
Flags:`)
fs.PrintDefaults()
}
_ = fs.Parse(args)
client := authedClient()
resp, err := client.MyAccount.MyAds(ctx(), &mrktplaats.MyAdsOptions{
Status: *status,
Limit: *limit,
Counts: true,
})
if err != nil {
fatalf("my-ads: %v", err)
}
type adResult struct {
ItemID string `json:"item_id"`
Title string `json:"title"`
Status string `json:"status"`
PriceCents int `json:"price_cents"`
PriceEuros float64 `json:"price_euros"`
}
var ads []adResult
for _, a := range resp.MyAds {
ads = append(ads, adResult{
ItemID: a.ItemID,
Title: a.Title,
Status: a.Status,
PriceCents: a.PriceInCents,
PriceEuros: float64(a.PriceInCents) / 100,
})
}
out(map[string]any{
"total": resp.MyAdsTotalCount,
"ads": ads,
})
}
// delete-ad — delete one of the authenticated user's listings
//
// mrktplaats delete-ad --urn URN [--reason 1|2|3|4]
func cmdDeleteAd(args []string) {
fs := flag.NewFlagSet("delete-ad", flag.ExitOnError)
urn := fs.String("urn", "", "listing URN to delete (required)")
reason := fs.Int("reason", 4, "delete reason: 1=sold on marktplaats, 2=sold elsewhere, 3=no longer selling, 4=other")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, `usage: mrktplaats delete-ad --urn URN [--reason N]
Delete one of the authenticated user's own listings.
Reasons:
1 — sold on marktplaats.nl
2 — sold elsewhere
3 — no longer selling
4 — other (default)
Output: {"status": "deleted", "item_urn": "..."}
Flags:`)
fs.PrintDefaults()
}
_ = fs.Parse(args)
if *urn == "" {
fs.Usage()
os.Exit(1)
}
client := authedClient()
if err := client.MyAccount.DeleteAds(ctx(), []string{*urn}, mrktplaats.DeleteAdReason(*reason)); err != nil {
fatalf("delete-ad: %v", err)
}
out(map[string]any{"status": "deleted", "item_urn": *urn})
}
// seller-listings — all listings by a seller
//
// mrktplaats seller-listings --seller SELLER_ID [--size N] [--page N]
// mrktplaats seller-listings --urn URN [--size N] [--page N]
func cmdSellerListings(args []string) {
fs := flag.NewFlagSet("seller-listings", flag.ExitOnError)
sellerID := fs.String("seller", "", "numeric seller ID (e.g. 57506580)")
urn := fs.String("urn", "", "any listing URN from this seller — seller ID is resolved automatically")
size := fs.Int("size", 50, "max results per page")
page := fs.Int("page", 1, "page number")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, `usage: mrktplaats seller-listings --seller ID [--size N] [--page N]
mrktplaats seller-listings --urn URN [--size N] [--page N]
List all active listings by a seller. Provide either the seller's numeric ID
directly (--seller) or any listing URN belonging to that seller (--urn) and the
seller ID will be resolved automatically from the listing details.
Output: {"seller_id": "...", "seller_name": "...", "total": N, "listings": [...]}
Flags:`)
fs.PrintDefaults()
}
_ = fs.Parse(args)
if *sellerID == "" && *urn == "" {
fs.Usage()
os.Exit(1)
}
client := authedClient()
resolvedName := ""
if *sellerID == "" {
detail, err := client.Listings.Get(ctx(), *urn)
if err != nil {
fatalf("seller-listings: fetching listing %s: %v", *urn, err)
}
*sellerID = fmt.Sprintf("%d", detail.SellerInformation.ID)
resolvedName = detail.SellerInformation.Name
}
resp, err := client.Search.Fetch(ctx(), &mrktplaats.SearchRequest{
Query: "",
SellerID: *sellerID,
Page: *page,
Size: *size,
SortBy: "DATE_DESC",
ShowListings: true,
SupportsReservedFlag: true,
SearchOnTitleAndDescription: true,
}, nil)
if err != nil {
fatalf("seller-listings: %v", err)
}
type listingResult struct {
URN string `json:"urn"`
Title string `json:"title"`
PriceCents int `json:"price_cents"`
PriceLabel string `json:"price_label"`
PriceType string `json:"price_type"`
City string `json:"city"`
}
seen := map[string]bool{}
var listings []listingResult
for _, item := range resp.Items {
l := item.Listing()
if l == nil || seen[l.AdCore.URN] {
continue
}
seen[l.AdCore.URN] = true
if resolvedName == "" {
resolvedName = l.SellerInformation.Name
}
listings = append(listings, listingResult{
URN: l.AdCore.URN,
Title: l.AdCore.Title,
PriceCents: l.AdCore.Price.PriceAmount,
PriceLabel: l.AdCore.Price.PriceTypeLabel,
PriceType: l.AdCore.Price.PriceType,
City: l.AdCore.AdAddress.City,
})
}
total := 0
if resp.SearchHistograms != nil {
total = resp.SearchHistograms.NumFound
}
out(map[string]any{
"seller_id": *sellerID,
"seller_name": resolvedName,
"total": total,
"page": *page,
"listings": listings,
})
}
// reviews — view reviews for a seller
//
// mrktplaats reviews --seller SELLER_ID
// mrktplaats reviews --urn URN
func cmdReviews(args []string) {
fs := flag.NewFlagSet("reviews", flag.ExitOnError)
sellerID := fs.Int("seller", 0, "numeric seller ID (e.g. 57506580)")
urn := fs.String("urn", "", "any listing URN — seller ID is resolved automatically")
role := fs.String("role", "reviewee", "filter: reviewee (received) or all")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, `usage: mrktplaats reviews --seller ID [--role reviewee|all]
mrktplaats reviews --urn URN [--role reviewee|all]
View reviews for a seller. Provide either the seller's numeric ID directly
(--seller) or any listing URN belonging to that seller (--urn) and the seller
ID will be resolved automatically from the listing details.
Output: {"seller_id": N, "summary": {average_score, count}, "reviews": [...]}
Flags:`)
fs.PrintDefaults()
}
_ = fs.Parse(args)
if *sellerID == 0 && *urn == "" {
fs.Usage()
os.Exit(1)
}
client := authedClient()
if *sellerID == 0 {
detail, err := client.Listings.Get(ctx(), *urn)
if err != nil {
fatalf("reviews: fetching listing %s: %v", *urn, err)
}
*sellerID = detail.SellerInformation.ID
}
var reviewRole mrktplaats.ReviewRole
switch *role {
case "reviewee":
reviewRole = mrktplaats.ReviewRoleReviewee
case "all", "":
reviewRole = mrktplaats.ReviewRoleAll
default:
fatalf("invalid --role %q: must be 'reviewee' or 'all'", *role)
}
resp, err := client.Users.Reviews(ctx(), *sellerID, reviewRole)
if err != nil {
fatalf("reviews: %v", err)
}
type reviewResult struct {
ID int `json:"id"`
Score int `json:"score"`
ReviewerName string `json:"reviewer_name"`
ReviewerID int `json:"reviewer_id"`
Subject string `json:"subject"`
Direction string `json:"direction"`
ItemTitle string `json:"item_title"`
ItemID string `json:"item_id"`
Date string `json:"date"`
Details []detailResult `json:"details,omitempty"`
}
type summaryResult struct {
AverageScore float64 `json:"average_score"`
Count int `json:"count"`
}
var reviews []reviewResult
for _, r := range resp.Reviews {
var details []detailResult
for _, d := range r.Details {
var chars []string
for _, c := range d.Characteristics {
prefix := "+"
if !c.IsPositive {
prefix = "-"
}
chars = append(chars, prefix+c.Text)
}
details = append(details, detailResult{
Category: d.Category,
Score: d.Score,
Traits: chars,
})
}
reviews = append(reviews, reviewResult{
ID: r.ID,
Score: r.Score,
ReviewerName: r.Reviewer.Nickname,
ReviewerID: r.Reviewer.ID,
Subject: r.Content.Subject,
Direction: r.Direction,
ItemTitle: r.Advertisement.Title,
ItemID: r.Advertisement.ID,
Date: r.CreationDate.Time.Format("2006-01-02"),
Details: details,
})
}
out(map[string]any{
"seller_id": *sellerID,
"summary": summaryResult{
AverageScore: resp.Summary.AverageScore,
Count: resp.Summary.NumberOfReviews,
},
"reviews": reviews,
})
}
type detailResult struct {
Category string `json:"category"`
Score int `json:"score"`
Traits []string `json:"traits,omitempty"`
}
// categories — list full category tree
//
// mrktplaats categories
func cmdCategories(args []string) {
fs := flag.NewFlagSet("categories", flag.ExitOnError)
fs.Usage = func() {
fmt.Fprintln(os.Stderr, `usage: mrktplaats categories
List the full category tree. Does not require authentication.
Output: {"categories": [{category_id, name, children: [...], place_ad_allowed}]}
Flags:`)
fs.PrintDefaults()
}
_ = fs.Parse(args)
client := mrktplaats.NewClient()
resp, err := client.Categories.All(ctx())
if err != nil {
fatalf("categories: %v", err)
}
out(resp)
}
// similar — find similar/related listings
//
// mrktplaats similar --urn URN [--seller SELLER_ID] [--category CATEGORY_ID]
func cmdSimilar(args []string) {
fs := flag.NewFlagSet("similar", flag.ExitOnError)
urn := fs.String("urn", "", "listing URN (required)")
sellerID := fs.String("seller", "", "seller ID (resolved from listing if omitted)")
categoryID := fs.Int("category", 0, "category ID (resolved from listing if omitted)")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, `usage: mrktplaats similar --urn URN [--seller ID] [--category ID]
Find similar/related listings for a given listing. If --seller and --category
are omitted they are resolved automatically from the listing details.
Output: {"items": [{id, title, price, page_location}], "result_type": "..."}
Flags:`)
fs.PrintDefaults()
}
_ = fs.Parse(args)
if *urn == "" {
fs.Usage()
os.Exit(1)
}
client := authedClient()
// Resolve seller and category from the listing if not provided.
if *sellerID == "" || *categoryID == 0 {
detail, err := client.Listings.Get(ctx(), *urn)
if err != nil {
fatalf("similar: fetching listing %s: %v", *urn, err)
}
if *sellerID == "" {
*sellerID = fmt.Sprintf("%d", detail.SellerInformation.ID)
}
if *categoryID == 0 {
*categoryID = detail.AdCore.CategoryID
}
}
// The Relevant.Get API uses the numeric listing ID, not the full URN.
// Strip the "m" prefix if present (e.g. "m2372861012" -> "2372861012").
listingID := strings.TrimPrefix(*urn, "m")
resp, err := client.Relevant.Get(ctx(), listingID, *sellerID, *categoryID)
if err != nil {
fatalf("similar: %v", err)
}
out(resp)
}
// suggest — search autocomplete suggestions
//
// mrktplaats suggest --prefix TEXT [--category ID]
func cmdSuggest(args []string) {
fs := flag.NewFlagSet("suggest", flag.ExitOnError)
prefix := fs.String("prefix", "", "search prefix (required)")
category := fs.Int("category", 0, "category ID (0 = all)")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, `usage: mrktplaats suggest --prefix TEXT [--category ID]
Get search autocomplete suggestions. Does not require authentication.
Output: {"keyword": "...", "category": N, "suggestions": ["...", ...]}
Flags:`)
fs.PrintDefaults()
}
_ = fs.Parse(args)
if *prefix == "" {
fs.Usage()
os.Exit(1)
}
client := mrktplaats.NewClient()
resp, err := client.Search.KeywordSuggestions(ctx(), *prefix, *category)
if err != nil {
fatalf("suggest: %v", err)
}
out(resp)
}
// notifications — unread notification count
//
// mrktplaats notifications
func cmdNotifications(args []string) {
fs := flag.NewFlagSet("notifications", flag.ExitOnError)
fs.Usage = func() {
fmt.Fprintln(os.Stderr, `usage: mrktplaats notifications
Show unread notification count. Requires authentication.
Output: {"unread": N}
Flags:`)
fs.PrintDefaults()
}
_ = fs.Parse(args)
client := authedClient()
resp, err := client.Notifications.UnreadCount(ctx())
if err != nil {
fatalf("notifications: %v", err)
}
out(map[string]any{
"unread": resp.UnreadNotificationsCount,
})
}
// create-ad — publish a new listing
//
// mrktplaats create-ad --category ID --title T --description D --price EUROS [options]
func cmdCreateAd(args []string) {
fs := flag.NewFlagSet("create-ad", flag.ExitOnError)
categoryID := fs.Int("category", 0, "category ID (required — use 'syi-form' to find)")
title := fs.String("title", "", "listing title (required)")
description := fs.String("description", "", "listing description (required)")
price := fs.String("price", "", "price in euros, e.g. 25 or 25.50 (required)")
priceType := fs.String("price-type", "FIXED", "price type: FIXED | NEGOTIABLE | MIN_BID | FAST_BID | FREE | SEE_DESCRIPTION")
delivery := fs.String("delivery", "PICKUP", "delivery method: PICKUP | SHIP | PICKUP_AND_SHIP")
sellerName := fs.String("seller-name", "", "seller display name (required)")
postcode := fs.String("postcode", "", "postcode (required)")
pictureIDs := fs.String("pictures", "", "comma-separated picture IDs from 'upload-image'")
bidding := fs.Bool("bidding", false, "enable bidding")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, `usage: mrktplaats create-ad --category ID --title T --description D --price EUROS [options]
Publish a new listing. Requires authentication.
Use 'mrktplaats syi-form --category ID' to see required attributes for a category.
Use 'mrktplaats upload-image --file PATH' to upload images first.
Output: full CreateAdResponse with URN of the new listing
Flags:`)
fs.PrintDefaults()
}
_ = fs.Parse(args)
if *categoryID == 0 || *title == "" || *description == "" || *price == "" || *sellerName == "" || *postcode == "" {
fs.Usage()
os.Exit(1)
}
var picIDs []string
if *pictureIDs != "" {
picIDs = strings.Split(*pictureIDs, ",")
}
req := &mrktplaats.CreateAdRequest{
CategoryID: *categoryID,
Translations: []mrktplaats.Translation{{
Locale: "nl",
Title: *title,
Description: *description,
}},
PriceInCents: eurosToC(*price),
PriceType: *priceType,
BiddingEnabled: *bidding,
DeliveryMethod: *delivery,
SellerName: *sellerName,
Postcode: *postcode,
PictureIDs: picIDs,
Attributes: []any{},
FeatureTypes: []any{},
ShippingConfig: mrktplaats.ShippingConfig{Carriers: []string{}},
SelectedBundle: "FREE",
}
client := authedClient()
resp, err := client.SYI.Create(ctx(), req)
if err != nil {
fatalf("create-ad: %v", err)
}
out(map[string]any{
"status": "created",
"urn": resp.URN(),
"ad": resp.Ad,
})
}
// upload-image — upload an image for a listing
//
// mrktplaats upload-image --file PATH
func cmdUploadImage(args []string) {
fs := flag.NewFlagSet("upload-image", flag.ExitOnError)
filePath := fs.String("file", "", "path to image file (required)")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, `usage: mrktplaats upload-image --file PATH
Upload an image for use in a listing. Returns a picture ID to pass to 'create-ad --pictures'.
Requires authentication. Large images are automatically compressed.
Output: {"picture_id": "..."}
Flags:`)
fs.PrintDefaults()
}
_ = fs.Parse(args)
if *filePath == "" {
fs.Usage()
os.Exit(1)
}
f, err := os.Open(*filePath)
if err != nil {
fatalf("upload-image: opening file: %v", err)
}
defer f.Close()
filename := filepath.Base(*filePath)
client := authedClient()
picID, err := client.SYI.UploadImage(ctx(), f, filename)
if err != nil {
fatalf("upload-image: %v", err)
}
out(map[string]any{
"picture_id": picID,
})
}
// syi-form — show required attributes for a category
//
// mrktplaats syi-form --category ID
func cmdSYIForm(args []string) {
fs := flag.NewFlagSet("syi-form", flag.ExitOnError)
categoryID := fs.Int("category", 0, "category ID (required)")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, `usage: mrktplaats syi-form --category ID
Show the listing creation form (required/optional attributes) for a category.
Requires authentication.
Output: {"category_id": N, "attributes": [{id, key, label, type, mandatory, values: [...]}]}
Flags:`)
fs.PrintDefaults()
}
_ = fs.Parse(args)
if *categoryID == 0 {
fs.Usage()
os.Exit(1)
}
client := authedClient()
resp, err := client.SYI.Form(ctx(), *categoryID)
if err != nil {
fatalf("syi-form: %v", err)
}
type attrResult struct {
ID string `json:"id"`
Key string `json:"key"`
Label string `json:"label"`
Type string `json:"type"`
Mandatory bool `json:"mandatory"`
Values []struct {
ID string `json:"id"`
Key string `json:"key"`
Label string `json:"label"`
} `json:"values,omitempty"`
}
var attrs []attrResult
for _, a := range resp.SYIAttributes {
ar := attrResult{
ID: a.ID,
Key: a.Key,
Label: a.Label,
Type: a.AttributeType,
Mandatory: a.Mandatory,
}
for _, v := range a.SupportedValues {
ar.Values = append(ar.Values, struct {
ID string `json:"id"`
Key string `json:"key"`
Label string `json:"label"`
}{ID: v.ID, Key: v.Key, Label: v.Label})
}
attrs = append(attrs, ar)
}
out(map[string]any{
"category_id": resp.CategoryID,
"attributes": attrs,
})
}
// price-suggestion — get suggested price ranges
//
// mrktplaats price-suggestion --category ID --title TEXT
func cmdPriceSuggestion(args []string) {
fs := flag.NewFlagSet("price-suggestion", flag.ExitOnError)
categoryID := fs.Int("category", 0, "category ID (required)")
title := fs.String("title", "", "listing title (required)")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, `usage: mrktplaats price-suggestion --category ID --title TEXT
Get suggested price ranges for a listing based on similar items.
Does not require authentication.
Output: {"segments": [{title, min_price_cents, max_price_cents, min_euros, max_euros, similar_count}]}
Flags:`)
fs.PrintDefaults()
}
_ = fs.Parse(args)
if *categoryID == 0 || *title == "" {
fs.Usage()
os.Exit(1)
}
client := mrktplaats.NewClient()
resp, err := client.SYI.PriceSuggestion(ctx(), *categoryID, *title)
if err != nil {
fatalf("price-suggestion: %v", err)
}
type segResult struct {
Title string `json:"title"`
MinPriceCents int `json:"min_price_cents"`
MaxPriceCents int `json:"max_price_cents"`
MinEuros float64 `json:"min_euros"`
MaxEuros float64 `json:"max_euros"`
SimilarCount int `json:"similar_count"`
}
var segs []segResult
for _, s := range resp.Segments {
segs = append(segs, segResult{
Title: s.Title,
MinPriceCents: s.MinPrice,
MaxPriceCents: s.MaxPrice,
MinEuros: float64(s.MinPrice) / 100,
MaxEuros: float64(s.MaxPrice) / 100,
SimilarCount: s.TotalSimilarAdsCount,
})
}
out(map[string]any{
"segments": segs,
})
}
// recognize — auto-detect category from a photo
//
// mrktplaats recognize --file PATH
func cmdRecognize(args []string) {
fs := flag.NewFlagSet("recognize", flag.ExitOnError)
filePath := fs.String("file", "", "path to image file (required)")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, `usage: mrktplaats recognize --file PATH
Upload an image and get category/attribute predictions via image recognition.
Does not require authentication. Large images are automatically compressed.
Output: {"predictions": [{category_id, category_name, confidence, attributes}]}
Flags:`)
fs.PrintDefaults()
}
_ = fs.Parse(args)
if *filePath == "" {
fs.Usage()
os.Exit(1)
}
f, err := os.Open(*filePath)
if err != nil {
fatalf("recognize: opening file: %v", err)
}
defer f.Close()
filename := filepath.Base(*filePath)
client := mrktplaats.NewClient()
resp, err := client.Saleability.Recognize(ctx(), f, filename)
if err != nil {
fatalf("recognize: %v", err)
}
out(resp)
}
// ── usage ─────────────────────────────────────────────────────────────────────
func printUsage() {
fmt.Fprint(os.Stderr, `mrktplaats — marktplaats.nl command-line client
USAGE
mrktplaats <command> [flags]
AUTHENTICATION
Token resolution order:
1. MRKTPLAATS_TOKEN environment variable
2. ~/.config/mrktplaats/token (written automatically after login)
3. ./.mrktplaats-token in the current directory
mrktplaats login --email EMAIL --password PASS [--code 2FA_CODE]
COMMANDS
Authentication
login Authenticate and save access token
Browsing (no auth required)
search Search listings --query Q [--category ID] [--page N] [--size N] [--sort FIELD]
listing Get listing details --urn URN
seller-listings All listings by a seller --seller ID or --urn URN
reviews View seller reviews --seller ID or --urn URN [--role reviewee|all]
categories List full category tree
similar Find similar listings --urn URN [--seller ID] [--category ID]
suggest Search autocomplete --prefix TEXT [--category ID]
Messaging
conversations List conversations [--limit N]
messages Read conversation messages --conv CONV_ID [--limit N]
send Start a new conversation --urn URN --text TEXT
reply Reply in a conversation --conv CONV_ID --text TEXT
Bidding
bid Place a bid --urn URN --amount EUROS [--message TEXT]
remove-bid Remove a bid --id BID_ID
Favourites
favorites List saved listings [--limit N]
add-favorite Save a listing --urn URN
remove-favorite Unsave a listing --urn URN
My Account
me Show own profile
my-ads List own listings [--status active|inactive|sold] [--limit N]
delete-ad Delete a listing --urn URN [--reason 1-4]
notifications Unread notification count
Selling (SYI)
syi-form Show category form attrs --category ID
create-ad Publish a new listing --category ID --title T --description D --price EUROS
upload-image Upload image for listing --file PATH
price-suggestion Suggested price ranges --category ID --title TEXT
recognize Auto-detect category --file PATH (image recognition)
Run 'mrktplaats <command> --help' for flag details.
OUTPUT
All commands output pretty-printed JSON on stdout.
Errors are printed on stderr with exit code 1.
EXAMPLES
# Find a watch listing
mrktplaats search --query "seiko" --category 1831 --size 5
# Get full details including bid info
mrktplaats listing --urn m2372861012
# Message the seller
mrktplaats send --urn m2372861012 --text "Is this still available?"
# Reply to an existing conversation
mrktplaats conversations
mrktplaats reply --conv 1cgx:5qgvx3h:2p1gxt95d --text "Thanks, I will pick it up."
# Place a €25 bid
mrktplaats bid --urn m2372861012 --amount 25
# Save and remove a favourite
mrktplaats add-favorite --urn m2372861012
mrktplaats remove-favorite --urn m2372861012
# Sell an item
mrktplaats recognize --file photo.jpg
mrktplaats syi-form --category 1831
mrktplaats price-suggestion --category 1831 --title "Seiko SKX007"
mrktplaats upload-image --file photo.jpg
mrktplaats create-ad --category 1831 --title "Seiko SKX007" --description "Great watch" --price 150 --seller-name "Jan" --postcode "1000"
`)
}
// ── main ──────────────────────────────────────────────────────────────────────
func main() {
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
cmd := os.Args[1]
args := os.Args[2:]
switch cmd {
case "login":
cmdLogin(args)
case "search":
cmdSearch(args)
case "listing":
cmdListing(args)
case "conversations":
cmdConversations(args)
case "messages":
cmdMessages(args)
case "send":
cmdSend(args)
case "reply":
cmdReply(args)
case "bid":
cmdBid(args)
case "remove-bid":
cmdRemoveBid(args)
case "favorites":
cmdFavorites(args)
case "add-favorite":
cmdAddFavorite(args)
case "remove-favorite":
cmdRemoveFavorite(args)
case "me":
cmdMe(args)
case "my-ads":
cmdMyAds(args)
case "delete-ad":
cmdDeleteAd(args)
case "seller-listings":
cmdSellerListings(args)
case "reviews":
cmdReviews(args)
case "categories":
cmdCategories(args)
case "similar":
cmdSimilar(args)
case "suggest":
cmdSuggest(args)
case "notifications":
cmdNotifications(args)
case "create-ad":
cmdCreateAd(args)
case "upload-image":
cmdUploadImage(args)
case "syi-form":
cmdSYIForm(args)
case "price-suggestion":
cmdPriceSuggestion(args)
case "recognize":
cmdRecognize(args)
case "help", "--help", "-h":
printUsage()
default:
fmt.Fprintf(os.Stderr, "error: unknown command %q\n\n", cmd)
printUsage()
os.Exit(1)
}
}