fere-cli 0.2.0.dev15__tar.gz → 0.2.1.dev19__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 (24) hide show
  1. {fere_cli-0.2.0.dev15 → fere_cli-0.2.1.dev19}/PKG-INFO +2 -2
  2. {fere_cli-0.2.0.dev15 → fere_cli-0.2.1.dev19}/install.sh +78 -9
  3. {fere_cli-0.2.0.dev15 → fere_cli-0.2.1.dev19}/pyproject.toml +2 -2
  4. fere_cli-0.2.1.dev19/src/fere_cli/__init__.py +10 -0
  5. {fere_cli-0.2.0.dev15 → fere_cli-0.2.1.dev19}/src/fere_cli/async_util.py +1 -1
  6. {fere_cli-0.2.0.dev15 → fere_cli-0.2.1.dev19}/src/fere_cli/banner.py +2 -1
  7. {fere_cli-0.2.0.dev15 → fere_cli-0.2.1.dev19}/src/fere_cli/commands/auth.py +47 -14
  8. {fere_cli-0.2.0.dev15 → fere_cli-0.2.1.dev19}/src/fere_cli/commands/chat.py +5 -15
  9. {fere_cli-0.2.0.dev15 → fere_cli-0.2.1.dev19}/src/fere_cli/commands/hooks.py +10 -8
  10. {fere_cli-0.2.0.dev15 → fere_cli-0.2.1.dev19}/src/fere_cli/commands/limit_order.py +5 -4
  11. {fere_cli-0.2.0.dev15 → fere_cli-0.2.1.dev19}/src/fere_cli/commands/utility.py +1 -1
  12. {fere_cli-0.2.0.dev15 → fere_cli-0.2.1.dev19}/src/fere_cli/main.py +6 -1
  13. {fere_cli-0.2.0.dev15 → fere_cli-0.2.1.dev19}/src/fere_cli/output.py +13 -2
  14. fere_cli-0.2.1.dev19/src/fere_cli/update.py +335 -0
  15. {fere_cli-0.2.0.dev15 → fere_cli-0.2.1.dev19}/tests/test_install_sh.py +31 -3
  16. fere_cli-0.2.1.dev19/tests/test_update.py +219 -0
  17. fere_cli-0.2.0.dev15/src/fere_cli/__init__.py +0 -3
  18. {fere_cli-0.2.0.dev15 → fere_cli-0.2.1.dev19}/.gitignore +0 -0
  19. {fere_cli-0.2.0.dev15 → fere_cli-0.2.1.dev19}/README.md +0 -0
  20. {fere_cli-0.2.0.dev15 → fere_cli-0.2.1.dev19}/src/fere_cli/commands/__init__.py +0 -0
  21. {fere_cli-0.2.0.dev15 → fere_cli-0.2.1.dev19}/src/fere_cli/commands/earn.py +0 -0
  22. {fere_cli-0.2.0.dev15 → fere_cli-0.2.1.dev19}/src/fere_cli/commands/portfolio.py +0 -0
  23. {fere_cli-0.2.0.dev15 → fere_cli-0.2.1.dev19}/src/fere_cli/commands/swap.py +0 -0
  24. {fere_cli-0.2.0.dev15 → fere_cli-0.2.1.dev19}/src/fere_cli/config.py +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fere-cli
3
- Version: 0.2.0.dev15
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.0.dev15
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'
@@ -35,8 +35,8 @@ case "$TARGET" in
35
35
  ;;
36
36
  esac
37
37
 
38
- # Colors (disabled if not a TTY)
39
- if [ -t 1 ]; then
38
+ # Colors (disabled if not a suitable TTY or when piped)
39
+ if [ -t 1 ] && [ -t 0 ] && [ "${TERM:-}" != "dumb" ] && [ -z "${NO_COLOR:-}" ] && [ -z "${FERE_NO_COLOR:-}" ]; then
40
40
  RED='\033[0;31m'
41
41
  GREEN='\033[0;32m'
42
42
  YELLOW='\033[0;33m'
