talis-cli 0.2.0__tar.gz → 0.3.0__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.
- {talis_cli-0.2.0 → talis_cli-0.3.0}/PKG-INFO +54 -10
- {talis_cli-0.2.0 → talis_cli-0.3.0}/README.md +53 -9
- {talis_cli-0.2.0 → talis_cli-0.3.0}/pyproject.toml +1 -1
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/api.py +21 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/cli.py +66 -1
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/mcp_server.py +180 -16
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis_cli.egg-info/PKG-INFO +54 -10
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis_cli.egg-info/SOURCES.txt +1 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_mcp.py +2 -1
- talis_cli-0.3.0/tests/test_mcp_backtest.py +109 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_mcp_docstring_contract.py +45 -0
- talis_cli-0.3.0/tests/test_mcp_session_expiry.py +276 -0
- talis_cli-0.3.0/tests/test_session_autorenew.py +286 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_version_sync.py +6 -1
- talis_cli-0.2.0/tests/test_mcp_session_expiry.py +0 -124
- talis_cli-0.2.0/tests/test_session_autorenew.py +0 -141
- {talis_cli-0.2.0 → talis_cli-0.3.0}/setup.cfg +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/__init__.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/_eip55.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/__init__.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/_shared.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/auth.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/brief.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/chat.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/fund.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/guide.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/history.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/notifications.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/outcome.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/portfolio.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/research.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/snapshot.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/strategies.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/test_tenant.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/trade.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/wait.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/commands/withdraw.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/config.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/output.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/paper.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/spot.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis/symbols.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis_cli.egg-info/dependency_links.txt +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis_cli.egg-info/entry_points.txt +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis_cli.egg-info/requires.txt +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/talis_cli.egg-info/top_level.txt +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_api.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_api_key_auth.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_brief.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_chat.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_commands.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_config.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_confirm_prompt.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_eip55.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_guide.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_history.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_login_flow.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_loopback_login.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_mcp_balance_contract.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_mcp_mode_contract.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_mcp_sdk_compat.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_mcp_strategy_tools.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_non_interactive_auth.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_notifications.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_outcome.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_paper_mode.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_research.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_signup.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_snapshot_diff.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_spot.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_spot_gateway_first.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_strategies_create.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_test_tenant.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_wait.py +0 -0
- {talis_cli-0.2.0 → talis_cli-0.3.0}/tests/test_withdraw.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: talis-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Command-line client for the Talis trading platform. Sign in via device flow, manage sessions, view portfolio, place orders.
|
|
5
5
|
Author: Talis
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -114,6 +114,27 @@ suitable for parsing from scripts or LLM agents. Force JSON anywhere with
|
|
|
114
114
|
The CLI stores its session token at `~/.talis/credentials` with `chmod 600`
|
|
115
115
|
on POSIX systems. The token is a JWT; treat it like any other credential.
|
|
116
116
|
|
|
117
|
+
### Sessions renew themselves
|
|
118
|
+
|
|
119
|
+
You should not have to think about session expiry. When the CLI or the MCP
|
|
120
|
+
server runs with under 48 hours left on the clock, it renews in place against
|
|
121
|
+
`POST /auth/session/refresh` before doing its work — at most once every 15
|
|
122
|
+
minutes, so a burst of commands costs one renewal, not one each. Renewal
|
|
123
|
+
preserves the
|
|
124
|
+
session's **original grant**, not a fresh maximum — a token issued with a 24h
|
|
125
|
+
life renews to another 24h, never to something longer. That is deliberate: an
|
|
126
|
+
auto-renewing session must not be able to ratchet itself into a longer-lived
|
|
127
|
+
credential than the one you approved.
|
|
128
|
+
|
|
129
|
+
Renewal is best-effort and silent: if it cannot happen, your existing
|
|
130
|
+
credential is left untouched and the command still runs. What you WILL see is a
|
|
131
|
+
`session_warning` on MCP tool responses once renewal stops keeping up, or when
|
|
132
|
+
under six hours remain. If that appears and keeps counting down, renewal is
|
|
133
|
+
failing — run `talis login` to sign in again.
|
|
134
|
+
|
|
135
|
+
A steady `session_hours_remaining` with no `session_warning` is the healthy
|
|
136
|
+
state, not a countdown to worry about.
|
|
137
|
+
|
|
117
138
|
## Relationship to `cli/jarvis_cli.sh`
|
|
118
139
|
|
|
119
140
|
The bash script at [`cli/jarvis_cli.sh`](../../cli/jarvis_cli.sh) is the legacy
|
|
@@ -142,12 +163,16 @@ your portfolio, pull Talis Research, place paper trades, create strategies,
|
|
|
142
163
|
set price alerts, and talk to the Talis chat agent. Log in first
|
|
143
164
|
(`talis login`); the server authenticates as the logged-in user.
|
|
144
165
|
|
|
145
|
-
|
|
166
|
+
Install and add it to **Claude Code**:
|
|
146
167
|
|
|
147
168
|
```bash
|
|
169
|
+
pip install talis-cli # or: uv tool install talis-cli
|
|
170
|
+
talis login # device-flow browser approval, once
|
|
148
171
|
claude mcp add talis -- talis mcp
|
|
149
172
|
```
|
|
150
173
|
|
|
174
|
+
No local checkout required — `talis` is on PyPI.
|
|
175
|
+
|
|
151
176
|
Or to **Claude Desktop** (and other clients using the `mcpServers` config
|
|
152
177
|
shape) — `claude_desktop_config.json`:
|
|
153
178
|
|
|
@@ -162,14 +187,33 @@ shape) — `claude_desktop_config.json`:
|
|
|
162
187
|
}
|
|
163
188
|
```
|
|
164
189
|
|
|
165
|
-
Tools:
|
|
166
|
-
|
|
167
|
-
`
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
`
|
|
172
|
-
|
|
190
|
+
Tools (26):
|
|
191
|
+
|
|
192
|
+
`amend_strategy`, `backtest`, `balance`, `brief_latest`, `buy`, `cancel_all_orders`,
|
|
193
|
+
`cancel_order`, `chat`, `close`, `create_strategy`, `history`,
|
|
194
|
+
`notifications_create`, `open_orders`, `outcomes`, `packets`, `place_order`,
|
|
195
|
+
`portfolio`, `research`, `sell`, `start_strategy`, `stop_strategy`,
|
|
196
|
+
`strategies`, `strategy`, `strategy_activity`, `validate_strategy`, `whoami`.
|
|
197
|
+
|
|
198
|
+
**Strategy workflow.** `validate_strategy(dsl)` checks a DSL without side
|
|
199
|
+
effects; `backtest(pair, dsl, days=30, interval="1h")` runs it through the live
|
|
200
|
+
engine on real historical candles (read-only) and returns return, Sharpe,
|
|
201
|
+
drawdown, trade count; `create_strategy(pair, dsl, paper=True)` runs it on the
|
|
202
|
+
simulated book; the same call with `paper=False, confirm_real_money=True`
|
|
203
|
+
deploys it live.
|
|
204
|
+
|
|
205
|
+
**Safety — read this before pointing an agent at it.** The trading tools
|
|
206
|
+
(`buy` / `sell` / `close` / `create_strategy`) execute with **REAL MONEY by
|
|
207
|
+
default** (`paper=False`). Pass `paper=True` to simulate instead.
|
|
208
|
+
|
|
209
|
+
`buy`, `sell` and `create_strategy` additionally refuse to spend real money
|
|
210
|
+
unless the caller passes `confirm_real_money=True`. `close` deliberately has no
|
|
211
|
+
such gate: a protective exit must never be blocked by a missing flag.
|
|
212
|
+
`place_order` (limit orders, spot, HIP-4 outcome markets) has **no paper mode
|
|
213
|
+
at all** and refuses without `confirm_real_money=True`.
|
|
214
|
+
|
|
215
|
+
Set `TALIS_PAPER=1` in the server's environment to force paper mode regardless
|
|
216
|
+
of tool arguments.
|
|
173
217
|
Withdrawals and wallet creation are not exposed over MCP. For a strictly
|
|
174
218
|
read-only agent, log in with `talis login --read-only` — the server rejects
|
|
175
219
|
every mutation at the API layer.
|
|
@@ -86,6 +86,27 @@ suitable for parsing from scripts or LLM agents. Force JSON anywhere with
|
|
|
86
86
|
The CLI stores its session token at `~/.talis/credentials` with `chmod 600`
|
|
87
87
|
on POSIX systems. The token is a JWT; treat it like any other credential.
|
|
88
88
|
|
|
89
|
+
### Sessions renew themselves
|
|
90
|
+
|
|
91
|
+
You should not have to think about session expiry. When the CLI or the MCP
|
|
92
|
+
server runs with under 48 hours left on the clock, it renews in place against
|
|
93
|
+
`POST /auth/session/refresh` before doing its work — at most once every 15
|
|
94
|
+
minutes, so a burst of commands costs one renewal, not one each. Renewal
|
|
95
|
+
preserves the
|
|
96
|
+
session's **original grant**, not a fresh maximum — a token issued with a 24h
|
|
97
|
+
life renews to another 24h, never to something longer. That is deliberate: an
|
|
98
|
+
auto-renewing session must not be able to ratchet itself into a longer-lived
|
|
99
|
+
credential than the one you approved.
|
|
100
|
+
|
|
101
|
+
Renewal is best-effort and silent: if it cannot happen, your existing
|
|
102
|
+
credential is left untouched and the command still runs. What you WILL see is a
|
|
103
|
+
`session_warning` on MCP tool responses once renewal stops keeping up, or when
|
|
104
|
+
under six hours remain. If that appears and keeps counting down, renewal is
|
|
105
|
+
failing — run `talis login` to sign in again.
|
|
106
|
+
|
|
107
|
+
A steady `session_hours_remaining` with no `session_warning` is the healthy
|
|
108
|
+
state, not a countdown to worry about.
|
|
109
|
+
|
|
89
110
|
## Relationship to `cli/jarvis_cli.sh`
|
|
90
111
|
|
|
91
112
|
The bash script at [`cli/jarvis_cli.sh`](../../cli/jarvis_cli.sh) is the legacy
|
|
@@ -114,12 +135,16 @@ your portfolio, pull Talis Research, place paper trades, create strategies,
|
|
|
114
135
|
set price alerts, and talk to the Talis chat agent. Log in first
|
|
115
136
|
(`talis login`); the server authenticates as the logged-in user.
|
|
116
137
|
|
|
117
|
-
|
|
138
|
+
Install and add it to **Claude Code**:
|
|
118
139
|
|
|
119
140
|
```bash
|
|
141
|
+
pip install talis-cli # or: uv tool install talis-cli
|
|
142
|
+
talis login # device-flow browser approval, once
|
|
120
143
|
claude mcp add talis -- talis mcp
|
|
121
144
|
```
|
|
122
145
|
|
|
146
|
+
No local checkout required — `talis` is on PyPI.
|
|
147
|
+
|
|
123
148
|
Or to **Claude Desktop** (and other clients using the `mcpServers` config
|
|
124
149
|
shape) — `claude_desktop_config.json`:
|
|
125
150
|
|
|
@@ -134,14 +159,33 @@ shape) — `claude_desktop_config.json`:
|
|
|
134
159
|
}
|
|
135
160
|
```
|
|
136
161
|
|
|
137
|
-
Tools:
|
|
138
|
-
|
|
139
|
-
`
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
`
|
|
144
|
-
|
|
162
|
+
Tools (26):
|
|
163
|
+
|
|
164
|
+
`amend_strategy`, `backtest`, `balance`, `brief_latest`, `buy`, `cancel_all_orders`,
|
|
165
|
+
`cancel_order`, `chat`, `close`, `create_strategy`, `history`,
|
|
166
|
+
`notifications_create`, `open_orders`, `outcomes`, `packets`, `place_order`,
|
|
167
|
+
`portfolio`, `research`, `sell`, `start_strategy`, `stop_strategy`,
|
|
168
|
+
`strategies`, `strategy`, `strategy_activity`, `validate_strategy`, `whoami`.
|
|
169
|
+
|
|
170
|
+
**Strategy workflow.** `validate_strategy(dsl)` checks a DSL without side
|
|
171
|
+
effects; `backtest(pair, dsl, days=30, interval="1h")` runs it through the live
|
|
172
|
+
engine on real historical candles (read-only) and returns return, Sharpe,
|
|
173
|
+
drawdown, trade count; `create_strategy(pair, dsl, paper=True)` runs it on the
|
|
174
|
+
simulated book; the same call with `paper=False, confirm_real_money=True`
|
|
175
|
+
deploys it live.
|
|
176
|
+
|
|
177
|
+
**Safety — read this before pointing an agent at it.** The trading tools
|
|
178
|
+
(`buy` / `sell` / `close` / `create_strategy`) execute with **REAL MONEY by
|
|
179
|
+
default** (`paper=False`). Pass `paper=True` to simulate instead.
|
|
180
|
+
|
|
181
|
+
`buy`, `sell` and `create_strategy` additionally refuse to spend real money
|
|
182
|
+
unless the caller passes `confirm_real_money=True`. `close` deliberately has no
|
|
183
|
+
such gate: a protective exit must never be blocked by a missing flag.
|
|
184
|
+
`place_order` (limit orders, spot, HIP-4 outcome markets) has **no paper mode
|
|
185
|
+
at all** and refuses without `confirm_real_money=True`.
|
|
186
|
+
|
|
187
|
+
Set `TALIS_PAPER=1` in the server's environment to force paper mode regardless
|
|
188
|
+
of tool arguments.
|
|
145
189
|
Withdrawals and wallet creation are not exposed over MCP. For a strictly
|
|
146
190
|
read-only agent, log in with `talis login --read-only` — the server rejects
|
|
147
191
|
every mutation at the API layer.
|
|
@@ -491,6 +491,27 @@ class Client:
|
|
|
491
491
|
params={"tenant_id": tenant_id},
|
|
492
492
|
)
|
|
493
493
|
|
|
494
|
+
def get_candles(self, *, symbol: str, interval: str = "1h", limit: int = 500,
|
|
495
|
+
exchange: str | None = None) -> dict:
|
|
496
|
+
"""Historical candles from the control plane (``GET /candles``).
|
|
497
|
+
|
|
498
|
+
Returns ``{"candles": [{t,o,h,l,c,v}, ...], "symbol", "interval"}`` —
|
|
499
|
+
abbreviated OHLCV keys, newest last. The backtester wants the full
|
|
500
|
+
names; ``backtest_candles`` in the MCP layer does that mapping.
|
|
501
|
+
"""
|
|
502
|
+
params: dict[str, Any] = {"symbol": symbol, "interval": interval, "limit": limit}
|
|
503
|
+
if exchange:
|
|
504
|
+
params["exchange"] = exchange
|
|
505
|
+
return self._request("GET", "/candles", params=params)
|
|
506
|
+
|
|
507
|
+
def create_backtest(self, *, body: dict) -> dict:
|
|
508
|
+
"""Submit a backtest (``POST /backtests``). Returns ``{backtest_id, status}``."""
|
|
509
|
+
return self._request("POST", "/backtests", json=body)
|
|
510
|
+
|
|
511
|
+
def get_backtest(self, *, backtest_id: str) -> dict:
|
|
512
|
+
"""Backtest status + results (``GET /backtests/{id}``)."""
|
|
513
|
+
return self._request("GET", f"/backtests/{backtest_id}")
|
|
514
|
+
|
|
494
515
|
def validate_strategy(self, *, dsl: dict) -> dict:
|
|
495
516
|
"""Validate a DSL WITHOUT creating anything. No side effects.
|
|
496
517
|
|
|
@@ -13,7 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
import typer
|
|
15
15
|
|
|
16
|
-
from . import __version__, output
|
|
16
|
+
from . import __version__, config, output
|
|
17
17
|
from .commands import auth, mcp_server, trade
|
|
18
18
|
from .commands import chat as chat_cmd
|
|
19
19
|
from .commands import guide as guide_cmd
|
|
@@ -83,6 +83,71 @@ app.add_typer(fund_app, name="fund")
|
|
|
83
83
|
app.command("deposit")(__import__("talis.commands.fund",fromlist=["deposit"]).deposit)
|
|
84
84
|
|
|
85
85
|
|
|
86
|
+
# Commands that must NEVER trigger a renewal.
|
|
87
|
+
# login/signup/approve — there is no session yet to renew.
|
|
88
|
+
# logout — we are about to revoke the session we hold; renewing it first is
|
|
89
|
+
# wasted work and muddies the audit trail.
|
|
90
|
+
# version — must stay answerable with no credentials and no network at all.
|
|
91
|
+
_NO_RENEW = frozenset({"login", "signup", "approve", "logout", "version"})
|
|
92
|
+
|
|
93
|
+
# Minimum gap between renewal attempts across separate CLI invocations.
|
|
94
|
+
_RENEW_COOLDOWN_SECONDS = 900.0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _renewed_recently() -> bool:
|
|
98
|
+
"""True when the stored credential was rewritten inside the cooldown.
|
|
99
|
+
|
|
100
|
+
WHY A COOLDOWN AT ALL. ensure_fresh fires whenever under 48h remain, but a
|
|
101
|
+
session's grant is 24h — permanently under that threshold. Without a gate,
|
|
102
|
+
wiring renewal into the CLI would mint a new JWT and pay a network
|
|
103
|
+
round-trip on EVERY command, forever.
|
|
104
|
+
|
|
105
|
+
WHY MTIME rather than a new state file. The MCP server uses an in-memory
|
|
106
|
+
cooldown, which cannot work here: a CLI process is born and dies per
|
|
107
|
+
command. The credentials file's mtime is ALREADY an exact record of the
|
|
108
|
+
last SUCCESSFUL renewal (renewal rewrites it via atomic replace), so use
|
|
109
|
+
that instead of inventing a second source of truth that could disagree
|
|
110
|
+
with the first.
|
|
111
|
+
|
|
112
|
+
A FAILED renewal leaves mtime untouched, so a broken renewal keeps being
|
|
113
|
+
retried rather than being silenced by its own failure — the behaviour we
|
|
114
|
+
want, obtained for free.
|
|
115
|
+
"""
|
|
116
|
+
import os
|
|
117
|
+
import time as _t
|
|
118
|
+
try:
|
|
119
|
+
age = _t.time() - os.stat(str(config.credentials_path())).st_mtime
|
|
120
|
+
except OSError:
|
|
121
|
+
return False # unreadable/absent: let ensure_fresh decide
|
|
122
|
+
# A negative age means the clock moved; treat it as recent so a skewed
|
|
123
|
+
# machine backs off rather than renewing in a tight loop.
|
|
124
|
+
return age < _RENEW_COOLDOWN_SECONDS
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@app.callback()
|
|
128
|
+
def _keep_session_alive(ctx: typer.Context) -> None:
|
|
129
|
+
"""Renew a short-lived session before running the command.
|
|
130
|
+
|
|
131
|
+
WHY THIS EXISTS: renewal was wired into the MCP server only, so a human
|
|
132
|
+
running `talis portfolio` every day still watched their session run out —
|
|
133
|
+
the CLI shipped the renewal code and never called it. The README promised
|
|
134
|
+
otherwise. This is the code catching up to the claim.
|
|
135
|
+
|
|
136
|
+
Best-effort BY CONTRACT: any failure leaves the stored credential exactly
|
|
137
|
+
as it was and the command runs anyway. config.ensure_fresh also no-ops
|
|
138
|
+
without touching the network unless the session is genuinely short, so this
|
|
139
|
+
costs nothing on the overwhelming majority of invocations.
|
|
140
|
+
"""
|
|
141
|
+
if ctx.invoked_subcommand in _NO_RENEW or _renewed_recently():
|
|
142
|
+
return
|
|
143
|
+
try:
|
|
144
|
+
creds = config.load()
|
|
145
|
+
if creds:
|
|
146
|
+
config.ensure_fresh(creds)
|
|
147
|
+
except Exception:
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
|
|
86
151
|
@app.command("version")
|
|
87
152
|
def version(json_: bool = typer.Option(False, "--json", help="JSON output.")) -> None:
|
|
88
153
|
"""Print the CLI version."""
|
|
@@ -35,8 +35,11 @@ Design notes
|
|
|
35
35
|
from __future__ import annotations
|
|
36
36
|
|
|
37
37
|
import contextlib
|
|
38
|
+
import json
|
|
38
39
|
import os
|
|
40
|
+
import re
|
|
39
41
|
import sys
|
|
42
|
+
import time
|
|
40
43
|
from typing import Any
|
|
41
44
|
|
|
42
45
|
import typer
|
|
@@ -64,9 +67,31 @@ class NotLoggedInError(RuntimeError):
|
|
|
64
67
|
# warning, because the tool being used is the only one guaranteed to be seen.
|
|
65
68
|
_SESSION_WARN_HOURS = 48.0
|
|
66
69
|
|
|
70
|
+
# Below this the session is genuinely at risk and we warn however healthy
|
|
71
|
+
# renewal looks. Above it, a session that is renewing on schedule is NOT news.
|
|
72
|
+
#
|
|
73
|
+
# WHY THIS SECOND TIER EXISTS: /auth/session/refresh deliberately carries the
|
|
74
|
+
# ORIGINAL grant rather than issuing a fresh maximum-length one — a token minted
|
|
75
|
+
# with a 24h life renews to 24h, never to 7d. That is the token-lifetime ceiling
|
|
76
|
+
# working as designed. But it means a perfectly self-renewing session sits
|
|
77
|
+
# permanently at ~24h, which is permanently under a 48h warn floor. Warning on
|
|
78
|
+
# that alone emitted SESSION EXPIRES on every single tool response forever — one
|
|
79
|
+
# second after a successful renewal included. And because the warning is
|
|
80
|
+
# deliberately hoisted to the FRONT of the dict, it was the first thing the model
|
|
81
|
+
# read on every call. A warning that always fires is one the reader learns to
|
|
82
|
+
# skip, including on the day renewal actually breaks.
|
|
83
|
+
_SESSION_CRITICAL_HOURS = 6.0
|
|
84
|
+
|
|
85
|
+
# What the last renewal attempt actually ACHIEVED. Written by _maybe_renew, read
|
|
86
|
+
# below to tell "short grant, renewing fine" apart from "renewal is broken".
|
|
87
|
+
# Starts "unknown" and "unknown" warns: absence of evidence that renewal works
|
|
88
|
+
# is not evidence that it does.
|
|
89
|
+
_RENEW_OUTCOME = ["unknown"] # unknown | renewed | not_needed | failed
|
|
90
|
+
_RENEW_HEALTHY = ("renewed", "not_needed")
|
|
91
|
+
|
|
67
92
|
|
|
68
93
|
def _session_expiry_warning(creds) -> dict:
|
|
69
|
-
"""{} when the session is
|
|
94
|
+
"""{} when the session is alive AND renewing, else a loud actionable dict."""
|
|
70
95
|
raw = getattr(creds, "expires_at", None)
|
|
71
96
|
if not raw:
|
|
72
97
|
return {}
|
|
@@ -84,17 +109,21 @@ def _session_expiry_warning(creds) -> dict:
|
|
|
84
109
|
if hours <= 0:
|
|
85
110
|
return {"session_expired": True,
|
|
86
111
|
"session_warning": (
|
|
87
|
-
f"SESSION EXPIRED at {raw}. Every call will now fail.
|
|
88
|
-
"
|
|
89
|
-
"
|
|
90
|
-
|
|
112
|
+
f"SESSION EXPIRED at {raw}. Every call will now fail. "
|
|
113
|
+
"Automatic renewal did not save it — run `talis login` to "
|
|
114
|
+
"sign in again.")}
|
|
115
|
+
# Always REPORT the number; raise the ALARM only when renewal is not holding
|
|
116
|
+
# the line. Readers key off `session_warning`, never off this field.
|
|
117
|
+
remaining = {"session_hours_remaining": round(hours, 1)}
|
|
118
|
+
if hours > _SESSION_CRITICAL_HOURS and _RENEW_OUTCOME[0] in _RENEW_HEALTHY:
|
|
119
|
+
return remaining
|
|
120
|
+
return {**remaining,
|
|
91
121
|
"session_warning": (
|
|
92
|
-
f"SESSION EXPIRES IN {hours:.1f} HOURS ({raw}).
|
|
93
|
-
"
|
|
94
|
-
"
|
|
95
|
-
"
|
|
96
|
-
"
|
|
97
|
-
"that job is NOT running — check ~/.talis/token-refresh.log.")}
|
|
122
|
+
f"SESSION EXPIRES IN {hours:.1f} HOURS ({raw}). This client "
|
|
123
|
+
"renews itself against POST /auth/session/refresh as you use "
|
|
124
|
+
"it, but the last attempt did not extend the session. If this "
|
|
125
|
+
"keeps counting down, renewal is failing — run `talis login` "
|
|
126
|
+
"to sign in again before it expires.")}
|
|
98
127
|
|
|
99
128
|
|
|
100
129
|
_LAST_RENEW_ATTEMPT = [0.0]
|
|
@@ -111,7 +140,9 @@ def _maybe_renew() -> None:
|
|
|
111
140
|
and config.ensure_fresh itself no-ops unless the session is actually short.
|
|
112
141
|
|
|
113
142
|
Silent by contract: a renewal that cannot happen must never turn into a
|
|
114
|
-
tool error. The session_warning that follows is what tells the reader
|
|
143
|
+
tool error. The session_warning that follows is what tells the reader — and
|
|
144
|
+
it can only tell the truth if this records what actually happened, so every
|
|
145
|
+
exit path here writes _RENEW_OUTCOME.
|
|
115
146
|
"""
|
|
116
147
|
import time as _t
|
|
117
148
|
|
|
@@ -121,10 +152,30 @@ def _maybe_renew() -> None:
|
|
|
121
152
|
_LAST_RENEW_ATTEMPT[0] = now
|
|
122
153
|
try:
|
|
123
154
|
creds = config.load()
|
|
124
|
-
if creds:
|
|
125
|
-
|
|
155
|
+
if not creds:
|
|
156
|
+
_RENEW_OUTCOME[0] = "unknown"
|
|
157
|
+
return
|
|
158
|
+
before_token = getattr(creds, "token", None)
|
|
159
|
+
before_hours = config.hours_remaining(creds)
|
|
160
|
+
fresh = config.ensure_fresh(creds)
|
|
161
|
+
after_token = getattr(fresh, "token", None)
|
|
162
|
+
if after_token and after_token != before_token:
|
|
163
|
+
# A DIFFERENT token came back — renewal demonstrably worked. Keyed
|
|
164
|
+
# on token identity, not on elapsed hours: comparing expiries means
|
|
165
|
+
# comparing floats across a wall-clock read, and a no-op would drift
|
|
166
|
+
# below its own start value on any slow call.
|
|
167
|
+
_RENEW_OUTCOME[0] = "renewed"
|
|
168
|
+
elif before_hours is None or before_hours > config.REFRESH_UNDER_HOURS:
|
|
169
|
+
# ensure_fresh no-ops above the threshold, and for API-key creds
|
|
170
|
+
# which carry no expiry at all. Nothing was attempted, so nothing
|
|
171
|
+
# failed — do not cry wolf.
|
|
172
|
+
_RENEW_OUTCOME[0] = "not_needed"
|
|
173
|
+
else:
|
|
174
|
+
# Under the threshold and the token did not change: renewal was
|
|
175
|
+
# attempted and did not take. THIS is the state worth shouting at.
|
|
176
|
+
_RENEW_OUTCOME[0] = "failed"
|
|
126
177
|
except Exception:
|
|
127
|
-
|
|
178
|
+
_RENEW_OUTCOME[0] = "failed"
|
|
128
179
|
|
|
129
180
|
|
|
130
181
|
def _with_session_warning(fn):
|
|
@@ -708,6 +759,116 @@ def close(symbol: str, paper: bool = False) -> dict:
|
|
|
708
759
|
return resp
|
|
709
760
|
|
|
710
761
|
|
|
762
|
+
_CANDLE_KEYS = {"t": "timestamp", "o": "open", "h": "high", "l": "low", "c": "close", "v": "volume"}
|
|
763
|
+
_INTERVAL_MINUTES = {"1m": 1, "5m": 5, "15m": 15, "30m": 30, "1h": 60, "4h": 240, "1d": 1440}
|
|
764
|
+
_BACKTEST_MAX_CANDLES = 5000 # the venue's candleSnapshot ceiling per request
|
|
765
|
+
_PAIR_RE = re.compile(r"\b([A-Z0-9]{2,12}-USD)\b")
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def backtest_candles(raw: list[dict]) -> list[dict]:
|
|
769
|
+
"""Map the control plane's abbreviated candle keys onto the backtester's."""
|
|
770
|
+
return [{_CANDLE_KEYS.get(k, k): v for k, v in c.items()} for c in raw]
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def backtest(pair: str, dsl: dict, days: int = 30, interval: str = "1h",
|
|
774
|
+
timeout_seconds: int = 180) -> dict:
|
|
775
|
+
"""Backtest a Talis DSL strategy on `pair` over the last `days` of `interval` candles. Read-only — never places an order.
|
|
776
|
+
|
|
777
|
+
Runs the SAME engine that executes live strategies (fast mode) against
|
|
778
|
+
real historical candles fetched for you, so the result is what this exact
|
|
779
|
+
DSL would have done. Validates the DSL first; an invalid DSL returns the
|
|
780
|
+
validator's errors and runs nothing.
|
|
781
|
+
|
|
782
|
+
Returns the backtest id, the window actually covered, and `results` with
|
|
783
|
+
`total_return_pct`, `sharpe_ratio`, `max_drawdown_pct`, `total_trades`,
|
|
784
|
+
`profit_factor` (plus whatever else the engine reports). Cross-asset
|
|
785
|
+
triggers (e.g. "ETH-USD.rsi_14") get their candles fetched automatically.
|
|
786
|
+
|
|
787
|
+
Workflow: validate_strategy → backtest → create_strategy(paper=True) →
|
|
788
|
+
create_strategy(paper=False, confirm_real_money=True). `days` × `interval`
|
|
789
|
+
is capped at the venue's 5000-candle window; the response says if it was.
|
|
790
|
+
"""
|
|
791
|
+
if interval not in _INTERVAL_MINUTES:
|
|
792
|
+
raise ValueError(f"interval must be one of {sorted(_INTERVAL_MINUTES)}")
|
|
793
|
+
if days <= 0:
|
|
794
|
+
raise ValueError("days must be positive")
|
|
795
|
+
creds = _require_creds()
|
|
796
|
+
trading_pair = to_trading_pair(pair)
|
|
797
|
+
dsl_config = dict(dsl)
|
|
798
|
+
cfg = dict(dsl_config.get("config") or {})
|
|
799
|
+
cfg.setdefault("exchange", _EXCHANGE)
|
|
800
|
+
cfg.setdefault("trading_pair", trading_pair)
|
|
801
|
+
dsl_config["config"] = cfg
|
|
802
|
+
|
|
803
|
+
wanted = days * 1440 // _INTERVAL_MINUTES[interval]
|
|
804
|
+
limit = min(wanted, _BACKTEST_MAX_CANDLES)
|
|
805
|
+
capped = wanted > _BACKTEST_MAX_CANDLES
|
|
806
|
+
|
|
807
|
+
with api.client_from_credentials(creds) as c:
|
|
808
|
+
check = c.validate_strategy(dsl=dsl_config)
|
|
809
|
+
if isinstance(check, dict) and check.get("valid") is False:
|
|
810
|
+
return {"valid": False, "errors": check.get("errors"),
|
|
811
|
+
"summary": check.get("human_readable_summary"),
|
|
812
|
+
"note": "DSL rejected by the engine validator; nothing was run."}
|
|
813
|
+
primary = c.get_candles(symbol=base_coin(trading_pair), interval=interval,
|
|
814
|
+
limit=limit, exchange=cfg["exchange"])
|
|
815
|
+
raw = list(primary.get("candles") or [])
|
|
816
|
+
if not raw:
|
|
817
|
+
return {"error": f"no candles returned for {trading_pair} at {interval}"}
|
|
818
|
+
candles = backtest_candles(raw)
|
|
819
|
+
cross: dict[str, list[dict]] = {}
|
|
820
|
+
for other in sorted(set(_PAIR_RE.findall(json.dumps(dsl_config))) - {trading_pair}):
|
|
821
|
+
resp = c.get_candles(symbol=base_coin(other), interval=interval,
|
|
822
|
+
limit=limit, exchange=cfg["exchange"])
|
|
823
|
+
if resp.get("candles"):
|
|
824
|
+
cross[other] = backtest_candles(resp["candles"])
|
|
825
|
+
start_ts = int(candles[0]["timestamp"])
|
|
826
|
+
end_ts = int(candles[-1]["timestamp"])
|
|
827
|
+
body: dict[str, Any] = {
|
|
828
|
+
"tenant_id": creds.tenant_id,
|
|
829
|
+
"mode": "fast",
|
|
830
|
+
"exchange": cfg["exchange"],
|
|
831
|
+
"trading_pair": trading_pair,
|
|
832
|
+
"start_ts": start_ts,
|
|
833
|
+
"end_ts": end_ts,
|
|
834
|
+
"interval": interval,
|
|
835
|
+
"candles": candles,
|
|
836
|
+
"dsl": dsl_config,
|
|
837
|
+
}
|
|
838
|
+
if cross:
|
|
839
|
+
body["cross_asset_candles"] = cross
|
|
840
|
+
submitted = c.create_backtest(body=body)
|
|
841
|
+
backtest_id = submitted.get("backtest_id")
|
|
842
|
+
if not backtest_id:
|
|
843
|
+
return {"error": "backtest was not accepted", "response": submitted}
|
|
844
|
+
deadline = time.monotonic() + timeout_seconds
|
|
845
|
+
status = submitted
|
|
846
|
+
while time.monotonic() < deadline:
|
|
847
|
+
status = c.get_backtest(backtest_id=backtest_id)
|
|
848
|
+
if str(status.get("status", "")).upper() not in ("RUNNING", "PENDING", "QUEUED", ""):
|
|
849
|
+
break
|
|
850
|
+
time.sleep(2)
|
|
851
|
+
|
|
852
|
+
results = status.get("results") if isinstance(status, dict) else None
|
|
853
|
+
out: dict[str, Any] = {
|
|
854
|
+
"backtest_id": backtest_id,
|
|
855
|
+
"status": status.get("status"),
|
|
856
|
+
"pair": trading_pair,
|
|
857
|
+
"window": {"interval": interval, "candles": len(candles), "start_ts": start_ts,
|
|
858
|
+
"end_ts": end_ts, "requested_days": days, "capped_to_venue_window": capped},
|
|
859
|
+
"cross_assets": sorted(cross),
|
|
860
|
+
"results": results,
|
|
861
|
+
}
|
|
862
|
+
if isinstance(results, dict):
|
|
863
|
+
out["summary"] = {k: results.get(k) for k in (
|
|
864
|
+
"total_return_pct", "sharpe_ratio", "max_drawdown_pct", "total_trades", "profit_factor")}
|
|
865
|
+
if str(out["status"] or "").upper() in ("RUNNING", "PENDING", "QUEUED"):
|
|
866
|
+
out["note"] = f"still running after {timeout_seconds}s; call again later via the API: GET /backtests/{backtest_id}"
|
|
867
|
+
out["next"] = ("create_strategy(pair, dsl, paper=True) runs it on the simulated book; "
|
|
868
|
+
"create_strategy(pair, dsl, paper=False, confirm_real_money=True) goes live.")
|
|
869
|
+
return out
|
|
870
|
+
|
|
871
|
+
|
|
711
872
|
def create_strategy(pair: str, dsl: dict, paper: bool = False,
|
|
712
873
|
confirm_real_money: bool = False) -> dict:
|
|
713
874
|
"""Create and auto-start a trading strategy on `pair` from a Talis DSL dict. Trades REAL money by default — a real strategy requires confirm_real_money=True; pass paper=True to simulate.
|
|
@@ -1211,6 +1372,7 @@ TOOL_FUNCTIONS = (
|
|
|
1211
1372
|
strategy,
|
|
1212
1373
|
strategy_activity,
|
|
1213
1374
|
validate_strategy,
|
|
1375
|
+
backtest,
|
|
1214
1376
|
outcomes,
|
|
1215
1377
|
buy,
|
|
1216
1378
|
sell,
|
|
@@ -1245,7 +1407,9 @@ _SERVER_INSTRUCTIONS = (
|
|
|
1245
1407
|
"never left unprotected. `stop_strategy` does NOT close the position; it "
|
|
1246
1408
|
"only ends the automation and leaves any position unmanaged, so use `close` "
|
|
1247
1409
|
"to actually exit, and `cancel_order` to kill an unfilled limit order. "
|
|
1248
|
-
"Check `validate_strategy` before creating anything with paper=False
|
|
1410
|
+
"Check `validate_strategy` before creating anything with paper=False, and "
|
|
1411
|
+
"`backtest` (read-only, real historical candles, the live engine) to see what "
|
|
1412
|
+
"a DSL would have done before deploying it. "
|
|
1249
1413
|
"Withdrawals are not available through this server."
|
|
1250
1414
|
)
|
|
1251
1415
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: talis-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Command-line client for the Talis trading platform. Sign in via device flow, manage sessions, view portfolio, place orders.
|
|
5
5
|
Author: Talis
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -114,6 +114,27 @@ suitable for parsing from scripts or LLM agents. Force JSON anywhere with
|
|
|
114
114
|
The CLI stores its session token at `~/.talis/credentials` with `chmod 600`
|
|
115
115
|
on POSIX systems. The token is a JWT; treat it like any other credential.
|
|
116
116
|
|
|
117
|
+
### Sessions renew themselves
|
|
118
|
+
|
|
119
|
+
You should not have to think about session expiry. When the CLI or the MCP
|
|
120
|
+
server runs with under 48 hours left on the clock, it renews in place against
|
|
121
|
+
`POST /auth/session/refresh` before doing its work — at most once every 15
|
|
122
|
+
minutes, so a burst of commands costs one renewal, not one each. Renewal
|
|
123
|
+
preserves the
|
|
124
|
+
session's **original grant**, not a fresh maximum — a token issued with a 24h
|
|
125
|
+
life renews to another 24h, never to something longer. That is deliberate: an
|
|
126
|
+
auto-renewing session must not be able to ratchet itself into a longer-lived
|
|
127
|
+
credential than the one you approved.
|
|
128
|
+
|
|
129
|
+
Renewal is best-effort and silent: if it cannot happen, your existing
|
|
130
|
+
credential is left untouched and the command still runs. What you WILL see is a
|
|
131
|
+
`session_warning` on MCP tool responses once renewal stops keeping up, or when
|
|
132
|
+
under six hours remain. If that appears and keeps counting down, renewal is
|
|
133
|
+
failing — run `talis login` to sign in again.
|
|
134
|
+
|
|
135
|
+
A steady `session_hours_remaining` with no `session_warning` is the healthy
|
|
136
|
+
state, not a countdown to worry about.
|
|
137
|
+
|
|
117
138
|
## Relationship to `cli/jarvis_cli.sh`
|
|
118
139
|
|
|
119
140
|
The bash script at [`cli/jarvis_cli.sh`](../../cli/jarvis_cli.sh) is the legacy
|
|
@@ -142,12 +163,16 @@ your portfolio, pull Talis Research, place paper trades, create strategies,
|
|
|
142
163
|
set price alerts, and talk to the Talis chat agent. Log in first
|
|
143
164
|
(`talis login`); the server authenticates as the logged-in user.
|
|
144
165
|
|
|
145
|
-
|
|
166
|
+
Install and add it to **Claude Code**:
|
|
146
167
|
|
|
147
168
|
```bash
|
|
169
|
+
pip install talis-cli # or: uv tool install talis-cli
|
|
170
|
+
talis login # device-flow browser approval, once
|
|
148
171
|
claude mcp add talis -- talis mcp
|
|
149
172
|
```
|
|
150
173
|
|
|
174
|
+
No local checkout required — `talis` is on PyPI.
|
|
175
|
+
|
|
151
176
|
Or to **Claude Desktop** (and other clients using the `mcpServers` config
|
|
152
177
|
shape) — `claude_desktop_config.json`:
|
|
153
178
|
|
|
@@ -162,14 +187,33 @@ shape) — `claude_desktop_config.json`:
|
|
|
162
187
|
}
|
|
163
188
|
```
|
|
164
189
|
|
|
165
|
-
Tools:
|
|
166
|
-
|
|
167
|
-
`
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
`
|
|
172
|
-
|
|
190
|
+
Tools (26):
|
|
191
|
+
|
|
192
|
+
`amend_strategy`, `backtest`, `balance`, `brief_latest`, `buy`, `cancel_all_orders`,
|
|
193
|
+
`cancel_order`, `chat`, `close`, `create_strategy`, `history`,
|
|
194
|
+
`notifications_create`, `open_orders`, `outcomes`, `packets`, `place_order`,
|
|
195
|
+
`portfolio`, `research`, `sell`, `start_strategy`, `stop_strategy`,
|
|
196
|
+
`strategies`, `strategy`, `strategy_activity`, `validate_strategy`, `whoami`.
|
|
197
|
+
|
|
198
|
+
**Strategy workflow.** `validate_strategy(dsl)` checks a DSL without side
|
|
199
|
+
effects; `backtest(pair, dsl, days=30, interval="1h")` runs it through the live
|
|
200
|
+
engine on real historical candles (read-only) and returns return, Sharpe,
|
|
201
|
+
drawdown, trade count; `create_strategy(pair, dsl, paper=True)` runs it on the
|
|
202
|
+
simulated book; the same call with `paper=False, confirm_real_money=True`
|
|
203
|
+
deploys it live.
|
|
204
|
+
|
|
205
|
+
**Safety — read this before pointing an agent at it.** The trading tools
|
|
206
|
+
(`buy` / `sell` / `close` / `create_strategy`) execute with **REAL MONEY by
|
|
207
|
+
default** (`paper=False`). Pass `paper=True` to simulate instead.
|
|
208
|
+
|
|
209
|
+
`buy`, `sell` and `create_strategy` additionally refuse to spend real money
|
|
210
|
+
unless the caller passes `confirm_real_money=True`. `close` deliberately has no
|
|
211
|
+
such gate: a protective exit must never be blocked by a missing flag.
|
|
212
|
+
`place_order` (limit orders, spot, HIP-4 outcome markets) has **no paper mode
|
|
213
|
+
at all** and refuses without `confirm_real_money=True`.
|
|
214
|
+
|
|
215
|
+
Set `TALIS_PAPER=1` in the server's environment to force paper mode regardless
|
|
216
|
+
of tool arguments.
|
|
173
217
|
Withdrawals and wallet creation are not exposed over MCP. For a strictly
|
|
174
218
|
read-only agent, log in with `talis login --read-only` — the server rejects
|
|
175
219
|
every mutation at the API layer.
|