@drico2008/fincli 0.1.2 → 0.1.9

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 (36) hide show
  1. package/README.md +81 -7
  2. package/fincli/__init__.py +1 -1
  3. package/fincli/app/analysis/assistant_context.py +27 -1
  4. package/fincli/app/analysis/indicators.py +1 -1
  5. package/fincli/app/analysis/market_structure.py +1 -1
  6. package/fincli/app/cli/commands.py +12 -4
  7. package/fincli/app/cli/router.py +253 -13
  8. package/fincli/app/modules/session_history.py +113 -0
  9. package/fincli/app/providers/ai/anthropic_provider.py +8 -7
  10. package/fincli/app/providers/ai/gemini_provider.py +8 -7
  11. package/fincli/app/providers/ai/groq_provider.py +8 -7
  12. package/fincli/app/providers/ai/http_provider.py +3 -3
  13. package/fincli/app/providers/ai/huggingface_provider.py +8 -7
  14. package/fincli/app/providers/ai/openai_provider.py +8 -7
  15. package/fincli/app/providers/ai/openrouter_provider.py +8 -7
  16. package/fincli/app/providers/ai/together_provider.py +8 -7
  17. package/fincli/app/providers/market/custom_provider.py +2 -2
  18. package/fincli/app/providers/market/finnhub_provider.py +1 -1
  19. package/fincli/app/providers/market/manager.py +6 -5
  20. package/fincli/app/providers/market/news_provider.py +4 -4
  21. package/fincli/app/providers/market/twelvedata_provider.py +1 -1
  22. package/fincli/app/providers/market/yfinance_provider.py +1 -1
  23. package/fincli/app/services/web_research.py +267 -0
  24. package/fincli/app/storage/cache.py +2 -2
  25. package/fincli/app/storage/config.py +3 -4
  26. package/fincli/app/storage/config_paths.py +9 -0
  27. package/fincli/app/storage/database.py +17 -0
  28. package/fincli/app/storage/secrets.py +104 -0
  29. package/fincli/app/tui/components.py +1 -1
  30. package/fincli/app/tui/layout.py +8 -7
  31. package/fincli/app/tui/market_provider_selector.py +42 -2
  32. package/fincli/app/tui/model_selector.py +97 -55
  33. package/fincli/app/utils/formatting.py +50 -0
  34. package/npm/bin/fincli.js +9 -2
  35. package/package.json +1 -1
  36. package/pyproject.toml +1 -1
@@ -15,6 +15,7 @@ from textual.widgets import Input, Static
15
15
 
16
16
  from fincli.app.providers.market.manager import MarketProviderManager
17
17
  from fincli.app.storage.config import ConfigManager
18
+ from fincli.app.storage.secrets import save_secret
18
19
  from fincli.app.utils.formatting import mask_secret
19
20
 
20
21
 
@@ -109,6 +110,7 @@ class MarketProviderSelectorScreen(ModalScreen[tuple[str, ...] | None]):
109
110
  self.selected_index = 0
110
111
  self.selected_provider = config.settings.market_provider
111
112
  self.search = ""
113
+ self.pending_env_keys: list[str] = []
112
114
 
113
115
  def compose(self) -> ComposeResult:
114
116
  with Vertical(id="ai_selector_card"):
@@ -131,6 +133,9 @@ class MarketProviderSelectorScreen(ModalScreen[tuple[str, ...] | None]):
131
133
 
132
134
  def on_input_submitted(self, event: Input.Submitted) -> None:
133
135
  event.stop()
136
+ if self.mode == "key":
137
+ self._save_key(event.value)
138
+ return
134
139
  self.action_select()
135
140
 
136
141
  def on_key(self, event: Key) -> None:
@@ -170,7 +175,11 @@ class MarketProviderSelectorScreen(ModalScreen[tuple[str, ...] | None]):
170
175
  if self.mode == "provider":
171
176
  provider = selected.provider # type: ignore[attr-defined]
172
177
  self.selected_provider = provider
173
- self._set_mode("priority")
178
+ self.pending_env_keys = [key for key in PROVIDER_ENV_KEYS.get(provider, ()) if not os.getenv(key)]
179
+ if self.pending_env_keys:
180
+ self._set_mode("key")
181
+ else:
182
+ self._set_mode("priority")
174
183
  return
175
184
 
176
185
  providers = selected.providers # type: ignore[attr-defined]
