walla-cli 0.1.0__py3-none-any.whl
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/__init__.py +7 -0
- walla/account/__init__.py +23 -0
- walla/account/actions.py +135 -0
- walla/account/cookies.py +88 -0
- walla/account/inbox.py +147 -0
- walla/account/login.py +205 -0
- walla/account/prompt.py +56 -0
- walla/account/session_store.py +36 -0
- walla/cli/__init__.py +7 -0
- walla/cli/banner.py +33 -0
- walla/cli/catch.py +43 -0
- walla/cli/cmd_account.py +162 -0
- walla/cli/cmd_look.py +143 -0
- walla/cli/cmd_ops.py +100 -0
- walla/cli/console.py +9 -0
- walla/cli/main.py +44 -0
- walla/core/__init__.py +12 -0
- walla/core/envelope.py +46 -0
- walla/core/errors.py +90 -0
- walla/core/exceptions.py +67 -0
- walla/core/human_fix.py +26 -0
- walla/core/instruct.py +50 -0
- walla/core/path.py +32 -0
- walla/http/__init__.py +14 -0
- walla/http/client.py +169 -0
- walla/http/headers.py +36 -0
- walla/http/polite.py +108 -0
- walla/hunter/__init__.py +18 -0
- walla/hunter/profile_store.py +56 -0
- walla/hunter/verdict.py +42 -0
- walla/hunter/watches.py +60 -0
- walla/mcp/__init__.py +7 -0
- walla/mcp/_entry.py +17 -0
- walla/mcp/server.py +135 -0
- walla/models/__init__.py +18 -0
- walla/models/account.py +48 -0
- walla/models/listing.py +51 -0
- walla/models/profile.py +37 -0
- walla/search/__init__.py +15 -0
- walla/search/api.py +78 -0
- walla/search/parse.py +150 -0
- walla_cli-0.1.0.dist-info/METADATA +204 -0
- walla_cli-0.1.0.dist-info/RECORD +46 -0
- walla_cli-0.1.0.dist-info/WHEEL +4 -0
- walla_cli-0.1.0.dist-info/entry_points.txt +4 -0
- walla_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Session persistence (mode 0600). Never stores the password."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from walla.core.path import session_path
|
|
9
|
+
from walla.models.account import SessionData
|
|
10
|
+
|
|
11
|
+
__all__ = (
|
|
12
|
+
"clear_session",
|
|
13
|
+
"load_session",
|
|
14
|
+
"save_session",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_session(path: Path | None = None) -> SessionData | None:
|
|
19
|
+
p = path or session_path()
|
|
20
|
+
if not p.is_file():
|
|
21
|
+
return None
|
|
22
|
+
return SessionData.model_validate_json(p.read_text(encoding="utf-8"))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def save_session(session: SessionData, path: Path | None = None) -> Path:
|
|
26
|
+
p = path or session_path()
|
|
27
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
p.write_text(session.model_dump_json(indent=2), encoding="utf-8")
|
|
29
|
+
os.chmod(p, 0o600)
|
|
30
|
+
return p
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def clear_session(path: Path | None = None) -> None:
|
|
34
|
+
p = path or session_path()
|
|
35
|
+
if p.is_file():
|
|
36
|
+
p.unlink()
|
walla/cli/__init__.py
ADDED
walla/cli/banner.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Terminal brand mark for ``walla --help``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
|
|
7
|
+
from walla.cli.console import console
|
|
8
|
+
|
|
9
|
+
__all__ = ("print_banner",)
|
|
10
|
+
|
|
11
|
+
_BANNER_ROWS = (
|
|
12
|
+
r"██╗ ██╗ █████╗ ██╗ ██╗ █████╗ ██████╗ ██╗ ██╗",
|
|
13
|
+
r"██║ ██║ ██╔══██╗ ██║ ██║ ██╔══██╗ ██╔════╝ ██║ ██║",
|
|
14
|
+
r"██║ █╗ ██║ ███████║ ██║ ██║ ███████║ █████╗ ██║ ██║ ██║",
|
|
15
|
+
r"██║███╗██║ ██╔══██║ ██║ ██║ ██╔══██║ ╚════╝ ██║ ██║ ██║",
|
|
16
|
+
r"╚███╔███╔╝ ██║ ██║ ███████╗ ███████╗ ██║ ██║ ╚██████╗ ███████╗ ██║",
|
|
17
|
+
r" ╚══╝╚══╝ ╚═╝ ╚═╝ ╚══════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
_STYLE = "bold #A3E635"
|
|
21
|
+
_TAG = "italic #D9F99D"
|
|
22
|
+
_CREDIT = "#84CC16"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def print_banner() -> None:
|
|
26
|
+
if not console.is_terminal:
|
|
27
|
+
return
|
|
28
|
+
console.print()
|
|
29
|
+
for row in _BANNER_ROWS:
|
|
30
|
+
console.print(Text(row, style=_STYLE))
|
|
31
|
+
console.print(Text(" look · offer · talk", style=_TAG))
|
|
32
|
+
console.print(Text(" Dr. Berend Gort · www.berendgort.dev", style=_CREDIT))
|
|
33
|
+
console.print()
|
walla/cli/catch.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""CLI error catching and JSON envelope printing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any, TypeVar
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from walla.cli.console import console
|
|
12
|
+
from walla.core.envelope import error_payload, success_payload
|
|
13
|
+
|
|
14
|
+
__all__ = (
|
|
15
|
+
"emit",
|
|
16
|
+
"run_cmd",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def emit(payload: dict[str, Any], *, as_json: bool) -> None:
|
|
23
|
+
if as_json:
|
|
24
|
+
console.print_json(json.dumps(payload, ensure_ascii=False))
|
|
25
|
+
else:
|
|
26
|
+
if payload.get("ok"):
|
|
27
|
+
data = payload.get("data")
|
|
28
|
+
console.print(data)
|
|
29
|
+
else:
|
|
30
|
+
console.print(f"[red]{payload.get('error')}[/red]")
|
|
31
|
+
raise typer.Exit(code=1)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def run_cmd(fn: Callable[[], Any], *, as_json: bool) -> None:
|
|
35
|
+
try:
|
|
36
|
+
data = fn()
|
|
37
|
+
emit(success_payload(data), as_json=as_json)
|
|
38
|
+
except Exception as exc: # noqa: BLE001
|
|
39
|
+
emit(error_payload(exc), as_json=True if as_json else as_json)
|
|
40
|
+
if as_json:
|
|
41
|
+
raise typer.Exit(code=1) from exc
|
|
42
|
+
console.print(f"[red]{exc}[/red]")
|
|
43
|
+
raise typer.Exit(code=1) from exc
|
walla/cli/cmd_account.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Account commands: login, inbox, say, offer, fav."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from walla.account.actions import (
|
|
11
|
+
add_favorite,
|
|
12
|
+
list_favorites,
|
|
13
|
+
make_offer,
|
|
14
|
+
quote_offer,
|
|
15
|
+
remove_favorite,
|
|
16
|
+
)
|
|
17
|
+
from walla.account.inbox import list_conversations, list_messages, send_message
|
|
18
|
+
from walla.account.login import login_cookie_text, login_cookies, whoami
|
|
19
|
+
from walla.account.prompt import interactive_login, session_summary
|
|
20
|
+
from walla.account.session_store import clear_session, load_session
|
|
21
|
+
from walla.cli.catch import run_cmd
|
|
22
|
+
from walla.core.exceptions import WallaAuthError
|
|
23
|
+
from walla.search.api import get_item
|
|
24
|
+
|
|
25
|
+
__all__ = ("register",)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def register(app: typer.Typer) -> None:
|
|
29
|
+
@app.command("login")
|
|
30
|
+
def login_cmd(
|
|
31
|
+
cookies: Path | None = typer.Option(None, "--cookies", help="cookies.txt / JSON export"),
|
|
32
|
+
cookie: str | None = typer.Option(None, "--cookie", help="Raw session cookie value"),
|
|
33
|
+
password: bool = typer.Option(
|
|
34
|
+
False, "--password", help="Optional email/password prompt (often fails on MFA)"
|
|
35
|
+
),
|
|
36
|
+
json: bool = typer.Option(False, "--json"),
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Log in by pasting a browser session cookie (no .env)."""
|
|
39
|
+
|
|
40
|
+
def _run() -> dict[str, Any]:
|
|
41
|
+
if cookies is not None:
|
|
42
|
+
sess = login_cookies(cookies)
|
|
43
|
+
elif cookie:
|
|
44
|
+
sess = login_cookie_text(cookie)
|
|
45
|
+
elif json and not password:
|
|
46
|
+
raise WallaAuthError(
|
|
47
|
+
"Non-interactive login needs --cookie '<value>' or --cookies <file>. "
|
|
48
|
+
"Or run without --json to paste interactively."
|
|
49
|
+
)
|
|
50
|
+
else:
|
|
51
|
+
sess = interactive_login(password=password)
|
|
52
|
+
return session_summary(sess)
|
|
53
|
+
|
|
54
|
+
run_cmd(_run, as_json=json)
|
|
55
|
+
|
|
56
|
+
@app.command("logout")
|
|
57
|
+
def logout_cmd(json: bool = typer.Option(False, "--json")) -> None:
|
|
58
|
+
def _run() -> dict[str, Any]:
|
|
59
|
+
clear_session()
|
|
60
|
+
return {"authenticated": False}
|
|
61
|
+
|
|
62
|
+
run_cmd(_run, as_json=json)
|
|
63
|
+
|
|
64
|
+
@app.command("whoami")
|
|
65
|
+
def whoami_cmd(json: bool = typer.Option(False, "--json")) -> None:
|
|
66
|
+
run_cmd(lambda: whoami(), as_json=json)
|
|
67
|
+
|
|
68
|
+
@app.command("inbox")
|
|
69
|
+
def inbox_cmd(json: bool = typer.Option(False, "--json")) -> None:
|
|
70
|
+
def _run() -> dict[str, Any]:
|
|
71
|
+
rows = list_conversations()
|
|
72
|
+
return {"conversations": [c.model_dump(mode="json") for c in rows]}
|
|
73
|
+
|
|
74
|
+
run_cmd(_run, as_json=json)
|
|
75
|
+
|
|
76
|
+
@app.command("thread")
|
|
77
|
+
def thread_cmd(
|
|
78
|
+
conversation_id: str = typer.Argument(...),
|
|
79
|
+
json: bool = typer.Option(False, "--json"),
|
|
80
|
+
) -> None:
|
|
81
|
+
def _run() -> dict[str, Any]:
|
|
82
|
+
msgs = list_messages(conversation_id)
|
|
83
|
+
return {
|
|
84
|
+
"conversation_id": conversation_id,
|
|
85
|
+
"messages": [m.model_dump(mode="json") for m in msgs],
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
run_cmd(_run, as_json=json)
|
|
89
|
+
|
|
90
|
+
@app.command("say")
|
|
91
|
+
def say_cmd(
|
|
92
|
+
conversation_id: str = typer.Argument(...),
|
|
93
|
+
text: str = typer.Argument(...),
|
|
94
|
+
yes: bool = typer.Option(False, "--yes"),
|
|
95
|
+
json: bool = typer.Option(False, "--json"),
|
|
96
|
+
) -> None:
|
|
97
|
+
def _run() -> dict[str, Any]:
|
|
98
|
+
return send_message(conversation_id, text, confirm=yes)
|
|
99
|
+
|
|
100
|
+
run_cmd(_run, as_json=json)
|
|
101
|
+
|
|
102
|
+
@app.command("offer")
|
|
103
|
+
def offer_cmd(
|
|
104
|
+
item_id: str = typer.Argument(...),
|
|
105
|
+
eur: float = typer.Option(..., "--eur"),
|
|
106
|
+
yes: bool = typer.Option(False, "--yes"),
|
|
107
|
+
json: bool = typer.Option(False, "--json"),
|
|
108
|
+
) -> None:
|
|
109
|
+
def _run() -> dict[str, Any]:
|
|
110
|
+
if not yes:
|
|
111
|
+
item = get_item(item_id)
|
|
112
|
+
quote = quote_offer(item, eur)
|
|
113
|
+
return {
|
|
114
|
+
"needs_confirm": True,
|
|
115
|
+
"title": item.title,
|
|
116
|
+
"url": item.url,
|
|
117
|
+
"quote": quote.model_dump(mode="json"),
|
|
118
|
+
"hint": "Re-run with --yes to send",
|
|
119
|
+
}
|
|
120
|
+
return make_offer(item_id, eur, confirm=True)
|
|
121
|
+
|
|
122
|
+
run_cmd(_run, as_json=json)
|
|
123
|
+
|
|
124
|
+
fav_app = typer.Typer(help="Favorites")
|
|
125
|
+
app.add_typer(fav_app, name="fav")
|
|
126
|
+
|
|
127
|
+
@fav_app.command("list")
|
|
128
|
+
def fav_list(json: bool = typer.Option(False, "--json")) -> None:
|
|
129
|
+
def _run() -> dict[str, Any]:
|
|
130
|
+
return {"favorites": list_favorites()}
|
|
131
|
+
|
|
132
|
+
run_cmd(_run, as_json=json)
|
|
133
|
+
|
|
134
|
+
@fav_app.command("add")
|
|
135
|
+
def fav_add(
|
|
136
|
+
item_id: str = typer.Argument(...),
|
|
137
|
+
json: bool = typer.Option(False, "--json"),
|
|
138
|
+
) -> None:
|
|
139
|
+
run_cmd(lambda: add_favorite(item_id), as_json=json)
|
|
140
|
+
|
|
141
|
+
@fav_app.command("rm")
|
|
142
|
+
def fav_rm(
|
|
143
|
+
item_id: str = typer.Argument(...),
|
|
144
|
+
json: bool = typer.Option(False, "--json"),
|
|
145
|
+
) -> None:
|
|
146
|
+
run_cmd(lambda: remove_favorite(item_id), as_json=json)
|
|
147
|
+
|
|
148
|
+
@app.command("session")
|
|
149
|
+
def session_cmd(json: bool = typer.Option(False, "--json")) -> None:
|
|
150
|
+
def _run() -> dict[str, Any]:
|
|
151
|
+
sess = load_session()
|
|
152
|
+
if not sess:
|
|
153
|
+
return {"authenticated": False}
|
|
154
|
+
return {
|
|
155
|
+
"authenticated": True,
|
|
156
|
+
"has_access_token": bool(sess.access_token),
|
|
157
|
+
"has_cookie": bool(sess.session_cookie),
|
|
158
|
+
"user_id": sess.user_id,
|
|
159
|
+
"micro_name": sess.micro_name,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
run_cmd(_run, as_json=json)
|
walla/cli/cmd_look.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Look commands: search, item, categories, watch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from walla.cli.catch import run_cmd
|
|
11
|
+
from walla.hunter.profile_store import load_profile
|
|
12
|
+
from walla.hunter.verdict import score_listing
|
|
13
|
+
from walla.hunter.watches import (
|
|
14
|
+
Watch,
|
|
15
|
+
diff_new_ids,
|
|
16
|
+
load_watches,
|
|
17
|
+
merge_seen,
|
|
18
|
+
save_watches,
|
|
19
|
+
)
|
|
20
|
+
from walla.search.api import get_categories, get_item, search_listings
|
|
21
|
+
|
|
22
|
+
__all__ = ("register",)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def register(app: typer.Typer) -> None:
|
|
26
|
+
@app.command("search")
|
|
27
|
+
def search(
|
|
28
|
+
keywords: str = typer.Argument(...),
|
|
29
|
+
max_results: int = typer.Option(40, "--max"),
|
|
30
|
+
min_price: float | None = typer.Option(None, "--min"),
|
|
31
|
+
max_price: float | None = typer.Option(None, "--max-price"),
|
|
32
|
+
category_id: int | None = typer.Option(None, "--category"),
|
|
33
|
+
order_by: str = typer.Option("most_relevance", "--order"),
|
|
34
|
+
json: bool = typer.Option(False, "--json"),
|
|
35
|
+
) -> None:
|
|
36
|
+
def _run() -> dict[str, Any]:
|
|
37
|
+
profile = load_profile()
|
|
38
|
+
if not profile.ready:
|
|
39
|
+
return {
|
|
40
|
+
"ready": False,
|
|
41
|
+
"intake": {"prompt_to_user": profile.intake_prompt()},
|
|
42
|
+
}
|
|
43
|
+
result = search_listings(
|
|
44
|
+
keywords,
|
|
45
|
+
latitude=float(profile.lat or 0),
|
|
46
|
+
longitude=float(profile.lon or 0),
|
|
47
|
+
max_results=max_results,
|
|
48
|
+
min_price=min_price,
|
|
49
|
+
max_price=max_price or profile.budget,
|
|
50
|
+
category_id=category_id,
|
|
51
|
+
order_by=order_by,
|
|
52
|
+
)
|
|
53
|
+
rows = []
|
|
54
|
+
for listing in result.listings:
|
|
55
|
+
listing.verdict = score_listing(listing, profile)
|
|
56
|
+
rows.append(listing.model_dump(mode="json"))
|
|
57
|
+
return {
|
|
58
|
+
"count": len(rows),
|
|
59
|
+
"next_page": result.next_page,
|
|
60
|
+
"listings": rows,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
run_cmd(_run, as_json=json)
|
|
64
|
+
|
|
65
|
+
@app.command("item")
|
|
66
|
+
def item_cmd(
|
|
67
|
+
item_id: str = typer.Argument(...),
|
|
68
|
+
json: bool = typer.Option(False, "--json"),
|
|
69
|
+
) -> None:
|
|
70
|
+
def _run() -> dict[str, Any]:
|
|
71
|
+
listing = get_item(item_id)
|
|
72
|
+
profile = load_profile()
|
|
73
|
+
listing.verdict = score_listing(listing, profile)
|
|
74
|
+
return listing.model_dump(mode="json")
|
|
75
|
+
|
|
76
|
+
run_cmd(_run, as_json=json)
|
|
77
|
+
|
|
78
|
+
@app.command("categories")
|
|
79
|
+
def categories_cmd(json: bool = typer.Option(False, "--json")) -> None:
|
|
80
|
+
def _run() -> dict[str, Any]:
|
|
81
|
+
cats = get_categories()
|
|
82
|
+
return {"categories": [c.model_dump(mode="json") for c in cats]}
|
|
83
|
+
|
|
84
|
+
run_cmd(_run, as_json=json)
|
|
85
|
+
|
|
86
|
+
watch_app = typer.Typer(help="Local saved searches")
|
|
87
|
+
app.add_typer(watch_app, name="watch")
|
|
88
|
+
|
|
89
|
+
@watch_app.command("add")
|
|
90
|
+
def watch_add(
|
|
91
|
+
keywords: str = typer.Argument(...),
|
|
92
|
+
max_price: float | None = typer.Option(None, "--max-price"),
|
|
93
|
+
json: bool = typer.Option(False, "--json"),
|
|
94
|
+
) -> None:
|
|
95
|
+
def _run() -> dict[str, Any]:
|
|
96
|
+
store = load_watches()
|
|
97
|
+
w = Watch(id=str(uuid.uuid4())[:8], keywords=keywords, max_price=max_price)
|
|
98
|
+
store.watches.append(w)
|
|
99
|
+
save_watches(store)
|
|
100
|
+
return w.model_dump(mode="json")
|
|
101
|
+
|
|
102
|
+
run_cmd(_run, as_json=json)
|
|
103
|
+
|
|
104
|
+
@watch_app.command("list")
|
|
105
|
+
def watch_list(json: bool = typer.Option(False, "--json")) -> None:
|
|
106
|
+
def _run() -> dict[str, Any]:
|
|
107
|
+
store = load_watches()
|
|
108
|
+
return {"watches": [w.model_dump(mode="json") for w in store.watches]}
|
|
109
|
+
|
|
110
|
+
run_cmd(_run, as_json=json)
|
|
111
|
+
|
|
112
|
+
@watch_app.command("check")
|
|
113
|
+
def watch_check(
|
|
114
|
+
watch_id: str = typer.Argument(...),
|
|
115
|
+
json: bool = typer.Option(False, "--json"),
|
|
116
|
+
) -> None:
|
|
117
|
+
def _run() -> dict[str, Any]:
|
|
118
|
+
store = load_watches()
|
|
119
|
+
watch = next((w for w in store.watches if w.id == watch_id), None)
|
|
120
|
+
if watch is None:
|
|
121
|
+
raise ValueError(f"unknown watch {watch_id}")
|
|
122
|
+
profile = load_profile()
|
|
123
|
+
if not profile.ready:
|
|
124
|
+
raise ValueError("profile not ready; run walla setup")
|
|
125
|
+
result = search_listings(
|
|
126
|
+
watch.keywords,
|
|
127
|
+
latitude=float(profile.lat or 0),
|
|
128
|
+
longitude=float(profile.lon or 0),
|
|
129
|
+
max_results=40,
|
|
130
|
+
max_price=watch.max_price,
|
|
131
|
+
)
|
|
132
|
+
found = [L.id for L in result.listings]
|
|
133
|
+
new_ids = diff_new_ids(watch.seen_ids, found)
|
|
134
|
+
store.watches = [
|
|
135
|
+
merge_seen(w, found) if w.id == watch_id else w for w in store.watches
|
|
136
|
+
]
|
|
137
|
+
save_watches(store)
|
|
138
|
+
new_listings = [
|
|
139
|
+
L.model_dump(mode="json") for L in result.listings if L.id in set(new_ids)
|
|
140
|
+
]
|
|
141
|
+
return {"watch_id": watch_id, "new_ids": new_ids, "listings": new_listings}
|
|
142
|
+
|
|
143
|
+
run_cmd(_run, as_json=json)
|
walla/cli/cmd_ops.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Ops commands: doctor, instruct, setup, profile."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from walla import __version__
|
|
10
|
+
from walla.account.session_store import load_session
|
|
11
|
+
from walla.cli.catch import run_cmd
|
|
12
|
+
from walla.core.instruct import instruct_recipe
|
|
13
|
+
from walla.hunter.profile_store import load_profile, parse_intake, save_profile
|
|
14
|
+
|
|
15
|
+
__all__ = ("register",)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def register(app: typer.Typer) -> None:
|
|
19
|
+
@app.command("instruct")
|
|
20
|
+
def instruct(json: bool = typer.Option(False, "--json")) -> None:
|
|
21
|
+
"""Agent recipe."""
|
|
22
|
+
run_cmd(instruct_recipe, as_json=json)
|
|
23
|
+
|
|
24
|
+
@app.command("doctor")
|
|
25
|
+
def doctor(json: bool = typer.Option(False, "--json")) -> None:
|
|
26
|
+
"""Network + session (never prints secrets)."""
|
|
27
|
+
|
|
28
|
+
def _run() -> dict[str, Any]:
|
|
29
|
+
sess = load_session()
|
|
30
|
+
profile = load_profile()
|
|
31
|
+
reachable = False
|
|
32
|
+
try:
|
|
33
|
+
from walla.http.client import HttpClient
|
|
34
|
+
|
|
35
|
+
HttpClient().get("/api/v3/categories", use_cache=False)
|
|
36
|
+
reachable = True
|
|
37
|
+
except Exception: # noqa: BLE001
|
|
38
|
+
reachable = False
|
|
39
|
+
has_session = bool(sess and (sess.access_token or sess.session_cookie))
|
|
40
|
+
return {
|
|
41
|
+
"version": __version__,
|
|
42
|
+
"network": {"reachable": reachable},
|
|
43
|
+
"auth": {
|
|
44
|
+
"session": has_session,
|
|
45
|
+
"how": "walla login (paste browser session cookie)",
|
|
46
|
+
},
|
|
47
|
+
"profile": {"ready": profile.ready, "label": profile.label},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
run_cmd(_run, as_json=json)
|
|
51
|
+
|
|
52
|
+
@app.command("setup")
|
|
53
|
+
def setup(
|
|
54
|
+
lat: float | None = typer.Option(None),
|
|
55
|
+
lon: float | None = typer.Option(None),
|
|
56
|
+
km: float = typer.Option(30.0),
|
|
57
|
+
pickup_km: float = typer.Option(30.0, "--pickup-km"),
|
|
58
|
+
label: str | None = typer.Option(None),
|
|
59
|
+
budget: float | None = typer.Option(None),
|
|
60
|
+
intake: str | None = typer.Option(None, "--intake"),
|
|
61
|
+
json: bool = typer.Option(False, "--json"),
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Save home point + radius."""
|
|
64
|
+
|
|
65
|
+
def _run() -> dict[str, Any]:
|
|
66
|
+
if intake:
|
|
67
|
+
profile = parse_intake(intake)
|
|
68
|
+
else:
|
|
69
|
+
profile = load_profile()
|
|
70
|
+
if lat is not None:
|
|
71
|
+
profile.lat = lat
|
|
72
|
+
if lon is not None:
|
|
73
|
+
profile.lon = lon
|
|
74
|
+
profile.km = km
|
|
75
|
+
profile.pickup_km = pickup_km
|
|
76
|
+
if label is not None:
|
|
77
|
+
profile.label = label
|
|
78
|
+
if budget is not None:
|
|
79
|
+
profile.budget = budget
|
|
80
|
+
if not profile.ready:
|
|
81
|
+
return {
|
|
82
|
+
"ready": False,
|
|
83
|
+
"intake": {"prompt_to_user": profile.intake_prompt()},
|
|
84
|
+
}
|
|
85
|
+
save_profile(profile)
|
|
86
|
+
return {"ready": True, "profile": profile.model_dump(mode="json")}
|
|
87
|
+
|
|
88
|
+
run_cmd(_run, as_json=json)
|
|
89
|
+
|
|
90
|
+
@app.command("profile")
|
|
91
|
+
def profile_cmd(json: bool = typer.Option(False, "--json")) -> None:
|
|
92
|
+
def _run() -> dict[str, Any]:
|
|
93
|
+
p = load_profile()
|
|
94
|
+
return {
|
|
95
|
+
"ready": p.ready,
|
|
96
|
+
"profile": p.model_dump(mode="json"),
|
|
97
|
+
"intake": None if p.ready else {"prompt_to_user": p.intake_prompt()},
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
run_cmd(_run, as_json=json)
|
walla/cli/console.py
ADDED
walla/cli/main.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Typer CLI -- agent-friendly Wallapop client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from typer.core import TyperGroup
|
|
9
|
+
|
|
10
|
+
from walla.cli import cmd_account, cmd_look, cmd_ops
|
|
11
|
+
from walla.cli.banner import print_banner
|
|
12
|
+
|
|
13
|
+
__all__ = ("app", "cli")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _WallaGroup(TyperGroup):
|
|
17
|
+
def format_help(self, ctx: Any, formatter: Any) -> None:
|
|
18
|
+
print_banner()
|
|
19
|
+
super().format_help(ctx, formatter)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(
|
|
23
|
+
name="walla",
|
|
24
|
+
cls=_WallaGroup,
|
|
25
|
+
help=(
|
|
26
|
+
"Wallapop.es look · offer · talk for humans and agents. "
|
|
27
|
+
"Happy path: setup -> search -> login -> inbox / offer. "
|
|
28
|
+
"Run `walla instruct --json` for the recipe."
|
|
29
|
+
),
|
|
30
|
+
no_args_is_help=True,
|
|
31
|
+
add_completion=False,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
cmd_ops.register(app)
|
|
35
|
+
cmd_look.register(app)
|
|
36
|
+
cmd_account.register(app)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cli() -> None:
|
|
40
|
+
app()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
cli()
|
walla/core/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Core package facade."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from walla.core.envelope import error_payload, success_payload
|
|
6
|
+
from walla.core.exceptions import WallaError
|
|
7
|
+
|
|
8
|
+
__all__ = (
|
|
9
|
+
"WallaError",
|
|
10
|
+
"error_payload",
|
|
11
|
+
"success_payload",
|
|
12
|
+
)
|
walla/core/envelope.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Shared JSON envelope for CLI and MCP (agent contract)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from walla.core.errors import classify_error
|
|
8
|
+
from walla.core.exceptions import WallaAmbiguousError, WallaAuthError
|
|
9
|
+
|
|
10
|
+
__all__ = (
|
|
11
|
+
"API_VERSION",
|
|
12
|
+
"dump_model",
|
|
13
|
+
"error_payload",
|
|
14
|
+
"success_payload",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
API_VERSION = 1
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def success_payload(data: Any) -> dict[str, Any]:
|
|
21
|
+
return {"ok": True, "api_version": API_VERSION, "data": data}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def error_payload(exc: BaseException) -> dict[str, Any]:
|
|
25
|
+
classified = classify_error(exc)
|
|
26
|
+
payload: dict[str, Any] = {
|
|
27
|
+
"ok": False,
|
|
28
|
+
"api_version": API_VERSION,
|
|
29
|
+
"error": str(exc),
|
|
30
|
+
**classified.as_fields(),
|
|
31
|
+
}
|
|
32
|
+
if isinstance(exc, WallaAmbiguousError):
|
|
33
|
+
payload["candidates"] = [
|
|
34
|
+
c.model_dump(mode="json") if hasattr(c, "model_dump") else c
|
|
35
|
+
for c in exc.candidates
|
|
36
|
+
]
|
|
37
|
+
if isinstance(exc, WallaAuthError):
|
|
38
|
+
from walla.core.human_fix import cookie_export_fix
|
|
39
|
+
|
|
40
|
+
payload["human_fix"] = cookie_export_fix()
|
|
41
|
+
return payload
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def dump_model(model: Any) -> Any:
|
|
45
|
+
"""Pydantic model to JSON-ready dict."""
|
|
46
|
+
return model.model_dump(mode="json")
|