fere-cli 0.2.0.dev17__py3-none-any.whl → 0.2.1.dev19__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.
- fere_cli/__init__.py +8 -1
- fere_cli/async_util.py +1 -1
- fere_cli/banner.py +2 -1
- fere_cli/commands/auth.py +47 -14
- fere_cli/commands/chat.py +5 -15
- fere_cli/commands/hooks.py +10 -8
- fere_cli/commands/limit_order.py +5 -4
- fere_cli/commands/utility.py +1 -1
- fere_cli/main.py +6 -1
- fere_cli/output.py +13 -2
- fere_cli/update.py +335 -0
- {fere_cli-0.2.0.dev17.dist-info → fere_cli-0.2.1.dev19.dist-info}/METADATA +2 -2
- fere_cli-0.2.1.dev19.dist-info/RECORD +20 -0
- fere_cli-0.2.0.dev17.dist-info/RECORD +0 -19
- {fere_cli-0.2.0.dev17.dist-info → fere_cli-0.2.1.dev19.dist-info}/WHEEL +0 -0
- {fere_cli-0.2.0.dev17.dist-info → fere_cli-0.2.1.dev19.dist-info}/entry_points.txt +0 -0
fere_cli/__init__.py
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
1
|
"""FereAI CLI — terminal interface for crypto trading and research."""
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
# Always report the installed distribution version.
|
|
7
|
+
__version__ = version("fere-cli")
|
|
8
|
+
except PackageNotFoundError:
|
|
9
|
+
# Fallback for local/uninstalled source usage.
|
|
10
|
+
__version__ = "0.0.0"
|
fere_cli/async_util.py
CHANGED
fere_cli/banner.py
CHANGED
|
@@ -20,4 +20,5 @@ LOGO = r"""
|
|
|
20
20
|
|
|
21
21
|
def get_banner() -> str:
|
|
22
22
|
"""Return the full banner string with version."""
|
|
23
|
-
|
|
23
|
+
tagline = "crypto trading & research from your terminal"
|
|
24
|
+
return f"{LOGO}\n v{__version__} — {tagline}\n"
|
fere_cli/commands/auth.py
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
"""Auth commands: fere auth, fere whoami, fere credits."""
|
|
1
|
+
"""Auth commands: fere auth, fere whoami, fere credits, fere claim-daily."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import dataclasses
|
|
6
|
+
|
|
5
7
|
import click
|
|
6
8
|
from fere_sdk import FereClient
|
|
7
9
|
from rich.console import Console
|
|
8
10
|
|
|
9
|
-
from ..async_util import async_command, get_client, is_tty
|
|
11
|
+
from ..async_util import async_command, get_client, is_tty
|
|
10
12
|
from ..config import load_config, save_config
|
|
11
13
|
from ..output import print_error, print_success
|
|
12
14
|
|
|
@@ -21,11 +23,7 @@ async def auth(ctx, name: str | None):
|
|
|
21
23
|
"""Authenticate and register a new agent (first-run setup)."""
|
|
22
24
|
cfg = load_config()
|
|
23
25
|
|
|
24
|
-
agent_name = (
|
|
25
|
-
ctx.obj.get("agent_override")
|
|
26
|
-
or name
|
|
27
|
-
or cfg.get("agent_name")
|
|
28
|
-
)
|
|
26
|
+
agent_name = ctx.obj.get("agent_override") or name or cfg.get("agent_name")
|
|
29
27
|
if not agent_name:
|
|
30
28
|
if is_tty():
|
|
31
29
|
agent_name = click.prompt("Agent name")
|
|
@@ -68,18 +66,13 @@ async def auth(ctx, name: str | None):
|
|
|
68
66
|
@click.pass_context
|
|
69
67
|
@async_command
|
|
70
68
|
async def whoami(ctx):
|
|
71
|
-
"""Show current agent identity
|
|
69
|
+
"""Show current agent identity."""
|
|
72
70
|
try:
|
|
73
71
|
client = await get_client(ctx)
|
|
74
72
|
user = await client.get_user()
|
|
75
|
-
wallets_data = await client.get_wallets()
|
|
76
73
|
await client.close()
|
|
77
74
|
|
|
78
|
-
|
|
79
|
-
if wallets_data:
|
|
80
|
-
result["wallets"] = wallets_data
|
|
81
|
-
|
|
82
|
-
print_success(result)
|
|
75
|
+
print_success(user)
|
|
83
76
|
except click.ClickException:
|
|
84
77
|
raise
|
|
85
78
|
except Exception as e:
|
|
@@ -106,3 +99,43 @@ async def credits(ctx):
|
|
|
106
99
|
except Exception as e:
|
|
107
100
|
print_error(str(e))
|
|
108
101
|
raise SystemExit(1)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@click.command(name="claim-daily")
|
|
105
|
+
@click.pass_context
|
|
106
|
+
@async_command
|
|
107
|
+
async def claim_daily_credits(ctx):
|
|
108
|
+
"""Claim your daily credits bonus."""
|
|
109
|
+
try:
|
|
110
|
+
client = await get_client(ctx)
|
|
111
|
+
result = await client.claim_daily_credits()
|
|
112
|
+
await client.close()
|
|
113
|
+
|
|
114
|
+
data = dataclasses.asdict(result)
|
|
115
|
+
if not result.success:
|
|
116
|
+
if result.error == "claim_cooldown":
|
|
117
|
+
msg = (
|
|
118
|
+
"Already claimed today."
|
|
119
|
+
f" Next claim available at {result.next_claim_available_at}"
|
|
120
|
+
if result.next_claim_available_at
|
|
121
|
+
else "Already claimed today."
|
|
122
|
+
)
|
|
123
|
+
elif result.error == "balance_too_high":
|
|
124
|
+
bal = result.current_balance
|
|
125
|
+
msg = (
|
|
126
|
+
f"Balance too high to claim (current: {bal} credits)."
|
|
127
|
+
if bal is not None
|
|
128
|
+
else "Balance too high to claim."
|
|
129
|
+
)
|
|
130
|
+
elif result.error == "user_not_found":
|
|
131
|
+
msg = "User not found. Try logging in again."
|
|
132
|
+
else:
|
|
133
|
+
msg = result.error or "Daily claim failed."
|
|
134
|
+
print_error(msg, details=data)
|
|
135
|
+
raise SystemExit(1)
|
|
136
|
+
print_success(data)
|
|
137
|
+
except click.ClickException:
|
|
138
|
+
raise
|
|
139
|
+
except Exception as e:
|
|
140
|
+
print_error(str(e))
|
|
141
|
+
raise SystemExit(1)
|
fere_cli/commands/chat.py
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
-
import sys
|
|
7
6
|
|
|
8
7
|
import click
|
|
9
8
|
from rich.console import Console
|
|
@@ -14,7 +13,6 @@ from ..output import (
|
|
|
14
13
|
print_error,
|
|
15
14
|
print_streaming_text,
|
|
16
15
|
print_success,
|
|
17
|
-
print_table_from_dicts,
|
|
18
16
|
)
|
|
19
17
|
|
|
20
18
|
console = Console()
|
|
@@ -61,20 +59,16 @@ async def chat(ctx, query, stream_mode, thread_id, agent_type):
|
|
|
61
59
|
if query:
|
|
62
60
|
# One-shot mode
|
|
63
61
|
if stream_mode:
|
|
64
|
-
await _stream_chat(
|
|
65
|
-
client, query, thread_id, agent_type
|
|
66
|
-
)
|
|
62
|
+
await _stream_chat(client, query, thread_id, agent_type)
|
|
67
63
|
else:
|
|
68
|
-
await _oneshot_chat(
|
|
69
|
-
client, query, thread_id, agent_type
|
|
70
|
-
)
|
|
64
|
+
await _oneshot_chat(client, query, thread_id, agent_type)
|
|
71
65
|
elif is_tty():
|
|
72
66
|
# Interactive REPL
|
|
73
67
|
await _repl_chat(client, thread_id, agent_type)
|
|
74
68
|
else:
|
|
75
69
|
raise click.ClickException(
|
|
76
70
|
"Query required in non-interactive mode. "
|
|
77
|
-
|
|
71
|
+
'Usage: fere chat "your question"'
|
|
78
72
|
)
|
|
79
73
|
except click.ClickException:
|
|
80
74
|
raise
|
|
@@ -89,9 +83,7 @@ async def chat(ctx, query, stream_mode, thread_id, agent_type):
|
|
|
89
83
|
|
|
90
84
|
async def _oneshot_chat(client, query, thread_id, agent_type):
|
|
91
85
|
"""Send a query and print the final answer."""
|
|
92
|
-
result = await client.chat(
|
|
93
|
-
query, thread_id=thread_id, agent=agent_type
|
|
94
|
-
)
|
|
86
|
+
result = await client.chat(query, thread_id=thread_id, agent=agent_type)
|
|
95
87
|
print_success(
|
|
96
88
|
result,
|
|
97
89
|
quiet_value=result.get("answer", ""),
|
|
@@ -121,9 +113,7 @@ async def _stream_chat(client, query, thread_id, agent_type):
|
|
|
121
113
|
text = event.data.get("text", "")
|
|
122
114
|
if text:
|
|
123
115
|
click.echo() # newline after chunks
|
|
124
|
-
console.print(
|
|
125
|
-
f"\n[bold]Answer:[/bold] {text}"
|
|
126
|
-
)
|
|
116
|
+
console.print(f"\n[bold]Answer:[/bold] {text}")
|
|
127
117
|
elif event.event == "tool_response":
|
|
128
118
|
console.print(
|
|
129
119
|
f"[dim]Tool: {json.dumps(event.data, default=str)}[/dim]"
|
fere_cli/commands/hooks.py
CHANGED
|
@@ -17,21 +17,23 @@ def hooks():
|
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
@hooks.command()
|
|
20
|
-
@click.option(
|
|
21
|
-
|
|
22
|
-
)
|
|
23
|
-
@click.option(
|
|
24
|
-
"--token", required=True, help="Token contract address."
|
|
25
|
-
)
|
|
20
|
+
@click.option("--chain-id", required=True, type=int, help="Chain ID.")
|
|
21
|
+
@click.option("--token", required=True, help="Token contract address.")
|
|
26
22
|
@click.option(
|
|
27
23
|
"--stop-loss",
|
|
28
24
|
default=None,
|
|
29
|
-
help=
|
|
25
|
+
help=(
|
|
26
|
+
"Stop-loss config as JSON"
|
|
27
|
+
' (e.g. \'{"price_percentage": 0.3, "sell_percentage": 1.0}\').'
|
|
28
|
+
),
|
|
30
29
|
)
|
|
31
30
|
@click.option(
|
|
32
31
|
"--take-profit",
|
|
33
32
|
default=None,
|
|
34
|
-
help=
|
|
33
|
+
help=(
|
|
34
|
+
"Take-profit config as JSON"
|
|
35
|
+
' (e.g. \'{"price_percentage": 1.0, "sell_percentage": 0.5}\').'
|
|
36
|
+
),
|
|
35
37
|
)
|
|
36
38
|
@click.pass_context
|
|
37
39
|
@async_command
|
fere_cli/commands/limit_order.py
CHANGED
|
@@ -28,9 +28,7 @@ def limit_order():
|
|
|
28
28
|
@click.option(
|
|
29
29
|
"--token-out", required=True, help="Destination token contract address."
|
|
30
30
|
)
|
|
31
|
-
@click.option(
|
|
32
|
-
"--amount", required=True, help="Amount in smallest unit."
|
|
33
|
-
)
|
|
31
|
+
@click.option("--amount", required=True, help="Amount in smallest unit.")
|
|
34
32
|
@click.option(
|
|
35
33
|
"--price",
|
|
36
34
|
"price_usd_trigger",
|
|
@@ -52,7 +50,10 @@ def limit_order():
|
|
|
52
50
|
"--condition",
|
|
53
51
|
required=True,
|
|
54
52
|
type=click.Choice(["gte", "lte"]),
|
|
55
|
-
help=
|
|
53
|
+
help=(
|
|
54
|
+
"Trigger condition: 'gte' (price >= target)"
|
|
55
|
+
" or 'lte' (price <= target)."
|
|
56
|
+
),
|
|
56
57
|
)
|
|
57
58
|
@click.option("--slippage-bps", default=50, type=int, help="Slippage (bps).")
|
|
58
59
|
@click.option(
|
fere_cli/commands/utility.py
CHANGED
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import click
|
|
6
6
|
import httpx
|
|
7
7
|
|
|
8
|
-
from ..async_util import async_command, get_client
|
|
8
|
+
from ..async_util import async_command, get_client
|
|
9
9
|
from ..config import load_config, set_config_value
|
|
10
10
|
from ..output import print_error, print_success
|
|
11
11
|
|
fere_cli/main.py
CHANGED
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import click
|
|
6
6
|
|
|
7
7
|
from . import __version__
|
|
8
|
-
from .commands.auth import auth, credits, whoami
|
|
8
|
+
from .commands.auth import auth, claim_daily_credits, credits, whoami
|
|
9
9
|
from .commands.chat import chat, threads
|
|
10
10
|
from .commands.earn import earn
|
|
11
11
|
from .commands.hooks import hooks
|
|
@@ -13,6 +13,7 @@ from .commands.limit_order import limit_order
|
|
|
13
13
|
from .commands.portfolio import holdings, notifications, wallets
|
|
14
14
|
from .commands.swap import swap
|
|
15
15
|
from .commands.utility import chains, config, status
|
|
16
|
+
from .update import maybe_check_for_updates
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
@click.group(invoke_without_command=True)
|
|
@@ -53,6 +54,9 @@ def cli(ctx, output_json, quiet, agent, base_url):
|
|
|
53
54
|
if base_url:
|
|
54
55
|
ctx.obj["base_url_override"] = base_url
|
|
55
56
|
|
|
57
|
+
# Fetch runs in background; prompt fires on main thread after subcommand.
|
|
58
|
+
maybe_check_for_updates(output_json=output_json, quiet=quiet, ctx=ctx)
|
|
59
|
+
|
|
56
60
|
# Show banner when no subcommand is given
|
|
57
61
|
if ctx.invoked_subcommand is None and not output_json and not quiet:
|
|
58
62
|
from rich.console import Console
|
|
@@ -68,6 +72,7 @@ def cli(ctx, output_json, quiet, agent, base_url):
|
|
|
68
72
|
cli.add_command(auth)
|
|
69
73
|
cli.add_command(whoami)
|
|
70
74
|
cli.add_command(credits)
|
|
75
|
+
cli.add_command(claim_daily_credits, name="claim-daily")
|
|
71
76
|
|
|
72
77
|
# Chat
|
|
73
78
|
cli.add_command(chat)
|
fere_cli/output.py
CHANGED
|
@@ -72,10 +72,21 @@ def print_json(data: Any) -> None:
|
|
|
72
72
|
|
|
73
73
|
|
|
74
74
|
def print_dict(data: dict, title: str | None = None) -> None:
|
|
75
|
-
"""Print a dict as a Rich panel with key-value pairs.
|
|
75
|
+
"""Print a dict as a Rich panel with key-value pairs.
|
|
76
|
+
|
|
77
|
+
None values are omitted from human output.
|
|
78
|
+
"""
|
|
76
79
|
lines = []
|
|
77
80
|
for k, v in data.items():
|
|
78
|
-
if
|
|
81
|
+
if v is None:
|
|
82
|
+
continue
|
|
83
|
+
if isinstance(v, dict):
|
|
84
|
+
v = json_lib.dumps(
|
|
85
|
+
{k2: v2 for k2, v2 in v.items() if v2 is not None},
|
|
86
|
+
indent=2,
|
|
87
|
+
default=str,
|
|
88
|
+
)
|
|
89
|
+
elif isinstance(v, list):
|
|
79
90
|
v = json_lib.dumps(v, indent=2, default=str)
|
|
80
91
|
lines.append(f"[bold]{k}:[/bold] {v}")
|
|
81
92
|
content = "\n".join(lines)
|
fere_cli/update.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.request
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING, Optional, Tuple
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
import click as _click
|
|
18
|
+
|
|
19
|
+
import click
|
|
20
|
+
|
|
21
|
+
from . import __version__
|
|
22
|
+
|
|
23
|
+
PYPI_URL = "https://pypi.org/pypi/fere-cli/json"
|
|
24
|
+
CHECK_INTERVAL_SECONDS = 86400 # once per day
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _state_file() -> Path:
|
|
28
|
+
"""Return path to the update-check state file."""
|
|
29
|
+
config_dir = Path.home() / ".config" / "fere-cli"
|
|
30
|
+
return config_dir / "update-check.json"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _should_check() -> bool:
|
|
34
|
+
"""Return True if enough time has elapsed since last check."""
|
|
35
|
+
sf = _state_file()
|
|
36
|
+
if not sf.exists():
|
|
37
|
+
return True
|
|
38
|
+
try:
|
|
39
|
+
data = json.loads(sf.read_text())
|
|
40
|
+
last = data.get("last_check", 0)
|
|
41
|
+
return (time.time() - last) >= CHECK_INTERVAL_SECONDS
|
|
42
|
+
except Exception:
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _record_check() -> None:
|
|
47
|
+
"""Persist current timestamp so we skip checks for a day."""
|
|
48
|
+
sf = _state_file()
|
|
49
|
+
try:
|
|
50
|
+
sf.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
sf.write_text(json.dumps({"last_check": time.time()}))
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _is_stable_version(version: str) -> bool:
|
|
57
|
+
"""Return True if version is a pure X.Y.Z."""
|
|
58
|
+
parts = version.split(".")
|
|
59
|
+
if len(parts) != 3:
|
|
60
|
+
return False
|
|
61
|
+
try:
|
|
62
|
+
_ = (int(parts[0]), int(parts[1]), int(parts[2]))
|
|
63
|
+
except ValueError:
|
|
64
|
+
return False
|
|
65
|
+
return True
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _is_prerelease(version: str) -> bool:
|
|
69
|
+
"""Return True if version has a pre-release suffix."""
|
|
70
|
+
return not _is_stable_version(version)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _parse_version_tuple(
|
|
74
|
+
version: str,
|
|
75
|
+
) -> Optional[Tuple[int, int, int]]:
|
|
76
|
+
"""Parse the X.Y.Z prefix from a version string."""
|
|
77
|
+
base = version.split("a")[0].split("b")[0].split("rc")[0]
|
|
78
|
+
try:
|
|
79
|
+
major, minor, patch = base.split(".")
|
|
80
|
+
return int(major), int(minor), int(patch)
|
|
81
|
+
except Exception:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _parse_prerelease_index(version: str) -> int:
|
|
86
|
+
"""Return the numeric pre-release index (e.g. a2→2, rc1→1, stable→0).
|
|
87
|
+
|
|
88
|
+
Used as a tiebreaker so a2 sorts above a1 regardless of PyPI dict order.
|
|
89
|
+
Stable versions return 0 (they are ranked via the stability flag instead).
|
|
90
|
+
"""
|
|
91
|
+
m = re.search(r"(?:a|b|rc)(\d+)$", version)
|
|
92
|
+
return int(m.group(1)) if m else 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _fetch_pypi_releases(
|
|
96
|
+
timeout: float = 2.0,
|
|
97
|
+
) -> Optional[dict]:
|
|
98
|
+
"""Fetch release data from PyPI. Returns None on error."""
|
|
99
|
+
try:
|
|
100
|
+
req = urllib.request.Request(
|
|
101
|
+
PYPI_URL,
|
|
102
|
+
headers={"User-Agent": "fere-cli"},
|
|
103
|
+
)
|
|
104
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
105
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
106
|
+
except (
|
|
107
|
+
urllib.error.URLError,
|
|
108
|
+
TimeoutError,
|
|
109
|
+
json.JSONDecodeError,
|
|
110
|
+
OSError,
|
|
111
|
+
):
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _latest_from_releases(
|
|
116
|
+
releases: dict,
|
|
117
|
+
include_prerelease: bool = False,
|
|
118
|
+
) -> Optional[str]:
|
|
119
|
+
"""Pick the latest version from a PyPI releases dict."""
|
|
120
|
+
versions = list(releases.keys())
|
|
121
|
+
if not include_prerelease:
|
|
122
|
+
versions = [v for v in versions if _is_stable_version(v)]
|
|
123
|
+
|
|
124
|
+
parsed: list[Tuple[Tuple[int, int, int], int, int, str]] = []
|
|
125
|
+
for v in versions:
|
|
126
|
+
t = _parse_version_tuple(v)
|
|
127
|
+
if t is not None:
|
|
128
|
+
# stability: 1 for stable, 0 for pre-release (stable wins on tie)
|
|
129
|
+
# pre_idx: numeric pre-release index (a2 > a1 regardless of order)
|
|
130
|
+
stability = 1 if _is_stable_version(v) else 0
|
|
131
|
+
pre_idx = _parse_prerelease_index(v)
|
|
132
|
+
parsed.append((t, stability, pre_idx, v))
|
|
133
|
+
|
|
134
|
+
if not parsed:
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
parsed.sort(key=lambda item: (item[0], item[1], item[2]))
|
|
138
|
+
return parsed[-1][3]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def get_latest_stable_version(
|
|
142
|
+
timeout: float = 2.0,
|
|
143
|
+
) -> Optional[str]:
|
|
144
|
+
"""Fetch latest stable fere-cli version from PyPI."""
|
|
145
|
+
data = _fetch_pypi_releases(timeout)
|
|
146
|
+
if not data:
|
|
147
|
+
return None
|
|
148
|
+
return _latest_from_releases(
|
|
149
|
+
data.get("releases", {}),
|
|
150
|
+
include_prerelease=False,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def get_latest_prerelease_version(
|
|
155
|
+
timeout: float = 2.0,
|
|
156
|
+
) -> Optional[str]:
|
|
157
|
+
"""Fetch latest fere-cli version (including pre-releases)."""
|
|
158
|
+
data = _fetch_pypi_releases(timeout)
|
|
159
|
+
if not data:
|
|
160
|
+
return None
|
|
161
|
+
return _latest_from_releases(
|
|
162
|
+
data.get("releases", {}),
|
|
163
|
+
include_prerelease=True,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def is_newer_version(latest: str, current: str) -> bool:
|
|
168
|
+
"""Return True if latest > current.
|
|
169
|
+
|
|
170
|
+
When X.Y.Z base is equal, stable > pre-release so that a user on
|
|
171
|
+
e.g. 0.2.0a1 is prompted to upgrade to the stable 0.2.0 release.
|
|
172
|
+
"""
|
|
173
|
+
latest_t = _parse_version_tuple(latest)
|
|
174
|
+
current_t = _parse_version_tuple(current)
|
|
175
|
+
if latest_t is None or current_t is None:
|
|
176
|
+
return False
|
|
177
|
+
if latest_t != current_t:
|
|
178
|
+
return latest_t > current_t
|
|
179
|
+
# Same X.Y.Z base: stable release is newer than any pre-release.
|
|
180
|
+
return _is_stable_version(latest) and not _is_stable_version(current)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _perform_pipx_upgrade(pre: bool = False) -> bool:
|
|
184
|
+
pipx_path = shutil.which("pipx")
|
|
185
|
+
if not pipx_path:
|
|
186
|
+
return False
|
|
187
|
+
cmd = [pipx_path, "upgrade", "fere-cli"]
|
|
188
|
+
if pre:
|
|
189
|
+
cmd.append("--pip-args=--pre")
|
|
190
|
+
try:
|
|
191
|
+
subprocess.run(
|
|
192
|
+
cmd,
|
|
193
|
+
check=True,
|
|
194
|
+
stdout=subprocess.DEVNULL,
|
|
195
|
+
stderr=subprocess.DEVNULL,
|
|
196
|
+
)
|
|
197
|
+
return True
|
|
198
|
+
except subprocess.CalledProcessError:
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _perform_pip_upgrade(pre: bool = False) -> bool:
|
|
203
|
+
python = sys.executable or "python"
|
|
204
|
+
cmd = [python, "-m", "pip", "install", "--upgrade"]
|
|
205
|
+
if pre:
|
|
206
|
+
cmd.append("--pre")
|
|
207
|
+
cmd.append("fere-cli")
|
|
208
|
+
try:
|
|
209
|
+
subprocess.run(
|
|
210
|
+
cmd,
|
|
211
|
+
check=True,
|
|
212
|
+
stdout=subprocess.DEVNULL,
|
|
213
|
+
stderr=subprocess.DEVNULL,
|
|
214
|
+
)
|
|
215
|
+
return True
|
|
216
|
+
except subprocess.CalledProcessError:
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def perform_self_upgrade(pre: bool = False) -> bool:
|
|
221
|
+
"""Upgrade fere-cli via pipx (preferred) or pip."""
|
|
222
|
+
if _perform_pipx_upgrade(pre=pre):
|
|
223
|
+
return True
|
|
224
|
+
return _perform_pip_upgrade(pre=pre)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _do_prompt(current_is_pre: bool, latest: Optional[str]) -> None:
|
|
228
|
+
"""Handle version comparison and interactive prompt on the main thread."""
|
|
229
|
+
if not latest:
|
|
230
|
+
_record_check()
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
if not is_newer_version(latest, __version__):
|
|
234
|
+
_record_check()
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
channel = "pre-release" if current_is_pre else "stable"
|
|
238
|
+
click.echo(
|
|
239
|
+
f"A new {channel} FereAI CLI version {latest} "
|
|
240
|
+
f"is available (you have {__version__})."
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
should_upgrade = click.confirm(
|
|
245
|
+
"Do you want to upgrade now?",
|
|
246
|
+
default=False,
|
|
247
|
+
show_default=True,
|
|
248
|
+
)
|
|
249
|
+
except (click.Abort, EOFError):
|
|
250
|
+
_record_check()
|
|
251
|
+
return
|
|
252
|
+
|
|
253
|
+
if not should_upgrade:
|
|
254
|
+
_record_check()
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
click.echo(f"Upgrading fere-cli ({channel})...")
|
|
258
|
+
ok = perform_self_upgrade(pre=current_is_pre)
|
|
259
|
+
_record_check()
|
|
260
|
+
if ok:
|
|
261
|
+
# Omit flags intentionally — they may contain secrets.
|
|
262
|
+
subcmd = sys.argv[1] if len(sys.argv) > 1 else ""
|
|
263
|
+
rerun = f"fere {subcmd}" if subcmd else "fere"
|
|
264
|
+
click.echo(f"Upgrade completed. Please re-run: {rerun}")
|
|
265
|
+
# sys.exit(0) intentionally skips remaining call_on_close
|
|
266
|
+
# handlers — the process must restart after upgrade anyway.
|
|
267
|
+
sys.exit(0)
|
|
268
|
+
else:
|
|
269
|
+
click.echo(
|
|
270
|
+
"Upgrade failed. Manually upgrade with "
|
|
271
|
+
"'pipx upgrade fere-cli' or "
|
|
272
|
+
"'pip install --upgrade fere-cli'.",
|
|
273
|
+
err=True,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def maybe_check_for_updates(
|
|
278
|
+
output_json: bool,
|
|
279
|
+
quiet: bool,
|
|
280
|
+
ctx: "Optional[_click.Context]" = None,
|
|
281
|
+
) -> None:
|
|
282
|
+
"""Check PyPI for newer CLI version, at most once per day.
|
|
283
|
+
|
|
284
|
+
The PyPI fetch runs in a background thread so it never blocks the
|
|
285
|
+
subcommand. When *ctx* is provided the interactive prompt fires via
|
|
286
|
+
``ctx.call_on_close()`` on the main thread after the subcommand
|
|
287
|
+
completes, avoiding any race with terminal I/O. Without *ctx* the
|
|
288
|
+
check runs synchronously (useful for tests / direct calls).
|
|
289
|
+
|
|
290
|
+
- Skips non-interactive / piped usage.
|
|
291
|
+
- Skips when output_json or quiet is enabled.
|
|
292
|
+
- Skips when FERE_NO_UPDATE_CHECK=1 is set.
|
|
293
|
+
- When current version is a pre-release, checks for
|
|
294
|
+
the latest pre-release; otherwise checks stable only.
|
|
295
|
+
- Never raises; on any error it silently returns.
|
|
296
|
+
"""
|
|
297
|
+
if output_json or quiet:
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
if os.environ.get("FERE_NO_UPDATE_CHECK") == "1":
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
304
|
+
return
|
|
305
|
+
|
|
306
|
+
if not _should_check():
|
|
307
|
+
return
|
|
308
|
+
|
|
309
|
+
current_is_pre = _is_prerelease(__version__)
|
|
310
|
+
result: dict = {}
|
|
311
|
+
fetch_done = threading.Event()
|
|
312
|
+
|
|
313
|
+
def _bg_fetch() -> None:
|
|
314
|
+
try:
|
|
315
|
+
if current_is_pre:
|
|
316
|
+
result["latest"] = get_latest_prerelease_version()
|
|
317
|
+
else:
|
|
318
|
+
result["latest"] = get_latest_stable_version()
|
|
319
|
+
except Exception:
|
|
320
|
+
pass
|
|
321
|
+
finally:
|
|
322
|
+
fetch_done.set()
|
|
323
|
+
|
|
324
|
+
threading.Thread(target=_bg_fetch, daemon=True).start()
|
|
325
|
+
|
|
326
|
+
def _prompt() -> None:
|
|
327
|
+
# By the time the subcommand finishes the fetch is usually done;
|
|
328
|
+
# wait at most 2 s for any remaining network time.
|
|
329
|
+
fetch_done.wait(timeout=2.0)
|
|
330
|
+
_do_prompt(current_is_pre, result.get("latest"))
|
|
331
|
+
|
|
332
|
+
if ctx is not None:
|
|
333
|
+
ctx.call_on_close(_prompt)
|
|
334
|
+
else:
|
|
335
|
+
_prompt()
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fere-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1.dev19
|
|
4
4
|
Summary: Terminal CLI for FereAI crypto trading and research
|
|
5
5
|
Author-email: Fere AI <info@fere.ai>
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Requires-Python: >=3.10
|
|
8
8
|
Requires-Dist: click>=8.1
|
|
9
|
-
Requires-Dist: fere-sdk>=0.2.
|
|
9
|
+
Requires-Dist: fere-sdk>=0.2.1.dev19
|
|
10
10
|
Requires-Dist: rich>=13.0
|
|
11
11
|
Provides-Extra: dev
|
|
12
12
|
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
fere_cli/__init__.py,sha256=tQ7Vp1LCPsoI2hRFNekAZOEep_rO7TGuqhUqlm0ZMtU,341
|
|
2
|
+
fere_cli/async_util.py,sha256=Ai832fD956lcDdYrTSbe59iU1Dl59rRZRODFz3KA9lU,1562
|
|
3
|
+
fere_cli/banner.py,sha256=CE_sW4j460C3wGMwPVriZH-AJpWlGBHBxP1-h7DZUUM,823
|
|
4
|
+
fere_cli/config.py,sha256=BIVr6ViLCWCEloWbxF0aekS4uSrvbUVzbSN1q9dHh_o,1574
|
|
5
|
+
fere_cli/main.py,sha256=CDiWgb-4OPiVUakDq6wT6p5fYLGH3HKcdLcqfxGowTo,2510
|
|
6
|
+
fere_cli/output.py,sha256=j_2iad_g_IqySnHf0Zqh9HD7H2q_DeUHv2OlNGhBcBM,3647
|
|
7
|
+
fere_cli/update.py,sha256=vsdQTC_ue6xLpwvJGVmNt89XT64vfYV_S-vtumJEDiM,9731
|
|
8
|
+
fere_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
fere_cli/commands/auth.py,sha256=nQYY9Sx1e3p3p7ij4WggfCbj7YBV949dymfqe-pXRrw,3994
|
|
10
|
+
fere_cli/commands/chat.py,sha256=YF_bI3HBY6vOrPMYjRrvPWP74kfqwIBIcAfb9ZI47_c,5106
|
|
11
|
+
fere_cli/commands/earn.py,sha256=wDUmB7ZyQ4K2xGxm54WnnBFQiSZqThuC-h1J_yu4ito,3501
|
|
12
|
+
fere_cli/commands/hooks.py,sha256=e3A1TD_qBt-I3zbQ_Wyf3oWhSceCQwFx0Wvn17Z74R4,2007
|
|
13
|
+
fere_cli/commands/limit_order.py,sha256=Hv-9_erKUlyYE97IhSS9XOMJDrJy3qF7z-SA_ed7nzc,4188
|
|
14
|
+
fere_cli/commands/portfolio.py,sha256=7JJOIolmlud6yWStw7iv9GCOH1igOilKUJzPr7ixx2g,1758
|
|
15
|
+
fere_cli/commands/swap.py,sha256=v4urnENxLiwiGhSPTMDGEgRA0iglAC41xN_65h6xWw0,2507
|
|
16
|
+
fere_cli/commands/utility.py,sha256=0icyDsouq-NchWg_CY2WdrH9VoQjg1WBybkkmZgknAg,2198
|
|
17
|
+
fere_cli-0.2.1.dev19.dist-info/METADATA,sha256=sA1vjnn27fqqWPj1FR_jUGQpMvj7ESYKHIKkAHoVf7Y,3719
|
|
18
|
+
fere_cli-0.2.1.dev19.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
19
|
+
fere_cli-0.2.1.dev19.dist-info/entry_points.txt,sha256=vOltZxMv_RJwS1AqO3UstqpRp74xPd4UMlf62Ma-fWM,43
|
|
20
|
+
fere_cli-0.2.1.dev19.dist-info/RECORD,,
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
fere_cli/__init__.py,sha256=8YO0NbqDOIoOAlhEvrTGibA7Iz-RK46OvnhAgPNlfMU,96
|
|
2
|
-
fere_cli/async_util.py,sha256=1HFLWIyWfL30Qkz_QcWAb2EVaQ1jysCzxCbCqBjAPGc,1567
|
|
3
|
-
fere_cli/banner.py,sha256=56sSuANPj_DuUgn6sG15Y0MM6wOoqFrYydqIl4svWwQ,797
|
|
4
|
-
fere_cli/config.py,sha256=BIVr6ViLCWCEloWbxF0aekS4uSrvbUVzbSN1q9dHh_o,1574
|
|
5
|
-
fere_cli/main.py,sha256=9zxTaYRvwm04AXnK3_vFDE4EOy-txnFgXCqVe-itAwQ,2234
|
|
6
|
-
fere_cli/output.py,sha256=Q3Uo34huTasjqKnhbX92CtfQrquDDjM2UGaPTD5Atos,3356
|
|
7
|
-
fere_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
-
fere_cli/commands/auth.py,sha256=rdu3v7ARk9p56djEyI41ECzObWUOUHLCE0bB-d-Kz3Y,2763
|
|
9
|
-
fere_cli/commands/chat.py,sha256=kLxLnXzpxnGZSJ9vhMtZo3LABL9PdQzQ-r5lVos3k3E,5283
|
|
10
|
-
fere_cli/commands/earn.py,sha256=wDUmB7ZyQ4K2xGxm54WnnBFQiSZqThuC-h1J_yu4ito,3501
|
|
11
|
-
fere_cli/commands/hooks.py,sha256=oVSeI7jpYUSygqG8pqU9grtltZ7f_GRma5Z5JGlLGvE,1965
|
|
12
|
-
fere_cli/commands/limit_order.py,sha256=5d4F669K-fW4iNeZA44y5L7WQLMXqughJlwS51tUmwY,4167
|
|
13
|
-
fere_cli/commands/portfolio.py,sha256=7JJOIolmlud6yWStw7iv9GCOH1igOilKUJzPr7ixx2g,1758
|
|
14
|
-
fere_cli/commands/swap.py,sha256=v4urnENxLiwiGhSPTMDGEgRA0iglAC41xN_65h6xWw0,2507
|
|
15
|
-
fere_cli/commands/utility.py,sha256=8ofNJwxAYSBHjmX6VPIANUEPQkPXtVjIJYkK5UWXZJ4,2209
|
|
16
|
-
fere_cli-0.2.0.dev17.dist-info/METADATA,sha256=33hk4SpMmdYpe849Z7KBPkHE9OTe-ihtAjRkp1jSD2Q,3719
|
|
17
|
-
fere_cli-0.2.0.dev17.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
18
|
-
fere_cli-0.2.0.dev17.dist-info/entry_points.txt,sha256=vOltZxMv_RJwS1AqO3UstqpRp74xPd4UMlf62Ma-fWM,43
|
|
19
|
-
fere_cli-0.2.0.dev17.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|