@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.
- package/README.md +81 -7
- package/fincli/__init__.py +1 -1
- package/fincli/app/analysis/assistant_context.py +27 -1
- package/fincli/app/analysis/indicators.py +1 -1
- package/fincli/app/analysis/market_structure.py +1 -1
- package/fincli/app/cli/commands.py +12 -4
- package/fincli/app/cli/router.py +253 -13
- package/fincli/app/modules/session_history.py +113 -0
- package/fincli/app/providers/ai/anthropic_provider.py +8 -7
- package/fincli/app/providers/ai/gemini_provider.py +8 -7
- package/fincli/app/providers/ai/groq_provider.py +8 -7
- package/fincli/app/providers/ai/http_provider.py +3 -3
- package/fincli/app/providers/ai/huggingface_provider.py +8 -7
- package/fincli/app/providers/ai/openai_provider.py +8 -7
- package/fincli/app/providers/ai/openrouter_provider.py +8 -7
- package/fincli/app/providers/ai/together_provider.py +8 -7
- 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 +6 -5
- package/fincli/app/providers/market/news_provider.py +4 -4
- package/fincli/app/providers/market/twelvedata_provider.py +1 -1
- package/fincli/app/providers/market/yfinance_provider.py +1 -1
- package/fincli/app/services/web_research.py +267 -0
- package/fincli/app/storage/cache.py +2 -2
- package/fincli/app/storage/config.py +3 -4
- package/fincli/app/storage/config_paths.py +9 -0
- package/fincli/app/storage/database.py +17 -0
- package/fincli/app/storage/secrets.py +104 -0
- package/fincli/app/tui/components.py +1 -1
- package/fincli/app/tui/layout.py +8 -7
- package/fincli/app/tui/market_provider_selector.py +42 -2
- package/fincli/app/tui/model_selector.py +97 -55
- package/fincli/app/utils/formatting.py +50 -0
- package/npm/bin/fincli.js +9 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
"""OpenRouter provider
|
|
1
|
+
"""OpenRouter provider compatibility wrapper."""
|
|
2
2
|
|
|
3
|
-
from
|
|
4
|
-
from fincli.app.utils.errors import ProviderError
|
|
3
|
+
from __future__ import annotations
|
|
5
4
|
|
|
5
|
+
import os
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
name = "openrouter"
|
|
7
|
+
from fincli.app.providers.ai.http_provider import OpenAICompatibleProvider
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
1
|
+
"""Together AI provider compatibility wrapper."""
|
|
2
2
|
|
|
3
|
-
from
|
|
4
|
-
from fincli.app.utils.errors import ProviderError
|
|
3
|
+
from __future__ import annotations
|
|
5
4
|
|
|
5
|
+
import os
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
name = "together"
|
|
7
|
+
from fincli.app.providers.ai.http_provider import OpenAICompatibleProvider
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
"
|
|
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)
|
|
@@ -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
|
|
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":
|
|
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
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
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
|
|
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="
|
|
27
|
-
message="News
|
|
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.", "
|
|
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:
|
|
@@ -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
|
|
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
|
|
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()
|
|
@@ -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
|
package/fincli/app/tui/layout.py
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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")
|