@drico2008/fincli 0.1.1 → 0.1.3

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.
package/README.md CHANGED
@@ -90,6 +90,24 @@ copy .env.example .env
90
90
 
91
91
  Isi API key hanya untuk provider yang ingin digunakan. yfinance fallback tidak butuh API key. Config membaca status key tanpa menampilkan secret.
92
92
 
93
+ Untuk install global lewat npm, user tidak perlu membuka folder package atau mengedit `.env`. Simpan API key lewat command FinCLI:
94
+
95
+ ```text
96
+ /ai_model key groq <api_key>
97
+ /ai_model key openrouter <api_key>
98
+ /news_model key finnhub <api_key>
99
+ /news_model key twelvedata <api_key>
100
+ /news_model key custom <api_key> https://your-market-api.example.com
101
+ ```
102
+
103
+ Key disimpan lokal di:
104
+
105
+ ```text
106
+ ~/.fincli/secrets.env
107
+ ```
108
+
109
+ File ini tidak dicetak penuh di output terminal. `/config` dan `/provider key status` hanya menampilkan status/masked key.
110
+
93
111
  ## Run
94
112
 
95
113
  ```bash
@@ -202,6 +220,7 @@ Di TUI, command ini membuka selector seperti modern CLI:
202
220
  - Select Provider
203
221
  - Status provider current/configured
204
222
  - Use existing configuration / configure again
223
+ - Configure API key jika provider belum punya key
205
224
  - Select Model
206
225
  - Search model/provider
207
226
  - Navigasi `up/down`, `Enter`, `Tab`, dan `Esc`
@@ -222,6 +241,7 @@ Di TUI, command ini membuka selector untuk provider market/news dan fallback pri
222
241
 
223
242
  - Select Market/News Provider
224
243
  - Pilih `Twelve Data`, `Finnhub`, `Custom API`, atau `Yahoo Finance`
244
+ - Masukkan API key langsung dari popup jika provider belum dikonfigurasi
225
245
  - Pilih preset fallback: recommended, primary + yfinance, data API priority, atau yfinance only
226
246
  - Search provider/preset
227
247
  - Navigasi `up/down`, `Enter`, `Tab`, dan `Esc`
@@ -436,6 +456,7 @@ Contoh:
436
456
 
437
457
  ```bash
438
458
  /ai_model openrouter openai/gpt-4o-mini
459
+ /ai_model key openrouter <api_key>
439
460
  /ai jelaskan risiko market NVDA secara singkat
440
461
  /analyze AAPL 1d
441
462
  ```
@@ -496,6 +517,12 @@ Environment variable:
496
517
  FINNHUB_API_KEY=your-finnhub-key
497
518
  ```
498
519
 
520
+ Atau simpan dari FinCLI:
521
+
522
+ ```text
523
+ /news_model key finnhub <api_key>
524
+ ```
525
+
499
526
  Endpoint Finnhub yang dipakai:
500
527
 
501
528
  ```text
@@ -524,6 +551,12 @@ Environment variable:
524
551
  TWELVE_DATA_API_KEY=your-twelve-data-key
525
552
  ```
526
553
 
554
+ Atau simpan dari FinCLI:
555
+
556
+ ```text
557
+ /news_model key twelvedata <api_key>
558
+ ```
559
+
527
560
  Endpoint Twelve Data yang dipakai:
528
561
 
529
562
  ```text
@@ -571,6 +604,12 @@ MARKET_DATA_API_KEY=your-key
571
604
  MARKET_DATA_BASE_URL=https://your-market-api.example.com
572
605
  ```
573
606
 
607
+ Atau simpan dari FinCLI:
608
+
609
+ ```text
610
+ /news_model key custom <api_key> https://your-market-api.example.com
611
+ ```
612
+
574
613
  FinCLI akan memanggil endpoint:
575
614
 
576
615
  ```text
@@ -605,6 +644,7 @@ FinCLI menyimpan data lokal di:
605
644
  ```
606
645
 
607
646
  API key tidak disimpan di output terminal dan sebaiknya tetap berada di `.env` atau environment variable.
647
+ Untuk install global via npm, API key paling praktis disimpan lewat command FinCLI di `~/.fincli/secrets.env`.
608
648
 
609
649
  ## Test
610
650
 
@@ -622,7 +662,7 @@ Hasil terakhir di environment ini:
622
662
 
623
663
  - `fincli` tidak dikenali: jalankan `pip install -e .` dari root project.
