Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,9 @@ homebrew_casks:
# unless the quarantine flag Homebrew sets on the download is removed.
hooks:
post:
install: |
if OS.mac?
system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/wallapop"]
install_steps: |
on_macos do
run "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "{{ .StagedPath }}/wallapop"]
end

scoops:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ eligible interactive commands check for a new stable release at most once a day
| `category list` | category ids |
| `alert list` | your saved searches on wallapop; `watch add search --from-alert ID --name NAME` turns one into a watch |
| `item show / images / open / favorite / unfavorite` | inspect a listing by hash or url |
| `item reserve / sold / delete` | act on your own listings (`sold`, `delete` ask or need `--yes`) |
| `item reserve / reactivate / sold / delete` | act on your own listings (`reactivate` relists an expired one; `sold`, `delete` ask or need `--yes`) |
| `user show / items / reviews` | look at a seller by hash, url or slug |
| `me show / items / favorites` | your account |
| `chat list / show / send / start / open / archive` | messaging; `open` is a live line-mode chat |
Expand Down
8 changes: 8 additions & 0 deletions docs-site/content/docs/reference/item.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,14 @@ Open the listing in the browser
wallapop item open ITEM
```

## `wallapop item reactivate`

Relist one of your own expired listings

```bash
wallapop item reactivate ITEM
```

## `wallapop item reserve`

Mark one of your own listings as reserved (or --off)
Expand Down
7 changes: 6 additions & 1 deletion docs/cli-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ profile list | use | remove
search [keywords...] | filters
category list
alert list
item show | open | favorite | unfavorite | reserve | sold | delete
item show | open | favorite | unfavorite | reserve | reactivate | sold | delete
user show | items | reviews
me show | items | favorites
chat list | show | send | start | open | archive
Expand Down Expand Up @@ -182,6 +182,11 @@ detail with the page flags.
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
Expand Down
64 changes: 64 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1187,6 +1187,51 @@ func TestSellerActionsOnAnotherSellersItemFailBeforeWriting(t *testing.T) {
}
}

func TestItemReactivateRelistsExpiredOwnItem(t *testing.T) {
h := newHarness(t)
h.login()
mine := h.fake.AddItem(fakewallapop.Item{Hash: "hashexpired0", Slug: "old-thing-123", Title: "Old", Price: 5, Seller: fakewallapop.UserHash, Expired: true})
if r := h.run("", "item", "show", mine.Hash); r.code != 4 {
t.Fatalf("an expired item should not resolve publicly before reactivation, got exit %d", r.code)
}
r := h.must("", "item", "reactivate", h.fake.URL+"/item/"+mine.Slug+"?utm_source=test")
var got struct{ Action, Item string }
decode(t, r.stdout, &got)
if got.Action != "reactivated" || got.Item != mine.Hash {
t.Fatalf("expected reactivated %s, got %s", mine.Hash, r.stdout)
}
h.must("", "item", "show", mine.Hash)
if r := h.must("", "item", "reactivate", mine.Hash); !strings.Contains(r.stdout, "already active") {
t.Fatalf("second reactivate should be a no-op, got %s", r.stdout)
}
if n := len(h.fake.RequestsUnder("/api/v3/items/" + mine.Hash + "/reactivate")); n != 1 {
t.Fatalf("expected one reactivate write, saw %d", n)
}
theirs := h.fake.AddItem(fakewallapop.Item{Hash: "hashtheirs01", Title: "Theirs", Price: 5, Seller: fakewallapop.OtherHash, Expired: true})
if r := h.run("", "item", "reactivate", theirs.Hash); r.code != 2 || !strings.Contains(r.stderr, "not one of your listings") {
t.Fatalf("reactivating someone else's item must be a usage error, got exit %d stderr %q", r.code, r.stderr)
}
for _, rq := range h.fake.RequestsUnder("/api/v3/items/" + theirs.Hash) {
if rq.Method != "GET" {
t.Fatalf("no write may reach wallapop for someone else's item, saw %s %s", rq.Method, rq.Path)
}
}
}

func TestItemReactivateFindsListingOnLaterPageByNumericURL(t *testing.T) {
h := newHarness(t)
h.login()
h.fake.UserItemsPageSize = 1
h.fake.AddItem(fakewallapop.Item{Hash: "hashactive00", Slug: "active-thing-111", Title: "Active", Price: 5, Seller: fakewallapop.UserHash})
mine := h.fake.AddItem(fakewallapop.Item{Hash: "hashexpired1", Slug: "old-thing-222", Title: "Old", Price: 5, Seller: fakewallapop.UserHash, Expired: true})
r := h.must("", "item", "reactivate", h.fake.URL+"/item/222")
var got struct{ Action, Item string }
decode(t, r.stdout, &got)
if got.Action != "reactivated" || got.Item != mine.Hash {
t.Fatalf("expected reactivated %s, got %s", mine.Hash, r.stdout)
}
}

func testPNG(t *testing.T, path string) {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
Expand Down Expand Up @@ -1559,3 +1604,22 @@ func TestSearchWatchDeduplicatesRepeatedItemsWithinCheck(t *testing.T) {
t.Fatalf("second check should be silent: %s", r.stdout)
}
}

func TestItemReactivateReportsIncompleteOwnershipLookup(t *testing.T) {
h := newHarness(t)
h.login()
h.fake.UserItemsPageSize = 1
for i := 0; i < 51; i++ {
h.fake.AddItem(fakewallapop.Item{Hash: fmt.Sprintf("%012d", i), Title: "Expired", Seller: fakewallapop.UserHash, Expired: true})
}
r := h.run("", "item", "reactivate", "000000000050")
if r.code != 2 || !strings.Contains(r.stderr, "first 50 pages") {
t.Fatalf("expected an explicit lookup cap, got exit %d stderr %q", r.code, r.stderr)
}
if n := len(h.fake.RequestsUnder("/api/v3/user/items")); n != 50 {
t.Fatalf("expected bounded lookup of 50 pages, saw %d", n)
}
if n := len(h.fake.RequestsUnder("/api/v3/items/000000000050/reactivate")); n != 0 {
t.Fatalf("incomplete ownership lookup must not write, saw %d requests", n)
}
}
26 changes: 26 additions & 0 deletions internal/cli/item.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ Examples:
a.favoriteCmd("favorite", "Add the listing to your favorites", true),
a.favoriteCmd("unfavorite", "Remove the listing from your favorites", false),
a.reserveCmd(),
a.reactivateCmd(),
a.destructiveItemCmd("sold", "sold", "Mark one of your own listings as sold", "Mark %s as sold? This cannot be undone on Wallapop.",
// Closures, not method values: a.Client is built in setup, after this runs.
func(ctx context.Context, hash string) error { return a.Client.MarkSold(ctx, hash) }),
Expand Down Expand Up @@ -197,6 +198,31 @@ func (a *App) reserveCmd() *cobra.Command {
return cmd
}

// reactivateCmd relists an expired listing. ownedItemHash reads the public
// detail, which 404s while a listing is expired, so ownership comes from the
// account's own list instead.
func (a *App) reactivateCmd() *cobra.Command {
return &cobra.Command{
Use: "reactivate ITEM", Short: "Relist one of your own expired listings", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := a.requireSession(); err != nil {
return err
}
it, err := a.Client.MyItem(cmd.Context(), args[0])
if err != nil {
return err
}
if !it.Expired {
return a.Printer.Print(actionResult{Action: "already active", Item: it.Hash, OK: true})
}
if err := a.Client.ReactivateItem(cmd.Context(), it.Hash); err != nil {
return err
}
return a.Printer.Print(actionResult{Action: "reactivated", Item: it.Hash, OK: true})
},
}
}

// ownedItemHash resolves ITEM and refuses it unless the active account listed
// it. The check lives here because only the CLI knows the Profile's user hash;
// Wallapop's own answer to a foreign write carries no explanation (see
Expand Down
3 changes: 2 additions & 1 deletion internal/cli/skills/wallapop-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Output: `{"items":[{hash,title,price,currency,reserved,sold,shippable,location,u
wallapop item show HASH_OR_URL
wallapop item favorite HASH | unfavorite HASH
wallapop item reserve HASH [--off] | sold HASH --yes | delete HASH --yes # own items
wallapop item reactivate HASH_OR_URL # relist an own expired item
wallapop user show USER | items USER | reviews USER # hash, profile URL or slug
wallapop me show | items [--sold] | favorites
```
Expand Down Expand Up @@ -93,7 +94,7 @@ Install writes the server entry into `~/.claude.json`, `~/.codex/config.toml` or

Tools: `search`, `item_show`, `user_show`, `watch_check`. Arguments are the command's flags with dashes as underscores (`max_price`, `no_mark_read`, `next_page` for `--next`); `condition` takes an array, `filter` an object.

Not tools, on purpose: `item reserve`, `item sold`, `item delete`, `item create`, `item edit`. Destructive and seller actions stay with the person at the terminal; asking for them returns a refusal that says so. The chat tools (`chat_list`, `chat_show`, `chat_send`, `chat_start`) are written but withheld until live message receive is verified.
Not tools, on purpose: `item reserve`, `item reactivate`, `item sold`, `item delete`, `item create`, `item edit`. Destructive and seller actions stay with the person at the terminal; asking for them returns a refusal that says so. The chat tools (`chat_list`, `chat_show`, `chat_send`, `chat_start`) are written but withheld until live message receive is verified.

## Config

Expand Down
38 changes: 31 additions & 7 deletions internal/fakewallapop/fake.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ type Item struct {
Seller string
Reserved bool
Sold bool
Removed bool // page 404s, API detail 404s
// Expired models a lapsed listing: listed under /user/items with
// expired.flag, while the public detail and page 404 until reactivated.
Expired bool
Removed bool // page 404s, API detail 404s
// PageMissing models the window after a create where the API detail
// answers but the rendered page has not propagated yet.
PageMissing bool
Expand Down Expand Up @@ -120,9 +123,11 @@ type Server struct {
// NewItemPageLags makes a created listing's rendered page 404 while its
// API detail already answers, the propagation window Wallapop really has.
NewItemPageLags bool
Uploads map[string]int // pictures received per upload id
WebhookStatus int // status for POST /hook (0 = 200)
Hooks []json.RawMessage
// UserItemsPageSize pages /user/items via meta.next and ?since= (zero: one page).
UserItemsPageSize int
Uploads map[string]int // pictures received per upload id
WebhookStatus int // status for POST /hook (0 = 200)
Hooks []json.RawMessage
}

func New() *Server {
Expand Down Expand Up @@ -254,7 +259,17 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
if data == nil {
data = []map[string]any{}
}
writeJSON(w, 200, map[string]any{"data": data, "meta": map[string]any{}})
meta := map[string]any{}
if n := s.UserItemsPageSize; n > 0 && p != "/api/v3/user/items/favorited" {
start, _ := strconv.Atoi(r.URL.Query().Get("since"))
start = min(max(start, 0), len(data))
end := min(start+n, len(data))
if end < len(data) {
meta["next"] = strconv.Itoa(end)
}
data = data[start:end]
}
writeJSON(w, 200, map[string]any{"data": data, "meta": meta})
case p == "/api/v3/items" && r.Method == http.MethodPost:
s.createItem(w, r)
case p == "/api/v3/items/upload/components":
Expand Down Expand Up @@ -428,6 +443,7 @@ func (s *Server) userItemJSON(it *Item) map[string]any {
"price": map[string]any{"amount": it.Price, "currency": "EUR"},
"shipping": map[string]bool{"item_is_shippable": true},
"reserved": map[string]bool{"flag": it.Reserved}, "sold": map[string]bool{"flag": it.Sold},
"expired": map[string]bool{"flag": it.Expired},
}
}

Expand Down Expand Up @@ -533,11 +549,19 @@ func (s *Server) items(w http.ResponseWriter, r *http.Request) {
it, ok := s.Items[hash]
malformed := s.MalformedItem
s.mu.Unlock()
if !ok || (it.Removed && action == "") {
if !ok || ((it.Removed || it.Expired) && action == "") {
writeJSON(w, 404, map[string]any{"code": 404, "message": "item not found"})
return
}
switch {
case action == "reactivate" && r.Method == http.MethodPut:
if !s.authed(w, r) || !s.owned(w, it, 401) {
return
}
s.mu.Lock()
it.Expired = false
s.mu.Unlock()
w.WriteHeader(204)
case action == "" && r.Method == http.MethodGet:
if malformed {
w.WriteHeader(200)
Expand Down Expand Up @@ -644,7 +668,7 @@ func (s *Server) itemPage(w http.ResponseWriter, r *http.Request) {
}
}
s.mu.Unlock()
if it == nil || it.Removed || it.PageMissing {
if it == nil || it.Removed || it.Expired || it.PageMissing {
w.WriteHeader(404)
fmt.Fprint(w, "<html>gone</html>")
return
Expand Down
35 changes: 35 additions & 0 deletions internal/fakewallapop/fake_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package fakewallapop

import (
"encoding/json"
"net/http"
"testing"
)

func TestUserItemsNegativeCursorReturnsFirstPage(t *testing.T) {
s := New()
defer s.Close()
s.UserItemsPageSize = 1
s.AddItem(Item{Hash: "hashactive00", Title: "Active", Seller: UserHash})
req, err := http.NewRequest(http.MethodGet, s.URL+"/api/v3/user/items?since=-1", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer access-token")
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
var body struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if res.StatusCode != http.StatusOK || len(body.Data) != 1 || body.Data[0].ID != "hashactive00" {
t.Fatalf("expected first page, got status %d body %+v", res.StatusCode, body)
}
}
1 change: 1 addition & 0 deletions internal/mcp/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ var withheld = []struct{ name, why string }{
{"item_sold", "marking a listing sold is irreversible on Wallapop; run `wallapop item sold` yourself"},
{"item_delete", "deleting a listing is irreversible on Wallapop; run `wallapop item delete` yourself"},
{"item_reserve", "reserving a listing changes what buyers see; run `wallapop item reserve` yourself"},
{"item_reactivate", "relisting an expired listing changes what buyers see; run `wallapop item reactivate` yourself"},
{"item_create", "publishing a listing is a seller action; run `wallapop item create` yourself"},
{"item_edit", "editing a listing is a seller action; run `wallapop item edit` yourself"},
}
Expand Down
Loading
Loading