kitecli 0.2.4__tar.gz → 0.2.6__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 (30) hide show
  1. {kitecli-0.2.4 → kitecli-0.2.6}/PKG-INFO +1 -1
  2. {kitecli-0.2.4 → kitecli-0.2.6}/cli/api_client.py +48 -1
  3. {kitecli-0.2.4 → kitecli-0.2.6}/cli/config.py +48 -1
  4. {kitecli-0.2.4 → kitecli-0.2.6}/cli/kite_manager.py +1 -23
  5. {kitecli-0.2.4 → kitecli-0.2.6}/cli/kotak_manager.py +44 -51
  6. {kitecli-0.2.4 → kitecli-0.2.6}/cli/live_session.py +127 -27
  7. {kitecli-0.2.4 → kitecli-0.2.6}/cli/main.py +1 -0
  8. {kitecli-0.2.4 → kitecli-0.2.6}/kitecli.egg-info/PKG-INFO +1 -1
  9. {kitecli-0.2.4 → kitecli-0.2.6}/pyproject.toml +1 -1
  10. {kitecli-0.2.4 → kitecli-0.2.6}/README.md +0 -0
  11. {kitecli-0.2.4 → kitecli-0.2.6}/cli/__init__.py +0 -0
  12. {kitecli-0.2.4 → kitecli-0.2.6}/cli/advisor.py +0 -0
  13. {kitecli-0.2.4 → kitecli-0.2.6}/cli/base_manager.py +0 -0
  14. {kitecli-0.2.4 → kitecli-0.2.6}/cli/display.py +0 -0
  15. {kitecli-0.2.4 → kitecli-0.2.6}/cli/executor.py +0 -0
  16. {kitecli-0.2.4 → kitecli-0.2.6}/cli/nli.py +0 -0
  17. {kitecli-0.2.4 → kitecli-0.2.6}/cli/parser.py +0 -0
  18. {kitecli-0.2.4 → kitecli-0.2.6}/cli/recorder.py +0 -0
  19. {kitecli-0.2.4 → kitecli-0.2.6}/cli/telegram_bot.py +0 -0
  20. {kitecli-0.2.4 → kitecli-0.2.6}/kitecli.egg-info/SOURCES.txt +0 -0
  21. {kitecli-0.2.4 → kitecli-0.2.6}/kitecli.egg-info/dependency_links.txt +0 -0
  22. {kitecli-0.2.4 → kitecli-0.2.6}/kitecli.egg-info/entry_points.txt +0 -0
  23. {kitecli-0.2.4 → kitecli-0.2.6}/kitecli.egg-info/requires.txt +0 -0
  24. {kitecli-0.2.4 → kitecli-0.2.6}/kitecli.egg-info/top_level.txt +0 -0
  25. {kitecli-0.2.4 → kitecli-0.2.6}/setup.cfg +0 -0
  26. {kitecli-0.2.4 → kitecli-0.2.6}/tests/test_multi_broker.py +0 -0
  27. {kitecli-0.2.4 → kitecli-0.2.6}/tests/test_nli.py +0 -0
  28. {kitecli-0.2.4 → kitecli-0.2.6}/tests/test_parser.py +0 -0
  29. {kitecli-0.2.4 → kitecli-0.2.6}/tests/test_telegram.py +0 -0
  30. {kitecli-0.2.4 → kitecli-0.2.6}/tests/test_ui.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kitecli
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: KiteCLI — Multi-account, multi-broker trading positions viewer (Zerodha + Kotak Neo)
5
5
  Author: KiteCLI Team
6
6
  License: MIT
@@ -7,8 +7,12 @@ identical to the previous single-broker version so that cli/main.py and
7
7
  cli/live_session.py require zero changes.