624
664
  - TUI tidak tampil rapi: perbesar terminal desktop.
625
- - API key tidak terbaca: pastikan file `.env` ada di root project atau export environment variable.
665
+ - API key tidak terbaca: gunakan `/ai_model key <provider> <api_key>` atau `/news_model key <provider> <api_key>`, lalu cek `/config` atau `/provider key status`.
626
666
  - `/price` gagal karena yfinance belum ada: jalankan `pip install -e ".[dev]"` atau `pip install -r requirements.txt`.
627
667
  - Config rusak: hapus `~/.fincli/config.json` untuk kembali ke default.
628
668
 
@@ -1,3 +1,3 @@
1
1
  """FinCLI package."""
2
2
 
3
- __version__ = "0.1.1"
3
+ __version__ = "0.1.3"
@@ -17,7 +17,9 @@ COMMANDS: tuple[CommandSpec, ...] = (
17
17
  CommandSpec("/help", "Tampilkan bantuan, command list, dan contoh.", "/help"),
18
18
  CommandSpec("/dashboard", "Tampilkan dashboard compact FinCLI.", "/dashboard", "General"),
19
19
  CommandSpec("/ai_model", "Lihat atau ganti AI provider/model.", "/ai_model openrouter openai/gpt-4o-mini", "AI"),
20
+ CommandSpec("/ai_model key", "Simpan API key AI lokal.", "/ai_model key groq <api_key>", "AI"),
20
21
  CommandSpec("/news_model", "Buka selector provider market/news dan fallback.", "/news_model", "Provider"),
22
+ CommandSpec("/news_model key", "Simpan API key market/news lokal.", "/news_model key finnhub <api_key>", "Provider"),
21
23
  CommandSpec("/market", "Ringkasan market profesional untuk instrumen.", "/market AAPL 1d", "Market"),
22
24
  CommandSpec("/news", "Tampilkan news/fundamental terbaru untuk instrumen.", "/news AAPL", "Market"),
23
25
  CommandSpec("/technical", "Analisis teknikal instrumen.", "/technical BTC-USD 1d", "Analysis"),
@@ -50,6 +50,7 @@ from fincli.app.storage.cache import TTLCache
50
50
  from fincli.app.storage.config import ConfigManager
51
51
  from fincli.app.storage.database import FinCLIDatabase
52
52
  from fincli.app.storage.market_cache import MarketCache
53
+ from fincli.app.storage.secrets import save_secret
53
54
  from fincli.app.utils.errors import CommandError, FinCLIError
54
55
 
55
56
 
@@ -211,6 +212,23 @@ class CommandRouter:
211
212
  if len(args) == 0:
212
213
  current = self.config.settings
213
214
  return CommandResult(Panel(f"{current.ai_provider} / {current.ai_model}", title="Active AI Model"))
215
+ if args[0].lower() == "key":
216
+ if len(args) < 3:
217
+ raise CommandError("Format: /ai_model key <provider> <api_key>")
218
+ provider = args[1].lower()
219
+ info = AIProviderManager().get(provider)
220
+ if info is None:
221
+ raise CommandError(f"AI provider tidak dikenal: {provider}")
222
+ save_secret(info.env_key, args[2])
223
+ if self.config.settings.ai_provider == provider:
224
+ self.ai_provider = AIProviderManager().create(provider)
225
+ return CommandResult(
226
+ Panel(
227
+ f"API key AI untuk {provider} disimpan di ~/.fincli/secrets.env.\nKey tidak ditampilkan di terminal.",
228
+ title="AI API Key Saved",
229
+ border_style="green",
230
+ )
231
+ )
214
232
  if len(args) < 2:
215
233
  raise CommandError("Format: /ai_model <provider> <model>")
216
234
  self.config.set_ai_model(args[0], args[1])
@@ -232,6 +250,27 @@ class CommandRouter:
232
250
  title="Active Data Provider",
233
251
  )
234
252
  )
253
+ if args[0].lower() == "key":
254
+ if len(args) < 3:
255
+ raise CommandError("Format: /news_model key <provider> <api_key> [base_url untuk custom]")
256
+ provider = args[1].lower()
257
+ env_keys = _market_provider_secret_keys(provider)
258
+ if not env_keys:
259
+ raise CommandError(f"Provider {provider} tidak membutuhkan API key atau tidak dikenal.")
260
+ save_secret(env_keys[0], args[2])
261
+ if provider == "custom" and len(args) >= 4:
262
+ save_secret("MARKET_DATA_BASE_URL", args[3])
263
+ self._refresh_market_service()
264
+ self.cache.clear()
265
+ extra = "\nBase URL custom juga disimpan." if provider == "custom" and len(args) >= 4 else ""
266
+ return CommandResult(
267
+ Panel(
268
+ f"API key market/news untuk {provider} disimpan di ~/.fincli/secrets.env.{extra}\n"
269
+ "Key tidak ditampilkan di terminal.",
270
+ title="Market API Key Saved",
271
+ border_style="green",
272
+ )
273
+ )
235
274
  self.config.set_market_provider(args[0])
236
275
  self.config.set_news_provider(args[0])
237
276
  self.config.set_market_provider_priority([args[0], *self._priority_tail(args[0])])
@@ -1121,6 +1160,14 @@ def _format_provider_key_status(manager: MarketProviderManager) -> Table:
1121
1160
  return table
1122
1161
 
1123
1162
 
1163
+ def _market_provider_secret_keys(provider: str) -> tuple[str, ...]:
1164
+ return {
1165
+ "custom": ("MARKET_DATA_API_KEY", "MARKET_DATA_BASE_URL"),
1166
+ "finnhub": ("FINNHUB_API_KEY",),
1167
+ "twelvedata": ("TWELVE_DATA_API_KEY",),
1168
+ }.get(provider.lower(), ())
1169
+
1170
+
1124
1171
  def _format_transactions(rows: list[dict[str, object]]) -> Table:
1125
1172
  table = Table(title="Transaction Ledger", expand=True)
1126
1173
  table.add_column("ID", justify="right")
@@ -27,7 +27,7 @@ class OpenAICompatibleProvider(BaseAIProvider):
27
27
  if not self.api_key:
28
28
  raise ProviderError(
29
29
  f"API key untuk provider {self.name} belum diatur.",
30
- "Isi API key di .env lalu jalankan ulang FinCLI.",
30
+ f"Gunakan /ai_model key {self.name} <api_key> atau set environment variable.",
31
31
  )
32
32
 
33
33
  payload = {
@@ -69,7 +69,7 @@ class GeminiProviderHTTP(BaseAIProvider):
69
69
 
70
70
  async def complete(self, request: AIRequest) -> AIResponse:
71
71
  if not self.api_key:
72
- raise ProviderError("API key untuk provider gemini belum diatur.")
72
+ raise ProviderError("API key untuk provider gemini belum diatur.", "Gunakan /ai_model key gemini <api_key>.")
73
73
  url = f"{self.base_url}/models/{request.model}:generateContent?key={self.api_key}"
74
74
  payload = {"contents": [{"parts": [{"text": request.prompt}]}]}
75
75
  try:
@@ -100,7 +100,7 @@ class AnthropicProviderHTTP(BaseAIProvider):
100
100
 
101
101
  async def complete(self, request: AIRequest) -> AIResponse:
102
102
  if not self.api_key:
103
- raise ProviderError("API key untuk provider anthropic belum diatur.")
103
+ raise ProviderError("API key untuk provider anthropic belum diatur.", "Gunakan /ai_model key anthropic <api_key>.")
104
104
  payload = {
105
105
  "model": request.model,
106
106
  "max_tokens": 1200,
@@ -119,12 +119,12 @@ class CustomMarketProvider(BaseMarketProvider):
119
119
  if not self.api_key:
120
120
  raise ProviderError(
121
121
  "API key custom market provider belum diatur.",
122
- "Isi MARKET_DATA_API_KEY di .env.",
122
+ "Gunakan /news_model key custom <api_key> <base_url>.",
123
123
  )
124
124
  if not self.base_url:
125
125
  raise ProviderError(
126
126
  "Base URL custom market provider belum diatur.",
127
- "Isi MARKET_DATA_BASE_URL di .env.",
127
+ "Gunakan /news_model key custom <api_key> <base_url>.",
128
128
  )
129
129
 
130
130
  close_client = self._client is None
@@ -141,7 +141,7 @@ class FinnhubProvider:
141
141
 
142
142
  async def _get(self, path: str, params: dict[str, object]) -> Any:
143
143
  if not self.api_key:
144
- raise ProviderError("API key Finnhub belum diatur.", "Isi FINNHUB_API_KEY di .env.")
144
+ raise ProviderError("API key Finnhub belum diatur.", "Gunakan /news_model key finnhub <api_key>.")
145
145
  close_client = self._client is None
146
146
  client = self._client or httpx.AsyncClient(timeout=30)
147
147
  query = {**params, "token": self.api_key}
@@ -10,6 +10,7 @@ from fincli.app.providers.market.custom_provider import CustomMarketProvider
10
10
  from fincli.app.providers.market.finnhub_provider import FinnhubProvider
11
11
  from fincli.app.providers.market.twelvedata_provider import TwelveDataProvider
12
12
  from fincli.app.providers.market.yfinance_provider import YFinanceProvider
13
+ from fincli.app.storage.secrets import secret_source
13
14
 
14
15
 
15
16
  @dataclass(frozen=True, slots=True)
@@ -92,25 +93,25 @@ class MarketProviderManager:
92
93
  "provider": "custom",
93
94
  "key": "MARKET_DATA_API_KEY",
94
95
  "status": _mask_status(os.getenv("MARKET_DATA_API_KEY")),
95
- "source": ".env" if os.getenv("MARKET_DATA_API_KEY") else "-",
96
+ "source": secret_source("MARKET_DATA_API_KEY"),
96
97
  },
97
98
  {
98
99
  "provider": "custom",
99
100
  "key": "MARKET_DATA_BASE_URL",
100
101
  "status": _mask_status(os.getenv("MARKET_DATA_BASE_URL")),
101
- "source": ".env" if os.getenv("MARKET_DATA_BASE_URL") else "-",
102
+ "source": secret_source("MARKET_DATA_BASE_URL"),
102
103
  },
103
104
  {
104
105
  "provider": "finnhub",
105
106
  "key": "FINNHUB_API_KEY",
106
107
  "status": _mask_status(os.getenv("FINNHUB_API_KEY")),
107
- "source": ".env" if os.getenv("FINNHUB_API_KEY") else "-",
108
+ "source": secret_source("FINNHUB_API_KEY"),
108
109
  },
109
110
  {
110
111
  "provider": "twelvedata",
111
112
  "key": "TWELVE_DATA_API_KEY",
112
113
  "status": _mask_status(os.getenv("TWELVE_DATA_API_KEY")),
113
- "source": ".env" if os.getenv("TWELVE_DATA_API_KEY") else "-",
114
+ "source": secret_source("TWELVE_DATA_API_KEY"),
114
115
  },
115
116
  ]
