@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
@@ -1,11 +1,12 @@
1
- """OpenRouter provider placeholder for Phase 2."""
1
+ """OpenRouter provider compatibility wrapper."""
2
2
 
3
- from fincli.app.providers.ai.base import AIRequest, AIResponse, BaseAIProvider
4
- from fincli.app.utils.errors import ProviderError
3
+ from __future__ import annotations
5
4
 
5
+ import os
6
6
 
7
- class OpenRouterProvider(BaseAIProvider):
8
- name = "openrouter"
7
+ from fincli.app.providers.ai.http_provider import OpenAICompatibleProvider
9
8
 
10
- async def complete(self, request: AIRequest) -> AIResponse:
11
- raise ProviderError("OpenRouter client belum diimplementasi di Phase 1.")
9
+
10
+ class OpenRouterProvider(OpenAICompatibleProvider):
11
+ def __init__(self, api_key: str | None = None) -> None:
12
+ super().__init__("openrouter", "https://openrouter.ai/api/v1", api_key or os.getenv("OPENROUTER_API_KEY"))
@@ -1,11 +1,12 @@
1
- """Together AI provider placeholder for Phase 2."""
1
+ """Together AI provider compatibility wrapper."""
2
2
 
3
- from fincli.app.providers.ai.base import AIRequest, AIResponse, BaseAIProvider
4
- from fincli.app.utils.errors import ProviderError
3
+ from __future__ import annotations
5
4
 
5
+ import os
6
6
 
7
- class TogetherProvider(BaseAIProvider):
8
- name = "together"
7
+ from fincli.app.providers.ai.http_provider import OpenAICompatibleProvider
9
8
 
10
- async def complete(self, request: AIRequest) -> AIResponse:
11
- raise ProviderError("Together AI client belum diimplementasi di Phase 1.")
9
+
10
+ class TogetherProvider(OpenAICompatibleProvider):
11
+ def __init__(self, api_key: str | None = None) -> None:
12
+ super().__init__("together", "https://api.together.xyz/v1", api_key or os.getenv("TOGETHER_API_KEY"))
@@ -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)
@@ -31,7 +32,7 @@ MARKET_PROVIDERS: dict[str, MarketProviderInfo] = {
31
32
  name="custom",
32
33
  realtime=True,
33
34
  status="configured",
34
- notes="Custom API provider scaffold. Requires MARKET_DATA_API_KEY.",
35
+ notes="Custom REST API provider. Requires MARKET_DATA_API_KEY and MARKET_DATA_BASE_URL.",
35
36
  ),