8
8
  """
9
9
 
10
+ import logging
10
11
  from concurrent.futures import ThreadPoolExecutor
11
12
  from cli.kite_manager import KiteAccountManager
13
+ from cli.config import remove_session
14
+
15
+ logger = logging.getLogger(__name__)
12
16
 
13
17
 
14
18
  class KCLIClientError(Exception):
@@ -132,8 +136,29 @@ class KCLIClient:
132
136
  )
133
137
  _account_manager_map[account_key] = mgr
134
138
  acct.setdefault("api_key", account_key)
139
+
140
+ # Validation: verify token with a REST query (limits)
141
+ is_valid = False
142
+ if mgr.is_authenticated(account_key):
143
+ try:
144
+ resp = mgr._clients[account_key].limits()
145
+ from cli.kotak_manager import _check_neo_error
146
+ _check_neo_error(resp)
147
+ is_valid = True
148
+ except Exception as exc:
149
+ msg = str(exc).lower()
150
+ is_auth_error = any(x in msg for x in ["100008", "100022", "1037", "unauthorized", "invalid token", "invalid session", "session expired", "session has been closed", "session closed"])
151
+ if is_auth_error:
152
+ logger.info("Kotak validation failed (expired/invalid) for %s: %s", name, exc)
153
+ mgr._authenticated[account_key] = False
154
+ remove_session(f"kotak:{account_key}")
155
+ else:
156
+ # Network/timeout error: fallback to assuming token is valid
157
+ logger.warning("Kotak validation query failed due to network/other issue. Assuming valid. Error: %s", exc)
158
+ is_valid = True
159
+
135
160
  # Attempt auto-login for Kotak
136
- if not mgr.is_authenticated(account_key):
161
+ if not is_valid:
137
162
  success = mgr.auto_login(account_key)
138
163
  return {
139
164
  "name": name, "api_key": account_key,
@@ -158,7 +183,29 @@ class KCLIClient:
158
183
  )
159
184
  _account_manager_map[api_key] = _kite_manager
160
185
 
186
+ # Validation: verify token with a REST query (profile)
187
+ is_valid = False
161
188
  if _kite_manager.is_authenticated(api_key):
189
+ try:
190
+ _kite_manager._clients[api_key].profile()
191
+ is_valid = True
192
+ except Exception as exc:
193
+ try:
194
+ from kiteconnect.exceptions import TokenException, PermissionException
195
+ except Exception:
196
+ TokenException = PermissionException = ()
197
+ msg = str(exc).lower()
198
+ is_auth_error = isinstance(exc, (TokenException, PermissionException)) or any(x in msg for x in ["403", "401", "unauthorized", "token", "session"])
199
+ if is_auth_error:
200
+ logger.info("Zerodha validation failed (expired/invalid) for %s: %s", name, exc)
201
+ _kite_manager._authenticated[api_key] = False
202
+ remove_session(api_key)
203
+ else:
204
+ # Network/timeout error: fallback to assuming token is valid
205
+ logger.warning("Zerodha validation query failed due to network/other issue. Assuming valid. Error: %s", exc)
206
+ is_valid = True
207
+
208
+ if is_valid:
162
209
  return {
163
210
  "name": name, "api_key": api_key,
164
211
  "login_url": login_url, "auto_logged_in": True,
@@ -6,12 +6,19 @@ stored at ~/.kcli/config.yaml.
6
6
  """
7
7
 
8
8
  from pathlib import Path
9
- from typing import Optional
9
+ from typing import Optional, Any, Dict
10
+ import json
11
+ import logging
12
+ import threading
10
13
 
11
14
  import yaml
12
15
 
13
16
  CONFIG_DIR = Path.home() / ".kcli"
14
17
  CONFIG_FILE = CONFIG_DIR / "config.yaml"
18
+ SESSIONS_FILE = CONFIG_DIR / "sessions.json"
19
+
20
+ sessions_lock = threading.RLock()
21
+ logger = logging.getLogger(__name__)
15
22
 
