@drico2008/fincli 0.1.2 → 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 +41 -1
- package/fincli/__init__.py +1 -1
- package/fincli/app/cli/commands.py +2 -0
- package/fincli/app/cli/router.py +47 -0
- package/fincli/app/providers/ai/http_provider.py +3 -3
- package/fincli/app/providers/market/custom_provider.py +2 -2
- package/fincli/app/providers/market/finnhub_provider.py +1 -1
- package/fincli/app/providers/market/manager.py +5 -4
- package/fincli/app/providers/market/twelvedata_provider.py +1 -1
- package/fincli/app/storage/config.py +3 -4
- package/fincli/app/storage/config_paths.py +9 -0
- package/fincli/app/storage/secrets.py +101 -0
- package/fincli/app/tui/market_provider_selector.py +39 -2
- package/fincli/app/tui/model_selector.py +86 -52
- package/package.json +1 -1
- package/pyproject.toml +1 -1
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:
|
|
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
|
|
package/fincli/__init__.py
CHANGED
|
@@ -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"),
|
package/fincli/app/cli/router.py
CHANGED
|
@@ -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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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.", "
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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.", "
|
|
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,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.
|
|
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.
|
|
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.
|
|
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.
|
|
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("
|
|
296
|
-
return
|
|
297
|
-
if self.mode == "configured":
|
|
298
|
-
if selected == "Configure
|
|
299
|
-
self._set_mode("
|
|
300
|
-
|
|
301
|
-
self._set_mode("model")
|
|
302
|
-
|
|
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.
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
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 == "
|
|
391
|
-
return "
|
|
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
package/pyproject.toml
CHANGED