Skip to content

Latest commit

 

History

History
637 lines (536 loc) · 40.1 KB

cli-spec.md

File metadata and controls

637 lines (536 loc) · 40.1 KB

wallapop-cli spec

Status: draft agreed in the design interview on 2026-09-14. Vocabulary in ../CONTEXT.md. Endpoint facts come from live read-only probes on that date; the API is unofficial and may drift.

1. Name

Binary wallapop. Module github.com/Microck/wallapop-cli. Go 1.27, single static binary.

2. One-liner

Search, track and chat on Wallapop from the terminal, with your own account.

3. Usage

wallapop [global flags] <noun> <verb> [args] [flags]

Noun-verb throughout. No catch-all subcommand, no abbreviations.

4. Command tree

auth       login | status | refresh | logout
profile    list | use | remove
search     [keywords...] | filters
category   list
alert      list
item       show | open | favorite | unfavorite | reserve | reactivate | sold | delete
user       show | items | reviews
me         show | items | favorites
chat       list | show | send | start | open | archive
watch      add (search|item|seller) | list | remove | check | run | events | service (install|uninstall|status)
sink       list | test
mcp        (no verb: serves) | install claude-code|codex|cursor
config     path | get | set | list
doctor
skills     list | get
completion <shell>

auth

  • auth login [--profile NAME] [--cookies FILE | --cookies-stdin] [--cookies-format auto|netscape|json|header] [--service | --no-service] Default is the cookie import prompt: it asks for a cookie export or the pasted value of __Secure-next-auth.session-token. Exports are parsed by Galleton's cookiefile package: Netscape cookies.txt, Cookie-Editor/EditThisCookie JSON (array or {"url", "cookies"} wrapper), DevTools/Playwright cookie lists, and Cookie: headers; a bare session value is also accepted. Only cookies for the Wallapop web and API hosts are read, so a whole-browser export cannot supply another site's NextAuth cookie. It reads that cookie plus device_id, mints an Access token once to validate, stores the Session, and seeds the Profile's default Location from GET /api/v3/users/me. Every login method (password, Google, Apple, Facebook) ends in the same browser cookie, so this is the only path. Email+password login (POST /api/v3/access/login) is not implemented: the flow was not verified and cookie import covers every account. Implicitly creates the Profile; the first Profile becomes default. The Session records session_expires, the imported cookie's expiry (not exposed by auth status for managed sessions; only the renewal warning reads it), and session_issued. session_issued is the import time, and when the login's own mint, auth status --check, auth refresh or doctor see a later engine renewal (last_refresh), the expiry moves to that renewal plus the recorded lifetime, at least 30 days, since each mint re-issues the cookie with a fresh 30-day expiry. After a successful login, when stdin and stderr are terminals, --no-input is not set, CI is not true, WALLAPOP_SESSION_TOKEN and WALLAPOP_GALLETON_* are unset, and the renewal service is not installed yet, it asks on stderr keep this login alive in the background? [Y/n]. Yes or an empty line runs the auth service enable code path and reports the unit on stderr; end of input counts as no; no prints run `wallapop auth service enable` later to turn it on. --service answers yes without asking and always runs the enable again, which retries a half-failed one (works with --no-input; a failure is exit 1 with the login kept), --no-service answers no; the two are exclusive (exit 2). The status is printed on stdout after the choice.
  • auth status shows locally stored profile/account metadata and the session source; --check obtains a usable token and reports session_valid. Managed cookie expiry is omitted because the daemon API does not expose it. When the imported cookie expires within 48 h and the renewal service is not installed, it adds a warning (also on stderr; never with WALLAPOP_GALLETON_* overrides, whose owner runs the engine); the exit code is unchanged.
  • auth refresh explicitly requests renewal and prints status. A failed or paused renewal fails the command; provider expiry/revocation can require another browser import.
  • auth logout [--yes] forgets the daemon session before deleting the local reference. Failed deletion preserves the reference for retry. This does not revoke the browser login.
  • auth service run keeps renewal in the foreground. enable explicitly installs a user OS-login service/task, disable removes it, status inspects without starting it, and stop drains an idle engine. Active clients prevent shutdown. No service is installed without consent: only enable, or a yes to the login prompt or --service. status also reports, from a running engine, proxied, paused_until, and proxy_mismatch when it runs with another proxy setting.