@@ -191,9 +200,16 @@ class MarketProviderSelectorScreen(ModalScreen[tuple[str, ...] | None]):
191
200
 
192
201
  def _sync_search_placeholder(self) -> None:
193
202
  search = self.query_one("#ai_selector_search", Input)
194
- search.placeholder = "Search fallback presets..." if self.mode == "priority" else "Search providers..."
203
+ search.password = self.mode == "key"
204
+ if self.mode == "key":
205
+ env_key = self.pending_env_keys[0] if self.pending_env_keys else "API_KEY"
206
+ search.placeholder = f"Paste {env_key}..."
207
+ else:
208
+ search.placeholder = "Search fallback presets..." if self.mode == "priority" else "Search providers..."
195
209
 
196
210
  def _visible_items(self) -> list[MarketProviderChoice] | list[PriorityChoice]:
211
+ if self.mode == "key":
212
+ return []
197
213
  if self.mode == "priority":
198
214
  presets = list(priority_presets(self.selected_provider))
199
215
  if self.search:
@@ -222,6 +238,10 @@ class MarketProviderSelectorScreen(ModalScreen[tuple[str, ...] | None]):
222
238
  if self.mode == "priority":
223
239
  title.update("Select Provider Priority")
224
240
  provider.update(f"[cyan]Primary:[/] {self.selected_provider} [dim](tab to change provider)[/]")
241
+ elif self.mode == "key":
242
+ env_key = self.pending_env_keys[0] if self.pending_env_keys else "API_KEY"
243
+ title.update("Configure Market API Key")
244
+ provider.update(f"[cyan]Provider:[/] {self.selected_provider} [dim]{env_key} saved to ~/.fincli/secrets.env[/]")
225
245
  else:
226
246
  title.update("Select Market/News Provider")
227
247
  provider.update("")
@@ -231,6 +251,11 @@ class MarketProviderSelectorScreen(ModalScreen[tuple[str, ...] | None]):
231
251
 
232
252
  def _items_text(self, items: list[MarketProviderChoice] | list[PriorityChoice]) -> Text:
233
253
  text = Text()
254
+ if self.mode == "key":
255
+ env_key = self.pending_env_keys[0] if self.pending_env_keys else "API_KEY"
256
+ text.append(f"Paste {env_key} above, then press Enter.\n", style="bold")
257
+ text.append("The value is stored locally and will not be printed in output.\n", style="dim")
258
+ return text
234
259
  text.append("Providers\n" if self.mode == "provider" else "Fallback presets\n", style="bold dim")
235
260
  for index, item in enumerate(items):
236
261
  selected = index == self.selected_index
@@ -252,10 +277,25 @@ class MarketProviderSelectorScreen(ModalScreen[tuple[str, ...] | None]):
252
277
  return text
253
278
 
254
279
  def _help_text(self) -> str:
280
+ if self.mode == "key":
281
+ return "Paste key/value, Enter to save, Esc to close"
255
282
  if self.mode == "priority":
256
283
  return "Type to search, up/down navigate, Enter to save priority, Tab to change provider, Esc to close"
257
284
  return "Type to search, up/down navigate, Enter to select provider, Esc to close"
258
285
 
286
+ def _save_key(self, value: str) -> None:
287
+ if not self.pending_env_keys or not value.strip():
288
+ return
289
+ env_key = self.pending_env_keys.pop(0)
290
+ save_secret(env_key, value)
291
+ if self.pending_env_keys:
292
+ self._set_mode("key")
293
+ else:
294
+ providers = recommended_provider_priority(self.selected_provider)
295
+ self.config.set_market_provider_priority(list(providers))
296
+ self.on_selected(providers)
297
+ self._set_mode("priority")
298
+
259
299
 
260
300
  def _provider_key_status(choice: MarketProviderChoice) -> str:
261
301
  if not choice.env_keys:
@@ -13,9 +13,10 @@ from textual.events import Key
13
13
  from textual.screen import ModalScreen
14
14
  from textual.widgets import Input, Static
15
15
 
16
- from fincli.app.providers.ai.manager import AIProviderManager
17
- from fincli.app.storage.config import ConfigManager
18
- from fincli.app.utils.formatting import mask_secret
16
+ from fincli.app.providers.ai.manager import AIProviderManager
17
+ from fincli.app.storage.config import ConfigManager
18
+ from fincli.app.storage.secrets import save_secret
19
+ from fincli.app.utils.formatting import mask_secret
19
20
 
20
21
 
21
22
  @dataclass(frozen=True, slots=True)
@@ -248,9 +249,12 @@ class AIModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
248
249
  self.selected_index = 0
249
250
  self._render_selector()
250
251
 
251
- def on_input_submitted(self, event: Input.Submitted) -> None:
252
- event.stop()
253
- self.action_select()
252
+ def on_input_submitted(self, event: Input.Submitted) -> None:
253
+ event.stop()
254
+ if self.mode == "key":
255
+ self._save_key(event.value)
256
+ return
257
+ self.action_select()
254
258
 
255
259
  def on_key(self, event: Key) -> None:
256
260
  if event.key == "up":
@@ -286,20 +290,22 @@ class AIModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
286
290
  if not items:
287
291
  return
288
292
  selected = items[self.selected_index]
289
- if self.mode == "provider":
290
- provider = selected.provider # type: ignore[attr-defined]
291
- self.selected_provider = provider
292
- if _has_key(provider):
293
- self._set_mode("configured")
294
- else:
295
- self._set_mode("model")
296
- return
297
- if self.mode == "configured":
298
- if selected == "Configure again":
299
- self._set_mode("model")
300
- else:
301
- self._set_mode("model")
302
- return
293
+ if self.mode == "provider":
294
+ provider = selected.provider # type: ignore[attr-defined]
295
+ self.selected_provider = provider
296
+ if _has_key(provider):
297
+ self._set_mode("configured")
298
+ else:
299
+ self._set_mode("key")
300
+ return
301
+ if self.mode == "configured":
302
+ if selected == "Configure API key":
303
+ self._set_mode("key")
304
+ elif selected == "Change model":
305
+ self._set_mode("model")
306
+ else:
307
+ self._set_mode("model")
308
+ return
303
309
  model = selected.model # type: ignore[attr-defined]
304
310
  self.config.set_ai_model(self.selected_provider, model)
305
311
  self.on_selected(self.selected_provider, model)
@@ -312,23 +318,31 @@ class AIModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
312
318
  self.mode = mode
313
319
  self.selected_index = 0
314
320
  self.search = ""
315
- self.query_one("#ai_selector_search", Input).value = ""
316
- self._sync_search_placeholder()
317
- self._render_selector()
318
-
319
- def _sync_search_placeholder(self) -> None:
320
- search = self.query_one("#ai_selector_search", Input)
321
- search.placeholder = "Search models..." if self.mode == "model" else "Search providers..."
322
-
323
- def _visible_items(self) -> list[ProviderChoice] | list[ModelChoice] | list[str]:
324
- if self.mode == "provider":
325
- items = list(PROVIDERS)
326
- if self.search:
327
- items = [item for item in items if self.search in item.label.lower() or self.search in item.provider]
328
- return items
329
- if self.mode == "configured":
330
- return ["Use existing configuration", "Configure again"]
331
- models = list(MODEL_CATALOG.get(self.selected_provider, ()))
321
+ self.query_one("#ai_selector_search", Input).value = ""
322
+ self._sync_search_placeholder()
323
+ self._render_selector()
324
+
325
+ def _sync_search_placeholder(self) -> None:
326
+ search = self.query_one("#ai_selector_search", Input)
327
+ search.password = self.mode == "key"
328
+ if self.mode == "key":
329
+ choice = _provider_choice(self.selected_provider)
330
+ env_key = choice.env_key if choice else "API_KEY"
331
+ search.placeholder = f"Paste {env_key}..."
332
+ else:
333
+ search.placeholder = "Search models..." if self.mode == "model" else "Search providers..."
334
+
335
+ def _visible_items(self) -> list[ProviderChoice] | list[ModelChoice] | list[str]:
336
+ if self.mode == "provider":
337
+ items = list(PROVIDERS)
338
+ if self.search:
339
+ items = [item for item in items if self.search in item.label.lower() or self.search in item.provider]
340
+ return items
341
+ if self.mode == "configured":
342
+ return ["Use existing configuration", "Configure API key", "Change model"]
343
+ if self.mode == "key":
344
+ return []
345
+ models = list(MODEL_CATALOG.get(self.selected_provider, ()))
332
346
  if self.search:
333
347
  models = [item for item in models if self.search in item.label.lower() or self.search in item.model.lower()]
334
348
  if not models:
@@ -344,12 +358,17 @@ class AIModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
344
358
  if self.mode == "provider":
345
359
  title.update("Select Provider")
346
360
  provider.update("")
347
- elif self.mode == "configured":
348
- choice = _provider_choice(self.selected_provider)
349
- label = choice.label if choice else self.selected_provider
350
- title.update(f"{label} is already configured")
351
- provider.update(f"[cyan]Provider:[/] {label} [dim]{_masked_key(self.selected_provider)}[/]")
352
- else:
361
+ elif self.mode == "configured":
362
+ choice = _provider_choice(self.selected_provider)
363
+ label = choice.label if choice else self.selected_provider
364
+ title.update(f"{label} is already configured")
365
+ provider.update(f"[cyan]Provider:[/] {label} [dim]{_masked_key(self.selected_provider)}[/]")
366
+ elif self.mode == "key":
367
+ choice = _provider_choice(self.selected_provider)
368
+ label = choice.label if choice else self.selected_provider
369
+ title.update("Configure API Key")
370
+ provider.update(f"[cyan]Provider:[/] {label} [dim](saved to ~/.fincli/secrets.env)[/]")
371
+ else:
353
372
  choice = _provider_choice(self.selected_provider)
354
373
  label = choice.label if choice else self.selected_provider
355
374
  title.update("Select Model")
@@ -361,11 +380,17 @@ class AIModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
361
380
 
362
381
  def _items_text(self, items: list[ProviderChoice] | list[ModelChoice] | list[str]) -> Text:
363
382
  text = Text()
364
- if self.mode == "provider":
365
- text.append("Popular\n", style="bold dim")
366
- elif self.mode == "model":
367
- hidden = max(0, len(MODEL_CATALOG.get(self.selected_provider, ())) - len(items))
368
- text.append(f"{hidden} filtered\n" if self.search else "Available models\n", style="bold dim")
383
+ if self.mode == "provider":
384
+ text.append("Popular\n", style="bold dim")
385
+ elif self.mode == "model":
386
+ hidden = max(0, len(MODEL_CATALOG.get(self.selected_provider, ())) - len(items))
387
+ text.append(f"{hidden} filtered\n" if self.search else "Available models\n", style="bold dim")
388
+ elif self.mode == "key":
389
+ choice = _provider_choice(self.selected_provider)
390
+ env_key = choice.env_key if choice else "API_KEY"
391
+ text.append(f"Paste {env_key} above, then press Enter.\n", style="bold")
392
+ text.append("The key is stored locally and will not be printed in output.\n", style="dim")
393
+ return text
369
394
 
370
395
  for index, item in enumerate(items):
371
396
  selected = index == self.selected_index
@@ -386,12 +411,24 @@ class AIModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
386
411
  text.append("No matches.\n", style="dim")
387
412
  return text
388
413
 
389
- def _help_text(self) -> str:
390
- if self.mode == "configured":
391
- return "Up/down navigate, Enter to select, Esc to go back"
414
+ def _help_text(self) -> str:
415
+ if self.mode == "key":
416
+ return "Paste API key, Enter to save, Esc to close"
417
+ if self.mode == "configured":
418
+ return "Up/down navigate, Enter to select, Esc to go back"
392
419
  if self.mode == "model":
393
420
  return "Type to search, up/down navigate, Enter to select, Tab to change provider, Esc to close"
394
- return "Type to search, up/down navigate, Enter to select, Esc to close"
421
+ return "Type to search, up/down navigate, Enter to select, Esc to close"
422
+
423
+ def _save_key(self, value: str) -> None:
424
+ choice = _provider_choice(self.selected_provider)
425
+ if choice is None or not value.strip():
426
+ return
427
+ save_secret(choice.env_key, value)
428
+ model = self.config.settings.ai_model if self.config.settings.ai_provider == choice.provider else _default_model(choice.provider)
429
+ self.config.set_ai_model(choice.provider, model)
430
+ self.on_selected(choice.provider, model)
431
+ self._set_mode("model")
395
432
 
396
433
 
397
434
  def _provider_choice(provider: str) -> ProviderChoice | None:
@@ -404,9 +441,14 @@ def _has_key(provider: str) -> bool:
404
441
  return bool(choice and os.getenv(choice.env_key))
405
442
 
406
443
 
407
- def _masked_key(provider: str) -> str:
444
+ def _masked_key(provider: str) -> str:
408
445
  choice = _provider_choice(provider)
409
446
  if choice is None:
410
447
  return "not configured"
411
- masked = mask_secret(os.getenv(choice.env_key))
412
- return "not configured" if masked == "not set" else f"configured {masked}"
448
+ masked = mask_secret(os.getenv(choice.env_key))
449
+ return "not configured" if masked == "not set" else f"configured {masked}"
450
+
451
+
452
+ def _default_model(provider: str) -> str:
453
+ info = AIProviderManager().get(provider)
454
+ return info.default_model if info else provider
@@ -2,6 +2,12 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from rich.console import Console, ConsoleOptions, RenderResult
6
+ from rich.markdown import Markdown
7
+ from rich.text import Text
8
+
9
+ from fincli.app.providers.ai.base import AIResponse
10
+
5
11
 
6
12
  def mask_secret(value: str | None) -> str:
7
13
  """Mask API keys and tokens before displaying them."""
@@ -15,3 +21,47 @@ def mask_secret(value: str | None) -> str:
15
21
  def normalize_symbol(symbol: str) -> str:
16
22
  """Normalize user-entered market symbols."""
17
23
  return symbol.strip().upper()
24
+
25
+
26
+ class AIResponseView:
27
+ """Renderable AI response that preserves Markdown formatting in Rich/Textual."""
28
+
29
+ def __init__(self, response: AIResponse) -> None:
30
+ self.response = response
31
+
32
+ def __str__(self) -> str:
33
+ return (
34
+ f"Provider: {self.response.provider}\n"
35
+ f"Model: {self.response.model}\n"
36
+ f"Response:\n{self.response.content}"
37
+ )
38
+
39
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
40
+ header = Text()
41
+ header.append("Provider: ", style="bold cyan")
42
+ header.append(self.response.provider, style="white")
43
+ header.append(" Model: ", style="bold cyan")
44
+ header.append(self.response.model, style="white")
45
+ yield header
46
+ yield Markdown(self.response.content)
47
+
48
+
49
+ class MarkdownBlock:
50
+ """Small renderable block for titled Markdown content."""
51
+
52
+ def __init__(self, title: str, body: object, footer: str | None = None) -> None:
53
+ self.title = title
54
+ self.body = body
55
+ self.footer = footer
56
+
57
+ def __str__(self) -> str:
58
+ parts = [self.title, str(self.body)]
59
+ if self.footer:
60
+ parts.append(self.footer)
61
+ return "\n".join(parts)
62
+
63
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
64
+ yield Text(self.title, style="bold cyan")
65
+ yield self.body
66
+ if self.footer:
67
+ yield Text(self.footer, style="dim")
package/npm/bin/fincli.js CHANGED
@@ -5,20 +5,27 @@ const path = require("path");
5
5
  const { spawn } = require("child_process");
6
6
 
7
7
  const packageRoot = path.resolve(__dirname, "..", "..");
8
+ const packageJson = require(path.join(packageRoot, "package.json"));
8
9
  const venvDir = path.join(packageRoot, ".npm-python");
9
10
  const pythonBin = process.platform === "win32"
10
11
  ? path.join(venvDir, "Scripts", "python.exe")
11
12
  : path.join(venvDir, "bin", "python");
12
13
 
13
14
  function run() {
15
+ const args = process.argv.slice(2);
16
+ if (args.includes("--version") || args.includes("-v")) {
17
+ console.log(packageJson.version);
18
+ return;
19
+ }
20
+
14
21
  if (!fs.existsSync(pythonBin)) {
15
22
  console.error("FinCLI Python runtime is missing.");
16
- console.error("Try reinstalling with: npm install -g fincli");
23
+ console.error("Try reinstalling with: npm install -g @drico2008/fincli");
17
24
  console.error("Python 3.11+ must be available during npm install.");
18
25
  process.exit(1);
19
26
  }
20
27
 
21
- const child = spawn(pythonBin, ["-m", "fincli.app.main", ...process.argv.slice(2)], {
28
+ const child = spawn(pythonBin, ["-m", "fincli.app.main", ...args], {
22
29
  cwd: packageRoot,
23
30
  stdio: "inherit"
24
31
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drico2008/fincli",
3
- "version": "0.1.2",
3
+ "version": "0.1.9",
4
4
  "description": "Modern financial CLI/TUI terminal for market monitoring and analysis.",
5
5
  "license": "MIT",
6
6
  "bin": {
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fincli"
7
- version = "0.1.2"
7
+ version = "0.1.9"
8
8
  description = "Modern financial CLI/TUI terminal for market monitoring and analysis."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"