116
117
 
@@ -76,7 +76,7 @@ class TwelveDataProvider:
76
76
 
77
77
  async def _get(self, path: str, params: dict[str, object]) -> Any:
78
78
  if not self.api_key:
79
- raise ProviderError("API key Twelve Data belum diatur.", "Isi TWELVE_DATA_API_KEY di .env.")
79
+ raise ProviderError("API key Twelve Data belum diatur.", "Gunakan /news_model key twelvedata <api_key>.")
80
80
  close_client = self._client is None
81
81
  client = self._client or httpx.AsyncClient(timeout=30)
82
82
  try:
@@ -19,10 +19,8 @@ except ImportError: # pragma: no cover - dependency exists in normal install
19
19
 
20
20
  from fincli.app.utils.errors import ConfigError
21
21
  from fincli.app.utils.formatting import mask_secret
22
-
23
-
24
- APP_DIR = Path.home() / ".fincli"
25
- CONFIG_FILE = APP_DIR / "config.json"
22
+ from fincli.app.storage.config_paths import APP_DIR, CONFIG_FILE
23
+ from fincli.app.storage.secrets import load_local_secrets
26
24
 
27
25
 
28
26
  @dataclass(slots=True)
@@ -66,6 +64,7 @@ class ConfigManager:
66
64
  def load(self) -> FinCLISettings:
67
65
  if load_dotenv is not None:
68
66
  load_dotenv()
67
+ load_local_secrets()
69
68
 
70
69
  if not self.config_file.exists():
71
70
  return FinCLISettings()