Session mechanics: a pinned Galleton v0.1.1 executable is included in release binaries and managed by the CLI. It renews through the existing GET /api/auth/session endpoint, extracts /token, requires a replacement cookie, and persists rotated credentials in its encrypted vault. The provider adapter's interval is 240 seconds, not a promise about token lifetime. Its block_signatures turn a CloudFront 403 page, or any non-JSON 403, during renewal into a temporary refusal (upstream_blocked, exit 5) that keeps the session; min_interval_ms spaces renewals one second apart. When a proxy is set, the adapter gets proxy_env: WALLAPOP_PROXY and the engine gets the URL in its environment only; a running engine with a different proxy setting is refused, not shared. These fields are outside Galleton's configuration hash, so sessions imported by v0.3.x keep working. Only the bearer token is handed to the existing API transport. Uploads and PubNub keep separate transports.

When a request of the CLI's own meets Wallapop's bot protection (a non-JSON 403 after the browser user agent retry, or a 429), the CLI pauses the engine it already uses for 15 minutes through Galleton's persisted provider pause. Paused renewals fail with provider_paused and the pause's end time (exit 5); a held token keeps working.

Session lifetime remains controlled by Wallapop. The historical sliding-cookie behavior recorded in ADR 0001 does not establish indefinite validity. No renewal is automatically replayed after an ambiguous failure. Watch checks obtain a token when a stored session exists; failure is a warning so public Watch checks can still run.

Legacy cookie profiles migrate on first authenticated use. After obtaining a usable token, the CLI atomically replaces the local cookie with a stable galleton_id. Concurrent saves preserve other profiles; stale saves cannot resurrect a removed profile. An interrupted migration adopts the existing daemon session instead of overwriting its rotated cookie. WALLAPOP_SESSION_TOKEN remains a non-persistent explicit environment override; it is not silently migrated. See the lifecycle guide and ADR 0003.

profile

  • profile list (name, account, default marker, session expiry)
  • profile use NAME sets the default
  • profile remove NAME [--yes] removes session and its Watches

Selection order: --profile > WALLAPOP_PROFILE > default in config.

search

  • search [keywords...] [--lat F --lng F] [--distance KM] [--min-price N --max-price N] [--condition new|as_good_as_new|good|fair|has_given_it_all]... [--category ID] [--shipping] [--since today|week|month] [--sort relevance|newest|price_asc|price_desc] [--filter key=value]... [--limit N] [--pages N] [--next TOKEN] Hits GET /api/v3/search. Location defaults to the Profile's, then config location. Without a Profile and without --lat/--lng, exit 2 with a hint. --filter is validated against GET /api/v3/search/filters/regular-filters for the resolved category; unknown keys exit 2 listing the valid ones. JSON output carries next_page for continuation.
  • search filters [--category ID] [keywords...] prints the filters Wallapop offers for that category (id, type, allowed values). Source of truth for --filter.

category

  • category list [--tree] from GET /api/v3/categories.

alert

Wallapop's server-side Saved searches (CONTEXT.md), read-only: the CLI never creates, enables or deletes them.

  • alert list from GET /api/v3/searchalerts/savedsearch/ (authenticated, needs X-AppVersion: 0 or the service answers 400). Each entry: id (UUID), title, keywords, location_label, Location (lat/lng), radius, Filters (the stored query minus location and bookkeeping, with Wallapop's own keys: max_sale_price, order_by, category_id...), alert enabled, new hits, created. category_ids is stored as a list and replayed as the first element under category_id, as the web does; other lists join with commas like the CLI's own multi-value flags.
  • watch add search --from-alert ID --name NAME reads GET .../savedsearch/{id} and stores a search Watch with that exact query, so its first Check returns what the web shows when the alert is opened. Unknown id: {"status":"NOT_FOUND"} 404, exit 4. Keywords or search flags alongside --from-alert are a usage error. Verified live 2026-09-15.

item

Item argument accepts a 12-char hash or an es.wallapop.com/item/... URL. Bare numeric ids are rejected with a hint: the item page needs the slug, and Wallapop has no numeric-id route. URLs resolve to the hash through the item page's __NEXT_DATA__; hashes combine the API detail with the page flags.

  • The item detail endpoint is the API of record: title, description, condition and price come from it, empty and zero included, and the rendered page contributes only what it alone knows (flags, counters, slug and the full taxonomy path). The page is a cached snapshot and lags writes. Both reference forms take the same merge, so a hash and its URL always report the same listing.
  • item show ITEM merges GET /api/v3/items/{hash} with the page flags (reserved, sold, expired, modified date, views, favorites).
  • item images ITEM returns the direct image URLs in gallery order, using the same item read as item show. JSON is an array of URL strings; JSONL emits one string per line; pretty emits one unadorned URL per line. No images returns an empty array (or no lines in JSONL/pretty). item show --format pretty also lists each image URL without truncation. These commands read metadata, not image bytes.
  • item open ITEM opens the web page in the browser.
  • item favorite ITEM / item unfavorite ITEM via PUT /api/v3/items/{hash}/favorite.
  • item reserve ITEM [--off], item sold ITEM [--yes], item delete ITEM [--yes] act on the account's own Items: PUT /api/v3/items/{hash}/reserve {reserved}, PUT .../sold, DELETE /api/v3/items/{hash}, all 204 with Authorization and X-DeviceOS: 0 alone. sold and delete prompt in a TTY and require --yes otherwise. The CLI fetches the Item first and refuses one listed by another account (usage error, exit 2) because Wallapop answers such writes with a bare 401 (reserve, delete) or 403 (sold); should such a status still come back (ownership changed between check and write), the API package reports it as a refused write, exit 1, not as a dead session. Verified live 2026-09-15 on a throwaway listing: reserve, unreserve, sold (moves to me items --sold) and delete (page and API 404 afterwards), each reflected on the web.
  • item reactivate ITEM relists one of the account's own expired Items with PUT /api/v3/items/{hash}/reactivate {} (204, the web's "Reactivar"). Expired Items 404 on the detail and page, so ownership is checked against GET /api/v3/user/items (expired.flag): not found is exit 2 with no write, not expired prints already active. Verified live 2026-10-24.
  • item create --title T --description D --price P --category LEAF --condition C --image F... [--attr k=v...] publishes with images: POST /api/v3/items/upload/components opens a client-UUID upload session, each picture goes to POST /api/v3/upload/{uuid}/pictures (multipart image part, 204), then POST /api/v3/items sends multipart (the first image plus an item JSON part: attributes, category_leaf_id as string, location, upload_id) with Accept: application/vnd.upload-v2+json (application/json answers 405), returning 200 {"id"}. Remaining images go to POST /api/v3/items/{id}/picture2 with one multipart image and an order field starting at 1. Failed gallery uploads warn with the item id and picture number while returning the published item successfully. Once that id comes back the listing exists, so a failure to read it straight back is reported as a notice over the listing as sent, never as a failed create: exiting non-zero there would invite a retry that publishes twice. --category resolves against GET /api/v3/categories?context=create (leaf id or name); --attr keys validate against that leaf's inherited attribute list and are sent under the category's own spelling; title, description, condition and price_amount are refused there, since they have typed flags; a missing title, description, price, category, condition or image is a usage error (exit 2), as is --lat without --lng or the reverse. Verified live 2026-09-15: Books leaf 24097 published with photo, page 200, then edited, image-replaced, deleted (old page 404).
  • item edit ITEM [--title T] [--description D] [--price P] [--condition C] [--category LEAF] [--attr k=v...] [--image F...] overlays only the flags passed onto the account's own listing (PUT /api/v3/items/{hash}, same multipart shape, JSON body when no images) and refuses foreign listings (exit 2). The write replaces the whole attribute set, so the listing's existing category attributes go back untouched under the flags passed. It also resends the leaf category: a single-level taxonomy is its own leaf, a nested path is matched back against the create tree (detail APIs expose the path, not the leaf id), and a nested path that will not resolve is an error rather than a guess, since a root id sent as the leaf recategorises the listing. An explicit positive --category pins the leaf and controls attribute validation. Image replacement sends the first image in the edit, then the rest through picture2 in order; later upload failures warn and return the edited item. Location is resent unchanged and omitted when the item carries none. No change flags is a usage error (exit 2). As with create, once the write lands a failed read-back is a notice over the listing with the overlay applied, not a failed edit. Verified live 2026-09-15 on the throwaway above: title+price edit, picture replacement, then delete; one older sofa listing (vjrewv00elzk) remained.

user / me

  • user show USER, user items USER, user reviews USER (public endpoints; USER is id or slug).
  • me show, me items [--sold], me favorites (authenticated). GET /api/v3/user/items[/sold] carries no location, so me items shows an empty city (observed 2026-09-15).

chat

