Merge pull request #43 from 0xrsydn/release/v0.3.2

chore: release 0.3.2
This commit is contained in:
brutaljokerz 2026-10-16 17:07:56 +07:00 • committed by GitHub
commit 1adf6e4cec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 931 additions and 134 deletions

View file

@ -33,6 +33,7 @@ Docs:
- `docs/OWNERSHIP_SYNC.md` / `docs/OWNERSHIP_PUBLISH.md`: snapshot consumer contract / maintainer publishing - `docs/OWNERSHIP_SYNC.md` / `docs/OWNERSHIP_PUBLISH.md`: snapshot consumer contract / maintainer publishing
- `docs/SMOKE.md`: Nix checks, test layout, and the live drift check - `docs/SMOKE.md`: Nix checks, test layout, and the live drift check
- `docs/DESIGN.md`: idx-core / idx-mcp design and parity gates - `docs/DESIGN.md`: idx-core / idx-mcp design and parity gates
- `docs/IDX_NEWS.md`: news sources research (IDX disclosures, Google News, Yahoo) and a proposed publisher design
- `ROADMAP.md`: open work - `ROADMAP.md`: open work
## Rules ## Rules

6
Cargo.lock generated
View file

@ -901,7 +901,7 @@ dependencies = [
[[package]] [[package]]
name = "idx-cli" name = "idx-cli"
version = "0.3.1" version = "0.3.2"
dependencies = [ dependencies = [
"assert_cmd", "assert_cmd",
"chrono", "chrono",
@ -921,7 +921,7 @@ dependencies = [
[[package]] [[package]]
name = "idx-core" name = "idx-core"
version = "0.3.1" version = "0.3.2"
dependencies = [ dependencies = [
"chrono", "chrono",
"clap", "clap",
@ -940,7 +940,7 @@ dependencies = [
[[package]] [[package]]
name = "idx-mcp" name = "idx-mcp"
version = "0.3.1" version = "0.3.2"
dependencies = [ dependencies = [
"clap", "clap",
"idx-core", "idx-core",

View file

@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/idx-core", "crates/idx-cli", "crates/idx-mcp"] members = ["crates/idx-core", "crates/idx-cli", "crates/idx-mcp"]
[workspace.package] [workspace.package]
version = "0.3.1" version = "0.3.2"
edition = "2024" edition = "2024"
rust-version = "1.89" rust-version = "1.89"
license = "MIT" license = "MIT"
@ -11,7 +11,7 @@ repository = "https://github.com/0xrsydn/idx-cli"
homepage = "https://idx.rasyidanaf.com" homepage = "https://idx.rasyidanaf.com"
[workspace.dependencies] [workspace.dependencies]
idx-core = { path = "crates/idx-core", version = "=0.3.1" } idx-core = { path = "crates/idx-core", version = "=0.3.2" }
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["raw_value"] } serde_json = { version = "1", features = ["raw_value"] }

View file

@ -13,7 +13,7 @@ curl -fsSL https://raw.githubusercontent.com/0xrsydn/idx-cli/main/install.sh | s
Downloads the prebuilt `idx` binary for your platform from GitHub Releases, Downloads the prebuilt `idx` binary for your platform from GitHub Releases,
verifies it against the release `SHA256SUMS`, and installs it into verifies it against the release `SHA256SUMS`, and installs it into
`~/.local/bin` without sudo. Pin a version or change the directory with `~/.local/bin` without sudo. Pin a version or change the directory with
`sh -s -- --version v0.3.1 --dir ~/bin`. See [docs/INSTALL.md](docs/INSTALL.md). `sh -s -- --version v0.3.2 --dir ~/bin`. See [docs/INSTALL.md](docs/INSTALL.md).
### npm / npx ### npm / npx

View file

@ -11,6 +11,9 @@ history lives in git.
- `screen presets` / `screen run <PRESET>`: built-in screener presets - `screen presets` / `screen run <PRESET>`: built-in screener presets
(graduate `screen` out of `stocks` only if these grow into a richer workflow) (graduate `screen` out of `stocks` only if these grow into a richer workflow)
- Multi-period financial statements (today `financials` is single-period) - Multi-period financial statements (today `financials` is single-period)
- News beyond MSN in `news` / `get_company` (`docs/IDX_NEWS.md`): Google News
RSS per ticker, and IDX disclosures and exchange news through a
maintainer-published feed
## Workflow ## Workflow
- `watchlist`: named symbol sets usable by other commands - `watchlist`: named symbol sets usable by other commands

View file

@ -837,10 +837,11 @@ pub fn print_news(items: &[NewsItem]) -> Result<(), IdxError> {
let mut table = Table::new(); let mut table = Table::new();
table table
.load_preset(UTF8_FULL) .load_preset(UTF8_FULL)
.set_header(vec!["TITLE", "PROVIDER", "DATE", "URL"]); .set_header(vec!["TITLE", "ABOUT", "PROVIDER", "DATE", "URL"]);
for item in items { for item in items {
table.add_row(vec![ table.add_row(vec![
Cell::new(&item.title), Cell::new(&item.title),
Cell::new(item.relevance.as_str()),
Cell::new(&item.provider), Cell::new(&item.provider),
Cell::new(&item.published_at), Cell::new(&item.published_at),
Cell::new(truncate_url(&item.url)), Cell::new(truncate_url(&item.url)),

View file

@ -1,4 +1,4 @@
exit: 0 exit: 0
--- stdout --- stdout
[{"id":"news-1","symbol":"BBCA.JK","title":"BCA reports steady growth","url":"https://example.com/news/bca-growth","description":"BCA delivered steady quarterly growth across lending and deposits.","provider":"Contoso News","published_at":"2026-03-20T10:00:00Z","read_time_min":3}] [{"id":"news-1","symbol":"BBCA.JK","title":"BCA reports steady growth","url":"https://example.com/news/bca-growth","description":"BCA delivered steady quarterly growth across lending and deposits.","provider":"Contoso News","published_at":"2026-03-20T10:00:00Z","read_time_min":3,"relevance":"market"}]
--- stderr --- stderr

View file

@ -1,8 +1,8 @@
exit: 0 exit: 0
--- stdout --- stdout
┌───────────────────────────┬──────────────┬──────────────────────┬─────────────────────────────────────┐ ┌───────────────────────────┬────────┬──────────────┬──────────────────────┬─────────────────────────────────────┐
│ TITLE ┆ PROVIDER ┆ DATE ┆ URL │ │ TITLE ┆ ABOUT ┆ PROVIDER ┆ DATE ┆ URL │
╞═══════════════════════════╪══════════════╪══════════════════════╪═════════════════════════════════════╡ ╞═══════════════════════════╪════════╪══════════════╪══════════════════════╪═════════════════════════════════════╡
│ BCA reports steady growth ┆ Contoso News ┆ 2026-03-20T10:00:00Z ┆ https://example.com/news/bca-growth │ │ BCA reports steady growth ┆ market ┆ Contoso News ┆ 2026-03-20T10:00:00Z ┆ https://example.com/news/bca-growth │
└───────────────────────────┴──────────────┴──────────────────────┴─────────────────────────────────────┘ └───────────────────────────┴────────┴──────────────┴──────────────────────┴─────────────────────────────────────┘
--- stderr --- stderr

View file

@ -18,6 +18,8 @@ const MSN_ASSETS_BASE_URL: &str = "https://assets.msn.com/service/";
const MSN_API_BASE_URL: &str = "https://api.msn.com/msn/v0/pages/finance/"; const MSN_API_BASE_URL: &str = "https://api.msn.com/msn/v0/pages/finance/";
// Public API key from MSN Money website (embedded in frontend JS) // Public API key from MSN Money website (embedded in frontend JS)
const MSN_API_KEY: &str = "0QfOX3Vn51YCzitbLaRkTTBadtWpgTN8NZLW0C1SEM"; const MSN_API_KEY: &str = "0QfOX3Vn51YCzitbLaRkTTBadtWpgTN8NZLW0C1SEM";
/// Items requested from a stock's news feed, at least; MSN returns ~40-50.
const NEWS_FETCH_MIN: usize = 50;
pub(super) struct MsnClient { pub(super) struct MsnClient {
agent: ureq::Agent, agent: ureq::Agent,
@ -272,8 +274,11 @@ impl MsnClient {
pub(super) fn fetch_news(&self, symbol: &str, limit: usize) -> Result<RawNewsFeed, IdxError> { pub(super) fn fetch_news(&self, symbol: &str, limit: usize) -> Result<RawNewsFeed, IdxError> {
let id = let id =
resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?; resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
// Company items can sit below general market news in a stock's feed;
// fetch wider and let parse_news rank and trim to `limit`.
let top = limit.max(NEWS_FETCH_MIN);
let url = format!( let url = format!(
"{MSN_ASSETS_BASE_URL}MSN/Feed/me?$top={limit}&apikey={MSN_API_KEY}&cm=id-id&contentType=article,video,slideshow&it=web&query=ef_stock_{id}&queryType=entityfeed&responseSchema=cardview&scn=ANON&wrapodata=false" "{MSN_ASSETS_BASE_URL}MSN/Feed/me?$top={top}&apikey={MSN_API_KEY}&cm=id-id&contentType=article,video,slideshow&it=web&query=ef_stock_{id}&queryType=entityfeed&responseSchema=cardview&scn=ANON&wrapodata=false"
); );
self.get_json(&url, symbol, "news") self.get_json(&url, symbol, "news")
} }

View file

@ -6,7 +6,7 @@ use super::raw_types::{
use super::symbols::{normalized_symbol, ticker_from_symbol}; use super::symbols::{normalized_symbol, ticker_from_symbol};
use crate::api::types::{ use crate::api::types::{
CompanyProfile, EarningsData, EarningsReport, FinancialStatements, Fundamentals, InsightData, CompanyProfile, EarningsData, EarningsReport, FinancialStatements, Fundamentals, InsightData,
InstrumentInfo, NewsItem, Officer, Ohlc, Quote, SentimentData, SentimentPeriod, InstrumentInfo, NewsItem, NewsRelevance, Officer, Ohlc, Quote, SentimentData, SentimentPeriod,
StatementSection, StatementSection,
}; };
use crate::error::IdxError; use crate::error::IdxError;
@ -551,21 +551,44 @@ pub(super) fn parse_insights(symbol: &str, raw: &[RawInsight]) -> Result<Insight
}) })
} }
pub(super) fn parse_news(symbol: &str, raw: &RawNewsFeed) -> Result<Vec<NewsItem>, IdxError> { /// Parses a stock's news feed, ranks this company's items first and then the
/// rest (general market news), newest first within each, and keeps `limit`.
/// An item is about the company when MSN tags it with the stock's instrument
/// id (`msn_id`), or its title or description names the ticker or one of
/// `names`.
pub(super) fn parse_news(
symbol: &str,
msn_id: &str,
names: &[String],
raw: &RawNewsFeed,
limit: usize,
) -> Result<Vec<NewsItem>, IdxError> {
let source = raw let source = raw
.sub_cards .sub_cards
.as_ref() .as_ref()
.or(raw.value.as_ref()) .or(raw.value.as_ref())
.ok_or_else(|| IdxError::ParseError("no news data".into()))?; .ok_or_else(|| IdxError::ParseError("no news data".into()))?;
let ticker = ticker_from_symbol(symbol).unwrap_or_default();
Ok(source let mut items: Vec<NewsItem> = source
.iter() .iter()
.map(|item| NewsItem { .map(|item| {
let title = item.title.clone().unwrap_or_default();
let description = item.description.clone().unwrap_or_default();
let tagged = item.finance_metadata.as_ref().is_some_and(|meta| {
meta.stocks
.iter()
.any(|stock| stock.stock_id.as_deref() == Some(msn_id))
});
let named = [&title, &description]
.iter()
.any(|text| mentions_company(text, &ticker, names));
NewsItem {
id: item.id.clone().unwrap_or_default(), id: item.id.clone().unwrap_or_default(),
symbol: symbol.to_string(), symbol: symbol.to_string(),
title: item.title.clone().unwrap_or_default(), title,
url: item.url.clone().unwrap_or_default(), url: item.url.clone().unwrap_or_default(),
description: item.description.clone().unwrap_or_default(), description,
provider: item provider: item
.provider .provider
.as_ref() .as_ref()
@ -573,8 +596,65 @@ pub(super) fn parse_news(symbol: &str, raw: &RawNewsFeed) -> Result<Vec<NewsItem
.unwrap_or_default(), .unwrap_or_default(),
published_at: item.published_date_time.clone().unwrap_or_default(), published_at: item.published_date_time.clone().unwrap_or_default(),
read_time_min: item.read_time_min, read_time_min: item.read_time_min,
relevance: if tagged || named {
NewsRelevance::Company
} else {
NewsRelevance::Market
},
}
}) })
.collect()) .collect();
// Compare instants, not strings (fractional seconds and offsets vary);
// missing or unparseable dates sort last.
let instant = |item: &NewsItem| DateTime::parse_from_rfc3339(&item.published_at).ok();
items.sort_by(|a, b| {
a.relevance
.cmp(&b.relevance)
.then_with(|| instant(b).cmp(&instant(a)))
});
items.truncate(limit);
Ok(items)
}
/// Whether `text` names the ticker as a whole word (tickers are written in
/// capitals) or contains one of the company `names`, ignoring case.
fn mentions_company(text: &str, ticker: &str, names: &[String]) -> bool {
let whole_word = |at: usize| {
let before = text[..at].chars().next_back();
let after = text[at + ticker.len()..].chars().next();
!before.is_some_and(char::is_alphanumeric) && !after.is_some_and(char::is_alphanumeric)
};
if !ticker.is_empty() && text.match_indices(ticker).any(|(at, _)| whole_word(at)) {
return true;
}
let text = text.to_lowercase();
names.iter().any(|name| text.contains(&name.to_lowercase()))
}
/// Names an article may use for a company, from MSN's quote names: the short
/// name ("Telkom Indonesia") and the display name without legal suffixes
/// ("Telkom Indonesia (Persero) Tbk PT" -> "Telkom Indonesia"). Abbreviated
/// short names ("Bank Cntrl Asia") never match and are harmless.
pub(super) fn company_names(short_name: Option<&str>, display_name: Option<&str>) -> Vec<String> {
const SUFFIXES: [&str; 3] = ["(persero)", "tbk", "pt"];
let display = display_name.map(|name| {
name.split_whitespace()
.filter(|word| !SUFFIXES.contains(&word.to_lowercase().trim_end_matches('.')))
.collect::<Vec<_>>()
.join(" ")
});
let mut names: Vec<String> = Vec::new();
for name in [short_name.map(str::to_string), display]
.into_iter()
.flatten()
{
let name = name.trim().to_string();
// Short fragments ("PT", "BRI") would match unrelated text.
if name.len() >= 6 && !names.iter().any(|n| n.eq_ignore_ascii_case(&name)) {
names.push(name);
}
}
names
} }
pub(super) fn parse_screener_results(raw: &RawScreenerResponse) -> Result<Vec<Quote>, IdxError> { pub(super) fn parse_screener_results(raw: &RawScreenerResponse) -> Result<Vec<Quote>, IdxError> {
@ -785,9 +865,10 @@ fn collect_earnings(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
KeyRatios, MsnQuote, RawChartResponse, RawFinancialStatement, RawNewsFeed, KeyRatios, MsnQuote, NewsRelevance, RawChartResponse, RawFinancialStatement, RawNewsFeed,
RawScreenerResponse, RawSentiment, parse_financial_statements, parse_fundamentals, RawScreenerResponse, RawSentiment, company_names, parse_financial_statements,
parse_history, parse_news, parse_quote, parse_screener_results, parse_sentiment, parse_fundamentals, parse_history, parse_news, parse_quote, parse_screener_results,
parse_sentiment,
}; };
use crate::error::IdxError; use crate::error::IdxError;
@ -814,7 +895,7 @@ mod tests {
serde_json::from_str(include_str!("../../../tests/fixtures/msn_news_bbca.json")) serde_json::from_str(include_str!("../../../tests/fixtures/msn_news_bbca.json"))
.expect("news fixture should deserialize"); .expect("news fixture should deserialize");
let items = parse_news("BBCA.JK", &raw).expect("news should parse"); let items = parse_news("BBCA.JK", "bn91jc", &[], &raw, 10).expect("news should parse");
assert_eq!(items.len(), 1); assert_eq!(items.len(), 1);
assert_eq!(items[0].id, "news-1"); assert_eq!(items[0].id, "news-1");
@ -823,6 +904,102 @@ mod tests {
assert_eq!(items[0].provider, "Contoso News"); assert_eq!(items[0].provider, "Contoso News");
assert_eq!(items[0].published_at, "2026-03-20T10:00:00Z"); assert_eq!(items[0].published_at, "2026-03-20T10:00:00Z");
assert_eq!(items[0].read_time_min, Some(3)); assert_eq!(items[0].read_time_min, Some(3));
assert_eq!(items[0].relevance, NewsRelevance::Market);
}
fn news_feed(items: serde_json::Value) -> RawNewsFeed {
serde_json::from_value(serde_json::json!({ "value": items })).expect("news feed")
}
#[test]
fn parse_news_ranks_company_items_first_then_newest() {
let raw = news_feed(serde_json::json!([
{"id": "market-new", "title": "IHSG anjlok usai pengumuman MSCI",
"publishedDateTime": "2026-10-16T08:00:00Z",
"financeMetadata": {"stocks": [{"stockId": "a1xz1h"}]}},
{"id": "tagged", "title": "Laba bersih naik",
"publishedDateTime": "2026-10-14T08:00:00Z",
"financeMetadata": {"stocks": [{"stockId": "bn9o8m"}]}},
{"id": "ticker", "title": "Saham TLKM menguat",
"publishedDateTime": "2026-10-15T08:00:00Z"},
{"id": "name", "title": "Dirut baru", "abstract": "Telkom Indonesia menunjuk direksi",
"publishedDateTime": "2026-10-13T08:00:00Z"},
{"id": "market-old", "title": "Rupiah melemah",
"publishedDateTime": "2026-10-12T08:00:00Z"}
]));
let names = vec!["Telkom Indonesia".to_string()];
let items = parse_news("TLKM.JK", "bn9o8m", &names, &raw, 10).expect("news");
let order: Vec<_> = items.iter().map(|i| (i.id.as_str(), i.relevance)).collect();
assert_eq!(
order,
[
("ticker", NewsRelevance::Company),
("tagged", NewsRelevance::Company),
("name", NewsRelevance::Company),
("market-new", NewsRelevance::Market),
("market-old", NewsRelevance::Market),
]
);
let limited = parse_news("TLKM.JK", "bn9o8m", &names, &raw, 2).expect("news");
assert_eq!(limited.len(), 2);
assert_eq!(limited[1].id, "tagged");
}
#[test]
fn parse_news_orders_by_instant_with_undated_items_last() {
let raw = news_feed(serde_json::json!([
{"id": "undated", "title": "No date"},
{"id": "whole", "title": "A", "publishedDateTime": "2026-10-16T10:00:00Z"},
{"id": "fraction", "title": "B", "publishedDateTime": "2026-10-16T10:00:00.500Z"},
{"id": "offset", "title": "C", "publishedDateTime": "2026-10-16T16:30:00+07:00"},
{"id": "garbage", "title": "D", "publishedDateTime": "yesterday"}
]));
let items = parse_news("BBCA.JK", "bn91jc", &[], &raw, 10).expect("news");
let order: Vec<_> = items.iter().map(|i| i.id.as_str()).collect();
// 16:30+07:00 is 09:30Z, the oldest dated item.
assert_eq!(&order[..3], ["fraction", "whole", "offset"]);
assert!(order[3..].contains(&"undated") && order[3..].contains(&"garbage"));
}
#[test]
fn ticker_match_requires_a_whole_word() {
let raw = news_feed(serde_json::json!([
{"id": "a", "title": "BBRI dan BMRI melemah"},
{"id": "b", "title": "Saham XBBRI dan BBRIX"},
{"id": "c", "title": "bbri lowercase is not a ticker"}
]));
let items = parse_news("BBRI.JK", "bn91p2", &[], &raw, 10).expect("news");
let company: Vec<_> = items
.iter()
.filter(|i| i.relevance == NewsRelevance::Company)
.map(|i| i.id.as_str())
.collect();
assert_eq!(company, ["a"]);
}
#[test]
fn company_names_strip_legal_suffixes_and_skip_short_names() {
assert_eq!(
company_names(
Some("Telkom Indonesia"),
Some("Telkom Indonesia (Persero) Tbk PT")
),
["Telkom Indonesia"]
);
assert_eq!(
company_names(
Some("Bank BRI"),
Some("Bank Rakyat Indonesia (Persero) Tbk PT")
),
["Bank BRI", "Bank Rakyat Indonesia"]
);
assert!(company_names(Some("PT"), Some("Tbk PT")).is_empty());
} }
#[test] #[test]

View file

@ -19,9 +19,11 @@ use crate::error::IdxError;
use client::MsnClient; use client::MsnClient;
use map::{ use map::{
parse_earnings, parse_financial_statements, parse_fundamentals, parse_history, parse_insights, company_names, parse_earnings, parse_financial_statements, parse_fundamentals, parse_history,
parse_news, parse_profile, parse_quote, parse_screener_results, parse_sentiment, parse_insights, parse_news, parse_profile, parse_quote, parse_screener_results,
parse_sentiment,
}; };
use symbols::resolve_msn_id;
#[cfg(feature = "testing")] #[cfg(feature = "testing")]
pub(crate) use parse::{parse_fundamentals_from_str, parse_history_from_str, parse_quote_from_str}; pub(crate) use parse::{parse_fundamentals_from_str, parse_history_from_str, parse_quote_from_str};
@ -102,8 +104,19 @@ impl InsightsProvider for MsnProvider {
impl NewsProvider for MsnProvider { impl NewsProvider for MsnProvider {
fn news(&self, symbol: &str, limit: usize) -> Result<Vec<NewsItem>, IdxError> { fn news(&self, symbol: &str, limit: usize) -> Result<Vec<NewsItem>, IdxError> {
let id =
resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
let raw = self.client.fetch_news(symbol, limit)?; let raw = self.client.fetch_news(symbol, limit)?;
parse_news(symbol, &raw) // Company names catch articles MSN did not tag; without them (quote
// unavailable) the ticker and tags still classify.
let names = self
.client
.fetch_quotes(symbol)
.ok()
.and_then(|quotes| quotes.into_iter().next())
.map(|quote| company_names(quote.short_name.as_deref(), quote.display_name.as_deref()))
.unwrap_or_default();
parse_news(symbol, id, &names, &raw, limit)
} }
} }

View file

@ -11,6 +11,8 @@ pub(crate) struct MsnQuote {
pub(crate) symbol: Option<String>, pub(crate) symbol: Option<String>,
#[serde(default)] #[serde(default)]
pub(crate) short_name: Option<String>, pub(crate) short_name: Option<String>,
#[serde(default)]
pub(crate) display_name: Option<String>,
pub(crate) price: Option<f64>, pub(crate) price: Option<f64>,
#[serde(default)] #[serde(default)]
pub(crate) price_change: Option<f64>, pub(crate) price_change: Option<f64>,
@ -281,6 +283,21 @@ pub(super) struct RawNewsItem {
pub(super) provider: Option<RawNewsProvider>, pub(super) provider: Option<RawNewsProvider>,
pub(super) published_date_time: Option<String>, pub(super) published_date_time: Option<String>,
pub(super) read_time_min: Option<i32>, pub(super) read_time_min: Option<i32>,
#[serde(default)]
pub(super) finance_metadata: Option<RawNewsFinanceMetadata>,
}
#[derive(Debug, Deserialize)]
pub(super) struct RawNewsFinanceMetadata {
/// MSN instrument ids the article is tagged with.
#[serde(default)]
pub(super) stocks: Vec<RawNewsStock>,
}
#[derive(Debug, Deserialize)]
pub(super) struct RawNewsStock {
#[serde(rename = "stockId")]
pub(super) stock_id: Option<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]

View file

@ -214,6 +214,27 @@ pub struct NewsItem {
pub provider: String, pub provider: String,
pub published_at: String, pub published_at: String,
pub read_time_min: Option<i32>, pub read_time_min: Option<i32>,
/// Whether the item is about this company or general market news; the
/// provider's feed for a stock can be mostly the latter.
#[serde(default)]
pub relevance: NewsRelevance,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NewsRelevance {
Company,
#[default]
Market,
}
impl NewsRelevance {
pub fn as_str(self) -> &'static str {
match self {
Self::Company => "company",
Self::Market => "market",
}
}
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]

View file

@ -122,7 +122,8 @@ impl Financial<'_> {
policy: CachePolicy, policy: CachePolicy,
) -> Result<Fetched<Vec<NewsItem>>, IdxError> { ) -> Result<Fetched<Vec<NewsItem>>, IdxError> {
let resolved = self.idx.resolve(symbol)?; let resolved = self.idx.resolve(symbol)?;
let key = format!("{resolved}-{limit}"); // "ranked": entries cached before relevance ranking lack the field.
let key = format!("{resolved}-{limit}-ranked");
let provider = self.idx.provider.news_provider(&resolved)?; let provider = self.idx.provider.news_provider(&resolved)?;
let ttl = self.idx.config.quote_ttl; let ttl = self.idx.config.quote_ttl;
let fetched = self.fetch("news", &key, &resolved, ttl, policy, || { let fetched = self.fetch("news", &key, &resolved, ttl, policy, || {

View file

@ -144,9 +144,12 @@ pub struct ChangesParams {
/// Only this ticker's changes. /// Only this ticker's changes.
#[serde(default)] #[serde(default)]
pub ticker: Option<String>, pub ticker: Option<String>,
/// Maximum rows returned (default 200, at most 2000); `total` reports the full count. /// Rows per page (default 200, at most 300); `total` reports the full count.
#[serde(default)] #[serde(default)]
pub limit: Option<usize>, pub limit: Option<usize>,
/// Rows to skip, for the next page: the previous response's `next_offset`.
#[serde(default)]
pub offset: Option<usize>,
} }
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]

View file

@ -2,12 +2,15 @@
//! `Idx` (as one CLI invocation would), so tools share the CLI's on-disk //! `Idx` (as one CLI invocation would), so tools share the CLI's on-disk
//! cache and ownership database. //! cache and ownership database.
use std::collections::BTreeMap;
use idx_core::analysis::fundamental::FundamentalReport; use idx_core::analysis::fundamental::FundamentalReport;
use idx_core::config::CoreConfig; use idx_core::config::CoreConfig;
use idx_core::error::IdxError; use idx_core::error::IdxError;
use idx_core::financial::{EarningsFilter, StatementFilter}; use idx_core::financial::{EarningsFilter, StatementFilter};
use idx_core::investor::{HolderSource, Investor}; use idx_core::investor::{HolderSource, Investor};
use idx_core::market::{ScreenFilter, ScreenQuery, ScreenRegion}; use idx_core::market::{ScreenFilter, ScreenQuery, ScreenRegion};
use idx_core::ownership::types::ChangeRow;
use idx_core::{CachePolicy, Fetched, Idx}; use idx_core::{CachePolicy, Fetched, Idx};
use rmcp::handler::server::router::tool::ToolRouter; use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::wrapper::Parameters; use rmcp::handler::server::wrapper::Parameters;
@ -32,7 +35,9 @@ const CHANGES_LIMIT: usize = 200;
const MAX_SYMBOLS: usize = 20; const MAX_SYMBOLS: usize = 20;
const MAX_SCREEN_LIMIT: usize = 200; const MAX_SCREEN_LIMIT: usize = 200;
const MAX_SEARCH_LIMIT: usize = 100; const MAX_SEARCH_LIMIT: usize = 100;
const MAX_CHANGES_LIMIT: usize = 2000; // Pages above a few hundred rows (~40 KB) overflow some agents' tool output.
const MAX_CHANGES_LIMIT: usize = 300;
const CHANGES_TOP_TICKERS: usize = 10;
/// Tool calls running at once; the rest wait their turn. /// Tool calls running at once; the rest wait their turn.
static CALL_SLOTS: Semaphore = Semaphore::const_new(4); static CALL_SLOTS: Semaphore = Semaphore::const_new(4);
@ -162,8 +167,9 @@ for one or more IDX tickers."
#[tool( #[tool(
description = "Company data for one IDX ticker: profile, financial statements, \ description = "Company data for one IDX ticker: profile, financial statements, \
earnings, analyst sentiment, insights, and news. Sections that fail are listed under \ earnings, analyst sentiment, insights, and news. News items carry `relevance`: \
`errors`; the call fails only if every section fails." `company` (about this ticker) first, then `market` (general market news). Sections \
that fail are listed under `errors`; the call fails only if every section fails."
)] )]
async fn get_company(&self, params: Parameters<CompanyParams>) -> ToolResult { async fn get_company(&self, params: Parameters<CompanyParams>) -> ToolResult {
let CompanyParams { symbol, sections } = params.0; let CompanyParams { symbol, sections } = params.0;
@ -293,8 +299,10 @@ with concentration metrics (top1, top3, HHI, free float)."
} }
#[tool( #[tool(
description = "Holder changes (new, exit, increase, decrease) between two KSEI \ description = "Holder changes (new, exited, increased, decreased) between two KSEI \
release dates, optionally for one ticker." release dates, optionally for one ticker. Rows come largest change first, one page at a time: \
`summary` covers all of them (counts by type, tickers with the most changes), and \
`next_offset` is the `offset` for the next page, or null after the last."
)] )]
async fn get_ownership_changes(&self, params: Parameters<ChangesParams>) -> ToolResult { async fn get_ownership_changes(&self, params: Parameters<ChangesParams>) -> ToolResult {
let ChangesParams { let ChangesParams {
@ -302,13 +310,39 @@ release dates, optionally for one ticker."
to, to,
ticker, ticker,
limit, limit,
offset,
} = params.0; } = params.0;
self.local(move |investor| { self.local(move |investor| {
let limit = bounded_limit("limit", limit, CHANGES_LIMIT, MAX_CHANGES_LIMIT)?; let limit = bounded_limit("limit", limit, CHANGES_LIMIT, MAX_CHANGES_LIMIT).map_err(
|error| match error {
IdxError::InvalidInput(message) => IdxError::InvalidInput(format!(
"{message}; page with `offset`, or use `summary` for an overview"
)),
other => other,
},
)?;
let offset = offset.unwrap_or(0);
let mut changes = investor.changes(&from, &to, ticker.as_deref())?; let mut changes = investor.changes(&from, &to, ticker.as_deref())?;
changes.sort_by(|a, b| {
b.delta_bps
.abs()
.cmp(&a.delta_bps.abs())
.then_with(|| a.ticker_code.cmp(&b.ticker_code))
.then_with(|| a.entity_name.cmp(&b.entity_name))
});
let total = changes.len(); let total = changes.len();
changes.truncate(limit); let summary = changes_summary(&changes);
Ok(json!({ "from": from, "to": to, "total": total, "changes": changes })) let page: Vec<_> = changes.into_iter().skip(offset).take(limit).collect();
let next_offset = (offset + page.len() < total).then_some(offset + page.len());
Ok(json!({
"from": from,
"to": to,
"total": total,
"summary": summary,
"offset": offset,
"next_offset": next_offset,
"changes": page,
}))
}) })
.await .await
} }
@ -454,6 +488,33 @@ fn dedup_in_order<T: PartialEq + Copy>(values: &mut Vec<T>) {
}); });
} }
/// Counts by change type and the tickers with the most changes, so an agent
/// can answer an overview question without paging through every row.
fn changes_summary(changes: &[ChangeRow]) -> Value {
let mut by_type: BTreeMap<String, usize> = BTreeMap::new();
let mut by_ticker: BTreeMap<&str, usize> = BTreeMap::new();
for row in changes {
let kind = serde_json::to_value(row.change_type)
.ok()
.and_then(|value| value.as_str().map(str::to_string))
.unwrap_or_default();
*by_type.entry(kind).or_default() += 1;
*by_ticker.entry(row.ticker_code.as_str()).or_default() += 1;
}
let tickers = by_ticker.len();
let mut top: Vec<_> = by_ticker.into_iter().collect();
top.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
top.truncate(CHANGES_TOP_TICKERS);
json!({
"by_type": by_type,
"tickers": tickers,
"top_tickers": top
.into_iter()
.map(|(ticker, changes)| json!({ "ticker": ticker, "changes": changes }))
.collect::<Vec<_>>(),
})
}
fn bounded_limit( fn bounded_limit(
field: &str, field: &str,
value: Option<usize>, value: Option<usize>,

View file

@ -58,7 +58,7 @@
"name": "compare_stocks" "name": "compare_stocks"
}, },
{ {
"description": "Company data for one IDX ticker: profile, financial statements, earnings, analyst sentiment, insights, and news. Sections that fail are listed under `errors`; the call fails only if every section fails.", "description": "Company data for one IDX ticker: profile, financial statements, earnings, analyst sentiment, insights, and news. News items carry `relevance`: `company` (about this ticker) first, then `market` (general market news). Sections that fail are listed under `errors`; the call fails only if every section fails.",
"inputSchema": { "inputSchema": {
"$defs": { "$defs": {
"CompanySection": { "CompanySection": {
@ -115,7 +115,7 @@
"name": "get_investor_holdings" "name": "get_investor_holdings"
}, },
{ {
"description": "Holder changes (new, exit, increase, decrease) between two KSEI release dates, optionally for one ticker.", "description": "Holder changes (new, exited, increased, decreased) between two KSEI release dates, optionally for one ticker. Rows come largest change first, one page at a time: `summary` covers all of them (counts by type, tickers with the most changes), and `next_offset` is the `offset` for the next page, or null after the last.",
"inputSchema": { "inputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": { "properties": {
@ -125,7 +125,17 @@
}, },
"limit": { "limit": {
"default": null, "default": null,
"description": "Maximum rows returned (default 200, at most 2000); `total` reports the full count.", "description": "Rows per page (default 200, at most 300); `total` reports the full count.",
"format": "uint",
"minimum": 0,
"type": [
"integer",
"null"
]
},
"offset": {
"default": null,
"description": "Rows to skip, for the next page: the previous response's `next_offset`.",
"format": "uint", "format": "uint",
"minimum": 0, "minimum": 0,
"type": [ "type": [

View file

@ -346,6 +346,61 @@ fn ownership_tools_read_the_local_database() {
); );
assert_eq!(limited["total"], total); assert_eq!(limited["total"], total);
assert_eq!(limited["changes"].as_array().unwrap().len(), 1); assert_eq!(limited["changes"].as_array().unwrap().len(), 1);
assert_eq!(limited["next_offset"], 1);
// Pages cover every row exactly once, largest change first.
let mut paged = Vec::new();
let mut offset = Some(0);
while let Some(next) = offset {
let page = client.ok(
"get_ownership_changes",
json!({"from": "2026-01-30", "to": "2026-02-27", "limit": 2, "offset": next}),
);
paged.extend(page["changes"].as_array().unwrap().iter().cloned());
// The summary covers all rows, so every page carries the same one.
assert_eq!(page["summary"], all["summary"], "offset {next}");
offset = page["next_offset"].as_u64();
}
assert_eq!(paged.len() as u64, total);
assert_eq!(&paged, all["changes"].as_array().unwrap());
let deltas: Vec<_> = paged
.iter()
.map(|row| row["delta_bps"].as_i64().unwrap().abs())
.collect();
assert!(
deltas.windows(2).all(|pair| pair[0] >= pair[1]),
"{deltas:?}"
);
let by_type = all["summary"]["by_type"].as_object().unwrap();
assert_eq!(
by_type.values().map(|n| n.as_u64().unwrap()).sum::<u64>(),
total
);
assert!(all["summary"]["tickers"].as_u64().unwrap() >= 1, "{all}");
// Past the end: an empty last page, not an error.
for past in [total, total + 5] {
let page = client.ok(
"get_ownership_changes",
json!({"from": "2026-01-30", "to": "2026-02-27", "offset": past}),
);
assert_eq!(page["total"], total, "offset {past}");
assert_eq!(
page["changes"].as_array().unwrap().len(),
0,
"offset {past}"
);
assert!(page["next_offset"].is_null(), "offset {past}: {page}");
}
let (is_error, body) = client.call(
"get_ownership_changes",
json!({"from": "2026-01-30", "to": "2026-02-27", "limit": 301}),
);
assert!(is_error, "{body}");
assert_eq!(body["code"], "INVALIDINPUT");
assert!(body.to_string().contains("offset"), "{body}");
let one = client.ok( let one = client.ok(
"get_ownership_changes", "get_ownership_changes",

166
docs/IDX_NEWS.md Normal file
View file

@ -0,0 +1,166 @@
# News Sources Research
Research notes on news beyond MSN: IDX's own news and company disclosures,
and other public sources. Nothing here ships yet; `idx stocks news` and the
`get_company` news section use MSN only (see `docs/ARCHITECTURE.md`), and MSN
is enough for now. Measured 2026-10-16 from the maintainer host unless noted.
## Why
MSN's feed for a stock is often general market news. After relevance ranking,
10 items for ADRO, BREN and CUAN held no company items even 50 deep, and BBRI
one. IDX publishes what MSN lacks: every issuer's disclosures (keterbukaan
informasi), such as monthly shareholder registers, financial statements,
material transactions, and replies to IDX queries, plus the exchange's own
news.
## What blocks direct access
`www.idx.co.id` is behind a Cloudflare JavaScript challenge ("Just a
moment..."). Every page and API below returns `403` with that challenge to
plain curl and to `curl_chrome142` (curl-impersonate, the client ownership
discovery uses), from the maintainer host and from a second datacenter
(greencloud-vps, Singapore, AS8888 xTom). A client that cannot run JavaScript
fails the challenge whatever its IP, so a residential network is unlikely to
help curl; whether a real browser passes as easily from other IPs is
untested.
The Camofox stealth browser on the maintainer host (`127.0.0.1:9377`, a local
service, not shipped) gets through:
1. Open a tab on a page that passes the challenge. The ownership page
`/id/perusahaan-tercatat/data-kepemilikan-saham/` passed in ~12 s; the
disclosure page `/id/perusahaan-tercatat/keterbukaan-informasi/` stayed on
"Performing security verification".
2. `POST /tabs/{id}/navigate` the same tab to an API URL; the clearance cookie
carries over.
3. `GET /tabs/{id}/snapshot` returns one line, `- text: "<escaped JSON>"`:
`sed 's/^- text: //' | jq 'fromjson'`.
4. Close the tab. Camofox has no fetch or evaluate endpoint, so JSON comes only
through the snapshot.
## Endpoints
All under `https://www.idx.co.id`, all JSON once past the challenge.
| Endpoint | Returns |
| --- | --- |
| `/primary/ListedCompany/GetAnnouncement?kodeEmiten=ADRO&emitenType=*&indexFrom=0&pageSize=10&dateFrom=19010101&dateTo=20991231&lang=id&keyword=` | one issuer's disclosures, newest first (ADRO 188, BREN 177) |
| `/primary/NewsAnnouncement/GetAllAnnouncement?keywords=&pageNumber=1&pageSize=100&dateFrom=20261016&dateTo=20261016&lang=id` | every issuer's disclosures in a date range (78 on 2026-10-16) |
| `/primary/NewsAnnouncement/GetNewsSearch?pageNumber=1&pageSize=10&keyword=&lang=id` | exchange news behind `/id/berita/berita` and `/en/news/news` (11,390 items) |
Shapes:
- `GetAnnouncement`: `{ResultCount, Replies: [{pengumuman: {Kode_Emiten,
JudulPengumuman, PerihalPengumuman, TglPengumuman, NoPengumuman, ...},
attachments: [{FullSavePath, OriginalFilename, ...}]}]}`. `Kode_Emiten` is
space-padded (`"ADRO "`).
- `GetAllAnnouncement`: `{ItemCount, PageCount, Items: [{Code, Title, Perihal,
PublishDate, AnnouncementNo, Attachments: [{FullSavePath, ...}]}]}`. Attachment
PDFs are under `/StaticData/NewsAndAnnouncement/...`.
- `GetNewsSearch`: `{ItemCount, Items: [{Id, PublishedDate, Title, Summary,
Tags, IsHeadline, ImageUrl, Links: [{Href: "/api/api/publication/news/<Id>"}]}]}`.
Quirks:
- `GetAllAnnouncement` ignores `kodeEmiten` and returns every issuer; filter on
`Code`, or use `GetAnnouncement` per issuer.
- `GetNewsSearch` returned the same `en-us` items for `lang=id` and `lang=en`.
- Guessed paths (`/primary/NewsAnnouncement/GetNews`, `/primary/News/GetNews`)
return `503`, not `404`.
- Exchange news is about IDX itself (meetings, events, regulation), not about
a given company; it suits a market-news section, not per-ticker news.
## Options
| Option | Verdict |
| --- | --- |
| Users fetch IDX directly (curl-impersonate) | Fails the JavaScript challenge; not viable. |
| Each user runs a stealth browser (idx launches one per fetch) | No: a full browser engine per install and per fetch (hundreds of MB, seconds to start), every user facing the challenge on their own, and IDX seeing thousands of scrapers. |
| Hosted API that fetches IDX on request (e.g. a Cloudflare Worker) | Gets the same challenge; needs a browser anyway. |
| **One publisher fetches on a schedule → static JSON → users** | **Recommended.** Same model as the ownership snapshot. |
One browser, in one place, fetches for everyone; users download small static
files:
```text
one place, on a schedule every user
┌──────────────────────────────────────┐ ┌─────────────────────────┐
│ publisher │ │ idx / idx-mcp │
│ stealth browser, ~2 requests/hour │ ───> │ plain HTTPS GET of a │
│ -> normalize -> JSON per ticker │ JSON │ few-KB JSON file, │
│ -> upload to a static host │ │ cached; MSN fallback │
└──────────────────────────────────────┘ └─────────────────────────┘
```
The browser's cost does not grow with users, and IDX sees one polite client.
## Proposed design
**Publisher** (maintainer host with Camofox, on a schedule):
1. Warm a Camofox tab on a page that passes the challenge.
2. Page `GetAllAnnouncement` for the window since the last run (100 per page;
a trading day is ~80) and `GetNewsSearch` for new exchange news.
3. Normalize to a small schema: `{ticker, title, subject, published_at,
number, attachments: [url]}` for disclosures; `{id, title, summary,
published_at, tags, url}` for news. Trim padding, convert WIB timestamps to
RFC 3339 with `+07:00`.
4. Keep a rolling window, e.g. 90 days of disclosures (~6,000 items, a few MB
of JSON; per-ticker shards if size matters) and the latest few hundred news
items.
5. Publish to a static host (to be chosen, see below). Run hourly on trading
days; a freshness check alerts when the feed is stale.
**Hosting** (undecided; the project website may serve it later):
| Host | Fit |
| --- | --- |
| GitHub release asset, like `ownership-snapshot-current` | Proven here; suits one bundle (manifest + content-addressed JSON), less so many per-ticker files overwritten hourly. |
| Cloudflare Pages or R2 (the website is on Pages) | A CDN, per-ticker files, cheap frequent overwrites, cache headers for freshness. |
| A small API (a Worker over R2/KV) | Only for queries static files cannot answer, such as all of today's disclosures matching a keyword. |
**Consumer** (idx-core):
- A provider that fetches the JSON over plain HTTPS (no challenge on the
static host), cached like other providers.
- `stocks news <TICKER>` / `get_company` news: merge the ticker's IDX
disclosures with MSN articles, each item marked with `source: idx | msn`;
disclosures count as `relevance: company`.
- Possibly `idx market news` (and an MCP tool) for exchange news.
- If the feed is unreachable, fall back to MSN only, as today.
## Other news sources
| Source | Finding |
| --- | --- |
| MSN (shipped) | Per-stock entity feed, tagged by MSN instrument id. Strong for large caps (BBCA 10/10 company items); for ADRO, BREN and CUAN no company items even 50 deep. Telkom articles are tagged with its NYSE ADR (`TLK`), not `TLKM`. |
| Google News RSS, `https://news.google.com/rss/search?q=<TICKER>+saham+when:7d&hl=id&gl=ID&ceid=ID:id` | Plain HTTP, no key, no browser; each user can fetch directly. Far better per-company coverage: in 7 days, titles naming the ticker: BREN 75/100, GOTO 75/100, CUAN 54/100, BBRI 37/83, TLKM 17/51, ADRO 14/55. Caught the OJK fine on DSSA, BREN and CUAN that MSN missed. Caveats: mixed source quality (real outlets beside low-quality sites, so an allowlist or ranking), links are Google redirect URLs, the feeds are offered for personal use, and some tickers are words ("CUAN" means profit), so match company names too, as `parse_news` does. |
| Yahoo Finance search (`query1/2.finance.yahoo.com/v1/finance/search?q=<T>.JK&newsCount=10`) | `429` from the maintainer host on both hosts; not usable as is. |
| Publisher RSS (Kontan, Bisnis, CNBC Indonesia, Katadata) | Not tested; whole-site feeds, so ticker filtering would be ours. |
If MSN falls short, Google News RSS is the cheapest upgrade: no
infrastructure, and it fits the existing `relevance` labeling. IDX
disclosures are the authoritative source but need the publisher above.
## Camofox notes
- It is a shared local service (`127.0.0.1:9377`), not something to ship; it
has no per-tab proxy setting, so testing other egress IPs means a separate
browser on that host.
- JSON comes only through the accessibility snapshot. A raw-content or
evaluate endpoint would make a publisher sturdier.
- Use your own `userId`, close your own tabs, never `POST /stop`.
## Open questions
- IDX terms of use for automated access and redistributing disclosure
metadata; IDX also sells official data feeds, the legitimate source if
scraping is not allowed. Links to IDX-hosted PDFs rather than copies keep the
footprint small.
- Where the publisher runs (this host with Camofox, or a VPS with its own
stealth browser), and where its files are hosted.
- Publisher reliability: one browser process; the challenge can change. The
freshness check and MSN fallback bound the impact.
- Whether `GetAnnouncement` per issuer is needed for backfill beyond the
rolling window.

View file

@ -11,7 +11,7 @@ Each release also carries a copy of the script as a release asset, so a
versioned URL stays stable: versioned URL stays stable:
```bash ```bash
curl -fsSL https://github.com/0xrsydn/idx-cli/releases/download/v0.3.1/install.sh | sh curl -fsSL https://github.com/0xrsydn/idx-cli/releases/download/v0.3.2/install.sh | sh
``` ```
## What it does ## What it does
@ -45,7 +45,7 @@ them yourself.
Pass flags through the pipe with `sh -s --`: Pass flags through the pipe with `sh -s --`:
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/0xrsydn/idx-cli/main/install.sh | sh -s -- --version v0.3.1 --dir ~/bin curl -fsSL https://raw.githubusercontent.com/0xrsydn/idx-cli/main/install.sh | sh -s -- --version v0.3.2 --dir ~/bin
``` ```
## Supported platforms ## Supported platforms

View file

@ -38,7 +38,7 @@ npx idx-cli --help
``` ```
The package version and the release tag must match. For example, package The package version and the release tag must match. For example, package
version `0.3.1` downloads assets from the `v0.3.1` GitHub Release. version `0.3.2` downloads assets from the `v0.3.2` GitHub Release.
The postinstall script downloads `SHA256SUMS` from the same release and The postinstall script downloads `SHA256SUMS` from the same release and
refuses to install a binary whose checksum does not match. refuses to install a binary whose checksum does not match.
@ -52,6 +52,15 @@ itself on first start, logging to stderr so the `idx-mcp` stdout channel stays
clean; it fails only when that download fails too. Hermes Agent runs `npx` with clean; it fails only when that download fails too. Hermes Agent runs `npx` with
its own npm 12, which is how 0.3.1 broke there (issue #41). its own npm 12, which is how 0.3.1 broke there (issue #41).
The binary goes in the package's `bin/`, or, when that is not writable (a
root-owned `sudo npm install -g` whose postinstall npm 12 skipped), in a
per-user cache: `$XDG_CACHE_HOME` (if absolute), else `~/.cache`
(`~/Library/Caches` on macOS), under `<package>/<version>/<platform>-<arch>/`.
The launcher looks in both. With no absolute cache root (an empty or relative
`HOME`) there is no fallback, so nothing under the current directory is run. A
termination signal during that first-run download stops the installer too,
and it removes its partial file.
To install the binary up front instead, allow the script explicitly: To install the binary up front instead, allow the script explicitly:
```bash ```bash
@ -70,7 +79,8 @@ npm install --global --allow-scripts=idx-cli idx-cli
The smoke script builds or reuses `target/release/idx`, packs the npm wrapper, The smoke script builds or reuses `target/release/idx`, packs the npm wrapper,
installs the tarball into a new temporary directory, and runs the installed installs the tarball into a new temporary directory, and runs the installed
`idx` command, then installs `idx-mcp` again with `--ignore-scripts` to check `idx` command, then installs `idx-mcp` again with `--ignore-scripts` to check
the launcher's first-run install. It uses `IDX_BINARY_URL` with a local `file:` URL, so the test the launcher's first-run install, also with a read-only package directory
(the per-user cache fallback). It uses `IDX_BINARY_URL` with a local `file:` URL, so the test
does not need a registry publish or a network download. does not need a registry publish or a network download.
```bash ```bash
@ -95,7 +105,7 @@ URLs must stay aligned with `scripts/install.js`.
To attach binaries to a tag that already exists, run the workflow manually: To attach binaries to a tag that already exists, run the workflow manually:
```bash ```bash
gh workflow run release.yml -f tag=v0.3.1 gh workflow run release.yml -f tag=v0.3.2
``` ```
Pull requests that touch the workflow or the installer run the build jobs Pull requests that touch the workflow or the installer run the build jobs
@ -109,7 +119,14 @@ Release order:
2. Push the tag, or run the workflow for an existing tag. 2. Push the tag, or run the workflow for an existing tag.
3. Publish the crates from the tagged commit, dependency first: 3. Publish the crates from the tagged commit, dependency first:
`cargo publish -p idx-core`, then `-p idx-cli` and `-p idx-mcp`. `cargo publish -p idx-core`, then `-p idx-cli` and `-p idx-mcp`.
4. Confirm the release has all eight binaries plus `SHA256SUMS`. 4. Confirm the release has all eight binaries plus `SHA256SUMS`, then write
its notes (`gh release edit vX.Y.Z --notes-file <file>`; the workflow
creates the release without them). Open with a one-line upgrade note when
a fix is urgent, then `## New Features`, `## Bug Fixes`, `## Improvements`
(and `## Testing` or `## Docs` when useful): one user-facing sentence per
item, ending with the PR or issue numbers, e.g. `(#46)`. Omit empty
sections, and end with
`**Full Changelog**: https://github.com/0xrsydn/idx-cli/compare/<previous>...<this>`.
5. Run `npm publish` in the repo root and in `npm/idx-mcp/` from the tagged 5. Run `npm publish` in the repo root and in `npm/idx-mcp/` from the tagged
commit. The account has two-factor auth, so npm asks for a browser commit. The account has two-factor auth, so npm asks for a browser
approval or `--otp=<code>`. approval or `--otp=<code>`.

View file

@ -106,13 +106,22 @@ each run keeps a copy next to its output as `<case>.<run>.mcp-stderr`.
output but still deletes every copied agent config. output but still deletes every copied agent config.
- Tools are auto-approved for the run (Claude `dontAsk` with - Tools are auto-approved for the run (Claude `dontAsk` with
`mcp__idx__*`, Codex `default_tools_approval_mode="approve"`); Claude and pi `mcp__idx__*`, Codex `default_tools_approval_mode="approve"`); Claude and pi
also get no built-in tools, and Hermes only the `idx` toolset, so they also get no built-in tools, Hermes only the `idx` toolset, and opencode
can only answer through MCP. permissions that deny its shell, file, and web tools, so they can only
answer through MCP.
- opencode 1 (`opencode-ai`) and 2 (`@opencode/cli`) are both supported; the
harness reads `opencode --version` and writes the matching config. Version
2 runs with `--standalone`, off the shared background service. A wrapper
that pins `XDG_*` paths (such as a Nix `opencode2` wrapper) defeats the
isolated config dirs; put the plain binary first on PATH.
- `IDX_AGENT_MODEL_<AGENT>` picks a model (for example - `IDX_AGENT_MODEL_<AGENT>` picks a model (for example
`IDX_AGENT_MODEL_CLAUDE=sonnet`). `IDX_AGENT_EXEC_PREFIX` prefixes every `IDX_AGENT_MODEL_CLAUDE=sonnet`). `IDX_AGENT_EXEC_PREFIX` prefixes every
agent command, for hosts that inject API keys through a wrapper. agent command, for hosts that inject API keys through a wrapper.
- With `--live` (and `--npx`), a case's `live` fields replace its mock ones,
for prompts that name data only the fixtures have, such as release dates.
- The run fails only when an agent fails a case in every run; use `--runs` to - The run fails only when an agent fails a case in every run; use `--runs` to
measure pass rates. `--keep` keeps raw agent output and `results.jsonl`. measure pass rates. `--keep` keeps raw agent output and `results.jsonl`.
- `nix develop .#agents` adds Claude Code, Codex, and opencode from nixpkgs - `nix develop .#agents` adds Claude Code, Codex, and opencode from nixpkgs
after your PATH, so installed versions win. pi and Hermes Agent must be after your PATH, so installed versions win. pi and Hermes Agent must be
installed on the host. installed on the host. The nixpkgs opencode (1.2.17) lacks `run --pure`;
use a current opencode 1 or 2.

View file

@ -1,6 +1,6 @@
{ {
"name": "idx-mcp", "name": "idx-mcp",
"version": "0.3.1", "version": "0.3.2",
"description": "MCP server for Indonesian stock market (IDX) market, financial, and ownership data", "description": "MCP server for Indonesian stock market (IDX) market, financial, and ownership data",
"license": "MIT", "license": "MIT",
"repository": { "repository": {

View file

@ -1,6 +1,6 @@
{ {
"name": "idx-cli", "name": "idx-cli",
"version": "0.3.1", "version": "0.3.2",
"description": "CLI tool for Indonesian stock market (IDX) analysis", "description": "CLI tool for Indonesian stock market (IDX) analysis",
"license": "MIT", "license": "MIT",
"repository": { "repository": {

View file

@ -3,51 +3,93 @@
const fs = require("node:fs"); const fs = require("node:fs");
const path = require("node:path"); const path = require("node:path");
const { spawn, spawnSync } = require("node:child_process"); const { spawn } = require("node:child_process");
const packageManifest = JSON.parse( const packageManifest = JSON.parse(
fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8"), fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8"),
); );
const nativeBinary = packageManifest.nativeBinary || "idx";
const packageName = packageManifest.name; const packageName = packageManifest.name;
const binaryName = process.platform === "win32" ? `${nativeBinary}.exe` : nativeBinary; const installer = path.join(__dirname, "install.js");
const binaryPath = path.join(__dirname, "..", "bin", binaryName); // Requiring the installer only reads its paths; it installs when run directly.
const { binaryLocations } = require(installer);
// postinstall never ran (ignore-scripts) or its download failed: install now. const findBinary = () => binaryLocations.find((candidate) => fs.existsSync(candidate));
// Its log goes to stderr, since stdout is the MCP channel for idx-mcp.
if (!fs.existsSync(binaryPath)) { // The running child: the first-run installer, then the binary.
console.error(`${packageName}: binary not installed; running the installer`); let child = null;
spawnSync(process.execPath, [path.join(__dirname, "install.js")], { // A termination signal received while installing; the installer cleans up
stdio: ["ignore", 2, 2], // and exits, and the launcher then stops the same way.
let stopping = null;
let installing = false;
// Pass termination signals from a supervisor (kill, docker stop) to the child.
for (const signal of ["SIGTERM", "SIGHUP"]) {
process.on(signal, () => {
stopping = signal;
if (child) {
child.kill(signal);
} else {
process.exit(1);
}
}); });
} }
// Ctrl+C already reaches the binary through the terminal's process group, so
if (!fs.existsSync(binaryPath)) { // the launcher ignores SIGINT then. During the first-run install, forward it
console.error(`${packageName}: installed binary not found at ${binaryPath}`); // too: a supervisor may signal only the launcher's PID.
console.error(`${packageName}: rerun npm install or set IDX_BINARY_URL for a local binary`); process.on("SIGINT", () => {
process.exit(1); if (installing) {
} stopping = "SIGINT";
child.kill("SIGINT");
const child = spawn(binaryPath, process.argv.slice(2), { stdio: "inherit" }); }
// Pass termination signals from a supervisor (kill, docker stop) to the binary.
for (const signal of ["SIGTERM", "SIGHUP"]) {
process.on(signal, () => child.kill(signal));
}
// Ctrl+C already reaches the binary through the terminal's process group.
process.on("SIGINT", () => {});
child.once("error", (error) => {
console.error(`${packageName}: failed to start ${binaryPath}: ${error.message}`);
process.exit(1);
}); });
child.once("exit", (code, signal) => { function exitLike(code, signal) {
if (signal) { if (signal) {
process.removeAllListeners(signal); process.removeAllListeners(signal);
process.kill(process.pid, signal); process.kill(process.pid, signal);
return; return;
} }
process.exit(code === null ? 1 : code); process.exit(code === null ? 1 : code);
}); }
function run(binaryPath) {
child = spawn(binaryPath, process.argv.slice(2), { stdio: "inherit" });
child.once("error", (error) => {
console.error(`${packageName}: failed to start ${binaryPath}: ${error.message}`);
process.exit(1);
});
child.once("exit", exitLike);
}
const installed = findBinary();
if (installed) {
run(installed);
} else {
// postinstall never ran (npm 12 `allow-scripts`, pnpm, bun, --ignore-scripts)
// or its download failed: install now. Its log goes to stderr, since stdout
// is the MCP channel for idx-mcp.
console.error(`${packageName}: binary not installed; running the installer`);
installing = true;
child = spawn(process.execPath, [installer], { stdio: ["ignore", 2, 2] });
child.once("error", (error) => {
console.error(`${packageName}: failed to run the installer: ${error.message}`);
process.exit(1);
});
child.once("exit", (code, signal) => {
child = null;
installing = false;
if (signal || stopping) {
exitLike(code, signal || stopping);
return;
}
const binaryPath = findBinary();
if (!binaryPath) {
console.error(`${packageName}: no installed binary at ${binaryLocations.join(" or ")}`);
console.error(
`${packageName}: check access to github.com and rerun, or set IDX_BINARY_URL to a local binary`,
);
process.exit(1);
}
run(binaryPath);
});
}

View file

@ -5,6 +5,7 @@ const crypto = require("node:crypto");
const fs = require("node:fs"); const fs = require("node:fs");
const http = require("node:http"); const http = require("node:http");
const https = require("node:https"); const https = require("node:https");
const os = require("node:os");
const path = require("node:path"); const path = require("node:path");
const { pipeline } = require("node:stream"); const { pipeline } = require("node:stream");
const { URL, fileURLToPath } = require("node:url"); const { URL, fileURLToPath } = require("node:url");
@ -18,8 +19,42 @@ const packageManifest = JSON.parse(
const nativeBinary = packageManifest.nativeBinary || "idx"; const nativeBinary = packageManifest.nativeBinary || "idx";
const packageName = packageManifest.name; const packageName = packageManifest.name;
const binaryName = process.platform === "win32" ? `${nativeBinary}.exe` : nativeBinary; const binaryName = process.platform === "win32" ? `${nativeBinary}.exe` : nativeBinary;
const binaryDirectory = path.join(packageRoot, "bin"); // The binary goes in the package's own bin/, or, when that is not writable
const binaryPath = path.join(binaryDirectory, binaryName); // (a root-owned global install whose postinstall npm 12 skipped), in a
// per-user cache. scripts/idx.js looks in both, in this order.
const packageBinaryPath = path.join(packageRoot, "bin", binaryName);
// The cache root must be absolute: a relative one (an XDG_CACHE_HOME that the
// XDG spec calls invalid, or an empty or relative HOME) would make the
// launcher run a binary from the current directory. Without one there is no
// cache fallback.
function cacheRoot() {
const xdgCacheHome = process.env.XDG_CACHE_HOME;
if (xdgCacheHome && path.isAbsolute(xdgCacheHome)) {
return xdgCacheHome;
}
const home = os.homedir();
if (!home || !path.isAbsolute(home)) {
return null;
}
return process.platform === "darwin"
? path.join(home, "Library", "Caches")
: path.join(home, ".cache");
}
const cacheDirectory = cacheRoot();
const binaryLocations = [packageBinaryPath];
if (cacheDirectory) {
binaryLocations.push(
path.join(
cacheDirectory,
packageName,
packageManifest.version,
// A home directory shared across machines must not mix architectures.
`${process.platform}-${process.arch}`,
binaryName,
),
);
}
let binaryPath = packageBinaryPath;
const githubRepository = "0xrsydn/idx-cli"; const githubRepository = "0xrsydn/idx-cli";
const maxRedirects = 5; const maxRedirects = 5;
const requestTimeoutMs = 60_000; const requestTimeoutMs = 60_000;
@ -65,15 +100,43 @@ function releaseAssetUrl(assetName) {
return `https://github.com/${githubRepository}/releases/download/v${packageManifest.version}/${assetName}`; return `https://github.com/${githubRepository}/releases/download/v${packageManifest.version}/${assetName}`;
} }
// Picks the first writable location and points binaryPath at it.
function ensureBinaryDirectory() { function ensureBinaryDirectory() {
fs.mkdirSync(binaryDirectory, { recursive: true }); for (const candidate of binaryLocations) {
const directory = path.dirname(candidate);
try {
fs.mkdirSync(directory, { recursive: true });
fs.accessSync(directory, fs.constants.W_OK);
binaryPath = candidate;
return;
} catch (error) {
if (!["EACCES", "EPERM", "EROFS"].includes(error.code)) {
throw error;
}
}
}
throw new Error(`no writable location for the binary: ${binaryLocations.join(", ")}`);
} }
let activeTemporaryPath = null;
function removeActiveTemporary() {
if (activeTemporaryPath && fs.existsSync(activeTemporaryPath)) {
fs.unlinkSync(activeTemporaryPath);
}
activeTemporaryPath = null;
}
// Each install writes its own temporary file and renames it into place, so
// concurrent first runs never expose a partial binary.
function temporaryBinaryPath() { function temporaryBinaryPath() {
return `${binaryPath}.${process.pid}.tmp`; activeTemporaryPath = `${binaryPath}.${process.pid}.tmp`;
return activeTemporaryPath;
} }
function replaceWithLocalBinary(sourcePath) { // Verifies the copy rather than the source, so the installed bytes are the
// checked ones even if the source changes meanwhile.
function replaceWithLocalBinary(sourcePath, expectedSha256) {
const resolvedSource = path.resolve(sourcePath); const resolvedSource = path.resolve(sourcePath);
const sourceStat = fs.statSync(resolvedSource, { throwIfNoEntry: false }); const sourceStat = fs.statSync(resolvedSource, { throwIfNoEntry: false });
if (!sourceStat || !sourceStat.isFile()) { if (!sourceStat || !sourceStat.isFile()) {
@ -83,12 +146,13 @@ function replaceWithLocalBinary(sourcePath) {
const temporaryPath = temporaryBinaryPath(); const temporaryPath = temporaryBinaryPath();
try { try {
fs.copyFileSync(resolvedSource, temporaryPath); fs.copyFileSync(resolvedSource, temporaryPath);
if (expectedSha256) {
verifyChecksum(temporaryPath, expectedSha256, resolvedSource);
}
fs.chmodSync(temporaryPath, 0o755); fs.chmodSync(temporaryPath, 0o755);
fs.renameSync(temporaryPath, binaryPath); fs.renameSync(temporaryPath, binaryPath);
} finally { } finally {
if (fs.existsSync(temporaryPath)) { removeActiveTemporary();
fs.unlinkSync(temporaryPath);
}
} }
} }
@ -199,9 +263,7 @@ async function replaceWithDownloadedBinary(url, expectedSha256) {
fs.chmodSync(temporaryPath, 0o755); fs.chmodSync(temporaryPath, 0o755);
fs.renameSync(temporaryPath, binaryPath); fs.renameSync(temporaryPath, binaryPath);
} finally { } finally {
if (fs.existsSync(temporaryPath)) { removeActiveTemporary();
fs.unlinkSync(temporaryPath);
}
} }
} }
@ -241,10 +303,7 @@ async function main() {
if (override) { if (override) {
const localPath = localPathFromOverride(override); const localPath = localPathFromOverride(override);
if (localPath) { if (localPath) {
if (overrideSha256) { replaceWithLocalBinary(localPath, overrideSha256);
verifyChecksum(localPath, overrideSha256, localPath);
}
replaceWithLocalBinary(localPath);
console.log(`${packageName}: installed local binary from ${path.resolve(localPath)}`); console.log(`${packageName}: installed local binary from ${path.resolve(localPath)}`);
return; return;
} }
@ -260,7 +319,19 @@ async function main() {
console.log(`${packageName}: installed binary at ${binaryPath}`); console.log(`${packageName}: installed binary at ${binaryPath}`);
} }
main().catch((error) => { module.exports = { binaryLocations };
if (require.main === module) {
// A supervisor stopping the launcher mid-download stops this too; leave no
// temporary file behind.
for (const [signal, number] of [["SIGTERM", 15], ["SIGHUP", 1], ["SIGINT", 2]]) {
process.on(signal, () => {
removeActiveTemporary();
process.exit(128 + number);
});
}
main().catch((error) => {
console.error(`${packageName}: ${error.message}`); console.error(`${packageName}: ${error.message}`);
process.exit(1); process.exit(1);
}); });
}

View file

@ -88,11 +88,43 @@ echo "npm smoke: installing idx-mcp with --ignore-scripts"
no_scripts_dir="${smoke_dir}/idx-mcp-no-scripts" no_scripts_dir="${smoke_dir}/idx-mcp-no-scripts"
npm_config_cache="$npm_cache" npm install --prefix "$no_scripts_dir" "${smoke_dir}"/idx-mcp/idx-mcp-*.tgz \ npm_config_cache="$npm_cache" npm install --prefix "$no_scripts_dir" "${smoke_dir}"/idx-mcp/idx-mcp-*.tgz \
--no-save --no-package-lock --no-audit --no-fund --ignore-scripts --no-save --no-package-lock --no-audit --no-fund --ignore-scripts
IDX_BINARY_URL="${repo_root}/target/release/idx-mcp" \ # An empty user cache, so a binary cached by an earlier run cannot stand in
# for the first-run install.
XDG_CACHE_HOME="${smoke_dir}/no-scripts-cache" IDX_BINARY_URL="${repo_root}/target/release/idx-mcp" \
"${no_scripts_dir}/node_modules/.bin/idx-mcp" --help > "${smoke_dir}/no-scripts-help.txt" "${no_scripts_dir}/node_modules/.bin/idx-mcp" --help > "${smoke_dir}/no-scripts-help.txt"
if ! head -n 1 "${smoke_dir}/no-scripts-help.txt" | grep -q "MCP server"; then if ! head -n 1 "${smoke_dir}/no-scripts-help.txt" | grep -q "MCP server" \
|| [[ ! -x "${no_scripts_dir}/node_modules/idx-mcp/bin/idx-mcp" ]]; then
echo "npm smoke: idx-mcp without postinstall did not start cleanly:" >&2 echo "npm smoke: idx-mcp without postinstall did not start cleanly:" >&2
cat "${smoke_dir}/no-scripts-help.txt" >&2 cat "${smoke_dir}/no-scripts-help.txt" >&2
exit 1 exit 1
fi fi
echo "npm smoke: PASS (idx-cli and idx-mcp: pack, install, --help; idx-mcp without postinstall)" # A read-only package (root-owned global install) falls back to the user cache.
echo "npm smoke: idx-mcp without postinstall, read-only package directory"
read_only_dir="${smoke_dir}/idx-mcp-read-only"
npm_config_cache="$npm_cache" npm install --prefix "$read_only_dir" "${smoke_dir}"/idx-mcp/idx-mcp-*.tgz \
--no-save --no-package-lock --no-audit --no-fund --ignore-scripts
chmod -R a-w "${read_only_dir}/node_modules/idx-mcp"
XDG_CACHE_HOME="${smoke_dir}/user-cache" IDX_BINARY_URL="${repo_root}/target/release/idx-mcp" \
"${read_only_dir}/node_modules/.bin/idx-mcp" --help > "${smoke_dir}/read-only-help.txt"
# Without an absolute cache root (empty HOME) there is no cache fallback, so a
# binary planted under the current directory is never run.
planted_dir="${smoke_dir}/planted"
planted_rel="idx-mcp/$(node -p 'require("./npm/idx-mcp/package.json").version')/$(node -p 'process.platform + "-" + process.arch')/idx-mcp"
# Both cache layouts: ~/.cache (Linux) and ~/Library/Caches (macOS).
for planted in "${planted_dir}/.cache/${planted_rel}" "${planted_dir}/Library/Caches/${planted_rel}"; do
mkdir -p "$(dirname "$planted")"
printf '#!/bin/sh\necho PLANTED\n' > "$planted"
chmod +x "$planted"
done
if (cd "$planted_dir" && HOME='' XDG_CACHE_HOME='' "${read_only_dir}/node_modules/.bin/idx-mcp" --help 2>&1) | grep -q PLANTED; then
echo "npm smoke: the launcher ran a binary from the current directory" >&2
exit 1
fi
chmod -R u+w "${read_only_dir}/node_modules/idx-mcp"
cached=("${smoke_dir}"/user-cache/idx-mcp/*/*/idx-mcp)
if ! head -n 1 "${smoke_dir}/read-only-help.txt" | grep -q "MCP server" || [[ ! -x "${cached[0]}" ]]; then
echo "npm smoke: idx-mcp with a read-only package did not install to the user cache:" >&2
cat "${smoke_dir}/read-only-help.txt" >&2
exit 1
fi
echo "npm smoke: PASS (idx-cli and idx-mcp: pack, install, --help; idx-mcp without postinstall, also read-only)"

View file

@ -111,8 +111,12 @@ data_env=(
"XDG_CACHE_HOME=$work/idx/cache" "XDG_CACHE_HOME=$work/idx/cache"
"XDG_DATA_HOME=$work/idx/data" "XDG_DATA_HOME=$work/idx/data"
) )
latest_release='' previous_release=''
if [[ "$live" == 1 ]]; then if [[ "$live" == 1 ]]; then
env "${data_env[@]}" "$idx" -o json ownership sync >/dev/null env "${data_env[@]}" "$idx" -o json ownership sync >/dev/null
# Live cases name releases as {latest_release} and {previous_release}.
mapfile -t releases < <(env "${data_env[@]}" "$idx" -o json ownership releases | jq -r '.[].as_of_date')
latest_release="${releases[0]:-}" previous_release="${releases[1]:-}"
else else
data_env+=(IDX_USE_MOCK_PROVIDER=1) data_env+=(IDX_USE_MOCK_PROVIDER=1)
fixtures="$repo_root/crates/idx-core/tests/fixtures" fixtures="$repo_root/crates/idx-core/tests/fixtures"
@ -128,15 +132,17 @@ fi
# One server definition for every agent, as {command, args, env}. # One server definition for every agent, as {command, args, env}.
if [[ -n "$npx_spec" ]]; then if [[ -n "$npx_spec" ]]; then
# What users configure, so each agent's own npx handling is exercised # What users configure, so each agent's own npx handling is exercised
# (Hermes swaps in its managed Node). A fresh npm cache makes the first # (Hermes swaps in its managed Node). Each agent gets a fresh npm cache
# agent run a cold install; agents may filter env, so pass it explicitly. # below, so its first run is a cold install.
export npm_config_cache="$work/npm-cache" server_env=("${data_env[@]}")
server_env=("${data_env[@]}" "npm_config_cache=$npm_config_cache")
[[ -z "${IDX_BINARY_URL:-}" ]] || server_env+=("IDX_BINARY_URL=$IDX_BINARY_URL") [[ -z "${IDX_BINARY_URL:-}" ]] || server_env+=("IDX_BINARY_URL=$IDX_BINARY_URL")
# npx runs a tarball only as a package to install: `npx -y -p file:<tgz> idx-mcp`. # npx runs a tarball only as a package to install: `npx -y -p file:<tgz> idx-mcp`.
if [[ "$npx_spec" == *.tgz && ! -f "$npx_spec" ]]; then if [[ "$npx_spec" == *.tgz && ! -f "$npx_spec" ]]; then
echo "agent-smoke: tarball not found: $npx_spec" >&2; exit 2 echo "agent-smoke: tarball not found: $npx_spec" >&2; exit 2
fi fi
# In the dev shell target/debug is on PATH; drop it so a missing package
# bin cannot fall through to the local build.
PATH="$(tr ':' '\n' <<< "$PATH" | grep -vxF "$repo_root/target/debug" | paste -sd: -)"
npx_args=(-y "$npx_spec") npx_args=(-y "$npx_spec")
[[ ! -f "$npx_spec" ]] || npx_args=(-y -p "file:$(realpath "$npx_spec")" idx-mcp) [[ ! -f "$npx_spec" ]] || npx_args=(-y -p "file:$(realpath "$npx_spec")" idx-mcp)
server_json="$(jq -nc --argjson args "$(printf '%s\n' "${npx_args[@]}" | jq -Rnc '[inputs]')" '{command: "npx", args: $args, server_json="$(jq -nc --argjson args "$(printf '%s\n' "${npx_args[@]}" | jq -Rnc '[inputs]')" '{command: "npx", args: $args,
@ -227,7 +233,8 @@ setup_hermes() {
# Model providers can be user plugins. A symlink: cleanup removes only the link. # Model providers can be user plugins. A symlink: cleanup removes only the link.
[[ ! -d "$src/plugins" ]] || ln -s "$src/plugins" "$work/hermes/plugins" [[ ! -d "$src/plugins" ]] || ln -s "$src/plugins" "$work/hermes/plugins"
local server local server
server="$(jq -c '. + {enabled: true}' <<< "$server_json")" # A cold `npx` install can outlast the default connect timeout.
server="$(jq -c '. + {enabled: true, connect_timeout: 300}' <<< "$server_json")"
if jq -e 'type == "object"' "$src/config.yaml" >/dev/null 2>&1; then if jq -e 'type == "object"' "$src/config.yaml" >/dev/null 2>&1; then
# Hermes also writes config.yaml as JSON (e.g. in profiles). # Hermes also writes config.yaml as JSON (e.g. in profiles).
jq --argjson s "$server" '{model, mcp_servers: {idx: $s}} | del(.. | nulls)' \ jq --argjson s "$server" '{model, mcp_servers: {idx: $s}} | del(.. | nulls)' \
@ -241,11 +248,15 @@ setup_hermes() {
} > "$work/hermes/config.yaml" } > "$work/hermes/config.yaml"
fi fi
# A bare `npx` runs on Hermes' own Node, fetched into a fresh HERMES_HOME # A bare `npx` runs on Hermes' own Node, fetched into a fresh HERMES_HOME
# on first use (~20s); `hermes -z` waits only ~15s for MCP tools, so fetch # on first use (~20s), and the cold package install and binary download
# it first or the first case runs without them. # come on top. `hermes -z` waits only ~15s for MCP tools whatever
# connect_timeout says, so fetch Node and install the server first: `mcp
# test` starts it through Hermes' own npx and waits for connect_timeout.
if [[ -n "$npx_spec" ]]; then if [[ -n "$npx_spec" ]]; then
HERMES_HOME="$work/hermes" "${exec_prefix[@]}" hermes pm install npm >/dev/null \ HERMES_HOME="$work/hermes" "${exec_prefix[@]}" hermes pm install npm >/dev/null \
|| { echo "agent-smoke: hermes pm install npm failed" >&2; exit 2; } || { echo "agent-smoke: hermes pm install npm failed" >&2; exit 2; }
HERMES_HOME="$work/hermes" "${exec_prefix[@]}" hermes mcp test idx < /dev/null > "$work/hermes-warmup.log" 2>&1 \
|| echo "agent-smoke: hermes mcp test idx failed (see $work/hermes-warmup.log)" >&2
fi fi
} }
run_hermes() { run_hermes() {
@ -274,18 +285,39 @@ run_hermes() {
} }
setup_opencode() { setup_opencode() {
local version
mkdir -p "$work/opencode/cwd" mkdir -p "$work/opencode/cwd"
version="$("${exec_prefix[@]}" opencode --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1)"
if [[ "${version%%.*}" -ge 2 ]]; then
# OpenCode 2 (@opencode/cli). A denied call ends the run, and models
# use Code Mode's `execute` while the server connects, so deny only
# the tools that could route around it. --standalone keeps the run
# off the shared background service.
jq -n --argjson s "$server_json" \
'{mcp: {servers: {idx: {type: "local", command: ([$s.command] + $s.args),
environment: $s.env, codemode: false}}},
permissions: ((["read", "edit", "glob", "grep", "shell", "subagent", "skill",
"question", "webfetch", "websearch", "external_directory"]
| map({action: ., resource: "*", effect: "deny"}))
+ [{action: "idx_*", resource: "*", effect: "allow"}])}' \
> "$work/opencode/cwd/opencode.json"
echo --standalone > "$work/opencode/run-flag"
else
jq -n --argjson s "$server_json" \ jq -n --argjson s "$server_json" \
'{"$schema": "https://opencode.ai/config.json", '{"$schema": "https://opencode.ai/config.json",
mcp: {idx: {type: "local", command: ([$s.command] + $s.args), environment: $s.env, enabled: true}}}' \ mcp: {idx: {type: "local", command: ([$s.command] + $s.args), environment: $s.env, enabled: true}},
permission: {"*": "deny", "idx_*": "allow"}}' \
> "$work/opencode/cwd/opencode.json" > "$work/opencode/cwd/opencode.json"
echo --pure > "$work/opencode/run-flag"
fi
} }
run_opencode() { run_opencode() {
local prompt="$1" out="$2" model=() home="$work/opencode" local prompt="$1" out="$2" model=() home="$work/opencode" flag
[[ -n "${IDX_AGENT_MODEL_OPENCODE:-}" ]] && model=(-m "$IDX_AGENT_MODEL_OPENCODE") [[ -n "${IDX_AGENT_MODEL_OPENCODE:-}" ]] && model=(-m "$IDX_AGENT_MODEL_OPENCODE")
flag="$(< "$home/run-flag")"
(cd "$home/cwd" && XDG_CONFIG_HOME="$home/config" XDG_DATA_HOME="$home/data" \ (cd "$home/cwd" && XDG_CONFIG_HOME="$home/config" XDG_DATA_HOME="$home/data" \
XDG_CACHE_HOME="$home/cache" XDG_STATE_HOME="$home/state" \ XDG_CACHE_HOME="$home/cache" XDG_STATE_HOME="$home/state" \
"${exec_prefix[@]}" opencode run --pure --format json "${model[@]}" "$prompt" < /dev/null > "$out.raw") "${exec_prefix[@]}" opencode run "$flag" --format json "${model[@]}" "$prompt" < /dev/null > "$out.raw")
jq -c 'select(.type=="tool_use") | .part | select(.tool | startswith("idx_")) jq -c 'select(.type=="tool_use") | .part | select(.tool | startswith("idx_"))
| select(.state.status=="completed") | {name: (.tool | ltrimstr("idx_")), args: .state.input}' "$out.raw" > "$out.calls" | select(.state.status=="completed") | {name: (.tool | ltrimstr("idx_")), args: .state.input}' "$out.raw" > "$out.calls"
jq -r 'select(.type=="text") | .part.text' "$out.raw" > "$out.answer" jq -r 'select(.type=="text") | .part.text' "$out.raw" > "$out.answer"
@ -328,13 +360,30 @@ if [[ ${#only_cases[@]} -gt 0 ]]; then
case_ids=("${only_cases[@]}") case_ids=("${only_cases[@]}")
fi fi
# Run agents from an empty directory: from the checkout, agents read its
# AGENTS.md and docs (Hermes concluded from docs/IDX_NEWS.md that news "is not
# implemented"), which users' agents never see.
mkdir -p "$work/cwd"
cd "$work/cwd"
results="$work/results.jsonl" results="$work/results.jsonl"
: > "$results" : > "$results"
echo "agent-smoke: agents=${agents[*]} cases=${#case_ids[@]} runs=$runs" echo "agent-smoke: agents=${agents[*]} cases=${#case_ids[@]} runs=$runs"
for agent in "${agents[@]}"; do for agent in "${agents[@]}"; do
if [[ -n "$npx_spec" ]]; then
# A shared cache would let later agents reuse the first one's install.
# Agents may filter env, so pass it explicitly.
export npm_config_cache="$work/npm-cache/$agent"
server_json="$(jq -c --arg cache "$npm_config_cache" '.env.npm_config_cache = $cache' <<< "$server_json")"
fi
"setup_$agent" "setup_$agent"
for id in "${case_ids[@]}"; do for id in "${case_ids[@]}"; do
case_json="$(jq -c --arg id "$id" '.[] | select(.id == $id)' "$cases_file")" # A case's "live" fields replace the mock ones, e.g. release dates.
case_json="$(jq -c --arg id "$id" --argjson live "$live" '.[] | select(.id == $id)
| if $live == 1 then . + (.live // {}) else . end | del(.live)
| walk(if type == "string" then gsub("\\{latest_release\\}"; $latest)
| gsub("\\{previous_release\\}"; $previous) else . end)' \
--arg latest "$latest_release" --arg previous "$previous_release" "$cases_file")"
prompt="$(jq -r '.prompt' <<< "$case_json") Use the idx MCP tools." prompt="$(jq -r '.prompt' <<< "$case_json") Use the idx MCP tools."
for ((run = 1; run <= runs; run++)); do for ((run = 1; run <= runs; run++)); do
out="$work/runs/$agent/$id.$run" out="$work/runs/$agent/$id.$run"
@ -363,6 +412,20 @@ for agent in "${agents[@]}"; do
[[ -z "$failures" ]] || printf ' - %s\n' "${failures//$'\n'/$'\n - '}" [[ -z "$failures" ]] || printf ' - %s\n' "${failures//$'\n'/$'\n - '}"
done done
done done
if [[ -n "$npx_spec" ]]; then
# Proof that this agent's own npx installed the package binary, rather
# than the server coming from somewhere else on PATH.
status=pass failures=
installed=("$npm_config_cache"/_npx/*/node_modules/idx-mcp/bin/idx-mcp)
if [[ ! -x "${installed[0]}" ]]; then
status=fail failures="no idx-mcp binary under $npm_config_cache/_npx"
fi
jq -nc --arg agent "$agent" --arg status "$status" --arg failures "$failures" \
'{agent: $agent, case: "npx-install", run: 1, status: $status, secs: 0, tools: [],
failures: ([$failures] | map(select(. != "")))}' >> "$results"
printf ' %-9s %-13s %s\n' "$agent" npx-install "$status"
[[ -z "$failures" ]] || printf ' - %s\n' "$failures"
fi
done done
echo echo

View file

@ -33,6 +33,34 @@
"id": "changes", "id": "changes",
"prompt": "Which shareholders changed between the 2026-01-30 and 2026-02-27 KSEI releases?", "prompt": "Which shareholders changed between the 2026-01-30 and 2026-02-27 KSEI releases?",
"expect_tools": [{"name": "get_ownership_changes", "args": {"from": "2026-01-30", "to": "2026-02-27"}}], "expect_tools": [{"name": "get_ownership_changes", "args": {"from": "2026-01-30", "to": "2026-02-27"}}],
"expect_text": [],
"live": {
"prompt": "Which shareholders changed between the two most recent KSEI releases?",
"expect_tools": [{"name": "get_ownership_changes", "args": {"from": "{previous_release}", "to": "{latest_release}"}}]
}
},
{
"id": "news",
"prompt": "What is the latest news about BBCA?",
"expect_tools": [{"name": "get_company", "args": {"symbol": "BBCA", "sections": ["news"]}}],
"expect_text": []
},
{
"id": "history",
"prompt": "Show BBCA's daily closing prices for the past month.",
"expect_tools": [{"name": "get_price_history", "args": {"symbol": "BBCA", "period": "1mo"}}],
"expect_text": []
},
{
"id": "screen",
"prompt": "Which IDX stocks are the top performers today?",
"expect_tools": [{"name": "screen_stocks", "args": {"filter": "top-performers"}}],
"expect_text": []
},
{
"id": "search",
"prompt": "Search the KSEI ownership database for investors named Salim.",
"expect_tools": [{"name": "search_investors", "args": {"query": "Salim"}}],
"expect_text": [] "expect_text": []
} }
] ]

View file

@ -26,7 +26,7 @@ them from stdin. `history` takes `--period` (`1d` to `5y`) and `--interval`
| `earnings` | reported and estimated earnings | | `earnings` | reported and estimated earnings |
| `sentiment` | crowd sentiment | | `sentiment` | crowd sentiment |
| `insights` | AI insights from the provider | | `insights` | AI insights from the provider |
| `news` | recent news | | `news` | recent news, this company's first (`ABOUT` = `company`), then general market news (`market`) |
## Analysis ## Analysis

View file

@ -45,7 +45,7 @@ body, so the agent can decide whether to retry:
| tickers per call (`get_quotes`, `compare_stocks`) | 20, duplicates removed | | tickers per call (`get_quotes`, `compare_stocks`) | 20, duplicates removed |
| `screen_stocks` `limit` | 200 | | `screen_stocks` `limit` | 200 |
| `search_investors` `limit` | 100 | | `search_investors` `limit` | 100 |
| `get_ownership_changes` `limit` | 2000 (default 200) | | `get_ownership_changes` `limit` | 300 per page (default 200); page with `offset` |
| concurrent tool calls | 4 | | concurrent tool calls | 4 |
## Ownership data ## Ownership data

View file

@ -29,7 +29,7 @@ Compare fundamental reports (growth, valuation, risk) across IDX tickers. Ticker
## `get_company` ## `get_company`
Company data for one IDX ticker: profile, financial statements, earnings, analyst sentiment, insights, and news. Sections that fail are listed under `errors`; the call fails only if every section fails. Company data for one IDX ticker: profile, financial statements, earnings, analyst sentiment, insights, and news. News items carry `relevance`: `company` (about this ticker) first, then `market` (general market news). Sections that fail are listed under `errors`; the call fails only if every section fails.
| Input | Type | Required | Description | | Input | Type | Required | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |
@ -46,12 +46,13 @@ Every IDX holding (above 1%) of one investor, matched by name.
## `get_ownership_changes` ## `get_ownership_changes`
Holder changes (new, exit, increase, decrease) between two KSEI release dates, optionally for one ticker. Holder changes (new, exited, increased, decreased) between two KSEI release dates, optionally for one ticker. Rows come largest change first, one page at a time: `summary` covers all of them (counts by type, tickers with the most changes), and `next_offset` is the `offset` for the next page, or null after the last.
| Input | Type | Required | Description | | Input | Type | Required | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `from` | string | yes | Earlier release date, YYYY-MM-DD (see ownership_status). | | `from` | string | yes | Earlier release date, YYYY-MM-DD (see ownership_status). |
| `limit` | integer | no | Maximum rows returned (default 200, at most 2000); `total` reports the full count. | | `limit` | integer | no | Rows per page (default 200, at most 300); `total` reports the full count. |
| `offset` | integer | no | Rows to skip, for the next page: the previous response's `next_offset`. |
| `ticker` | string | no | Only this ticker's changes. | | `ticker` | string | no | Only this ticker's changes. |
| `to` | string | yes | Later release date, YYYY-MM-DD. | | `to` | string | yes | Later release date, YYYY-MM-DD. |

View file

@ -40,7 +40,7 @@ against the release `SHA256SUMS`, and installs it into `~/.local/bin` without
sudo. Pin a version or change the directory: sudo. Pin a version or change the directory:
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/0xrsydn/idx-cli/main/install.sh | sh -s -- --version v0.3.1 --dir ~/bin curl -fsSL https://raw.githubusercontent.com/0xrsydn/idx-cli/main/install.sh | sh -s -- --version v0.3.2 --dir ~/bin
``` ```
</TabItem> </TabItem>