walla-cli 0.1.3__tar.gz → 0.1.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. {walla_cli-0.1.3 → walla_cli-0.1.5}/PKG-INFO +33 -10
  2. {walla_cli-0.1.3 → walla_cli-0.1.5}/README.md +32 -9
  3. {walla_cli-0.1.3 → walla_cli-0.1.5}/pyproject.toml +1 -1
  4. walla_cli-0.1.5/scripts/dogfood_run.py +345 -0
  5. walla_cli-0.1.5/skills/walla/SKILL.md +42 -0
  6. {walla_cli-0.1.3 → walla_cli-0.1.5}/tests/test_edges.py +2 -1
  7. walla_cli-0.1.5/tests/test_export.py +55 -0
  8. walla_cli-0.1.5/tests/test_negotiate.py +118 -0
  9. {walla_cli-0.1.3 → walla_cli-0.1.5}/tests/test_session_store.py +3 -2
  10. walla_cli-0.1.5/tests/test_shortlist.py +50 -0
  11. {walla_cli-0.1.3 → walla_cli-0.1.5}/tests/test_verdict.py +23 -0
  12. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/__init__.py +1 -1
  13. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/account/actions.py +6 -0
  14. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/account/cookies.py +13 -1
  15. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/account/login.py +15 -2
  16. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/account/prompt.py +5 -5
  17. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/cli/banner.py +25 -23
  18. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/cli/cmd_account.py +17 -0
  19. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/cli/cmd_look.py +72 -4
  20. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/cli/cmd_ops.py +28 -2
  21. walla_cli-0.1.5/walla/core/instruct.py +150 -0
  22. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/core/path.py +5 -0
  23. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/hunter/__init__.py +3 -0
  24. walla_cli-0.1.5/walla/hunter/export.py +227 -0
  25. walla_cli-0.1.5/walla/hunter/export_pdf.py +75 -0
  26. walla_cli-0.1.5/walla/hunter/negotiate.py +221 -0
  27. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/hunter/profile_store.py +7 -0
  28. walla_cli-0.1.5/walla/hunter/shortlist.py +50 -0
  29. walla_cli-0.1.5/walla/hunter/verdict.py +76 -0
  30. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/mcp/server.py +13 -3
  31. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/models/profile.py +14 -2
  32. walla_cli-0.1.3/skills/walla/SKILL.md +0 -30
  33. walla_cli-0.1.3/walla/core/instruct.py +0 -51
  34. walla_cli-0.1.3/walla/hunter/verdict.py +0 -42
  35. {walla_cli-0.1.3 → walla_cli-0.1.5}/.cursor/rules/quality.mdc +0 -0
  36. {walla_cli-0.1.3 → walla_cli-0.1.5}/.github/workflows/ci.yml +0 -0
  37. {walla_cli-0.1.3 → walla_cli-0.1.5}/.gitignore +0 -0
  38. {walla_cli-0.1.3 → walla_cli-0.1.5}/AGENTS.md +0 -0
  39. {walla_cli-0.1.3 → walla_cli-0.1.5}/LICENSE +0 -0
  40. {walla_cli-0.1.3 → walla_cli-0.1.5}/docs/WIRE.md +0 -0
  41. {walla_cli-0.1.3 → walla_cli-0.1.5}/docs/assets/walla-github-banner.jpg +0 -0
  42. {walla_cli-0.1.3 → walla_cli-0.1.5}/docs/code_quality.md +0 -0
  43. {walla_cli-0.1.3 → walla_cli-0.1.5}/docs/data_engineering_standards.md +0 -0
  44. {walla_cli-0.1.3 → walla_cli-0.1.5}/docs/mcp.md +0 -0
  45. {walla_cli-0.1.3 → walla_cli-0.1.5}/docs/objective_function.md +0 -0
  46. {walla_cli-0.1.3 → walla_cli-0.1.5}/docs/rate_limit_analysis.md +0 -0
  47. {walla_cli-0.1.3 → walla_cli-0.1.5}/fixtures/categories.json +0 -0
  48. {walla_cli-0.1.3 → walla_cli-0.1.5}/fixtures/item_detail.json +0 -0
  49. {walla_cli-0.1.3 → walla_cli-0.1.5}/fixtures/search_bicicleta.json +0 -0
  50. {walla_cli-0.1.3 → walla_cli-0.1.5}/scripts/check_code_quality.py +0 -0
  51. {walla_cli-0.1.3 → walla_cli-0.1.5}/scripts/probe_rate.py +0 -0
  52. {walla_cli-0.1.3 → walla_cli-0.1.5}/tests/test_account.py +0 -0
  53. {walla_cli-0.1.3 → walla_cli-0.1.5}/tests/test_account_more.py +0 -0
  54. {walla_cli-0.1.3 → walla_cli-0.1.5}/tests/test_catch.py +0 -0
  55. {walla_cli-0.1.3 → walla_cli-0.1.5}/tests/test_cli_json.py +0 -0
  56. {walla_cli-0.1.3 → walla_cli-0.1.5}/tests/test_core.py +0 -0
  57. {walla_cli-0.1.3 → walla_cli-0.1.5}/tests/test_http_more.py +0 -0
  58. {walla_cli-0.1.3 → walla_cli-0.1.5}/tests/test_live.py +0 -0
  59. {walla_cli-0.1.3 → walla_cli-0.1.5}/tests/test_parsers.py +0 -0
  60. {walla_cli-0.1.3 → walla_cli-0.1.5}/tests/test_polite.py +0 -0
  61. {walla_cli-0.1.3 → walla_cli-0.1.5}/tests/test_search_api.py +0 -0
  62. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/account/__init__.py +0 -0
  63. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/account/inbox.py +0 -0
  64. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/account/session_store.py +0 -0
  65. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/cli/__init__.py +0 -0
  66. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/cli/catch.py +0 -0
  67. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/cli/console.py +0 -0
  68. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/cli/main.py +0 -0
  69. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/core/__init__.py +0 -0
  70. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/core/envelope.py +0 -0
  71. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/core/errors.py +0 -0
  72. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/core/exceptions.py +0 -0
  73. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/core/human_fix.py +0 -0
  74. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/http/__init__.py +0 -0
  75. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/http/client.py +0 -0
  76. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/http/headers.py +0 -0
  77. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/http/polite.py +0 -0
  78. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/hunter/watches.py +0 -0
  79. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/mcp/__init__.py +0 -0
  80. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/mcp/_entry.py +0 -0
  81. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/models/__init__.py +0 -0
  82. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/models/account.py +0 -0
  83. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/models/listing.py +0 -0
  84. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/search/__init__.py +0 -0
  85. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/search/api.py +0 -0
  86. {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/search/parse.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: walla-cli
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: Wallapop.es CLI - look · offer · talk for humans and agents
5
5
  Project-URL: Homepage, https://github.com/berendgort/wallapop-cli
6
6
  Project-URL: Repository, https://github.com/berendgort/wallapop-cli
@@ -71,19 +71,23 @@ The agent runs `walla` — ranks listings **GRAB / LOOK / PASS**, links you the
71
71
  item, asks before sending, then talks and offers on your behalf:
72
72
 
73
73
  ```bash
74
- walla setup --lat 41.39 --lon 2.17 --km 30 --label Barcelona --budget 400
74
+ walla setup --lat 41.39 --lon 2.17 --km 30 --label Barcelona \
75
+ --budget 400 --aggression fair --must "kite cabrinha"
75
76
  walla search "tabla kite Cabrinha" --max 40 --json
76
77
  # agent: GRAB · Cabrinha Moto 10m · €320 · 4 km
77
78
  # https://es.wallapop.com/item/...
78
79
 
80
+ walla search "…" --export md,csv,html,pdf --out ./shortlist --json
81
+ # or later: walla export --format md,csv,html,pdf --out ./shortlist --json
82
+
79
83
  walla login # paste session cookie once
80
- walla say <id> "Hola, ¿sigue disponible? Puedo pasar hoy." --yes
81
- walla offer <id> --eur 280 --yes # prints total; needs your confirm
82
- walla inbox --json # catch the reply
84
+ walla negotiate <id> --json # draft only; you approve text + EUR
85
+ walla say <id> "…" --yes # only after you approve
86
+ walla offer <id> --eur 280 --yes # only after you approve; never pays
83
87
  ```
84
88
 
85
- That is the product: a Cursor agent that can **look, talk, and offer** on
86
- Wallapop without opening the website for every step.
89
+ That is the product: a Cursor agent that can **look, draft, talk, and offer**
90
+ up to the point of payment. You finish pay/meet in the Wallapop app.
87
91
 
88
92
  **Quality bar (enforced in CI):**
89
93
 
@@ -116,7 +120,9 @@ Link listings as `https://es.wallapop.com/item/<web_slug>`. Verdicts:
116
120
  3. If no session: ask the human to run `walla login` and paste
117
121
  `__Secure-next-auth.session-token` from es.wallapop.com (Cookie-Editor).
118
122
  Agents may pass it non-interactively: `walla login --cookie '<value>' --json`.
119
- 4. Do the ask with `walla … --json` and narrate in prose.
123
+ 4. `walla instruct --json` — read `mandate` + `hitl`. If `mandate.budget` is
124
+ null, ask once for budget / aggression / must words, then `walla setup …`.
125
+ 5. Do the ask with `walla … --json` and narrate in prose.
120
126
 
121
127
  Optional MCP:
122
128
 
@@ -132,15 +138,31 @@ Optional MCP:
132
138
 
133
139
  | Ask | Command |
134
140
  |-----|---------|
141
+ | Mandate | `walla setup --budget 400 --aggression fair --must "kite"` |
135
142
  | Look | `walla search "tabla kite" --max 40 --json` then `walla item <id> --json` |
143
+ | Draft | `walla negotiate <id> --json` (win-win Spanish draft; never sends) |
136
144
  | Talk | `walla inbox --json` · `walla thread <id> --json` · `walla say <id> "…" --yes` |
137
145
  | Offer | `walla offer <id> --eur 180 --yes` |
138
146
 
147
+ ### HITL (ask once per decision)
148
+
149
+ | Step | Agent alone | Ask human |
150
+ |------|-------------|-----------|
151
+ | Search inside mandate | yes | if query vague or budget unset |
152
+ | Shortlist | show ranked Wallapop rows with markdown item links | if more than one GRAB, pick the id |
153
+ | Negotiate draft | yes | approve Spanish text |
154
+ | `say --yes` | never | yes to that exact text |
155
+ | `offer --yes` | never; refuse if EUR > budget | yes to that exact EUR |
156
+ | Pay / buy / reserve | **never** | human finishes in the Wallapop app |
157
+
139
158
  Rules:
140
159
 
141
- - Reads are safe. `say` / `offer` need `--yes` / `confirm=true`. Name listing + euros.
160
+ - Reads and negotiate drafts are safe. `say` / `offer` need `--yes`.
161
+ - `--yes` means send chat or price offer only. It never completes a purchase.
142
162
  - If they only asked to look, do not send.
143
- - Ambiguous match: ask once with candidates.
163
+ - Ambiguous match or multiple GRABs: ask once with candidates.
164
+ - Always present Wallapop shortlists ranked best-first with a markdown link on every item URL.
165
+ - Default search is all of Spain. Shipping counts anywhere. Use `walla search --local` only when the human asked for nearby or pickup.
144
166
  - In-person offers outside pickup radius: refuse.
145
167
  - Envelope: `ok`, `api_version`, `data` or `error` / `error_type` / `retryable`.
146
168
 
@@ -165,6 +187,7 @@ walla doctor --json
165
187
  | `walla search` / `item` / `categories` | Look |
166
188
  | `walla watch add\|list\|check` | Local saved searches |
167
189
  | `walla login` / `logout` / `whoami` | Session (paste cookie; optional `--password`) |
190
+ | `walla negotiate` | Win-win Spanish draft (never sends) |
168
191
  | `walla inbox` / `thread` / `say` | Talk |
169
192
  | `walla offer` | Offer (prints total, needs `--yes`) |
170
193
  | `walla fav list\|add\|rm` | Favorites |
@@ -27,19 +27,23 @@ The agent runs `walla` — ranks listings **GRAB / LOOK / PASS**, links you the
27
27
  item, asks before sending, then talks and offers on your behalf:
28
28
 
29
29
  ```bash
30
- walla setup --lat 41.39 --lon 2.17 --km 30 --label Barcelona --budget 400
30
+ walla setup --lat 41.39 --lon 2.17 --km 30 --label Barcelona \
31
+ --budget 400 --aggression fair --must "kite cabrinha"
31
32
  walla search "tabla kite Cabrinha" --max 40 --json
32
33
  # agent: GRAB · Cabrinha Moto 10m · €320 · 4 km
33
34
  # https://es.wallapop.com/item/...
34
35
 
36
+ walla search "…" --export md,csv,html,pdf --out ./shortlist --json
37
+ # or later: walla export --format md,csv,html,pdf --out ./shortlist --json
38
+
35
39
  walla login # paste session cookie once
36
- walla say <id> "Hola, ¿sigue disponible? Puedo pasar hoy." --yes
37
- walla offer <id> --eur 280 --yes # prints total; needs your confirm
38
- walla inbox --json # catch the reply
40
+ walla negotiate <id> --json # draft only; you approve text + EUR
41
+ walla say <id> "…" --yes # only after you approve
42
+ walla offer <id> --eur 280 --yes # only after you approve; never pays
39
43
  ```
40
44
 
41
- That is the product: a Cursor agent that can **look, talk, and offer** on
42
- Wallapop without opening the website for every step.
45
+ That is the product: a Cursor agent that can **look, draft, talk, and offer**
46
+ up to the point of payment. You finish pay/meet in the Wallapop app.
43
47
 
44
48
  **Quality bar (enforced in CI):**
45
49
 
@@ -72,7 +76,9 @@ Link listings as `https://es.wallapop.com/item/<web_slug>`. Verdicts:
72
76
  3. If no session: ask the human to run `walla login` and paste
73
77
  `__Secure-next-auth.session-token` from es.wallapop.com (Cookie-Editor).
74
78
  Agents may pass it non-interactively: `walla login --cookie '<value>' --json`.
75
- 4. Do the ask with `walla … --json` and narrate in prose.
79
+ 4. `walla instruct --json` — read `mandate` + `hitl`. If `mandate.budget` is
80
+ null, ask once for budget / aggression / must words, then `walla setup …`.
81
+ 5. Do the ask with `walla … --json` and narrate in prose.
76
82
 
77
83
  Optional MCP:
78
84
 
@@ -88,15 +94,31 @@ Optional MCP:
88
94
 
89
95
  | Ask | Command |
90
96
  |-----|---------|
97
+ | Mandate | `walla setup --budget 400 --aggression fair --must "kite"` |
91
98
  | Look | `walla search "tabla kite" --max 40 --json` then `walla item <id> --json` |
99
+ | Draft | `walla negotiate <id> --json` (win-win Spanish draft; never sends) |
92
100
  | Talk | `walla inbox --json` · `walla thread <id> --json` · `walla say <id> "…" --yes` |
93
101
  | Offer | `walla offer <id> --eur 180 --yes` |
94
102
 
103
+ ### HITL (ask once per decision)
104
+
105
+ | Step | Agent alone | Ask human |
106
+ |------|-------------|-----------|
107
+ | Search inside mandate | yes | if query vague or budget unset |
108
+ | Shortlist | show ranked Wallapop rows with markdown item links | if more than one GRAB, pick the id |
109
+ | Negotiate draft | yes | approve Spanish text |
110
+ | `say --yes` | never | yes to that exact text |
111
+ | `offer --yes` | never; refuse if EUR > budget | yes to that exact EUR |
112
+ | Pay / buy / reserve | **never** | human finishes in the Wallapop app |
113
+
95
114
  Rules:
96
115
 
97
- - Reads are safe. `say` / `offer` need `--yes` / `confirm=true`. Name listing + euros.
116
+ - Reads and negotiate drafts are safe. `say` / `offer` need `--yes`.
117
+ - `--yes` means send chat or price offer only. It never completes a purchase.
98
118
  - If they only asked to look, do not send.
99
- - Ambiguous match: ask once with candidates.
119
+ - Ambiguous match or multiple GRABs: ask once with candidates.
120
+ - Always present Wallapop shortlists ranked best-first with a markdown link on every item URL.
121
+ - Default search is all of Spain. Shipping counts anywhere. Use `walla search --local` only when the human asked for nearby or pickup.
100
122
  - In-person offers outside pickup radius: refuse.
101
123
  - Envelope: `ok`, `api_version`, `data` or `error` / `error_type` / `retryable`.
102
124
 
@@ -121,6 +143,7 @@ walla doctor --json
121
143
  | `walla search` / `item` / `categories` | Look |
122
144
  | `walla watch add\|list\|check` | Local saved searches |
123
145
  | `walla login` / `logout` / `whoami` | Session (paste cookie; optional `--password`) |
146
+ | `walla negotiate` | Win-win Spanish draft (never sends) |
124
147
  | `walla inbox` / `thread` / `say` | Talk |
125
148
  | `walla offer` | Offer (prints total, needs `--yes`) |
126
149
  | `walla fav list\|add\|rm` | Favorites |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "walla-cli"
3
- version = "0.1.3"
3
+ version = "0.1.5"
4
4
  description = "Wallapop.es CLI - look · offer · talk for humans and agents"
5
5
  authors = [{ name = "Dr. Berend Gort", email = "berend.gort@gmail.com" }]
6
6
  readme = "README.md"
@@ -0,0 +1,345 @@
1
+ #!/usr/bin/env python3
2
+ """Run one dogfood scenario. Usage: dogfood_run.py <scenario_id>"""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ from pathlib import Path
12
+
13
+ ROOT = Path(__file__).resolve().parents[1]
14
+ WALLA = ROOT / ".venv" / "bin" / "walla"
15
+ OUT_ROOT = Path("/tmp/walla-dogfood")
16
+
17
+
18
+ def run(cmd: list[str], env: dict[str, str]) -> dict:
19
+ t0 = time.perf_counter()
20
+ proc = subprocess.run(
21
+ cmd,
22
+ cwd=str(ROOT),
23
+ env=env,
24
+ capture_output=True,
25
+ text=True,
26
+ timeout=120,
27
+ )
28
+ ms = int((time.perf_counter() - t0) * 1000)
29
+ stdout = proc.stdout.strip()
30
+ payload = None
31
+ if "{" in stdout:
32
+ try:
33
+ payload = json.loads(stdout[stdout.find("{") :])
34
+ except json.JSONDecodeError:
35
+ payload = None
36
+ return {
37
+ "cmd": cmd,
38
+ "exit": proc.returncode,
39
+ "elapsed_ms": ms,
40
+ "stdout_head": stdout[:800],
41
+ "stderr_head": (proc.stderr or "")[:200],
42
+ "payload": payload,
43
+ }
44
+
45
+
46
+ def base_env(scenario: str) -> dict[str, str]:
47
+ cfg = OUT_ROOT / scenario / "config"
48
+ cfg.mkdir(parents=True, exist_ok=True)
49
+ env = os.environ.copy()
50
+ env["WALLA_CONFIG_DIR"] = str(cfg)
51
+ env["PATH"] = f"{ROOT / '.venv' / 'bin'}:{env.get('PATH', '')}"
52
+ env.pop("VIRTUAL_ENV", None)
53
+ return env
54
+
55
+
56
+ def scenario_fresh() -> dict:
57
+ env = base_env("fresh")
58
+ commands = [
59
+ run([str(WALLA), "doctor", "--json"], env),
60
+ run([str(WALLA), "search", "tabla", "--max", "3", "--json"], env),
61
+ run([str(WALLA), "instruct", "--json"], env),
62
+ ]
63
+ search = commands[1]
64
+ intake = bool(
65
+ search.get("payload")
66
+ and search["payload"].get("data", search["payload"]).get("ready") is False
67
+ ) or "intake" in (search.get("stdout_head") or "")
68
+ ok = commands[0]["exit"] == 0 and commands[2]["exit"] == 0 and not any(
69
+ "Traceback" in (c.get("stderr_head") or "") for c in commands
70
+ )
71
+ return {
72
+ "id": "fresh",
73
+ "ok": ok,
74
+ "commands": commands,
75
+ "unclear": "" if intake or ok else "expected intake when no profile",
76
+ "extra_steps": 0,
77
+ "notes": {"intake_or_ready_false": intake},
78
+ }
79
+
80
+
81
+ def scenario_mandate() -> dict:
82
+ env = base_env("mandate")
83
+ out = OUT_ROOT / "mandate" / "export"
84
+ out.mkdir(parents=True, exist_ok=True)
85
+ commands = [
86
+ run(
87
+ [
88
+ str(WALLA),
89
+ "setup",
90
+ "--lat",
91
+ "41.39",
92
+ "--lon",
93
+ "2.17",
94
+ "--budget",
95
+ "400",
96
+ "--aggression",
97
+ "fair",
98
+ "--must",
99
+ "kite",
100
+ "--json",
101
+ ],
102
+ env,
103
+ ),
104
+ run(
105
+ [
106
+ str(WALLA),
107
+ "search",
108
+ "tabla kite",
109
+ "--max",
110
+ "5",
111
+ "--json",
112
+ "--export",
113
+ "md,csv,html,pdf",
114
+ "--out",
115
+ str(out),
116
+ ],
117
+ env,
118
+ ),
119
+ ]
120
+ search = commands[1].get("payload") or {}
121
+ data = search.get("data", search)
122
+ listings = data.get("listings") or []
123
+ files_ok = all(
124
+ (out / f"walla-shortlist.{ext}").is_file() for ext in ("md", "csv", "html", "pdf")
125
+ )
126
+ urls_ok = all(
127
+ str(r.get("url", "")).startswith("https://es.wallapop.com/item/") for r in listings
128
+ ) if listings else False
129
+ fit_has_budget = False
130
+ if files_ok:
131
+ md = (out / "walla-shortlist.md").read_text(encoding="utf-8")
132
+ fit_has_budget = "400" in md or "budget" in md.lower()
133
+ ok = (
134
+ commands[0]["exit"] == 0
135
+ and commands[1]["exit"] == 0
136
+ and "hitl" in data
137
+ and files_ok
138
+ and urls_ok
139
+ and fit_has_budget
140
+ )
141
+ return {
142
+ "id": "mandate",
143
+ "ok": ok,
144
+ "commands": commands,
145
+ "unclear": "" if ok else "mandate/export checks failed",
146
+ "extra_steps": 0,
147
+ "notes": {
148
+ "files_ok": files_ok,
149
+ "urls_ok": urls_ok,
150
+ "fit_has_budget": fit_has_budget,
151
+ "grabs": (data.get("hitl") or {}).get("grabs"),
152
+ },
153
+ }
154
+
155
+
156
+ def scenario_draft() -> dict:
157
+ env = base_env("draft")
158
+ commands = [
159
+ run(
160
+ [
161
+ str(WALLA),
162
+ "setup",
163
+ "--lat",
164
+ "41.39",
165
+ "--lon",
166
+ "2.17",
167
+ "--budget",
168
+ "400",
169
+ "--aggression",
170
+ "fair",
171
+ "--json",
172
+ ],
173
+ env,
174
+ ),
175
+ run([str(WALLA), "search", "tabla kite", "--max", "5", "--json"], env),
176
+ ]
177
+ data = (commands[1].get("payload") or {}).get("data") or commands[1].get("payload") or {}
178
+ listings = data.get("listings") or []
179
+ grabs = [L for L in listings if L.get("verdict") == "GRAB"]
180
+ pick = (grabs or listings or [None])[0]
181
+ if not pick:
182
+ return {
183
+ "id": "draft",
184
+ "ok": False,
185
+ "commands": commands,
186
+ "unclear": "no listings to negotiate",
187
+ "extra_steps": 0,
188
+ }
189
+ commands.append(
190
+ run([str(WALLA), "negotiate", str(pick["id"]), "--json"], env)
191
+ )
192
+ neg = (commands[-1].get("payload") or {}).get("data") or commands[-1].get("payload") or {}
193
+ opening = str(neg.get("opening") or "")
194
+ hint = str(neg.get("hint") or "")
195
+ offer = neg.get("offer_eur")
196
+ ok = (
197
+ commands[-1]["exit"] == 0
198
+ and "¿Sigue disponible?" in opening
199
+ and offer is not None
200
+ and float(offer) <= 400
201
+ and ("never" in hint.lower() or "pay" in hint.lower())
202
+ )
203
+ return {
204
+ "id": "draft",
205
+ "ok": ok,
206
+ "commands": commands,
207
+ "unclear": "" if ok else "negotiate draft missing fields",
208
+ "extra_steps": 0,
209
+ "notes": {"offer_eur": offer, "opening_head": opening[:80]},
210
+ }
211
+
212
+
213
+ def scenario_gates() -> dict:
214
+ env = base_env("gates")
215
+ # no budget negotiate
216
+ commands = [
217
+ run(
218
+ [str(WALLA), "setup", "--lat", "41.39", "--lon", "2.17", "--json"],
219
+ env,
220
+ ),
221
+ run([str(WALLA), "search", "tabla", "--max", "1", "--json"], env),
222
+ ]
223
+ data = (commands[1].get("payload") or {}).get("data") or commands[1].get("payload") or {}
224
+ listings = data.get("listings") or []
225
+ item_id = listings[0]["id"] if listings else "missing"
226
+ commands.append(run([str(WALLA), "negotiate", item_id, "--json"], env))
227
+ no_budget = commands[-1]["exit"] != 0 or "set_budget" in (
228
+ commands[-1].get("stdout_head") or ""
229
+ ) + (commands[-1].get("stderr_head") or "")
230
+ # with budget: offer without --yes
231
+ env2 = base_env("gates-budget")
232
+ commands.append(
233
+ run(
234
+ [
235
+ str(WALLA),
236
+ "setup",
237
+ "--lat",
238
+ "41.39",
239
+ "--lon",
240
+ "2.17",
241
+ "--budget",
242
+ "100",
243
+ "--json",
244
+ ],
245
+ env2,
246
+ )
247
+ )
248
+ commands.append(run([str(WALLA), "search", "tabla", "--max", "1", "--json"], env2))
249
+ data2 = (commands[-1].get("payload") or {}).get("data") or commands[-1].get("payload") or {}
250
+ listings2 = data2.get("listings") or []
251
+ item2 = listings2[0]["id"] if listings2 else item_id
252
+ commands.append(
253
+ run([str(WALLA), "offer", item2, "--eur", "99999", "--json"], env2)
254
+ )
255
+ offer_payload = (commands[-1].get("payload") or {}).get("data") or commands[-1].get(
256
+ "payload"
257
+ ) or {}
258
+ needs_confirm = bool(offer_payload.get("needs_confirm")) or commands[-1]["exit"] != 0
259
+ # refuse over budget with --yes would send - don't use --yes for over budget check via negotiate
260
+ ok = no_budget and needs_confirm
261
+ return {
262
+ "id": "gates",
263
+ "ok": ok,
264
+ "commands": commands,
265
+ "unclear": "" if ok else "HITL gates not clear",
266
+ "extra_steps": 0,
267
+ "notes": {"no_budget": no_budget, "needs_confirm": needs_confirm},
268
+ }
269
+
270
+
271
+ def scenario_auth() -> dict:
272
+ env = base_env("auth")
273
+ commands = [
274
+ run([str(WALLA), "login", "--json"], env),
275
+ run([str(WALLA), "whoami", "--json"], env),
276
+ ]
277
+ texts = " ".join(
278
+ (c.get("stdout_head") or "") + (c.get("stderr_head") or "") for c in commands
279
+ )
280
+ names_next = "login" in texts.lower() or "--cookie" in texts.lower()
281
+ no_tb = "Traceback" not in texts
282
+ ok = commands[0]["exit"] != 0 and commands[1]["exit"] != 0 and names_next and no_tb
283
+ return {
284
+ "id": "auth",
285
+ "ok": ok,
286
+ "commands": commands,
287
+ "unclear": "" if ok else "auth errors unclear",
288
+ "extra_steps": 0,
289
+ }
290
+
291
+
292
+ def scenario_help() -> dict:
293
+ env = base_env("help")
294
+ commands = [
295
+ run([str(WALLA)], env),
296
+ run([str(WALLA), "negotiate", "--help"], env),
297
+ run([str(WALLA), "export", "--help"], env),
298
+ ]
299
+ text = " ".join(c.get("stdout_head") or "" for c in commands).lower()
300
+ ok = (
301
+ "kite" in text
302
+ and ("pipx" in text or "walla-cli" in text)
303
+ and "pdf" in text
304
+ and "negotiate" in text
305
+ )
306
+ return {
307
+ "id": "help",
308
+ "ok": ok,
309
+ "commands": commands,
310
+ "unclear": "" if ok else "banner missing kite example, install, or pdf export",
311
+ "extra_steps": 0,
312
+ }
313
+
314
+
315
+ SCENARIOS = {
316
+ "fresh": scenario_fresh,
317
+ "mandate": scenario_mandate,
318
+ "draft": scenario_draft,
319
+ "gates": scenario_gates,
320
+ "auth": scenario_auth,
321
+ "help": scenario_help,
322
+ }
323
+
324
+
325
+ def main() -> int:
326
+ if len(sys.argv) != 2 or sys.argv[1] not in SCENARIOS:
327
+ print("usage: dogfood_run.py <" + "|".join(SCENARIOS) + ">", file=sys.stderr)
328
+ return 2
329
+ sid = sys.argv[1]
330
+ OUT_ROOT.mkdir(parents=True, exist_ok=True)
331
+ result = SCENARIOS[sid]()
332
+ # strip bulky payloads for disk
333
+ for c in result.get("commands") or []:
334
+ if isinstance(c.get("payload"), dict):
335
+ c["payload_ok"] = c["payload"].get("ok")
336
+ c["payload_keys"] = list((c["payload"].get("data") or c["payload"]).keys())[:12]
337
+ c.pop("payload", None)
338
+ path = OUT_ROOT / f"{sid}.json"
339
+ path.write_text(json.dumps(result, indent=2), encoding="utf-8")
340
+ print(json.dumps({"wrote": str(path), "ok": result["ok"]}))
341
+ return 0 if result["ok"] else 1
342
+
343
+
344
+ if __name__ == "__main__":
345
+ raise SystemExit(main())
@@ -0,0 +1,42 @@
1
+ ---
2
+ name: walla
3
+ description: >
4
+ When the user pastes github.com/berendgort/wallapop-cli, wants Wallapop.es
5
+ search, inbox, offers, or chat: install walla, run doctor, have the human
6
+ walla login (paste session cookie), then look / negotiate / talk with
7
+ walla … --json. Never scrape HTML. Never print cookies or tokens. Never pay.
8
+ license: MIT
9
+ ---
10
+
11
+ # walla — agent skill (follow exactly)
12
+
13
+ ## Bootstrap
14
+
15
+ ```bash
16
+ pipx install 'walla-cli[mcp]' # PyPI: https://pypi.org/project/walla-cli/
17
+ walla doctor --json
18
+ # If auth.session is false: ask human to run `walla login` and paste cookie
19
+ walla instruct --json # read mandate + hitl.ladder + present
20
+ ```
21
+
22
+ If `mandate.budget` is null, ask once:
23
+
24
+ ```bash
25
+ walla setup --lat 41.39 --lon 2.17 --km 30 --budget 400 \
26
+ --aggression fair --must "kite cabrinha" --json
27
+ ```
28
+
29
+ ## Loop (efficient HITL)
30
+
31
+ 1. Search Spain-wide inside budget: `walla search "…" --json`
32
+ Distance only if the human asked nearby or pickup: add `--local`.
33
+ Optional shortlist share: `--export md,csv,html,pdf --out ./shortlist`
34
+ 2. **Present Wallapop results** from `data.shortlist` (already ranked best-first):
35
+ say they are on wallapop.es, number them 1…N, and put a markdown link on
36
+ every `https://es.wallapop.com/item/…` URL. Never summarize without links.
37
+ 3. One GRAB → `walla negotiate <id> --json`. Several GRABs → ask human to pick an id.
38
+ 4. Show opening + offer_eur + walk_away. Human approves text and EUR.
39
+ 5. Only then: `walla say … --yes` or `walla offer … --eur N --yes`
40
+ 6. **Stop.** Human pays or meets in the Wallapop app. `--yes` never means buy.
41
+
42
+ Reads and negotiate drafts are safe. Sends need confirm.
@@ -68,7 +68,8 @@ def test_verdict_distance_pass(tmp_path: Path, monkeypatch: pytest.MonkeyPatch)
68
68
  user_allows_shipping=False,
69
69
  location=ListingLocation(latitude=40.4, longitude=-3.7),
70
70
  )
71
- assert score_listing(far, p) == "PASS"
71
+ assert score_listing(far, p) == "GRAB"
72
+ assert score_listing(far, p, local=True) == "PASS"
72
73
 
73
74
 
74
75
  def test_cookie_bare_token(tmp_path: Path) -> None:
@@ -0,0 +1,55 @@
1
+ """Shortlist export unit tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from walla.hunter.export import fit_line, write_exports
8
+ from walla.models.profile import Profile
9
+
10
+
11
+ def _row() -> dict:
12
+ return {
13
+ "id": "abc",
14
+ "title": "Tabla kite Cabrinha 10m",
15
+ "description": "cabrinha kite",
16
+ "price": {"amount": 320.0, "currency": "EUR"},
17
+ "web_slug": "tabla-kite",
18
+ "url": "https://es.wallapop.com/item/tabla-kite-cabrinha",
19
+ "verdict": "GRAB",
20
+ "location": {"latitude": 41.39, "longitude": 2.17},
21
+ "user_allows_shipping": False,
22
+ "shippable": False,
23
+ }
24
+
25
+
26
+ def test_write_all_formats(tmp_path: Path) -> None:
27
+ profile = Profile(
28
+ lat=41.39, lon=2.17, km=30, budget=400, must_match=["kite"]
29
+ )
30
+ written = write_exports(
31
+ [_row()],
32
+ profile,
33
+ formats=["md", "csv", "html", "pdf"],
34
+ out_dir=tmp_path,
35
+ keywords="tabla kite",
36
+ )
37
+ assert set(written) == {"md", "csv", "html", "pdf"}
38
+ md = (tmp_path / "walla-shortlist.md").read_text(encoding="utf-8")
39
+ assert "https://es.wallapop.com/item/" in md
40
+ assert "400" in md or "budget" in md.lower() or "under" in md
41
+ csv = (tmp_path / "walla-shortlist.csv").read_text(encoding="utf-8")
42
+ assert "title,price_eur,verdict,url,fit" in csv
43
+ html = (tmp_path / "walla-shortlist.html").read_text(encoding="utf-8")
44
+ assert "<a href='https://es.wallapop.com/item/" in html
45
+ pdf = (tmp_path / "walla-shortlist.pdf").read_bytes()
46
+ assert pdf.startswith(b"%PDF")
47
+ assert b"es.wallapop.com" in pdf
48
+
49
+
50
+ def test_fit_mentions_budget() -> None:
51
+ profile = Profile(lat=41.39, lon=2.17, km=30, budget=400, must_match=["kite"])
52
+ fit = fit_line(_row(), profile)
53
+ assert "400" in fit
54
+ assert "GRAB" in fit
55
+ assert "kite" in fit.lower()