Conversation argument: conversation hash, unique prefix of it, or an Item hash the account already has a Conversation about. Text chat endpoints were exercised live against the owner's test account on 2026-09-14; offer send, decline and receive were recorded on 2026-09-16.

  • chat list [--unread] [--archived] [--limit N] from GET /bff/messaging/inbox (page_size, max_messages, from=<next_from>) or GET /bff/messaging/archived.

  • chat show CONV [--limit N] from GET /bff/messaging/conversation/{hash} (last 30 Messages embedded), older pages via GET /api/v3/instant-messaging/archive/conversation/{hash}/messages?max_messages=30&from=. Oldest first. Marks the Conversation read with a PubNub message action seen on the last Message, unless --no-mark-read.

  • chat send CONV TEXT (- reads TEXT from stdin). PubNub publish on the Conversation's channel with message={id,payload:{text}} and meta={type:"text",sender:{platform:{app_version,os_version}},to_user_hash,from_user_hash,conversation_hash}, auth token from GET /api/v3/instant-messaging/token (PAM v3, grants inbox.<me>, signals.<me>, chat.<me>.*.*, chat.*.*.<me>). The channel string is opaque; never build it.

  • chat start ITEM TEXT: POST /api/v3/instant-messaging/conversation {item_hash_id} (bundle) returns {conversation_id, channel}; error code 100 means Wallapop's cap on new conversations. Then chat send. If a Conversation for that Item already exists in the inbox, reuse it instead of posting.

  • chat open CONV line-mode REPL: prints history, then long-polls PubNub subscribe on inbox.<me> and prints Messages whose conversation_hash matches, while reading lines from stdin to send. /quit or Ctrl-D exits. No TUI. Messages arriving during the session are marked seen as they land, the way opening the Conversation marks what was already there.

    Text message inbox delivery is a forwarded copy, recorded live 2026-09-16 between the owner's two accounts. Its envelope carries u.original_time_token and u.original_channel, which are the Message's identity on its own Conversation channel, and the envelope's own p.t is a few milliseconds later. The REST Message list and the seen/received actions both key off the original, so that is what the CLI stores and what a receipt is posted against; using the inbox copy's timetoken addresses a Message that exists nowhere else.

    The same channel also carries message actions: e: 3, source: "actions", no u, a d.data holding type (seen or received), messageTimetoken and a JSON value. These are receipts, not Messages, and are skipped. The account's own Messages are echoed back here too, flagged by from_user_hash, and chat open drops them since it already printed what it sent.

    The PubNub Access Manager token's real TTL is 60 minutes (ttl: 60 in its CBOR body). The CLI keeps a conservative 10-minute local guess and refreshes ahead of it, so a long session rotates the token roughly six times per real lifetime. Verified live 2026-09-16: a session open for 18 minutes refreshed at 11 minutes and kept receiving afterwards.

  • chat archive CONV [--undo]: PUT /api/v3/instant-messaging/conversations/archive {conversation_ids:[hash]} (bundle), unarchive for --undo. 409 counts as success.

  • chat offer CONV AMOUNT: sends one buyer offer in the Item's currency. AMOUNT is a positive decimal with at most two fractional digits. The CLI reads GET /bff/delivery/make-an-offer?item_id=HASH and rejects an exhausted daily allowance, a price below the reported floor, a price above asking, or the caller's own Item with exit 2 before any offer write. The floor is asking price less max_offer_percentage, rounded to cents. A missing restriction or price is an API-changed error, not permission to send. POST /api/v3/delivery/buyer/offers sends {offer_id:<client UUID>,offer_price_amount,offer_price_currency,item_ids:[HASH]} and answers 201 with an empty body.

  • chat offer decline CONV: seller-only. Find the newest linked offer, paging older Messages when necessary, refresh its status card, verify its Item and Buyer match the Conversation, and require PENDING. Never fall back to an older pending offer when the latest is terminal. PATCH /api/v3/delivery/offers/{offer_id}/status with {"status":"DECLINED"} answers 200 with an empty body. No accept or counter-offer command exists.

Recorded offer shapes, 2026-09-16

Delivery calls require bearer auth, X-DeviceOS: 0 and X-AppVersion: 0. The terms read on the owner's EUR 1 Item returned the following relevant fields. Names and identifiers in the examples and fixtures are sanitized; omitted card UI fields are not used by the CLI.

{"restriction":{"max_offers_per_day":10,"remaining_offers_per_day":8,"max_offer_percentage":30},"item":{"title":"Test item","price":{"amount":1.00,"currency":"EUR"}},"items_price":{"amount":1.00,"currency":"EUR"},"offer":null,"suggested_offers":[]}

This means a EUR 0.70 minimum, not a 30-percent-of-asking minimum. A new EUR 0.85 offer was sent by the owner's buyer Profile, received on the seller's PubNub subscription, then declined by the owner's seller Profile. The prior pending EUR 0.90 test offer was declined first. The writes returned 201 and 200 respectively. A subsequent buyer-side chat show --no-mark-read displayed EUR 0.85 as declined. Neither accept nor payment was called.

Offers arrive with PubNub u.type: "server-message", not a new offer meta type:

{"u":{"type":"server-message","date":1789593225517,"to_user_hash":"SELLER","from_user_hash":"BUYER","conversation_hash":"CONV"},"d":{"id":"MESSAGE_UUID","payload":{"payload":"{\"text\":\"Buyer ha hecho una oferta de 0,85€.\",\"type\":\"delivery\",\"actionURL\":\"/delivery/CONV\",\"buttons\":[{\"loc-key\":\"chat_buyer_all_all_third_voice_button\",\"loc-value\":\"Ver\",\"action\":\"https://wallapop.com/app/chat/offer/11111111-1111-4111-8111-111111111111\"}],\"enrichedPayload\":null}"}}}

The decline payload has the same shape and link, with text Has rechazado la oferta.. These two observed envelopes had no original_time_token or original_channel; the CLI displays them but does not invent a channel for a live read receipt. Text Messages continue to use their original identity. The complete sanitized envelopes are in internal/fakewallapop/testdata/offer-events.json, served by the fake in regression cases.

The messaging BFF uses type: "delivery_generic", with the inner JSON string directly in payload; it also copies the localized sentence into text. The button link carries the offer UUID. GET /bff/delivery/offer-details?offer_id=UUID supplies machine-readable values:

{"offer_status":{"title":"You received an offer!"},"offer_analytics":{"item_id":"ITEM","offer_id":"11111111-1111-4111-8111-111111111111","offer_price":{"amount":0.85,"currency":"EUR"},"offer_status":"PENDING","buyer_user_id":"BUYER"}}

After decline, offer_status in offer_analytics is DECLINED. List, show and live output attach this current card as message.offer with amount, currency and status; older Messages about the same offer therefore show its current state, not a reconstructed historical state. Accepted and expired states are displayed when returned, without an accept action. Original text, type, payload, identity and offer_id remain available in JSON. If a card cannot be read, retain the Message and expose offer_error; pretty output labels the missing details. Unknown Message types are printed with their type and retained payload rather than discarded. PubNub action envelopes remain excluded.

Not exposed: block/unblock, phone sharing, translation.

watch

Watches, Checks, Events and Sinks are defined in CONTEXT.md.

  • watch add search [keywords...] [search flags] --name NAME [--notify SINK]... [--interval DUR] [--pages N] [--emit-initial] or watch add search --from-alert ID --name NAME ... (see alert).

  • watch add item ITEM --name NAME [--notify]... [--emit-initial]

  • watch add seller USER --name NAME [--notify]... [--emit-initial] add runs the first Check immediately as a silent baseline; with --emit-initial that baseline is reported (and delivered to Sinks) as item.new / seller.new_item Events.

  • watch list, watch remove NAME [--yes].

  • watch check [NAME...] [--all] runs one Check per named Watch (default: all due). Prints Events (JSON array, or one object per line with --format jsonl), delivers them to the Watch's Sinks, updates state. Idempotent; exit 0 even when nothing changed. Mints once when the Profile has a stored Session so the scheduled timer keeps it alive (see auth).

  • watch run [--interval DUR] foreground loop: check --all every interval until Ctrl-C. Interval default 5m, floor 30s, plus up to 10% jitter.

  • watch events [NAME] [--since DUR] [--limit N] reads stored Event history.

  • watch service install [--interval DUR] writes a systemd user service+timer (Linux) or a launchd agent (macOS) that runs wallapop watch check --all --profile .... On Windows it prints the schtasks command to run by hand in cmd.exe (whole minutes, rounded up). Other platforms get a usage error. The unit pins the environment that decides where the CLI reads and writes: HOME, XDG_CONFIG_HOME, XDG_DATA_HOME, XDG_STATE_HOME, WALLAPOP_CONFIG, WALLAPOP_PROXY and the three base-URL overrides go into the unit as they stood at install time, because a scheduler starts a job with a bare environment and the timer would otherwise check a different, empty state database and report nothing. A relative XDG value is left out, since the CLI ignores it too and falls back to the HOME default; a relative WALLAPOP_CONFIG is made absolute, since that one is read raw and the scheduler runs the job from its own working directory. WALLAPOP_SESSION_TOKEN is never written: unit files are world-readable. For the same reason install refuses a WALLAPOP_PROXY carrying credentials, and on Windows, where the printed task cannot carry it, any WALLAPOP_PROXY (use the config proxy). Install refuses outright when a path that would go into the unit is relative, which happens when HOME itself is relative: systemd ignores a relative StandardOutput= and would not look where the units landed. uninstall fails if the scheduler cannot be stopped. On Linux, install also runs loginctl enable-linger, without which a user timer stops at logout and does not start at boot. status says not installed when the unit files are missing, otherwise what the scheduler reports: active/inactive/failed on systemd (unknown if systemctl --user is unavailable), suffixed with a warning when the timer is not enabled or linger is off; loaded/not loaded on launchd. systemd also gives the last trigger time as last_run. doctor shows the same state. uninstall removes the units and the log. Output is appended to $XDG_STATE_HOME/wallapop-cli/wallapop-watch- <profile>.log; watch check renames it to .log.1 once it passes 1 MiB, so the log is bounded by two files of about that size.

    Verified live on systemd 249 (2026-09-16), against the fake Wallapop, at a 30s interval: install registers, enables and starts the timer; it fires on install and on every interval; Events from the scheduled runs reach the log, the Event history and the Watch's Sinks; status reports active with the last trigger time and doctor reports the same; a log padded past 1 MiB is rotated to .log.1 by the next scheduled run, which then starts a fresh log; uninstall leaves no unit file, no timer, no unit systemd still knows about and no log, and status and doctor then say not installed. Reboot itself was not exercised, and stands on systemctl --user is-enabled reporting enabled plus linger being on, which is what makes a user timer start without a login. The box already had linger enabled, so install's loginctl enable-linger branch ran but had nothing to do. The launchd agent is unverified: no macOS host was available. Its plist is covered by a test that parses it and checks the keys launchd needs, and the schtasks line by a test that pins its quoting and rounding, but neither has run on its own platform.