16
23
  DEFAULT_CONFIG = {
17
24
  "accounts": [
@@ -94,3 +101,43 @@ def create_default_config() -> dict:
94
101
  """
95
102
  save_config(DEFAULT_CONFIG)
96
103
  return DEFAULT_CONFIG
104
+
105
+
106
+ def load_sessions() -> Dict[str, Any]:
107
+ """Load the shared sessions from ~/.kcli/sessions.json thread-safely."""
108
+ with sessions_lock:
109
+ if not SESSIONS_FILE.exists():
110
+ return {}
111
+ try:
112
+ with open(SESSIONS_FILE, "r") as f:
113
+ return json.load(f)
114
+ except Exception as exc:
115
+ logger.error("Failed to load sessions: %s", exc)
116
+ return {}
117
+
118
+
119
+ def save_session(key: str, value: Any) -> None:
120
+ """Save a session value thread-safely to ~/.kcli/sessions.json."""
121
+ with sessions_lock:
122
+ sessions = load_sessions()
123
+ sessions[key] = value
124
+ try:
125
+ SESSIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
126
+ with open(SESSIONS_FILE, "w") as f:
127
+ json.dump(sessions, f, indent=2)
128
+ except Exception as exc:
129
+ logger.error("Failed to save session for %s: %s", key, exc)
130
+
131
+
132
+ def remove_session(key: str) -> None:
133
+ """Remove a session key thread-safely from ~/.kcli/sessions.json."""
134
+ with sessions_lock:
135
+ sessions = load_sessions()
136
+ if key in sessions:
137
+ del sessions[key]
138
+ try:
139
+ SESSIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
140
+ with open(SESSIONS_FILE, "w") as f:
141
+ json.dump(sessions, f, indent=2)
142
+ except Exception as exc:
143
+ logger.error("Failed to remove session for %s: %s", key, exc)
@@ -12,29 +12,7 @@ from cli.base_manager import BaseBrokerManager
12
12
 
13
13
  logger = logging.getLogger(__name__)
14
14
 
15
- SESSIONS_FILE = Path.home() / ".kcli" / "sessions.json"
16
-
17
-
18
- def _load_sessions() -> dict[str, str]:
19
- if not SESSIONS_FILE.exists():
20
- return {}
21
- try:
22
- with open(SESSIONS_FILE, "r") as f:
23
- return json.load(f)
24
- except Exception as exc:
25
- logger.error("Failed to load sessions: %s", exc)
26
- return {}
27
-
28
-
29
- def _save_session(api_key: str, access_token: str) -> None:
30
- sessions = _load_sessions()
31
- sessions[api_key] = access_token
32
- try:
33
- SESSIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
34
- with open(SESSIONS_FILE, "w") as f:
35
- json.dump(sessions, f, indent=2)
36
- except Exception as exc:
37
- logger.error("Failed to save session for %s: %s", api_key[:8], exc)
15
+ from cli.config import load_sessions as _load_sessions, save_session as _save_session
38
16
 
39
17
 
40
18
  class KiteAccountManager(BaseBrokerManager):
@@ -98,26 +98,7 @@ def _check_neo_error(resp: Any) -> None:
98
98
 
99
99
  # ── session persistence (shared file with Zerodha tokens) ─────────────────────
100
100
 
101
- def _load_sessions() -> dict[str, str]:
102
- if not SESSIONS_FILE.exists():
103
- return {}
104
- try:
105
- with open(SESSIONS_FILE, "r") as f:
106
- return json.load(f)
107
- except Exception as exc:
108
- logger.error("Failed to load sessions: %s", exc)
109
- return {}
110
-
111
-
112
- def _save_session(key: str, token: str) -> None:
113
- sessions = _load_sessions()
114
- sessions[key] = token
115
- try:
116
- SESSIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
117
- with open(SESSIONS_FILE, "w") as f:
118
- json.dump(sessions, f, indent=2)
119
- except Exception as exc:
120
- logger.error("Failed to save session for %s: %s", key, exc)
101
+ from cli.config import load_sessions as _load_sessions, save_session as _save_session
121
102
 
122
103
 
123
104
  # ── KotakAccountManager ────────────────────────────────────────────────────────
@@ -156,7 +137,9 @@ class KotakAccountManager(BaseBrokerManager):
156
137
 
157
138
  def init_account(self, account_key: str, **credentials) -> str:
158
139
  """ABC entry point — delegates to init_account_kotak."""
159
- return self.init_account_kotak(consumer_key=account_key, **credentials)
140
+ creds = dict(credentials)
141
+ creds.pop("consumer_key", None)
142
+ return self.init_account_kotak(consumer_key=account_key, **creds)
160
143
 
161
144
  def init_account_kotak(
162
145
  self,
@@ -284,17 +267,19 @@ class KotakAccountManager(BaseBrokerManager):
284
267
  except Exception:
285
268
  logger.debug("Kotak REST response body (raw): %s", resp.text[:200])
286
269
 
287
- # Check for IP mismatch error (stCode 1037) or Session expired (stCode 100008)
270
+ # Check for IP mismatch error (stCode 1037), Session expired (stCode 100008, 100022), or HTTP 401
288
271
  try:
289
- resp_json = resp.json()
272
+ resp_json = resp.json() if resp.text else {}
290
273
  if isinstance(resp_json, dict):
291
274
  st_code = resp_json.get("stCode")
292
275
  err_msg = str(resp_json.get("errMsg", "")).lower()
293
276
  if (
294
- st_code == 1037 or
295
- st_code == 100008 or
296
- "session ip" in err_msg or
297
- "unauthorized" in err_msg
277
+ resp.status_code == 401 or
278
+ st_code in (1037, 100008, 100022) or
279
+ "session ip" in err_msg or
280
+ "unauthorized" in err_msg or
281
+ "invalid session" in err_msg or
282
+ "invalid token" in err_msg
298
283
  ):
299
284
  logger.info("Kotak session invalid (stCode=%s, msg='%s') for account '%s'. Triggering auto-login...", st_code, resp_json.get("errMsg"), name)
300
285
  if self.auto_login(consumer_key):
@@ -320,30 +305,7 @@ class KotakAccountManager(BaseBrokerManager):
320
305
 
321
306
  client.api_client.rest_client.request = _proxied_request
322
307
  if proxy:
323
- logger.info("Kotak account '%s': HTTP proxy enabled (%s…)", name, proxy[:20])
324
-
325
- # Also monkeypatch websocket-client (used by Kotak Neo WebSocket) to route over the proxy.
326
- try:
327
- import websocket as _ws_lib
328
- from urllib.parse import urlparse as _urlparse
329
- _parsed = _urlparse(proxy)
330
- _proxy_host = _parsed.hostname
331
- _proxy_port = _parsed.port
332
- _proxy_auth = (_parsed.username, _parsed.password) if _parsed.username and _parsed.password else None
333
-
334
- _orig_run_forever = _ws_lib.WebSocketApp.run_forever
335
- def _proxied_run_forever(self, *args, **kwargs):
336
- kwargs.setdefault("http_proxy_host", _proxy_host)
337
- kwargs.setdefault("http_proxy_port", _proxy_port)
338
- kwargs.setdefault("proxy_type", "http")
339
- if _proxy_auth:
340
- kwargs.setdefault("http_proxy_auth", _proxy_auth)
341
- return _orig_run_forever(self, *args, **kwargs)
342
-
343
- _ws_lib.WebSocketApp.run_forever = _proxied_run_forever
344
- logger.info("Kotak account '%s': WebSocket proxy monkeypatch applied", name)
345
- except Exception as e:
346
- logger.error("Failed to apply WebSocket proxy patch for Kotak: %s", e)
308
+ logger.info("Kotak account '%s': HTTP proxy enabled for order APIs (%s…)", name, proxy[:20])
347
309
  self._clients[consumer_key] = client
348
310
  self._account_names[consumer_key] = name or consumer_key
349
311
  self._authenticated[consumer_key] = False
@@ -697,6 +659,7 @@ class KotakAccountManager(BaseBrokerManager):
697
659
 
698
660
  try:
699
661
  resp = client.limits()
662
+ _check_neo_error(resp)
700
663
  if isinstance(resp, dict):
701
664
  data = resp.get("data", resp)
702
665
  net = data.get("Net", data.get("net"))
@@ -936,6 +899,10 @@ class KotakAccountManager(BaseBrokerManager):
936
899
  return KotakTicker(account_key, self.get_access_token(account_key), client)
937
900
 
938
901
 
902
+ def _dummy_callback(*args, **kwargs):
903
+ pass
904
+
905
+
939
906
  # ── KotakTicker WebSocket Wrapper ─────────────────────────────────────────────
940
907
 
941
908
  class KotakTicker:
@@ -990,6 +957,13 @@ class KotakTicker:
990
957
  self._reconnect_timer.cancel()
991
958
  self._reconnect_timer = None
992
959
  logger.info("Closing Kotak Neo WebSocket (api_key=%s…)...", self.api_key[:8])
960
+
961
+ # Clear client callbacks to avoid recursive callback noise during close
962
+ self.client.on_open = _dummy_callback
963
+ self.client.on_message = _dummy_callback
964
+ self.client.on_error = _dummy_callback
965
+ self.client.on_close = _dummy_callback
966
+
993
967
  neo_ws = getattr(self.client, "NeoWebSocket", None)
994
968
  if neo_ws:
995
969
  if getattr(neo_ws, "hsWebsocket", None):
@@ -1027,6 +1001,16 @@ class KotakTicker:
1027
1001
 
1028
1002
  def _on_close(self, message: Any = None) -> None:
1029
1003
  logger.info("Kotak Neo WebSocket closed: %s", message or "Session closed")
1004
+ msg_str = str(message or "").lower()
1005
+ is_auth_error = any(x in msg_str for x in ["session has been closed", "unauthorized", "invalid token"])
1006
+
1007
+ if is_auth_error:
1008
+ logger.error("Kotak Neo WebSocket closed due to session expiry/auth failure: %s (mapped code=403)", message)
1009
+ if self.on_error:
1010
+ # Map to 403 so live_session stops reconnecting and prompts user to re-init
1011
+ self.on_error(self, 403, str(message or "Session closed"))
1012
+ return
1013
+
1030
1014
  if self.on_close:
1031
1015
  self.on_close(self, 1000, str(message or "Closed"))
1032
1016
  if self.reconnect and not self._stop_reconnect:
@@ -1072,6 +1056,10 @@ class KotakTicker:
1072
1056
 
1073
1057
  logger.info("Kotak Neo WS: Attempting reconnection now...")
1074
1058
  try:
1059
+ # Temporarily stub callbacks to avoid recursive on_error/on_close triggers during cleanup
1060
+ self.client.on_error = _dummy_callback
1061
+ self.client.on_close = _dummy_callback
1062
+
1075
1063
  # First, clean up sockets
1076
1064
  neo_ws = getattr(self.client, "NeoWebSocket", None)
1077
1065
  if neo_ws:
@@ -1098,6 +1086,11 @@ class KotakTicker:
1098
1086
  self.client.subscribe_to_orderfeed()
1099
1087
  except Exception as e:
1100
1088
  logger.error("Kotak Neo WS: Reconnection attempt failed: %s", e)
1089
+ # Re-register open/message/error/close hooks on client
1090
+ self.client.on_open = self._on_open
1091
+ self.client.on_message = self._on_message
1092
+ self.client.on_error = self._on_error
1093
+ self.client.on_close = self._on_close
1101
1094
  self._trigger_reconnect()
1102
1095
 
1103
1096
  def _on_message(self, message: Any) -> None:
@@ -1003,6 +1003,14 @@ class KCLILiveSession:
1003
1003
  " Advisor ",
1004
1004
  make_click_handler("advisor")
1005
1005
  ))
1006
+ fragments.append(("", " "))
1007
+
1008
+ # Tab 5: Help
1009
+ fragments.append((
1010
+ "class:tab.active" if self.info_mode == "help" else "class:tab.inactive",
1011
+ " Help ",
1012
+ make_click_handler("help")
1013
+ ))
1006
1014
 
1007
1015
  return fragments
1008
1016
 
@@ -1162,6 +1170,12 @@ class KCLILiveSession:
1162
1170
  async def _initial_fetch_and_connect(self) -> None:
1163
1171
  """Initial fetch of data and connect all WebSockets."""
1164
1172
  self.log_message("Initializing connections and fetching data...")
1173
+
1174
+ # Ensure accounts are validated & auto-logged in
1175
+ try:
1176
+ await self._run_api_call(self.client.init_accounts, self.accounts)
1177
+ except Exception as e:
1178
+ self.log_message(f"[#ff8700]Account init warning:[/#] {e}")
1165
1179
 
1166
1180
  # Fire off REST data fetch in the background so it doesn't block WebSockets
1167
1181
  if hasattr(self, "app") and self.app and self.app.loop:
@@ -1188,7 +1202,7 @@ class KCLILiveSession:
1188
1202
  )
1189
1203
 
1190
1204
  # Connect WebSockets for accounts that support it (Zerodha and Kotak).
1191
- for idx, acct in enumerate(self.accounts):
1205
+ for acct in self.accounts:
1192
1206
  api_key = acct.get("api_key")
1193
1207
  broker = acct.get("broker", "zerodha").lower()
1194
1208
  access_token = self.client.get_access_token(api_key)
@@ -1201,9 +1215,6 @@ class KCLILiveSession:
1201
1215
  )
1202
1216
  continue
1203
1217
 
1204
- if idx > 0:
1205
- await asyncio.sleep(0.2)
1206
-
1207
1218
  if api_key and (access_token or broker == "kotak"):
1208
1219
  try:
1209
1220
  if broker == "kotak":
@@ -1228,29 +1239,8 @@ class KCLILiveSession:
1228
1239
  ticker.on_close = self._make_on_close(api_key)
1229
1240
  ticker.on_error = self._make_on_error(api_key)
1230
1241
 
1231
- # Parse proxy if configured for this account (Zerodha WS does not require IP whitelisting, direct is faster)
1232
- proxy_str = acct.get("proxy")
1233
- proxy_dict = None
1234
- if broker != "zerodha" and proxy_str:
1235
- from urllib.parse import urlparse
1236
- try:
1237
- p_str = proxy_str if "://" in proxy_str else f"http://{proxy_str}"
1238
- parsed = urlparse(p_str)
1239
- if parsed.hostname and parsed.port:
1240
- proxy_dict = {
1241
- "host": parsed.hostname,
1242
- "port": int(parsed.port),
1243
- }
1244
- if parsed.username and parsed.password:
1245
- proxy_dict["username"] = parsed.username
1246
- proxy_dict["password"] = parsed.password
1247
- except Exception as p_err:
1248
- self.log_message(f"[#ff8700]Failed to parse proxy for {acct.get('name')}:[/#] {p_err}")
1249
-
1250
- # connect() only accepts threaded + proxy
1242
+ # connect() only accepts threaded
1251
1243
  connect_kwargs = dict(threaded=True)
1252
- if proxy_dict:
1253
- connect_kwargs["proxy"] = proxy_dict
1254
1244
  ticker.connect(**connect_kwargs)
1255
1245
  self.tickers[api_key] = ticker
1256
1246
  except Exception as exc:
@@ -2341,6 +2331,44 @@ class KCLILiveSession:
2341
2331
  )
2342
2332
  return
2343
2333
 
2334
+ # Direct commands to switch info pane mode
2335
+ cmd_norm = cmd.strip().lower()
2336
+ if cmd_norm in ("pending", "p", "1", "/1", "/pending"):
2337
+ self.info_mode = "orders_pending"
2338
+ self._update_info_buffer()
2339
+ if hasattr(self, "app") and self.app:
2340
+ self.app.invalidate()
2341
+ self.log_message("Switched info pane to [bold]Pending Orders[/bold].")
2342
+ return
2343
+ elif cmd_norm in ("executed", "e", "ex", "2", "/2", "/executed"):
2344
+ self.info_mode = "orders_executed"
2345
+ self._update_info_buffer()
2346
+ if hasattr(self, "app") and self.app:
2347
+ self.app.invalidate()
2348
+ self.log_message("Switched info pane to [bold]Executed Orders[/bold].")
2349
+ return
2350
+ elif cmd_norm in ("oc", "optionchain", "chain", "3", "/3", "/oc"):
2351
+ self.info_mode = "oc"
2352
+ self._update_info_buffer()
2353
+ if hasattr(self, "app") and self.app:
2354
+ self.app.invalidate()
2355
+ self.log_message("Switched info pane to [bold]Option Chain[/bold].")
2356
+ return
2357
+ elif cmd_norm in ("advisor", "adv", "ai", "4", "/4", "/advisor"):
2358
+ self.info_mode = "advisor"
2359
+ self._update_info_buffer()
2360
+ if hasattr(self, "app") and self.app:
2361
+ self.app.invalidate()
2362
+ self.log_message("Switched info pane to [bold]AI Advisor[/bold].")
2363
+ return
2364
+ elif cmd_norm in ("help", "h", "5", "/5", "/help"):
2365
+ self.info_mode = "help"
2366
+ self._update_info_buffer()
2367
+ if hasattr(self, "app") and self.app:
2368
+ self.app.invalidate()
2369
+ self.log_message("Switched info pane to [bold]Help & Shortcuts[/bold].")
2370
+ return
2371
+
2344
2372
  # Attempt parsing via the unified parser
2345
2373
  try:
2346
2374
  parsed_cmds = parse_command_line(cmd)
@@ -3847,6 +3875,59 @@ class KCLILiveSession:
3847
3875
  lines.append("No pending orders across all accounts.")
3848
3876
  return "\n".join(lines)
3849
3877
 
3878
+ def _build_help_text(self) -> str:
3879
+ """Return formatted keyboard shortcuts & command cheat sheet."""
3880
+ return """=== KiteCLI Keyboard & Command Cheat Sheet ===
3881
+
3882
+ 1. TAB SWITCHING (RIGHT PANE)
3883
+ Type at prompt:
3884
+ p or pending or 1 -> Switch to Pending Orders tab
3885
+ e or executed or 2 -> Switch to Executed Orders tab
3886
+ oc or chain or 3 -> Switch to Option Chain tab
3887
+ adv or advisor or 4-> Switch to AI Advisor tab
3888
+ h or help or 5 -> Switch to this Help tab
3889
+
3890
+ Universal Hotkeys:
3891
+ Ctrl + P -> Pending Orders
3892
+ Ctrl + E -> Executed Orders
3893
+ Ctrl + O -> Option Chain
3894
+ Ctrl + G -> AI Advisor
3895
+ Ctrl + H -> Help
3896
+
3897
+ Function Keys / Alt:
3898
+ F1 / F2 / F3 / F4 / F5
3899
+ Option+1 / 2 / 3 / 4 / 5 (or Esc then 1..5)
3900
+ Mouse Click: Click directly on any tab title in header bar
3901
+
3902
+ 2. ORDER PLACEMENT & TRADING COMMANDS
3903
+ Buy / Sell Orders:
3904
+ b <symbol> <qty> [price] -> Buy limit/market (e.g. b NIFTY26JUL22500PE 100 1.40)
3905
+ s <symbol> <qty> [price] -> Sell limit/market (e.g. s BANKNIFTY26JUL50000CE 15@2.50)
3906
+ b 1 100 -> Buy 100 qty of active position #1
3907
+ s 2 -> Sell/exit all qty of active position #2
3908
+
3909
+ Exiting Positions:
3910
+ exit <symbol|id> [price] -> Exit position by symbol or table ID (e.g. exit 1, exit 2 1.50)
3911
+ exit all [price] -> Exit all active positions (e.g. exit all)
3912
+
3913
+ Modifying & Cancelling:
3914
+ modify <order_id> [qty] [price] -> Modify open order
3915
+ cancel <order_id> -> Cancel pending order
3916
+ cancel all -> Cancel all open/pending orders
3917
+
3918
+ 3. SELECTION & SELECTION CLEARING
3919
+ Selection:
3920
+ Click on symbol/account row -> Target specific position/account
3921
+ Esc -> Clear active selection / reset account filter
3922
+
3923
+ 4. GLOBAL NAVIGATION & CONTROL
3924
+ Tab -> Cycle focus (Input box -> Positions -> Right Pane)
3925
+ Ctrl + R -> Search command history
3926
+ Ctrl + Left -> Narrow left pane split width
3927
+ Ctrl + Right -> Widen left pane split width
3928
+ Ctrl + C -> Exit KiteCLI
3929
+ """
3930
+
3850
3931
  def _update_info_buffer(self, reset_scroll: bool = True) -> None:
3851
3932
  """Push current info_mode text into the info_buffer.
3852
3933
 
@@ -3867,6 +3948,8 @@ class KCLILiveSession:
3867
3948
  except Exception as exc:
3868
3949
  self._last_advisor_text = f"=== Tuesday Expiry Advisor ===\n\nFailed to plan: {exc}"
3869
3950
  text = self._last_advisor_text
3951
+ elif self.info_mode == "help":
3952
+ text = self._build_help_text()
3870
3953
  else:
3871
3954
  text = self._last_oc_text
3872
3955
  if reset_scroll:
@@ -4238,8 +4321,10 @@ class KCLILiveSession:
4238
4321
  nxt = self.input_field.control
4239
4322
  event.app.layout.focus(nxt)
4240
4323
 
4241
- # Ctrl+1/2/3/4 & F1-F4 — switch info pane content
4324
+ # Ctrl+1/2/3/4, Ctrl+P/E/O/G, Alt/Esc+1/2/3/4, F1-F4 — switch info pane content
4242
4325
  @kb.add("c-1")
4326
+ @kb.add("c-p")
4327
+ @kb.add("escape", "1")
4243
4328
  @kb.add("f1")
4244
4329
  def _c1(event):
4245
4330
  self.info_mode = "orders_pending"
@@ -4247,6 +4332,8 @@ class KCLILiveSession:
4247
4332
  event.app.invalidate()
4248
4333
 
4249
4334
  @kb.add("c-2")
4335
+ @kb.add("c-e")
4336
+ @kb.add("escape", "2")
4250
4337
  @kb.add("f2")
4251
4338
  def _c2(event):
4252
4339
  self.info_mode = "orders_executed"
@@ -4254,6 +4341,8 @@ class KCLILiveSession:
4254
4341
  event.app.invalidate()
4255
4342
 
4256
4343
  @kb.add("c-3")
4344
+ @kb.add("c-o")
4345
+ @kb.add("escape", "3")
4257
4346
  @kb.add("f3")
4258
4347
  def _c3(event):
4259
4348
  self.info_mode = "oc"
@@ -4261,12 +4350,23 @@ class KCLILiveSession:
4261
4350
  event.app.invalidate()
4262
4351
 
4263
4352
  @kb.add("c-4")
4353
+ @kb.add("c-g")
4354
+ @kb.add("escape", "4")
4264
4355
  @kb.add("f4")
4265
4356
  def _c4(event):
4266
4357
  self.info_mode = "advisor"
4267
4358
  self._update_info_buffer()
4268
4359
  event.app.invalidate()
4269
4360
 
4361
+ @kb.add("c-5")
4362
+ @kb.add("c-h")
4363
+ @kb.add("escape", "5")
4364
+ @kb.add("f5")
4365
+ def _c5(event):
4366
+ self.info_mode = "help"
4367
+ self._update_info_buffer()
4368
+ event.app.invalidate()
4369
+
4270
4370
  # Ctrl+Left/Right — adjust left/right pane split
4271
4371
  @kb.add("c-left")
4272
4372
  def _narrow_left(event):
@@ -206,6 +206,7 @@ def live() -> None:
206
206
  display_error("No accounts configured. Edit your config file to add accounts.")
207
207
  raise typer.Exit(code=1)
208
208
  client = _build_client(config)
209
+ client.init_accounts(accounts)
209
210
 
210
211
  # Launch interactive session
211
212
  session = KCLILiveSession(client, accounts)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kitecli
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: KiteCLI — Multi-account, multi-broker trading positions viewer (Zerodha + Kotak Neo)
5
5
  Author: KiteCLI Team
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kitecli"
7
- version = "0.2.4"
7
+ version = "0.2.6"
8
8
  description = "KiteCLI — Multi-account, multi-broker trading positions viewer (Zerodha + Kotak Neo)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes