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.
- {walla_cli-0.1.3 → walla_cli-0.1.5}/PKG-INFO +33 -10
- {walla_cli-0.1.3 → walla_cli-0.1.5}/README.md +32 -9
- {walla_cli-0.1.3 → walla_cli-0.1.5}/pyproject.toml +1 -1
- walla_cli-0.1.5/scripts/dogfood_run.py +345 -0
- walla_cli-0.1.5/skills/walla/SKILL.md +42 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/tests/test_edges.py +2 -1
- walla_cli-0.1.5/tests/test_export.py +55 -0
- walla_cli-0.1.5/tests/test_negotiate.py +118 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/tests/test_session_store.py +3 -2
- walla_cli-0.1.5/tests/test_shortlist.py +50 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/tests/test_verdict.py +23 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/__init__.py +1 -1
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/account/actions.py +6 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/account/cookies.py +13 -1
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/account/login.py +15 -2
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/account/prompt.py +5 -5
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/cli/banner.py +25 -23
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/cli/cmd_account.py +17 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/cli/cmd_look.py +72 -4
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/cli/cmd_ops.py +28 -2
- walla_cli-0.1.5/walla/core/instruct.py +150 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/core/path.py +5 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/hunter/__init__.py +3 -0
- walla_cli-0.1.5/walla/hunter/export.py +227 -0
- walla_cli-0.1.5/walla/hunter/export_pdf.py +75 -0
- walla_cli-0.1.5/walla/hunter/negotiate.py +221 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/hunter/profile_store.py +7 -0
- walla_cli-0.1.5/walla/hunter/shortlist.py +50 -0
- walla_cli-0.1.5/walla/hunter/verdict.py +76 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/mcp/server.py +13 -3
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/models/profile.py +14 -2
- walla_cli-0.1.3/skills/walla/SKILL.md +0 -30
- walla_cli-0.1.3/walla/core/instruct.py +0 -51
- walla_cli-0.1.3/walla/hunter/verdict.py +0 -42
- {walla_cli-0.1.3 → walla_cli-0.1.5}/.cursor/rules/quality.mdc +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/.github/workflows/ci.yml +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/.gitignore +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/AGENTS.md +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/LICENSE +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/docs/WIRE.md +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/docs/assets/walla-github-banner.jpg +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/docs/code_quality.md +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/docs/data_engineering_standards.md +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/docs/mcp.md +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/docs/objective_function.md +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/docs/rate_limit_analysis.md +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/fixtures/categories.json +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/fixtures/item_detail.json +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/fixtures/search_bicicleta.json +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/scripts/check_code_quality.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/scripts/probe_rate.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/tests/test_account.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/tests/test_account_more.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/tests/test_catch.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/tests/test_cli_json.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/tests/test_core.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/tests/test_http_more.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/tests/test_live.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/tests/test_parsers.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/tests/test_polite.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/tests/test_search_api.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/account/__init__.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/account/inbox.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/account/session_store.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/cli/__init__.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/cli/catch.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/cli/console.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/cli/main.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/core/__init__.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/core/envelope.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/core/errors.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/core/exceptions.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/core/human_fix.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/http/__init__.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/http/client.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/http/headers.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/http/polite.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/hunter/watches.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/mcp/__init__.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/mcp/_entry.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/models/__init__.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/models/account.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/models/listing.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/search/__init__.py +0 -0
- {walla_cli-0.1.3 → walla_cli-0.1.5}/walla/search/api.py +0 -0
- {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
|
+
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
|
|
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
|
|
81
|
-
walla
|
|
82
|
-
walla
|
|
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**
|
|
86
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
37
|
-
walla
|
|
38
|
-
walla
|
|
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**
|
|
42
|
-
|
|
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.
|
|
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
|
|
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 |
|
|
@@ -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) == "
|
|
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()
|