Event types: item.new, item.price_changed, item.reserved, item.unreserved, item.sold, item.removed, item.edited, seller.new_item. Event shape:

{"type":"item.price_changed","watch":"iphone","profile":"default","at":"2026-09-14T10:40:00Z",
 "item":{"hash":"...","title":"...","price":{"amount":250,"currency":"EUR"},"url":"..."},
 "change":{"from":300,"to":250}}

Search Watches diff the first --pages pages (default 1): new hashes and price changes on already-seen hashes. Item Watches diff the item detail. Seller Watches diff the user's item list.

sink

Sinks live in config, not in the database:

[sinks.phone]
type = "ntfy"
url = "https://ntfy.sh"
topic = "wallapop"
token_file = "~/.config/wallapop-cli/ntfy-token"   # optional

[sinks.discord]
type = "webhook"
url = "https://discord.com/api/webhooks/..."
# body: the Event JSON; set template = "discord" for a {content: ...} wrapper

[sinks.script]
type = "exec"
command = ["~/bin/on-wallapop-event"]
# receives one Event JSON on stdin per Event
  • sink list, sink test NAME (sends a synthetic Event).

mcp

wallapop mcp serves an agent harness over the Model Context Protocol, stdio transport: newline-delimited JSON-RPC 2.0 on stdin and stdout, methods initialize, ping, tools/list, tools/call. Protocol revisions answered: 2024-11-05, 2025-03-26, 2025-06-18 (a client asking for one gets it back, any other gets the newest). Written against net/http-level primitives like the rest of the client, with no MCP SDK.

  • Each tool is defined as a CLI argv and runs the command in-process, so a tool's output is the same JSON the command prints, by construction. A command that fails comes back as a tool error carrying whatever it printed first and then the --error-format json envelope, exit code included, so a watch check that broke halfway still reports the Events it committed; a malformed call (unknown tool, argument of the wrong type, missing required argument) is a JSON-RPC -32602 instead, since no command ran.
  • Tools: search, item_show, user_show, watch_check. Arguments mirror the flags with dashes as underscores (max_price, next_page for --next); condition is an array, filter an object of key/value. Unknown argument names are refused rather than dropped.
  • Not exposed, and refused by name with the reason: item reserve, item sold, item delete, item create, item edit. Destructive and money-adjacent actions stay with the person at the terminal.
  • chat_list, chat_show, chat_send and chat_start are written but gated off (chatToolsEnabled in internal/mcp) until live PubNub receive is verified end to end; until then asking for one says so.
  • Every invocation re-reads config and credentials, so a long-running server follows a rotated session and picks up new watches. --profile given to wallapop mcp applies to every tool call.
  • wallapop mcp install claude-code|codex|cursor writes the entry into ~/.claude.json (or $CLAUDE_CONFIG_DIR/.claude.json), ~/.codex/config.toml (or $CODEX_HOME/config.toml) and ~/.cursor/mcp.json. The command is the running executable, the args are mcp plus --profile NAME when one is given. Other entries survive, and so do keys inside this one that the CLI does not own (env, timeouts): only command and args are written. Codex's TOML is edited as text so comments and key order survive; an existing entry written in a spelling that rewrite cannot match (["mcp_servers"."wallapop"], an inline table) stops the install with the lines to paste, rather than appending a duplicate table that would break the file. Quoted and escaped command and args keys are recognized by their decoded names. Table-valued command or args, including dotted keys and explicit subtables, stop the install without changing the file and must be edited by hand. A config that is a symlink is followed, so a dotfile manager keeps its file. Output is {harness,path,action,command,args} with action added, updated or unchanged, so a second run is a no-op that says so. Configure an exec sink entirely through the CLI with a TOML array of strings:
wallapop config set sinks.script.type exec
wallapop config set sinks.script.command '["/home/me/bin/on-wallapop-event", "--verbose"]'
wallapop config get sinks.script.command
wallapop sink test script

Quote the array for the shell. Invalid arrays or non-string elements return exit 2 and leave the previous config unchanged.

config / doctor / skills / completion

  • config path | list | get KEY | set KEY VALUE on config.toml. set proxy validates the scheme (http, https, socks5, socks5h), host and port, and refuses credentials because the file is world-readable; a rejected value is exit 2 and leaves the file untouched.
  • doctor checks: config parses, proxy in use (without credentials), Session mints, api.wallapop.com reachable, PubNub token obtainable, service unit state, and a renewal line marked as a warning ("warning": true, still ok) when the imported session cookie expires within 48 h and the renewal service is not installed. Exit 1 if any check fails.
  • skills list | get NAME prints the embedded agent-facing docs (usage, output shapes).
  • completion bash|zsh|fish|powershell (cobra).

5. Global flags

flag default meaning
--profile NAME config default which Profile acts
--format json|pretty|jsonl|toon json stdout rendering. pretty is for humans; jsonl streams one object per line (watch, chat open)
--no-color off also honours NO_COLOR, TERM=dumb, non-TTY
--error-format text|json text stderr error rendering
--no-input off never prompt; missing input is an error
--debug off HTTP request/response log on stderr, secrets redacted
-h, --help, --version

-q/--quiet is not offered; JSON output is already minimal and --format covers the rest.

6. I/O contract

  • stdout: data only, one JSON document per command (or JSON lines). pretty output never goes to stdout when stdout is not a TTY unless asked for explicitly.
  • stderr: errors, prompts, progress, debug.
  • Prompts only when stdin, stdout and stderr are all TTYs and --no-input is absent.
  • Secrets never appear in argv: cookies come from a file, stdin or the wizard.

7. Exit codes

code meaning
0 success (including "nothing changed")
1 generic failure
2 usage: bad flags, unknown filter key, missing location
3 auth: no Session, Session rejected, MFA pending
4 not found: item/user/conversation/watch
5 blocked or rate limited (CloudFront 403, 429), or session renewal paused after one
6 network / timeout
7 API changed: known endpoint returned an unexpected shape or status
130 interrupted

8. Errors

Text errors are one sentence, lowercase-led, consequence plus remedy. JSON envelope on stderr with --error-format json: {code, category, retryable, message, http_status, endpoint, suggested_commands, issue_url}.

Exit 7 always prints an issue_url pointing at https://github.com/Microck/wallapop-cli/issues/new?template=api-change.yml&... with the form fields prefilled: CLI version, OS/arch, command path (no argument values), endpoint path, HTTP status, first 300 characters of the response body with tokens redacted. Plus a hint to rerun with --debug.

User agent is wallapop-cli/<version> (+https://github.com/Microck/wallapop-cli) (verified accepted on both hosts). If CloudFront answers 403 to it, the request is retried once with a browser user agent and a one-time stderr note says so.

9. Config, state, env

Paths via XDG (adrg/xdg):

what path mode
config $XDG_CONFIG_HOME/wallapop-cli/config.toml 0644
session references $XDG_CONFIG_HOME/wallapop-cli/credentials.toml 0600, locked/atomic updates
session vault $XDG_CONFIG_HOME/wallapop-cli/galleton/ private directory, encrypted provider credentials
state $XDG_DATA_HOME/wallapop-cli/state.db (SQLite, modernc.org/sqlite) 0600
logs $XDG_STATE_HOME/wallapop-cli/

credentials.toml holds one table per Profile: Galleton session ID, device id, account hash, name. Legacy cookies are read only for safe migration and are removed after successful handoff. It is never relocatable by config, only by XDG variables.

Config (pelletier/go-toml/v2, unknown keys are errors):

default_profile = "default"
proxy = "socks5h://127.0.0.1:1080"   # optional; WALLAPOP_PROXY overrides it

[profiles.default]
location = { lat = 40.4168, lng = -3.7038, radius_km = 20 }

[watch]
interval = "5m"

[sinks.phone]
type = "ntfy"
url = "https://ntfy.sh"
topic = "wallapop"

Env: WALLAPOP_PROFILE, WALLAPOP_CONFIG (config file path), WALLAPOP_SESSION_TOKEN (session cookie value for CI, wins over the credentials file), WALLAPOP_ERROR_FORMAT, WALLAPOP_API_BASE_URL / WALLAPOP_WEB_BASE_URL / WALLAPOP_PUBNUB_BASE_URL (tests), WALLAPOP_PROXY, NO_COLOR. WALLAPOP_PROXY (or config proxy) sends every Wallapop and PubNub request and every session renewal through that proxy, ignores HTTPS_PROXY and NO_PROXY, and fails with exit 6 rather than going direct when the proxy is unreachable; an invalid proxy fails every command but config with exit 2. Without one, HTTPS_PROXY/HTTP_PROXY apply to the CLI's own requests only. Precedence: flags > env > config.

10. Safety

  • Destructive on Wallapop's side, so prompt or --yes: item sold, item delete.
  • --yes accepted but never required elsewhere.
  • watch check and watch run are idempotent and crash-only: state is written after Events are delivered, so a crash re-emits rather than loses.
  • Ctrl-C exits within one in-flight request; a second Ctrl-C exits immediately.
  • Never phones home. No analytics.

11. Examples

wallapop auth login                                   # wizard, cookie export
wallapop auth login --cookies ~/Downloads/wallapop.txt --profile work
wallapop search "thinkpad x1" --max-price 400 --distance 50 --sort newest --format pretty
wallapop search --category 100 --filter brand=Toyota --filter max_km=120000 --limit 20
wallapop search filters --category 100 --format pretty
wallapop item show https://es.wallapop.com/item/thinkpad-x1-carbon-1092837465
wallapop item favorite k2j3h4g5f6d7
wallapop watch add search "thinkpad x1" --max-price 400 --name x1 --notify phone
wallapop watch add item k2j3h4g5f6d7 --name that-bike
wallapop watch check --all --format jsonl | jq -r 'select(.type=="item.new") | .item.url'
wallapop watch service install --interval 10m
wallapop chat list --unread --format pretty
wallapop chat send 8f1c2 "Sigue disponible?"
wallapop chat start k2j3h4g5f6d7 "Hola, lo recogería hoy"
echo "Te lo dejo en 200" | wallapop chat send 8f1c2 -
wallapop chat open 8f1c2
wallapop mcp install claude-code                      # then the harness runs `wallapop mcp`

12. Open items carried into implementation

  • PubNub subscribe (live receive in chat open) was verified end to end on 2026-09-16 with a second account: receive, receipts and token refresh across a long session. The fake now serves the recorded envelopes rather than an invented shape.
  • Authenticated writes work with Authorization + X-DeviceOS: 0 alone; no extra device headers were needed.
  • Legacy XMPP transport exists behind a feature flag on the web; the CLI implements PubNub only.

13. Not in v1

Email+password login, creating or editing Items (image upload), Wallapop server-side saved searches (v2, read-only), accepting offers (deferred to a separately approved ticket), wallet, shipping execution or payments (never), settings edits (never), MCP server (v2), AUR/npm/Mintlify docs (after first stable release), Windows service install.

Sending and declining price offers are in scope despite their delivery API paths. Accepting enters the delivery checkout flow and remains absent; see ADR 0002.

14. Stack

cobra + pflag, net/http (no HTTP framework, no PubNub SDK, no MCP SDK), modernc.org/sqlite, pelletier/go-toml/v2, adrg/xdg, goreleaser 2.x (homebrew cask, scoop, checksums with GitHub attestations), curl | sh installer. Tests: stdlib testing, httptest.Server as a fake Wallapop with redacted recorded fixtures, binary-level tests with an isolated XDG home. No mocks.

15. Layout

cmd/wallapop/main.go entrypoint; lives under cmd/ so `go install` names the binary wallapop
internal/cli/        one file per noun (auth.go, search.go, item.go, chat.go, watch.go, ...)
internal/wallapop/   API client: session.go, search.go, items.go, users.go, chat.go, pubnub.go
internal/store/      sqlite schema, watches, events, seen-state
internal/watch/      check logic per target type, diffing, event construction
internal/sink/       ntfy, webhook, exec
internal/mcp/        mcp stdio server, tool table, harness install
internal/output/     json/jsonl/pretty/toon renderers, error envelope, exit codes
internal/config/     config.toml, credentials.toml, xdg paths
internal/cli/skills/ embedded agent docs (go:embed needs them beside the package)
docs/                this spec, ADRs