@@ -130,9 +130,9 @@ install_with_pipx() {
130
130
  if command -v pipx >/dev/null 2>&1; then
131
131
  info "Installing with pipx..."
132
132
  if [ -n "$PIPX_EXTRA_ARGS" ]; then
133
- pipx install --force "$PIPX_EXTRA_ARGS" "$PKG_SPEC" || return 1
133
+ pipx install --python "$PYTHON" --force "$PIPX_EXTRA_ARGS" "$PKG_SPEC" || return 1
134
134
  else
135
- pipx install --force "$PKG_SPEC" || return 1
135
+ pipx install --python "$PYTHON" --force "$PKG_SPEC" || return 1
136
136
  fi
137
137
  return 0
138
138
  fi
@@ -187,6 +187,69 @@ detect_user_bin() {
187
187
  return 1
188
188
  }
189
189
 
190
+ expand_path() {
191
+ local path="$1"
192
+ case "$path" in
193
+ "~")
194
+ printf "%s\n" "${HOME:-}"
195
+ ;;
196
+ "~/"*)
197
+ printf "%s\n" "${HOME:-}/${path#~/}"
198
+ ;;
199
+ *)
200
+ printf "%s\n" "$path"
201
+ ;;
202
+ esac
203
+ }
204
+
205
+ append_path_export_if_missing() {
206
+ local rc_file="$1"
207
+ local user_bin="$2"
208
+ local rc_path rc_dir
209
+
210
+ [ -n "$rc_file" ] || return 1
211
+ [ -n "$user_bin" ] || return 1
212
+
213
+ rc_path=$(expand_path "$rc_file")
214
+ [ -n "$rc_path" ] || return 1
215
+
216
+ rc_dir=$(dirname "$rc_path")
217
+ if [ ! -d "$rc_dir" ]; then
218
+ mkdir -p "$rc_dir" 2>/dev/null || return 1
219
+ fi
220
+
221
+ touch "$rc_path" 2>/dev/null || return 1
222
+
223
+ if grep -v "^[[:space:]]*#" "$rc_path" 2>/dev/null | grep -qE "(^|:|=\"|=)${user_bin}(:|\"|\\\$|$)"; then
224
+ return 0
225
+ fi
226
+ # Also catch $HOME-relative variants (e.g. $HOME/.local/bin or ${HOME}/.local/bin)
227
+ # to avoid duplicates when the user already has an equivalent entry.
228
+ home_rel="${user_bin#"${HOME:-}"}"
229
+ if [ "$home_rel" != "$user_bin" ]; then
230
+ if grep -v "^[[:space:]]*#" "$rc_path" 2>/dev/null | grep -qE "(^|:|=\"|=)\\\$HOME${home_rel}(:|\"|\\\$|$)"; then
231
+ return 0
232
+ fi
233
+ if grep -v "^[[:space:]]*#" "$rc_path" 2>/dev/null | grep -qE "(^|:|=\"|=)\\\$\{HOME\}${home_rel}(:|\"|\\\$|$)"; then
234
+ return 0
235
+ fi
236
+ fi
237
+
238
+ {
239
+ echo ""
240
+ echo "# Added by FereAI CLI installer"
241
+ # Write $HOME-relative path when possible so the entry stays valid
242
+ # even if the home directory path changes (e.g. LDAP migration).
243
+ if [ "$home_rel" != "$user_bin" ]; then
244
+ echo "export PATH=\"\$HOME${home_rel}:\$PATH\""
245
+ else
246
+ echo "export PATH=\"$user_bin:\$PATH\""
247
+ fi
248
+ } >>"$rc_path" 2>/dev/null || return 1
249
+
250
+ return 0
251
+ }
252
+
190
253
  detect_shell_rc() {
191
254
  local shell_path
192
255
  shell_path="${SHELL:-}"
@@ -258,11 +321,17 @@ else
258
321
  echo "This takes effect immediately in all fish sessions."
259
322
  ;;
260
323
  *)
261
- echo "To fix this, add the following line to $rc_file:"
262
- echo ""
263
- echo " export PATH=\"$user_bin:\$PATH\""
264
- echo ""
265
- echo "Then restart your terminal or reload your shell (e.g. 'source $rc_file')."
324
+ if append_path_export_if_missing "$rc_file" "$user_bin"; then
325
+ success "Updated your PATH in $rc_file to include '$user_bin'."
326
+ echo ""
327
+ info "Please restart your terminal so 'fere' is available in new shells."
328
+ else
329
+ echo "To fix this, add the following line to $rc_file:"
330
+ echo ""
331
+ echo " export PATH=\"$user_bin:\$PATH\""
332
+ echo ""
333
+ echo "After updating, restart your terminal so 'fere' is available."
334
+ fi
266
335
  ;;
267
336
  esac
268
337
  else
@@ -1,13 +1,13 @@
1
1
  [project]
2
2
  name = "fere-cli"
3
- version = "0.2.0.dev15"
3
+ version = "0.2.1.dev19"
4
4
  description = "Terminal CLI for FereAI crypto trading and research"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
7
7
  license = "MIT"
8
8
  authors = [{ name = "Fere AI", email = "info@fere.ai" }]