36
37
  "finnhub": MarketProviderInfo(
37
38
  name="finnhub",
@@ -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
 
@@ -1,4 +1,4 @@
1
- """News provider placeholder for Phase 2."""
1
+ """Base news-only provider adapter."""
2
2
 
3
3
  from fincli.app.providers.market.base import BaseMarketProvider, Candle, FundamentalSnapshot, NewsItem, ProviderStatus, Quote
4
4
  from fincli.app.utils.errors import ProviderError
@@ -14,7 +14,7 @@ class NewsProvider(BaseMarketProvider):
14
14
  raise ProviderError("News provider tidak menyediakan OHLCV.")
15
15
 
16
16
  async def news(self, symbol: str, limit: int = 5) -> list[NewsItem]:
17
- raise ProviderError("News fetching belum diimplementasi di Phase 2.")
17
+ raise ProviderError("News provider belum dikonfigurasi untuk mengambil berita.", "Gunakan /news_model untuk memilih provider market/news aktif.")
18
18
 
19
19
  async def fundamentals(self, symbol: str) -> FundamentalSnapshot:
20
20
  raise ProviderError("News provider tidak menyediakan fundamental.")
@@ -23,6 +23,6 @@ class NewsProvider(BaseMarketProvider):
23
23
  return ProviderStatus(
24
24
  name=self.name,
25
25
  realtime=False,
26
- status="configured",
27
- message="News fetching pending in Phase 2.",
26
+ status="unavailable",
27
+ message="News-only adapter belum memiliki source aktif. Gunakan /news_model untuk provider aktual.",
28
28
  )
@@ -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:
@@ -1,4 +1,4 @@
1
- """yfinance fallback provider placeholder for Phase 2."""
1
+ """yfinance fallback provider for delayed public market data."""
2
2
 
3
3
  import asyncio
4
4
  from dataclasses import dataclass
@@ -0,0 +1,267 @@
1
+ """Lightweight web research service for AI assistance."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from html import unescape
7
+ from html.parser import HTMLParser
8
+ import re
9
+ from urllib.parse import parse_qs, quote_plus, unquote, urlparse
10
+ from xml.etree import ElementTree
11
+
12
+ import httpx
13
+
14
+ from fincli.app.utils.errors import ProviderError
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class WebSearchResult:
19
+ title: str
20
+ url: str
21
+ snippet: str = ""
22
+ content: str = ""
23
+
24
+
25
+ class WebResearchService:
26
+ """Search and fetch public web pages without browser automation."""
27
+
28
+ def __init__(self, client: httpx.AsyncClient | None = None, timeout_seconds: float = 6.0) -> None:
29
+ self._client = client
30
+ self.timeout_seconds = timeout_seconds
31
+
32
+ async def research(self, query: str, limit: int = 3) -> list[WebSearchResult]:
33
+ normalized = query.strip()
34
+ if not normalized:
35
+ return []
36
+ search_results = await self.search(normalized, limit=limit)
37
+ enriched: list[WebSearchResult] = []
38
+ for result in search_results[:limit]:
39
+ content = await self.fetch_text(result.url)
40
+ enriched.append(
41
+ WebSearchResult(
42
+ title=result.title,
43
+ url=result.url,
44
+ snippet=result.snippet,
45
+ content=content,
46
+ )
47
+ )
48
+ return enriched
49
+
50
+ async def search(self, query: str, limit: int = 5) -> list[WebSearchResult]:
51
+ errors: list[str] = []
52
+ for searcher in (self._search_duckduckgo, self._search_google_news):
53
+ try:
54
+ results = await searcher(query, limit)
55
+ except ProviderError as exc:
56
+ errors.append(str(exc))
57
+ continue
58
+ if results:
59
+ return results
60
+ detail = "\n".join(f"- {error}" for error in errors) if errors else "Tidak ada hasil publik."
61
+ raise ProviderError(
62
+ "Semua web search provider gagal atau kosong.",
63
+ f"{detail}\nCoba ulangi, sederhanakan query, atau cek koneksi/DNS.",
64
+ )
65
+
66
+ async def _search_duckduckgo(self, query: str, limit: int) -> list[WebSearchResult]:
67
+ html = await self._get_text(f"https://duckduckgo.com/html/?q={quote_plus(query)}")
68
+ parser = _DuckDuckGoParser()
69
+ parser.feed(html)
70
+ results: list[WebSearchResult] = []
71
+ seen: set[str] = set()
72
+ for item in parser.results:
73
+ target = _clean_duckduckgo_url(item.url)
74
+ if not target or target in seen:
75
+ continue
76
+ seen.add(target)
77
+ results.append(WebSearchResult(title=_clean_text(item.title), url=target, snippet=_clean_text(item.snippet)))
78
+ if len(results) >= limit:
79
+ break
80
+ return results
81
+
82
+ async def _search_google_news(self, query: str, limit: int) -> list[WebSearchResult]:
83
+ rss = await self._get_text(f"https://news.google.com/rss/search?q={quote_plus(query)}&hl=id&gl=ID&ceid=ID:id")
84
+ try:
85
+ root = ElementTree.fromstring(rss)
86
+ except ElementTree.ParseError as exc:
87
+ raise ProviderError("Google News RSS tidak valid.") from exc
88
+
89
+ results: list[WebSearchResult] = []
90
+ seen: set[str] = set()
91
+ for item in root.findall(".//item"):
92
+ title = _clean_text(item.findtext("title") or "")
93
+ url = _clean_text(item.findtext("link") or "")
94
+ snippet = _clean_text(_html_to_text(item.findtext("description") or ""))
95
+ if not title or not url or url in seen:
96
+ continue
97
+ seen.add(url)
98
+ results.append(WebSearchResult(title=title, url=url, snippet=snippet))
99
+ if len(results) >= limit:
100
+ break
101
+ return results
102
+
103
+ async def fetch_text(self, url: str, max_chars: int = 2400) -> str:
104
+ if not url.startswith(("http://", "https://")):
105
+ return ""
106
+ try:
107
+ html = await self._get_text(url)
108
+ except ProviderError:
109
+ return ""
110
+ text = _html_to_text(html)
111
+ return text[:max_chars]
112
+
113
+ async def _get_text(self, url: str) -> str:
114
+ headers = {
115
+ "User-Agent": "FinCLI/0.1 web research (+https://www.npmjs.com/package/@drico2008/fincli)",
116
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.7",
117
+ }
118
+ close_client = self._client is None
119
+ client = self._client or httpx.AsyncClient(timeout=self.timeout_seconds, follow_redirects=True, headers=headers)
120
+ try:
121
+ response = await client.get(url, headers=headers)
122
+ response.raise_for_status()
123
+ return response.text
124
+ except httpx.TimeoutException as exc:
125
+ raise ProviderError("Web research timeout.", f"URL: {url}") from exc
126
+ except httpx.HTTPStatusError as exc:
127
+ raise ProviderError(f"Web research gagal: HTTP {exc.response.status_code}.", f"URL: {url}") from exc
128
+ except httpx.RequestError as exc:
129
+ raise ProviderError(f"Web research gagal terhubung: {exc}.", f"URL: {url}") from exc
130
+ finally:
131
+ if close_client:
132
+ await client.aclose()
133
+
134
+
135
+ def should_use_web_research(prompt: str) -> bool:
136
+ """Detect prompts that benefit from current public web context."""
137
+ normalized = prompt.lower()
138
+ keywords = (
139
+ "terkini",
140
+ "terbaru",
141
+ "hari ini",
142
+ "sekarang",
143
+ "saat ini",
144
+ "update",
145
+ "berita",
146
+ "news",
147
+ "web",
148
+ "search",
149
+ "cari",
150
+ "penyebab",
151
+ "mengapa",
152
+ "kenapa",
153
+ "rupiah",
154
+ "inflasi",
155
+ "suku bunga",
156
+ "bank indonesia",
157
+ "fed",
158
+ "dollar",
159
+ "dolar",
160
+ "yield",
161
+ "minyak",
162
+ "emas",
163
+ )
164
+ return any(keyword in normalized for keyword in keywords)
165
+
166
+
167
+ def build_web_research_context(results: list[WebSearchResult]) -> str:
168
+ if not results:
169
+ return "Web Research: no public web context returned."
170
+ sections = ["Web Research Context:"]
171
+ for index, result in enumerate(results, start=1):
172
+ sections.extend(
173
+ [
174
+ f"{index}. {result.title}",
175
+ f"URL: {result.url}",
176
+ f"Snippet: {result.snippet or 'N/A'}",
177
+ f"Extract: {result.content or 'N/A'}",
178
+ ]
179
+ )
180
+ return "\n".join(sections)
181
+
182
+
183
+ class _DuckResult:
184
+ def __init__(self) -> None:
185
+ self.title = ""
186
+ self.url = ""
187
+ self.snippet = ""
188
+
189
+
190
+ class _DuckDuckGoParser(HTMLParser):
191
+ def __init__(self) -> None:
192
+ super().__init__()
193
+ self.results: list[_DuckResult] = []
194
+ self._current: _DuckResult | None = None
195
+ self._capture: str | None = None
196
+ self._buffer: list[str] = []
197
+
198
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
199
+ attr = dict(attrs)
200
+ classes = attr.get("class", "")
201
+ if tag == "a" and "result__a" in classes:
202
+ self._current = _DuckResult()
203
+ self._current.url = attr.get("href", "") or ""
204
+ self._capture = "title"
205
+ self._buffer = []
206
+ elif self._current is not None and tag in {"a", "div"} and "result__snippet" in classes:
207
+ self._capture = "snippet"
208
+ self._buffer = []
209
+
210
+ def handle_data(self, data: str) -> None:
211
+ if self._capture:
212
+ self._buffer.append(data)
213
+
214
+ def handle_endtag(self, tag: str) -> None:
215
+ if self._current is None or self._capture is None:
216
+ return
217
+ if self._capture == "title" and tag == "a":
218
+ self._current.title = _clean_text(" ".join(self._buffer))
219
+ self._capture = None
220
+ self._buffer = []
221
+ elif self._capture == "snippet" and tag in {"a", "div"}:
222
+ self._current.snippet = _clean_text(" ".join(self._buffer))
223
+ self.results.append(self._current)
224
+ self._current = None
225
+ self._capture = None
226
+ self._buffer = []
227
+
228
+
229
+ class _TextExtractor(HTMLParser):
230
+ def __init__(self) -> None:
231
+ super().__init__()
232
+ self.parts: list[str] = []
233
+ self._skip_depth = 0
234
+
235
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
236
+ if tag in {"script", "style", "noscript", "svg"}:
237
+ self._skip_depth += 1
238
+
239
+ def handle_endtag(self, tag: str) -> None:
240
+ if tag in {"script", "style", "noscript", "svg"} and self._skip_depth:
241
+ self._skip_depth -= 1
242
+
243
+ def handle_data(self, data: str) -> None:
244
+ if not self._skip_depth:
245
+ cleaned = _clean_text(data)
246
+ if cleaned:
247
+ self.parts.append(cleaned)
248
+
249
+
250
+ def _html_to_text(html: str) -> str:
251
+ extractor = _TextExtractor()
252
+ extractor.feed(html)
253
+ return _clean_text(" ".join(extractor.parts))
254
+
255
+
256
+ def _clean_text(value: str) -> str:
257
+ text = unescape(value)
258
+ text = re.sub(r"\s+", " ", text)
259
+ return text.strip()
260
+
261
+
262
+ def _clean_duckduckgo_url(url: str) -> str:
263
+ parsed = urlparse(url)
264
+ if parsed.netloc.endswith("duckduckgo.com") and parsed.path.startswith("/l/"):
265
+ target = parse_qs(parsed.query).get("uddg", [""])[0]
266
+ return unquote(target)
267
+ return url
@@ -1,4 +1,4 @@
1
- """Lightweight in-memory cache scaffold for provider responses."""
1
+ """Lightweight in-memory TTL cache for provider responses."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -16,7 +16,7 @@ class CacheEntry(Generic[T]):
16
16
 
17
17
 
18
18
  class TTLCache(Generic[T]):
19
- """Simple TTL cache for Phase 1. Provider modules can replace this later."""
19
+ """Simple runtime TTL cache used by router and provider workflows."""
20
20
 
21
21
  def __init__(self, default_ttl: int = 300) -> None:
22
22
  self.default_ttl = default_ttl
@@ -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"
@@ -80,6 +80,23 @@ class FinCLIDatabase:
80
80
  created_at TEXT DEFAULT CURRENT_TIMESTAMP,
81
81
  PRIMARY KEY (namespace, cache_key)
82
82
  );