@@ -0,0 +1,9 @@
1
+ """Shared local storage paths."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+
8
+ APP_DIR = Path.home() / ".fincli"
9
+ CONFIG_FILE = APP_DIR / "config.json"
@@ -0,0 +1,101 @@
1
+ """Local secret storage for globally installed FinCLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ from fincli.app.storage.config_paths import APP_DIR
9
+ from fincli.app.utils.errors import ConfigError
10
+
11
+
12
+ SECRETS_FILE = APP_DIR / "secrets.env"
13
+
14
+
15
+ def load_local_secrets(path: Path | None = None, *, override: bool = False) -> None:
16
+ """Load persisted secrets into process environment."""
17
+ path = path or SECRETS_FILE
18
+ if not path.exists():
19
+ return
20
+ for line in path.read_text(encoding="utf-8").splitlines():
21
+ stripped = line.strip()
22
+ if not stripped or stripped.startswith("#") or "=" not in stripped:
23
+ continue
24
+ key, value = stripped.split("=", 1)
25
+ key = key.strip()
26
+ value = _unquote(value.strip())
27
+ if key and (override or key not in os.environ):
28
+ os.environ[key] = value
29
+
30
+
31
+ def save_secret(env_key: str, value: str, path: Path | None = None) -> None:
32
+ """Persist a secret locally and expose it to the current process."""
33
+ path = path or SECRETS_FILE
34
+ key = _validate_env_key(env_key)
35
+ secret = _sanitize_value(value)
36
+ if not secret:
37
+ raise ConfigError(f"Nilai {key} kosong.")
38
+
39
+ secrets = read_secrets(path)
40
+ secrets[key] = secret
41
+
42
+ try:
43
+ path.parent.mkdir(parents=True, exist_ok=True)
44
+ lines = ["# FinCLI local secrets. Do not commit or share this file."]
45
+ lines.extend(f"{item_key}={_quote(item_value)}" for item_key, item_value in sorted(secrets.items()))
46
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
47
+ try:
48
+ os.chmod(path, 0o600)
49
+ except OSError:
50
+ pass
51
+ except OSError as exc:
52
+ raise ConfigError("Secret lokal gagal disimpan.", f"Path: {path}") from exc
53
+
54
+ os.environ[key] = secret
55
+
56
+
57
+ def read_secrets(path: Path | None = None) -> dict[str, str]:
58
+ """Read local secrets without printing or masking them."""
59
+ path = path or SECRETS_FILE
60
+ if not path.exists():
61
+ return {}
62
+ result: dict[str, str] = {}
63
+ for line in path.read_text(encoding="utf-8").splitlines():
64
+ stripped = line.strip()
65
+ if not stripped or stripped.startswith("#") or "=" not in stripped:
66
+ continue
67
+ key, value = stripped.split("=", 1)
68
+ result[key.strip()] = _unquote(value.strip())
69
+ return result
70
+
71
+
72
+ def secret_source(env_key: str, path: Path | None = None) -> str:
73
+ """Return a display-safe source for a secret."""
74
+ path = path or SECRETS_FILE
75
+ if env_key in os.environ:
76
+ if env_key in read_secrets(path):
77
+ return "~/.fincli/secrets.env"
78
+ return "environment/.env"
79
+ return "-"
80
+
81
+
82
+ def _validate_env_key(env_key: str) -> str:
83
+ key = env_key.strip().upper()
84
+ if not key or not all(char.isalnum() or char == "_" for char in key):
85
+ raise ConfigError(f"Nama environment key tidak valid: {env_key}")
86
+ return key
87
+
88
+
89
+ def _sanitize_value(value: str) -> str:
90
+ return value.strip().replace("\r", "").replace("\n", "")
91
+
92
+
93
+ def _quote(value: str) -> str:
94
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
95
+ return f'"{escaped}"'
96
+
97
+
98
+ def _unquote(value: str) -> str:
99
+ if len(value) >= 2 and value[0] == value[-1] == '"':
100
+ return value[1:-1].replace('\\"', '"').replace("\\\\", "\\")
101
+ return value
@@ -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,22 @@ 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
+ self._set_mode("priority")
295
+
259
296
 
260
297
  def _provider_key_status(choice: MarketProviderChoice) -> str:
261
298
  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,21 @@ 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
+ self._set_mode("model")
395
429
 
396
430
 
397
431
  def _provider_choice(provider: str) -> ProviderChoice | None:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drico2008/fincli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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.1"
7
+ version = "0.1.3"
8
8
  description = "Modern financial CLI/TUI terminal for market monitoring and analysis."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -26,6 +26,10 @@ market = ["yfinance>=0.2.50", "pandas>=2.2.3", "numpy>=2.1.0"]
26
26
  [project.scripts]
27
27
  fincli = "fincli.app.main:main"
28
28
 
29
+ [tool.setuptools.packages.find]
30
+ include = ["fincli*"]
31
+ exclude = ["npm*", "tests*"]
32
+
29
33
  [tool.pytest.ini_options]
30
34
  testpaths = ["tests"]
31
35
  pythonpath = ["."]