9
9
  dependencies = [
10
- "fere-sdk>=0.2.0.dev15",
10
+ "fere-sdk>=0.2.1.dev19",
11
11
  "click>=8.1",
12
12
  "rich>=13.0",
13
13
  ]
@@ -0,0 +1,10 @@
1
+ """FereAI CLI — terminal interface for crypto trading and research."""
2
+
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"
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
  import asyncio
11
11
  import sys
12
12
  from functools import wraps
13
- from typing import Any, Callable
13
+ from typing import Callable
14
14
 
15
15
  import click
16
16
  from fere_sdk import FereClient
@@ -20,4 +20,5 @@ LOGO = r"""
20
20
 
21
21
  def get_banner() -> str:
22
22
  """Return the full banner string with version."""
23
- return f"{LOGO}\n v{__version__} — crypto trading & research from your terminal\n"
23
+ tagline = "crypto trading & research from your terminal"
24
+ return f"{LOGO}\n v{__version__} — {tagline}\n"
@@ -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, run_async
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 and wallet addresses."""
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
- result = {**user}
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)
@@ -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
- "Usage: fere chat \"your question\""
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]"
@@ -17,21 +17,23 @@ def hooks():
17
17
 
18
18
 
19
19
  @hooks.command()
20
- @click.option(
21
- "--chain-id", required=True, type=int, help="Chain ID."
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='Stop-loss config as JSON (e.g. \'{"price_percentage": 0.3, "sell_percentage": 1.0}\').',
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='Take-profit config as JSON (e.g. \'{"price_percentage": 1.0, "sell_percentage": 0.5}\').',
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
@@ -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="Trigger condition: 'gte' (price >= target) or 'lte' (price <= target).",
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(
@@ -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, run_async
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
 
@@ -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)
@@ -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 isinstance(v, (dict, list)):
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)
@@ -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()
@@ -102,8 +102,35 @@ def test_install_prints_path_guidance_when_fere_missing(tmp_path: Path) -> None:
102
102
  fake_brew = bin_dir / "brew"
103
103
  _make_executable(fake_brew, "#!/bin/sh\nexit 1\n")
104
104
 
105
+ # Symlink essential system utilities so the shell script can run, but
106
+ # exclude python/pipx/pip/fere (those are controlled by fakes above).
107
+ exclude = {
108
+ "python",
109
+ "python3",
110
+ "python3.11",
111
+ "python3.12",
112
+ "python3.13",
113
+ "pip",
114
+ "pip3",
115
+ "pipx",
116
+ "fere",
117
+ }
118
+ for sysdir in ["/usr/bin", "/bin"]:
119
+ p = Path(sysdir)
120
+ if p.is_dir():
121
+ for entry in p.iterdir():
122
+ if (
123
+ entry.name not in exclude
124
+ and not entry.name.startswith("python")
125
+ and not (bin_dir / entry.name).exists()
126
+ ):
127
+ try:
128
+ (bin_dir / entry.name).symlink_to(entry)
129
+ except OSError:
130
+ pass
131
+
105
132
  env = os.environ.copy()
106
- env["PATH"] = f"{bin_dir}{os.pathsep}{env.get('PATH', '')}"
133
+ env["PATH"] = str(bin_dir) # isolated — real fere must not be found
107
134
  env["HOME"] = str(home_dir)
108
135
  env["SHELL"] = "/bin/bash"
109
136
 
@@ -118,12 +145,13 @@ def test_install_prints_path_guidance_when_fere_missing(tmp_path: Path) -> None:
118
145
  # Installer exits 0 (install succeeded) but warns fere isn't on PATH
119
146
  assert result.returncode == 0
120
147
  assert "Installation complete, but 'fere' is not in PATH." in out
121
- # Verify specific PATH guidance (not the generic fallback)
122
- assert "export PATH=" in out
148
+ # Verify the rc file was updated with PATH guidance
123
149
  expected_rc = (
124
150
  ".bash_profile" if platform.system() == "Darwin" else ".bashrc"
125
151
  )
126
152
  assert expected_rc in out
153
+ # Either the script auto-updated the rc file or printed manual guidance
154
+ assert "Updated your PATH" in out or "export PATH=" in out
127
155
 
128
156
 
129
157
  def test_invalid_version_argument(tmp_path: Path) -> None:
@@ -0,0 +1,219 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import types
5
+
6
+ import fere_cli.update as update
7
+
8
+
9
+ def test_is_stable_version_accepts_pure_semver() -> None:
10
+ assert update._is_stable_version("0.1.0")
11
+ assert update._is_stable_version("10.20.30")
12
+
13
+
14
+ def test_is_stable_version_rejects_prereleases() -> None:
15
+ assert not update._is_stable_version("0.1.0a1")
16
+ assert not update._is_stable_version("0.1.0rc1")
17
+ assert not update._is_stable_version("1.2")
18
+ assert not update._is_stable_version("1.2.3.4")
19
+ assert not update._is_stable_version("foo")
20
+
21
+
22
+ def test_is_prerelease() -> None:
23
+ assert update._is_prerelease("0.2.0a1")
24
+ assert update._is_prerelease("0.2.0rc1")
25
+ assert not update._is_prerelease("0.2.0")
26
+
27
+
28
+ def test_is_newer_version_compares_semver() -> None:
29
+ assert update.is_newer_version("0.2.0", "0.1.0")
30
+ assert update.is_newer_version("1.0.0", "0.9.9")
31
+ assert not update.is_newer_version("0.1.0", "0.1.0")
32
+ assert not update.is_newer_version("0.1.0", "0.2.0")
33
+
34
+
35
+ def test_is_newer_version_prerelease_prefix() -> None:
36
+ assert update.is_newer_version("0.3.0a1", "0.2.0")
37
+ assert not update.is_newer_version("0.2.0a1", "0.2.0")
38
+ # stable is newer than same-base pre-release
39
+ assert update.is_newer_version("0.2.0", "0.2.0a1")
40
+
41
+
42
+ def _make_fake_urlopen(releases: dict):
43
+ class FakeResponse:
44
+ def __init__(self, payload: bytes) -> None:
45
+ self._payload = payload
46
+
47
+ def read(self) -> bytes:
48
+ return self._payload
49
+
50
+ def __enter__(self) -> "FakeResponse":
51
+ return self
52
+
53
+ def __exit__(self, *exc: object) -> None:
54
+ return None
55
+
56
+ def fake_urlopen(req, timeout=0):
57
+ payload = {"releases": releases}
58
+ return FakeResponse(json.dumps(payload).encode("utf-8"))
59
+
60
+ return fake_urlopen
61
+
62
+
63
+ def test_get_latest_stable_version_ignores_prereleases(
64
+ monkeypatch,
65
+ ) -> None:
66
+ releases = {
67
+ "0.1.0": [],
68
+ "0.2.0a1": [],
69
+ "0.2.0": [],
70
+ }
71
+ monkeypatch.setattr(
72
+ update.urllib.request,
73
+ "urlopen",
74
+ _make_fake_urlopen(releases),
75
+ )
76
+ latest = update.get_latest_stable_version()
77
+ assert latest == "0.2.0"
78
+
79
+
80
+ def test_get_latest_prerelease_version_includes_all(
81
+ monkeypatch,
82
+ ) -> None:
83
+ releases = {
84
+ "0.1.0": [],
85
+ "0.2.0a1": [],
86
+ "0.2.0": [],
87
+ "0.3.0a1": [],
88
+ }
89
+ monkeypatch.setattr(
90
+ update.urllib.request,
91
+ "urlopen",
92
+ _make_fake_urlopen(releases),
93
+ )
94
+ latest = update.get_latest_prerelease_version()
95
+ assert latest == "0.3.0a1"
96
+
97
+
98
+ def test_latest_from_releases_prerelease_index_ordering() -> None:
99
+ """a2 must beat a1 regardless of PyPI dict insertion order."""
100
+ releases_a1_first = {"0.2.0a1": [], "0.2.0a2": []}
101
+ releases_a2_first = {"0.2.0a2": [], "0.2.0a1": []}
102
+ assert (
103
+ update._latest_from_releases(releases_a1_first, include_prerelease=True)
104
+ == "0.2.0a2"
105
+ )
106
+ assert (
107
+ update._latest_from_releases(releases_a2_first, include_prerelease=True)
108
+ == "0.2.0a2"
109
+ )
110
+
111
+
112
+ def test_should_check_respects_interval(monkeypatch, tmp_path) -> None:
113
+ sf = tmp_path / "update-check.json"
114
+ monkeypatch.setattr(update, "_state_file", lambda: sf)
115
+
116
+ # No file → should check
117
+ assert update._should_check()
118
+
119
+ # Just checked → skip
120
+ update._record_check()
121
+ assert not update._should_check()
122
+
123
+ # Old timestamp → should check
124
+ import time
125
+
126
+ sf.write_text(json.dumps({"last_check": time.time() - 100_000}))
127
+ assert update._should_check()
128
+
129
+
130
+ def test_maybe_check_skips_when_env_var_set_with_tty(
131
+ monkeypatch, capsys
132
+ ) -> None:
133
+ """FERE_NO_UPDATE_CHECK=1 suppresses check even when TTY is active."""
134
+ fake_tty = types.SimpleNamespace(isatty=lambda: True)
135
+ monkeypatch.setattr(update.sys, "stdin", fake_tty)
136
+ monkeypatch.setattr(update.sys, "stdout", fake_tty)
137
+ monkeypatch.setenv("FERE_NO_UPDATE_CHECK", "1")
138
+ monkeypatch.setattr(update, "_should_check", lambda: True)
139
+ monkeypatch.setattr(
140
+ update, "get_latest_stable_version", lambda **kw: "999.0.0"
141
+ )
142
+ monkeypatch.setattr(
143
+ update, "is_newer_version", lambda latest, current: True
144
+ )
145
+
146
+ update.maybe_check_for_updates(output_json=False, quiet=False)
147
+ captured = capsys.readouterr()
148
+ assert captured.out == ""
149
+ assert captured.err == ""
150
+
151
+
152
+ def test_maybe_check_skips_non_tty(monkeypatch, capsys) -> None:
153
+ """Non-TTY stdin/stdout suppresses check regardless of env var."""
154
+ fake_non_tty = types.SimpleNamespace(isatty=lambda: False)
155
+ monkeypatch.setattr(update.sys, "stdin", fake_non_tty)
156
+ monkeypatch.setattr(update.sys, "stdout", fake_non_tty)
157
+ monkeypatch.delenv("FERE_NO_UPDATE_CHECK", raising=False)
158
+ monkeypatch.setattr(update, "_should_check", lambda: True)
159
+ monkeypatch.setattr(
160
+ update, "get_latest_stable_version", lambda **kw: "999.0.0"
161
+ )
162
+ monkeypatch.setattr(
163
+ update, "is_newer_version", lambda latest, current: True
164
+ )
165
+
166
+ update.maybe_check_for_updates(output_json=False, quiet=False)
167
+ captured = capsys.readouterr()
168
+ assert captured.out == ""
169
+ assert captured.err == ""
170
+
171
+
172
+ def _setup_tty_with_update(monkeypatch, tmp_path):
173
+ """Common setup: fake TTY, update available, state file in tmp_path."""
174
+ import io
175
+
176
+ sf = tmp_path / "update-check.json"
177
+ monkeypatch.setattr(update, "_state_file", lambda: sf)
178
+ # stdin only needs isatty(); stdout must also be writable for click.echo.
179
+ fake_stdin = types.SimpleNamespace(isatty=lambda: True)
180
+
181
+ class FakeTTYOut(io.StringIO):
182
+ def isatty(self) -> bool:
183
+ return True
184
+
185
+ monkeypatch.setattr(update.sys, "stdin", fake_stdin)
186
+ monkeypatch.setattr(update.sys, "stdout", FakeTTYOut())
187
+ monkeypatch.delenv("FERE_NO_UPDATE_CHECK", raising=False)
188
+ monkeypatch.setattr(update, "_should_check", lambda: True)
189
+ monkeypatch.setattr(
190
+ update, "get_latest_stable_version", lambda **kw: "999.0.0"
191
+ )
192
+ return sf
193
+
194
+
195
+ def test_maybe_check_user_declines_records_check(monkeypatch, tmp_path) -> None:
196
+ """User declines upgrade → _record_check() persists timestamp."""
197
+ sf = _setup_tty_with_update(monkeypatch, tmp_path)
198
+ monkeypatch.setattr(update.click, "confirm", lambda *a, **kw: False)
199
+
200
+ update.maybe_check_for_updates(output_json=False, quiet=False)
201
+
202
+ assert sf.exists(), "_record_check() must write state file on decline"
203
+
204
+
205
+ def test_maybe_check_user_confirms_upgrades_and_exits(
206
+ monkeypatch, tmp_path
207
+ ) -> None:
208
+ """User confirms upgrade → sys.exit(0) and state file written."""
209
+ import pytest
210
+
211
+ sf = _setup_tty_with_update(monkeypatch, tmp_path)
212
+ monkeypatch.setattr(update.click, "confirm", lambda *a, **kw: True)
213
+ monkeypatch.setattr(update, "perform_self_upgrade", lambda **kw: True)
214
+
215
+ with pytest.raises(SystemExit) as exc_info:
216
+ update.maybe_check_for_updates(output_json=False, quiet=False)
217
+
218
+ assert exc_info.value.code == 0
219
+ assert sf.exists(), "_record_check() must write state file on upgrade"
@@ -1,3 +0,0 @@
1
- """FereAI CLI — terminal interface for crypto trading and research."""
2
-
3
- __version__ = "0.1.0"
File without changes