83
+
84
+ CREATE TABLE IF NOT EXISTS sessions (
85
+ id TEXT PRIMARY KEY,
86
+ title TEXT NOT NULL,
87
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
88
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP
89
+ );
90
+
91
+ CREATE TABLE IF NOT EXISTS session_events (
92
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
93
+ session_id TEXT NOT NULL,
94
+ command TEXT NOT NULL,
95
+ status TEXT NOT NULL,
96
+ output_preview TEXT DEFAULT '',
97
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
98
+ FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
99
+ );
83
100
  """
84
101
  )
85
102
  except sqlite3.Error as exc:
@@ -0,0 +1,104 @@
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 or os.environ.get(key, "") == ""):
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
+ current = os.getenv(env_key)
76
+ if not current:
77
+ return "-"
78
+ if env_key in os.environ:
79
+ if read_secrets(path).get(env_key) == current:
80
+ return "~/.fincli/secrets.env"
81
+ return "environment/.env"
82
+ return "-"
83
+
84
+
85
+ def _validate_env_key(env_key: str) -> str:
86
+ key = env_key.strip().upper()
87
+ if not key or not all(char.isalnum() or char == "_" for char in key):
88
+ raise ConfigError(f"Nama environment key tidak valid: {env_key}")
89
+ return key
90
+
91
+
92
+ def _sanitize_value(value: str) -> str:
93
+ return value.strip().replace("\r", "").replace("\n", "")
94
+
95
+
96
+ def _quote(value: str) -> str:
97
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
98
+ return f'"{escaped}"'
99
+
100
+
101
+ def _unquote(value: str) -> str:
102
+ if len(value) >= 2 and value[0] == value[-1] == '"':
103
+ return value[1:-1].replace('\\"', '"').replace("\\\\", "\\")
104
+ return value
@@ -52,4 +52,4 @@ def format_thinking_message(message: str) -> Text:
52
52
 
53
53
 
54
54
  def format_ai_message(message: str) -> Markdown:
55
- return Markdown(f"* {message}")
55
+ return Markdown(message)
@@ -7,6 +7,7 @@ from threading import Lock
7
7
 
8
8
  from textual.app import App, ComposeResult, SystemCommand
9
9
  from textual.containers import Horizontal, Vertical, VerticalScroll
10
+ from textual.css.query import NoMatches
10
11
  from textual.screen import Screen
11
12
  from textual.worker import Worker, WorkerState
12
13
  from textual.widgets import Header, Input, RichLog, Static
@@ -16,7 +17,7 @@ from fincli.app.cli.autocomplete import SlashAutocomplete
16
17
  from fincli.app.cli.commands import CommandRegistry
17
18
  from fincli.app.cli.router import CommandResult, CommandRouter
18
19
  from fincli.app.providers.ai.manager import AIProviderManager
19
- from fincli.app.tui.components import CommandPalette, format_ai_message, format_thinking_message, format_user_message
20
+ from fincli.app.tui.components import CommandPalette, format_thinking_message, format_user_message
20
21
  from fincli.app.tui.market_provider_selector import MarketProviderSelectorScreen
21
22
  from fincli.app.tui.model_selector import AIModelSelectorScreen
22
23
  from fincli.app.tui.theme import APP_CSS
@@ -230,8 +231,11 @@ class FinCLIApp(App[None]):
230
231
  sequence = int(str(meta.get("sequence", "0")))
231
232
  if sequence < self._latest_worker_sequence:
232
233
  return
233
- output = self.query_one("#output", RichLog)
234
- status = self.query_one("#status_bar", Static)
234
+ try:
235
+ output = self.query_one("#output", RichLog)
236
+ status = self.query_one("#status_bar", Static)
237
+ except NoMatches:
238
+ return
235
239
  display_raw = str(meta["display_raw"])
236
240
 
237
241
  if event.state == WorkerState.CANCELLED:
@@ -248,10 +252,7 @@ class FinCLIApp(App[None]):
248
252
  if result.clear:
249
253
  output.clear()
250
254
  elif result.renderable:
251
- if bool(meta.get("chat")) and result.status == "ready":
252
- output.write(format_ai_message(str(result.renderable)))
253
- else:
254
- output.write(result.renderable)
255
+ output.write(result.renderable)
255
256
 
256
257
  if bool(meta.get("chat")):
257
258
  status.update(f"{result.status} | ai chat")