From 8fd9fcdadde7b49232acda84cfecbfcbbcc3619b Mon Sep 17 00:00:00 2001 From: hermes Date: Fri, 16 Oct 2026 06:07:47 +0000 Subject: [PATCH 01/21] test(agents): a cold npm cache per agent with --npx All agents shared one npm cache, so only the first ran a cold `npx -y idx-mcp` install and the others reused its installed binary, never exercising their own first-run install. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- tests/agents/agent-smoke.sh | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/agents/agent-smoke.sh b/tests/agents/agent-smoke.sh index 79d3f78..01a1462 100755 --- a/tests/agents/agent-smoke.sh +++ b/tests/agents/agent-smoke.sh @@ -128,10 +128,9 @@ fi # One server definition for every agent, as {command, args, env}. if [[ -n "$npx_spec" ]]; then # 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 - # agent run a cold install; agents may filter env, so pass it explicitly. - export npm_config_cache="$work/npm-cache" - server_env=("${data_env[@]}" "npm_config_cache=$npm_config_cache") + # (Hermes swaps in its managed Node). Each agent gets a fresh npm cache + # below, so its first run is a cold install. + server_env=("${data_env[@]}") [[ -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: idx-mcp`. if [[ "$npx_spec" == *.tgz && ! -f "$npx_spec" ]]; then @@ -332,6 +331,12 @@ results="$work/results.jsonl" : > "$results" echo "agent-smoke: agents=${agents[*]} cases=${#case_ids[@]} runs=$runs" 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" for id in "${case_ids[@]}"; do case_json="$(jq -c --arg id "$id" '.[] | select(.id == $id)' "$cases_file")" From 7ebf63ed427375fe6dc2a91581629f52a23b8a58 Mon Sep 17 00:00:00 2001 From: hermes Date: Fri, 16 Oct 2026 06:09:00 +0000 Subject: [PATCH 02/21] test(agents): a live variant of the ownership-changes case The `changes` case names the fixture releases (2026-01-30, 2026-02-27), which the live snapshot does not have, so under --live/--npx it failed agents that correctly declined to answer about missing releases and passed ones that called the tool anyway. A case's `live` fields now replace the mock ones; this one asks about the two most recent releases. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- docs/SMOKE.md | 2 ++ tests/agents/agent-smoke.sh | 4 +++- tests/agents/cases.json | 6 +++++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/SMOKE.md b/docs/SMOKE.md index 008933e..682b791 100644 --- a/docs/SMOKE.md +++ b/docs/SMOKE.md @@ -111,6 +111,8 @@ each run keeps a copy next to its output as `..mcp-stderr`. - `IDX_AGENT_MODEL_` picks a model (for example `IDX_AGENT_MODEL_CLAUDE=sonnet`). `IDX_AGENT_EXEC_PREFIX` prefixes every 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 measure pass rates. `--keep` keeps raw agent output and `results.jsonl`. - `nix develop .#agents` adds Claude Code, Codex, and opencode from nixpkgs diff --git a/tests/agents/agent-smoke.sh b/tests/agents/agent-smoke.sh index 01a1462..7fca924 100755 --- a/tests/agents/agent-smoke.sh +++ b/tests/agents/agent-smoke.sh @@ -339,7 +339,9 @@ for agent in "${agents[@]}"; do fi "setup_$agent" 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)' "$cases_file")" prompt="$(jq -r '.prompt' <<< "$case_json") Use the idx MCP tools." for ((run = 1; run <= runs; run++)); do out="$work/runs/$agent/$id.$run" diff --git a/tests/agents/cases.json b/tests/agents/cases.json index 37c1816..0d7ac64 100644 --- a/tests/agents/cases.json +++ b/tests/agents/cases.json @@ -33,6 +33,10 @@ "id": "changes", "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_text": [] + "expect_text": [], + "live": { + "prompt": "Which shareholders changed between the two most recent KSEI releases?", + "expect_tools": [{"name": "get_ownership_changes"}] + } } ] From 855ec0cdc33ed0f888a3d6ca68fc0eaf95461f24 Mon Sep 17 00:00:00 2001 From: hermes Date: Fri, 16 Oct 2026 06:50:29 +0000 Subject: [PATCH 03/21] test(agents): opencode answers only through the idx tools opencode kept its built-in tools, so it could route around the server under test: on published 0.3.1 it answered a quote from web search, and on the live `changes` case it queried the ownership SQLite file with bash and timed out. Deny every tool but `idx_*`, as Claude, pi, and Hermes already are. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- docs/SMOKE.md | 5 +++-- tests/agents/agent-smoke.sh | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/SMOKE.md b/docs/SMOKE.md index 682b791..4035634 100644 --- a/docs/SMOKE.md +++ b/docs/SMOKE.md @@ -106,8 +106,9 @@ each run keeps a copy next to its output as `..mcp-stderr`. output but still deletes every copied agent config. - Tools are auto-approved for the run (Claude `dontAsk` with `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 - can only answer through MCP. + also get no built-in tools, Hermes only the `idx` toolset, and opencode a + permission that denies everything but `idx_*`, so they can only answer + through MCP. - `IDX_AGENT_MODEL_` picks a model (for example `IDX_AGENT_MODEL_CLAUDE=sonnet`). `IDX_AGENT_EXEC_PREFIX` prefixes every agent command, for hosts that inject API keys through a wrapper. diff --git a/tests/agents/agent-smoke.sh b/tests/agents/agent-smoke.sh index 7fca924..55472f6 100755 --- a/tests/agents/agent-smoke.sh +++ b/tests/agents/agent-smoke.sh @@ -276,7 +276,8 @@ setup_opencode() { mkdir -p "$work/opencode/cwd" jq -n --argjson s "$server_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" } run_opencode() { From 1049ff3155179568e5ac51b66c70baba47584baa Mon Sep 17 00:00:00 2001 From: hermes Date: Fri, 16 Oct 2026 07:40:04 +0000 Subject: [PATCH 04/21] test(agents): run opencode 2 as well as opencode 1 opencode 2 (@opencode/cli) moved MCP servers to `mcp.servers`, turns Code Mode on by default, replaced `permission` with ordered `permissions` rules, and dropped `run --pure` for `--standalone` (runs otherwise go through a shared background service). Pick the config and flag from `opencode --version`. Its `execute` tool stays allowed: with it denied, opencode 2.0.30 never offers the idx tools to the model in `opencode run`, though the server connects before the first step. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- docs/SMOKE.md | 14 ++++++++++---- tests/agents/agent-smoke.sh | 34 +++++++++++++++++++++++++++------- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/docs/SMOKE.md b/docs/SMOKE.md index 4035634..5601eb5 100644 --- a/docs/SMOKE.md +++ b/docs/SMOKE.md @@ -106,9 +106,14 @@ each run keeps a copy next to its output as `..mcp-stderr`. output but still deletes every copied agent config. - Tools are auto-approved for the run (Claude `dontAsk` with `mcp__idx__*`, Codex `default_tools_approval_mode="approve"`); Claude and pi - also get no built-in tools, Hermes only the `idx` toolset, and opencode a - permission that denies everything but `idx_*`, so they can only answer - through MCP. + also get no built-in tools, Hermes only the `idx` toolset, and opencode + 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_` picks a model (for example `IDX_AGENT_MODEL_CLAUDE=sonnet`). `IDX_AGENT_EXEC_PREFIX` prefixes every agent command, for hosts that inject API keys through a wrapper. @@ -118,4 +123,5 @@ each run keeps a copy next to its output as `..mcp-stderr`. measure pass rates. `--keep` keeps raw agent output and `results.jsonl`. - `nix develop .#agents` adds Claude Code, Codex, and opencode from nixpkgs 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. diff --git a/tests/agents/agent-smoke.sh b/tests/agents/agent-smoke.sh index 55472f6..b8f4728 100755 --- a/tests/agents/agent-smoke.sh +++ b/tests/agents/agent-smoke.sh @@ -273,19 +273,39 @@ run_hermes() { } setup_opencode() { + local version mkdir -p "$work/opencode/cwd" - jq -n --argjson s "$server_json" \ - '{"$schema": "https://opencode.ai/config.json", - mcp: {idx: {type: "local", command: ([$s.command] + $s.args), environment: $s.env, enabled: true}}, - permission: {"*": "deny", "idx_*": "allow"}}' \ - > "$work/opencode/cwd/opencode.json" + 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" \ + '{"$schema": "https://opencode.ai/config.json", + mcp: {idx: {type: "local", command: ([$s.command] + $s.args), environment: $s.env, enabled: true}}, + permission: {"*": "deny", "idx_*": "allow"}}' \ + > "$work/opencode/cwd/opencode.json" + echo --pure > "$work/opencode/run-flag" + fi } 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") + flag="$(< "$home/run-flag")" (cd "$home/cwd" && XDG_CONFIG_HOME="$home/config" XDG_DATA_HOME="$home/data" \ 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_")) | 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" From 687c9ad83f88d09dac302f6cab6cdccaebfe6a3c Mon Sep 17 00:00:00 2001 From: hermes Date: Fri, 16 Oct 2026 08:03:39 +0000 Subject: [PATCH 05/21] test(agents): close ways an --npx run could pass or fail wrongly From a Codex review of the harness: - Drop target/debug from PATH in --npx mode (the dev shell adds it), so a missing package bin cannot fall through to the local build, and fail an agent whose npm cache holds no installed idx-mcp binary. - The live `changes` case must diff the two latest releases, read from the synced snapshot, not any two dates. - Hermes: `hermes -z` waits ~15s for MCP tools regardless of connect_timeout, so a slow cold install lost the first case. Warm up with `hermes mcp test idx`, which installs through Hermes' own npx and honors connect_timeout (now 300s). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- tests/agents/agent-smoke.sh | 36 ++++++++++++++++++++++++++++++++---- tests/agents/cases.json | 2 +- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/tests/agents/agent-smoke.sh b/tests/agents/agent-smoke.sh index b8f4728..f5b4b7d 100755 --- a/tests/agents/agent-smoke.sh +++ b/tests/agents/agent-smoke.sh @@ -111,8 +111,12 @@ data_env=( "XDG_CACHE_HOME=$work/idx/cache" "XDG_DATA_HOME=$work/idx/data" ) +latest_release='' previous_release='' if [[ "$live" == 1 ]]; then 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 data_env+=(IDX_USE_MOCK_PROVIDER=1) fixtures="$repo_root/crates/idx-core/tests/fixtures" @@ -136,6 +140,9 @@ if [[ -n "$npx_spec" ]]; then if [[ "$npx_spec" == *.tgz && ! -f "$npx_spec" ]]; then echo "agent-smoke: tarball not found: $npx_spec" >&2; exit 2 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") [[ ! -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, @@ -226,7 +233,8 @@ setup_hermes() { # Model providers can be user plugins. A symlink: cleanup removes only the link. [[ ! -d "$src/plugins" ]] || ln -s "$src/plugins" "$work/hermes/plugins" 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 # Hermes also writes config.yaml as JSON (e.g. in profiles). jq --argjson s "$server" '{model, mcp_servers: {idx: $s}} | del(.. | nulls)' \ @@ -240,11 +248,15 @@ setup_hermes() { } > "$work/hermes/config.yaml" fi # 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 - # it first or the first case runs without them. + # on first use (~20s), and the cold package install and binary download + # 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 HERMES_HOME="$work/hermes" "${exec_prefix[@]}" hermes pm install npm >/dev/null \ || { 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 } run_hermes() { @@ -362,7 +374,10 @@ for agent in "${agents[@]}"; do for id in "${case_ids[@]}"; do # 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)' "$cases_file")" + | 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." for ((run = 1; run <= runs; run++)); do out="$work/runs/$agent/$id.$run" @@ -391,6 +406,19 @@ for agent in "${agents[@]}"; do [[ -z "$failures" ]] || printf ' - %s\n' "${failures//$'\n'/$'\n - '}" 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= + if ! compgen -G "$npm_config_cache/_npx/*/node_modules/idx-mcp/bin/idx-mcp" > /dev/null; 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 echo diff --git a/tests/agents/cases.json b/tests/agents/cases.json index 0d7ac64..fda0c8e 100644 --- a/tests/agents/cases.json +++ b/tests/agents/cases.json @@ -36,7 +36,7 @@ "expect_text": [], "live": { "prompt": "Which shareholders changed between the two most recent KSEI releases?", - "expect_tools": [{"name": "get_ownership_changes"}] + "expect_tools": [{"name": "get_ownership_changes", "args": {"from": "{previous_release}", "to": "{latest_release}"}}] } } ] From d69998ebf5c19c6fb76b9ec9499d6db2aed84480 Mon Sep 17 00:00:00 2001 From: hermes Date: Fri, 16 Oct 2026 08:07:42 +0000 Subject: [PATCH 06/21] test(agents): check the npx install without compgen The dev shell's bash has no compgen (no programmable completion), so the npx-install check failed every agent. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- tests/agents/agent-smoke.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/agents/agent-smoke.sh b/tests/agents/agent-smoke.sh index f5b4b7d..9ecc053 100755 --- a/tests/agents/agent-smoke.sh +++ b/tests/agents/agent-smoke.sh @@ -410,7 +410,8 @@ for agent in "${agents[@]}"; do # Proof that this agent's own npx installed the package binary, rather # than the server coming from somewhere else on PATH. status=pass failures= - if ! compgen -G "$npm_config_cache/_npx/*/node_modules/idx-mcp/bin/idx-mcp" > /dev/null; then + 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" \ From 26eba9b875fbbd4519711d829b4662293b5cd510 Mon Sep 17 00:00:00 2001 From: hermes Date: Fri, 16 Oct 2026 08:39:39 +0000 Subject: [PATCH 07/21] test(agents): cases for news, price history, screening and investor search The six cases never asked for get_company (where news lives), get_price_history, screen_stocks or search_investors; across six agents only one called get_company and none screen_stocks. Cover each. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- tests/agents/cases.json | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/agents/cases.json b/tests/agents/cases.json index fda0c8e..f1f89bb 100644 --- a/tests/agents/cases.json +++ b/tests/agents/cases.json @@ -38,5 +38,29 @@ "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"}}], + "expect_text": [] + }, + { + "id": "history", + "prompt": "Show BBCA's daily closing prices for the past month.", + "expect_tools": [{"name": "get_price_history", "args": {"symbol": "BBCA"}}], + "expect_text": [] + }, + { + "id": "screen", + "prompt": "Which IDX stocks are the top performers today?", + "expect_tools": [{"name": "screen_stocks"}], + "expect_text": [] + }, + { + "id": "search", + "prompt": "Search the KSEI ownership database for investors named Salim.", + "expect_tools": [{"name": "search_investors"}], + "expect_text": [] } ] From 30449d9ac6e1efdaa8f64dab85292021a7c0c283 Mon Sep 17 00:00:00 2001 From: hermes Date: Fri, 16 Oct 2026 09:48:03 +0000 Subject: [PATCH 08/21] test(agents): the new cases check the arguments that answer the prompt The release-gate Codex review: news passed on get_company with only `sections: ["profile"]`, history on any period, search on any query. Require `sections: ["news"]`, `period: "1mo"`, `filter: "top-performers"` and a query containing "Salim"; all 24 calls from the last six-harness run still pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- tests/agents/cases.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/agents/cases.json b/tests/agents/cases.json index f1f89bb..c31184b 100644 --- a/tests/agents/cases.json +++ b/tests/agents/cases.json @@ -42,25 +42,25 @@ { "id": "news", "prompt": "What is the latest news about BBCA?", - "expect_tools": [{"name": "get_company", "args": {"symbol": "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"}}], + "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"}], + "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"}], + "expect_tools": [{"name": "search_investors", "args": {"query": "Salim"}}], "expect_text": [] } ] From 6f44c3ce500bfdca37396b0a2428b9f8db72d950 Mon Sep 17 00:00:00 2001 From: hermes Date: Fri, 16 Oct 2026 09:53:31 +0000 Subject: [PATCH 09/21] test(agents): run agents from an empty directory, not the checkout From the checkout, agents read its AGENTS.md and docs: Hermes concluded from docs/IDX_NEWS.md that news "is not implemented" and failed the news case it had passed before the doc existed. Users' agents never see our repo, so run every agent from an empty directory under the work dir. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- tests/agents/agent-smoke.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/agents/agent-smoke.sh b/tests/agents/agent-smoke.sh index 9ecc053..59c0258 100755 --- a/tests/agents/agent-smoke.sh +++ b/tests/agents/agent-smoke.sh @@ -360,6 +360,12 @@ if [[ ${#only_cases[@]} -gt 0 ]]; then case_ids=("${only_cases[@]}") 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" echo "agent-smoke: agents=${agents[*]} cases=${#case_ids[@]} runs=$runs" From 907223a8382c41deeb0d446aafa9bcd48b58806b Mon Sep 17 00:00:00 2001 From: hermes Date: Fri, 16 Oct 2026 08:07:41 +0000 Subject: [PATCH 10/21] fix(npm): install to a user cache when the package is read-only; signal-safe first run - When the package's bin/ is not writable (a root-owned `sudo npm i -g` whose postinstall npm 12 skipped), install the binary to a per-user cache ($XDG_CACHE_HOME, ~/.cache, ~/Library/Caches on macOS, under //); the launcher looks in both. Previously the first run failed with EACCES and advice to rerun npm install. - Run the first-run installer asynchronously with SIGTERM/SIGHUP forwarded, so a supervisor stopping the launcher stops the download; the installer removes its partial file and the launcher exits by the same signal. - Verify an IDX_BINARY_URL local file's checksum on the copy, not the source, so the installed bytes are the checked ones. - npm smoke covers the read-only fallback. The last three come from a Codex review. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- docs/NPM_DISTRIBUTION.md | 10 ++++- scripts/idx.js | 96 ++++++++++++++++++++++++++-------------- scripts/install.js | 88 ++++++++++++++++++++++++++++-------- scripts/npm-smoke.sh | 17 ++++++- 4 files changed, 158 insertions(+), 53 deletions(-) diff --git a/docs/NPM_DISTRIBUTION.md b/docs/NPM_DISTRIBUTION.md index 0f46d3d..0cdc162 100644 --- a/docs/NPM_DISTRIBUTION.md +++ b/docs/NPM_DISTRIBUTION.md @@ -52,6 +52,13 @@ 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 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`, else `~/.cache` (`~/Library/Caches` on +macOS), under `//`. The launcher looks in both. 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: ```bash @@ -70,7 +77,8 @@ npm install --global --allow-scripts=idx-cli idx-cli 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 `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. ```bash diff --git a/scripts/idx.js b/scripts/idx.js index 50232d8..ea9dde0 100755 --- a/scripts/idx.js +++ b/scripts/idx.js @@ -3,51 +3,83 @@ const fs = require("node:fs"); const path = require("node:path"); -const { spawn, spawnSync } = require("node:child_process"); +const { spawn } = require("node:child_process"); const packageManifest = JSON.parse( fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8"), ); -const nativeBinary = packageManifest.nativeBinary || "idx"; const packageName = packageManifest.name; -const binaryName = process.platform === "win32" ? `${nativeBinary}.exe` : nativeBinary; -const binaryPath = path.join(__dirname, "..", "bin", binaryName); +const installer = path.join(__dirname, "install.js"); +// 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. -// Its log goes to stderr, since stdout is the MCP channel for idx-mcp. -if (!fs.existsSync(binaryPath)) { - console.error(`${packageName}: binary not installed; running the installer`); - spawnSync(process.execPath, [path.join(__dirname, "install.js")], { - stdio: ["ignore", 2, 2], +const findBinary = () => binaryLocations.find((candidate) => fs.existsSync(candidate)); + +// The running child: the first-run installer, then the binary. +let child = null; +// A termination signal received while installing; the installer cleans up +// and exits, and the launcher then stops the same way. +let stopping = null; + +// 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); + } }); } - -if (!fs.existsSync(binaryPath)) { - console.error(`${packageName}: installed binary not found at ${binaryPath}`); - console.error(`${packageName}: rerun npm install or set IDX_BINARY_URL for a local binary`); - process.exit(1); -} - -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. +// Ctrl+C already reaches the child 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) { process.removeAllListeners(signal); process.kill(process.pid, signal); return; } - 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`); + 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; + 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); + }); +} diff --git a/scripts/install.js b/scripts/install.js index a92e465..b19c604 100755 --- a/scripts/install.js +++ b/scripts/install.js @@ -5,6 +5,7 @@ const crypto = require("node:crypto"); const fs = require("node:fs"); const http = require("node:http"); const https = require("node:https"); +const os = require("node:os"); const path = require("node:path"); const { pipeline } = require("node:stream"); const { URL, fileURLToPath } = require("node:url"); @@ -18,8 +19,21 @@ const packageManifest = JSON.parse( const nativeBinary = packageManifest.nativeBinary || "idx"; const packageName = packageManifest.name; const binaryName = process.platform === "win32" ? `${nativeBinary}.exe` : nativeBinary; -const binaryDirectory = path.join(packageRoot, "bin"); -const binaryPath = path.join(binaryDirectory, binaryName); +// The binary goes in the package's own bin/, or, when that is not writable +// (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); +const cacheBinaryPath = path.join( + process.env.XDG_CACHE_HOME || + (process.platform === "darwin" + ? path.join(os.homedir(), "Library", "Caches") + : path.join(os.homedir(), ".cache")), + packageName, + packageManifest.version, + binaryName, +); +const binaryLocations = [packageBinaryPath, cacheBinaryPath]; +let binaryPath = packageBinaryPath; const githubRepository = "0xrsydn/idx-cli"; const maxRedirects = 5; const requestTimeoutMs = 60_000; @@ -65,15 +79,43 @@ function releaseAssetUrl(assetName) { return `https://github.com/${githubRepository}/releases/download/v${packageManifest.version}/${assetName}`; } +// Picks the first writable location and points binaryPath at it. 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() { - 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 sourceStat = fs.statSync(resolvedSource, { throwIfNoEntry: false }); if (!sourceStat || !sourceStat.isFile()) { @@ -83,12 +125,13 @@ function replaceWithLocalBinary(sourcePath) { const temporaryPath = temporaryBinaryPath(); try { fs.copyFileSync(resolvedSource, temporaryPath); + if (expectedSha256) { + verifyChecksum(temporaryPath, expectedSha256, resolvedSource); + } fs.chmodSync(temporaryPath, 0o755); fs.renameSync(temporaryPath, binaryPath); } finally { - if (fs.existsSync(temporaryPath)) { - fs.unlinkSync(temporaryPath); - } + removeActiveTemporary(); } } @@ -199,9 +242,7 @@ async function replaceWithDownloadedBinary(url, expectedSha256) { fs.chmodSync(temporaryPath, 0o755); fs.renameSync(temporaryPath, binaryPath); } finally { - if (fs.existsSync(temporaryPath)) { - fs.unlinkSync(temporaryPath); - } + removeActiveTemporary(); } } @@ -241,10 +282,7 @@ async function main() { if (override) { const localPath = localPathFromOverride(override); if (localPath) { - if (overrideSha256) { - verifyChecksum(localPath, overrideSha256, localPath); - } - replaceWithLocalBinary(localPath); + replaceWithLocalBinary(localPath, overrideSha256); console.log(`${packageName}: installed local binary from ${path.resolve(localPath)}`); return; } @@ -260,7 +298,19 @@ async function main() { console.log(`${packageName}: installed binary at ${binaryPath}`); } -main().catch((error) => { - console.error(`${packageName}: ${error.message}`); - process.exit(1); -}); +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}`); + process.exit(1); + }); +} diff --git a/scripts/npm-smoke.sh b/scripts/npm-smoke.sh index d09ea0a..81c82db 100755 --- a/scripts/npm-smoke.sh +++ b/scripts/npm-smoke.sh @@ -95,4 +95,19 @@ if ! head -n 1 "${smoke_dir}/no-scripts-help.txt" | grep -q "MCP server"; then cat "${smoke_dir}/no-scripts-help.txt" >&2 exit 1 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" +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)" From 30416069162d4f15419a6124a22ad713859ff627 Mon Sep 17 00:00:00 2001 From: hermes Date: Fri, 16 Oct 2026 08:29:58 +0000 Subject: [PATCH 11/21] fix(npm): absolute cache root, per-arch cache dir, SIGINT during install From a second Codex review (gpt-6.1-sol, xhigh): - A relative XDG_CACHE_HOME (invalid per the XDG spec) made the launcher look for, and run, a binary under the current directory, which an untrusted checkout could supply. Use it only when absolute. - The cache dir now includes -, so a home directory shared across machines cannot hand an arm64 host an x64 binary. - SIGINT sent to the launcher's PID alone during the first-run install is forwarded to the installer (it was ignored); the launcher exits 130. - npm smoke runs the first-run install with an empty user cache and checks the package binary was created, so a cached binary cannot pass for it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- docs/NPM_DISTRIBUTION.md | 5 +++-- scripts/idx.js | 14 ++++++++++++-- scripts/install.js | 12 +++++++++--- scripts/npm-smoke.sh | 9 ++++++--- 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/docs/NPM_DISTRIBUTION.md b/docs/NPM_DISTRIBUTION.md index 0cdc162..53c54a8 100644 --- a/docs/NPM_DISTRIBUTION.md +++ b/docs/NPM_DISTRIBUTION.md @@ -54,8 +54,9 @@ 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`, else `~/.cache` (`~/Library/Caches` on -macOS), under `//`. The launcher looks in both. A +per-user cache: `$XDG_CACHE_HOME` (if absolute), else `~/.cache` +(`~/Library/Caches` on macOS), under `//-/`. +The launcher looks in both. A termination signal during that first-run download stops the installer too, and it removes its partial file. diff --git a/scripts/idx.js b/scripts/idx.js index ea9dde0..b743544 100755 --- a/scripts/idx.js +++ b/scripts/idx.js @@ -20,6 +20,7 @@ let child = null; // A termination signal received while installing; the installer cleans up // 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"]) { @@ -32,8 +33,15 @@ for (const signal of ["SIGTERM", "SIGHUP"]) { } }); } -// Ctrl+C already reaches the child through the terminal's process group. -process.on("SIGINT", () => {}); +// Ctrl+C already reaches the binary through the terminal's process group, so +// the launcher ignores SIGINT then. During the first-run install, forward it +// too: a supervisor may signal only the launcher's PID. +process.on("SIGINT", () => { + if (installing) { + stopping = "SIGINT"; + child.kill("SIGINT"); + } +}); function exitLike(code, signal) { if (signal) { @@ -61,6 +69,7 @@ if (installed) { // 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}`); @@ -68,6 +77,7 @@ if (installed) { }); child.once("exit", (code, signal) => { child = null; + installing = false; if (signal || stopping) { exitLike(code, signal || stopping); return; diff --git a/scripts/install.js b/scripts/install.js index b19c604..126e985 100755 --- a/scripts/install.js +++ b/scripts/install.js @@ -23,13 +23,19 @@ const binaryName = process.platform === "win32" ? `${nativeBinary}.exe` : native // (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); +// A relative XDG_CACHE_HOME is invalid per the XDG spec, and would make the +// launcher run a binary from the current directory: ignore it. +const xdgCacheHome = process.env.XDG_CACHE_HOME; const cacheBinaryPath = path.join( - process.env.XDG_CACHE_HOME || - (process.platform === "darwin" + xdgCacheHome && path.isAbsolute(xdgCacheHome) + ? xdgCacheHome + : process.platform === "darwin" ? path.join(os.homedir(), "Library", "Caches") - : path.join(os.homedir(), ".cache")), + : path.join(os.homedir(), ".cache"), packageName, packageManifest.version, + // A home directory shared across machines must not mix architectures. + `${process.platform}-${process.arch}`, binaryName, ); const binaryLocations = [packageBinaryPath, cacheBinaryPath]; diff --git a/scripts/npm-smoke.sh b/scripts/npm-smoke.sh index 81c82db..69c5161 100755 --- a/scripts/npm-smoke.sh +++ b/scripts/npm-smoke.sh @@ -88,9 +88,12 @@ echo "npm smoke: installing idx-mcp with --ignore-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 \ --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" -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 cat "${smoke_dir}/no-scripts-help.txt" >&2 exit 1 @@ -104,7 +107,7 @@ 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" chmod -R u+w "${read_only_dir}/node_modules/idx-mcp" -cached=("${smoke_dir}"/user-cache/idx-mcp/*/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 From d2cd7115f181496f75e27a10ab518fea33d6d750 Mon Sep 17 00:00:00 2001 From: hermes Date: Fri, 16 Oct 2026 09:47:51 +0000 Subject: [PATCH 12/21] fix(npm): no cache fallback without an absolute home directory The release-gate Codex review found the relative-path fix incomplete: with XDG_CACHE_HOME unset and HOME empty or relative, the fallback was `.cache/...` under the current directory, so a checkout could plant the binary the launcher runs. Use the home cache only when the home path is absolute; otherwise the package's bin/ is the only location. npm smoke plants such a binary and checks it is never run (it was, before this). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- docs/NPM_DISTRIBUTION.md | 3 ++- scripts/install.js | 47 ++++++++++++++++++++++++++-------------- scripts/npm-smoke.sh | 11 ++++++++++ 3 files changed, 44 insertions(+), 17 deletions(-) diff --git a/docs/NPM_DISTRIBUTION.md b/docs/NPM_DISTRIBUTION.md index 53c54a8..3d76548 100644 --- a/docs/NPM_DISTRIBUTION.md +++ b/docs/NPM_DISTRIBUTION.md @@ -56,7 +56,8 @@ 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 `//-/`. -The launcher looks in both. A +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. diff --git a/scripts/install.js b/scripts/install.js index 126e985..433359a 100755 --- a/scripts/install.js +++ b/scripts/install.js @@ -23,22 +23,37 @@ const binaryName = process.platform === "win32" ? `${nativeBinary}.exe` : native // (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); -// A relative XDG_CACHE_HOME is invalid per the XDG spec, and would make the -// launcher run a binary from the current directory: ignore it. -const xdgCacheHome = process.env.XDG_CACHE_HOME; -const cacheBinaryPath = path.join( - xdgCacheHome && path.isAbsolute(xdgCacheHome) - ? xdgCacheHome - : process.platform === "darwin" - ? path.join(os.homedir(), "Library", "Caches") - : path.join(os.homedir(), ".cache"), - packageName, - packageManifest.version, - // A home directory shared across machines must not mix architectures. - `${process.platform}-${process.arch}`, - binaryName, -); -const binaryLocations = [packageBinaryPath, cacheBinaryPath]; +// 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 maxRedirects = 5; diff --git a/scripts/npm-smoke.sh b/scripts/npm-smoke.sh index 69c5161..73d172f 100755 --- a/scripts/npm-smoke.sh +++ b/scripts/npm-smoke.sh @@ -106,6 +106,17 @@ npm_config_cache="$npm_cache" npm install --prefix "$read_only_dir" "${smoke_dir 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="${planted_dir}/.cache/idx-mcp/$(node -p 'require("./npm/idx-mcp/package.json").version')/$(node -p 'process.platform + "-" + process.arch')/idx-mcp" +mkdir -p "$(dirname "$planted")" +printf '#!/bin/sh\necho PLANTED\n' > "$planted" +chmod +x "$planted" +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 From 537b01901fff7e60cbcbbfe6d4abe4b5c6f92694 Mon Sep 17 00:00:00 2001 From: hermes Date: Fri, 16 Oct 2026 09:53:49 +0000 Subject: [PATCH 13/21] test(npm): plant the macOS cache layout too From the Codex re-gate: the planted-binary check used only ~/.cache, so the old macOS fallback (~/Library/Caches) would have escaped it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- scripts/npm-smoke.sh | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/scripts/npm-smoke.sh b/scripts/npm-smoke.sh index 73d172f..a97a5fc 100755 --- a/scripts/npm-smoke.sh +++ b/scripts/npm-smoke.sh @@ -109,10 +109,13 @@ XDG_CACHE_HOME="${smoke_dir}/user-cache" IDX_BINARY_URL="${repo_root}/target/rel # 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="${planted_dir}/.cache/idx-mcp/$(node -p 'require("./npm/idx-mcp/package.json").version')/$(node -p 'process.platform + "-" + process.arch')/idx-mcp" -mkdir -p "$(dirname "$planted")" -printf '#!/bin/sh\necho PLANTED\n' > "$planted" -chmod +x "$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 From 1796e581057418cfa93dfad6302269c3cdb551f8 Mon Sep 17 00:00:00 2001 From: hermes Date: Fri, 16 Oct 2026 07:58:57 +0000 Subject: [PATCH 14/21] feat(news): rank this company's news first and label each item MSN's feed for a stock is often mostly general market news: of 10 items, ADRO, BREN and CUAN had none about the company, BBRI one, yet they were presented as the company's news. Fetch at least 50 items, label each `relevance: company` (tagged by MSN with the stock's instrument id, or naming the ticker or the company) or `market`, and return company items first, newest first, trimmed to --limit. Company names come from the quote's short and display names, best effort: MSN tags Telkom articles with its NYSE ADR, not TLKM, so names catch what tags miss. Classification stays a pure function in parse_news. Output change, intentional: news JSON gains `relevance`, the table an ABOUT column (goldens regenerated), and the get_company tool description explains the field (tools.json and the website tools page regenerated). News cache keys change so entries without the field are not reused. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- crates/idx-cli/src/output/table.rs | 3 +- .../tests/golden/stocks-news.json.golden | 2 +- .../tests/golden/stocks-news.table.golden | 10 +- crates/idx-core/src/api/msn/client.rs | 7 +- crates/idx-core/src/api/msn/map.rs | 199 ++++++++++++++++-- crates/idx-core/src/api/msn/mod.rs | 19 +- crates/idx-core/src/api/msn/raw_types.rs | 17 ++ crates/idx-core/src/api/types.rs | 21 ++ crates/idx-core/src/financial.rs | 3 +- crates/idx-mcp/src/server.rs | 5 +- crates/idx-mcp/tests/golden/tools.json | 2 +- web/src/content/docs/cli/stocks.md | 2 +- web/src/content/docs/mcp/tools.md | 2 +- 13 files changed, 254 insertions(+), 38 deletions(-) diff --git a/crates/idx-cli/src/output/table.rs b/crates/idx-cli/src/output/table.rs index cfa3a2e..fb5df0c 100644 --- a/crates/idx-cli/src/output/table.rs +++ b/crates/idx-cli/src/output/table.rs @@ -837,10 +837,11 @@ pub fn print_news(items: &[NewsItem]) -> Result<(), IdxError> { let mut table = Table::new(); table .load_preset(UTF8_FULL) - .set_header(vec!["TITLE", "PROVIDER", "DATE", "URL"]); + .set_header(vec!["TITLE", "ABOUT", "PROVIDER", "DATE", "URL"]); for item in items { table.add_row(vec![ Cell::new(&item.title), + Cell::new(item.relevance.as_str()), Cell::new(&item.provider), Cell::new(&item.published_at), Cell::new(truncate_url(&item.url)), diff --git a/crates/idx-cli/tests/golden/stocks-news.json.golden b/crates/idx-cli/tests/golden/stocks-news.json.golden index 647f530..d75ca62 100644 --- a/crates/idx-cli/tests/golden/stocks-news.json.golden +++ b/crates/idx-cli/tests/golden/stocks-news.json.golden @@ -1,4 +1,4 @@ exit: 0 --- 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 diff --git a/crates/idx-cli/tests/golden/stocks-news.table.golden b/crates/idx-cli/tests/golden/stocks-news.table.golden index f0bdf1e..f244ca1 100644 --- a/crates/idx-cli/tests/golden/stocks-news.table.golden +++ b/crates/idx-cli/tests/golden/stocks-news.table.golden @@ -1,8 +1,8 @@ exit: 0 --- stdout -┌───────────────────────────┬──────────────┬──────────────────────┬─────────────────────────────────────┐ -│ TITLE ┆ PROVIDER ┆ DATE ┆ URL │ -╞═══════════════════════════╪══════════════╪══════════════════════╪═════════════════════════════════════╡ -│ BCA reports steady growth ┆ Contoso News ┆ 2026-03-20T10:00:00Z ┆ https://example.com/news/bca-growth │ -└───────────────────────────┴──────────────┴──────────────────────┴─────────────────────────────────────┘ +┌───────────────────────────┬────────┬──────────────┬──────────────────────┬─────────────────────────────────────┐ +│ TITLE ┆ ABOUT ┆ PROVIDER ┆ DATE ┆ URL │ +╞═══════════════════════════╪════════╪══════════════╪══════════════════════╪═════════════════════════════════════╡ +│ BCA reports steady growth ┆ market ┆ Contoso News ┆ 2026-03-20T10:00:00Z ┆ https://example.com/news/bca-growth │ +└───────────────────────────┴────────┴──────────────┴──────────────────────┴─────────────────────────────────────┘ --- stderr diff --git a/crates/idx-core/src/api/msn/client.rs b/crates/idx-core/src/api/msn/client.rs index a62bff0..f61ef71 100644 --- a/crates/idx-core/src/api/msn/client.rs +++ b/crates/idx-core/src/api/msn/client.rs @@ -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/"; // Public API key from MSN Money website (embedded in frontend JS) 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 { agent: ureq::Agent, @@ -272,8 +274,11 @@ impl MsnClient { pub(super) fn fetch_news(&self, symbol: &str, limit: usize) -> Result { let id = 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!( - "{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") } diff --git a/crates/idx-core/src/api/msn/map.rs b/crates/idx-core/src/api/msn/map.rs index 2889168..e94e594 100644 --- a/crates/idx-core/src/api/msn/map.rs +++ b/crates/idx-core/src/api/msn/map.rs @@ -6,7 +6,7 @@ use super::raw_types::{ use super::symbols::{normalized_symbol, ticker_from_symbol}; use crate::api::types::{ CompanyProfile, EarningsData, EarningsReport, FinancialStatements, Fundamentals, InsightData, - InstrumentInfo, NewsItem, Officer, Ohlc, Quote, SentimentData, SentimentPeriod, + InstrumentInfo, NewsItem, NewsRelevance, Officer, Ohlc, Quote, SentimentData, SentimentPeriod, StatementSection, }; use crate::error::IdxError; @@ -551,30 +551,108 @@ pub(super) fn parse_insights(symbol: &str, raw: &[RawInsight]) -> Result Result, 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, IdxError> { let source = raw .sub_cards .as_ref() .or(raw.value.as_ref()) .ok_or_else(|| IdxError::ParseError("no news data".into()))?; + let ticker = ticker_from_symbol(symbol).unwrap_or_default(); - Ok(source + let mut items: Vec = source .iter() - .map(|item| NewsItem { - id: item.id.clone().unwrap_or_default(), - symbol: symbol.to_string(), - title: item.title.clone().unwrap_or_default(), - url: item.url.clone().unwrap_or_default(), - description: item.description.clone().unwrap_or_default(), - provider: item - .provider - .as_ref() - .and_then(|p| p.name.clone()) - .unwrap_or_default(), - published_at: item.published_date_time.clone().unwrap_or_default(), - read_time_min: item.read_time_min, + .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(), + symbol: symbol.to_string(), + title, + url: item.url.clone().unwrap_or_default(), + description, + provider: item + .provider + .as_ref() + .and_then(|p| p.name.clone()) + .unwrap_or_default(), + published_at: item.published_date_time.clone().unwrap_or_default(), + read_time_min: item.read_time_min, + relevance: if tagged || named { + NewsRelevance::Company + } else { + NewsRelevance::Market + }, + } }) - .collect()) + .collect(); + // ISO 8601 UTC timestamps sort as strings; missing dates sort last. + items.sort_by(|a, b| { + a.relevance + .cmp(&b.relevance) + .then_with(|| b.published_at.cmp(&a.published_at)) + }); + 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 { + 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::>() + .join(" ") + }); + let mut names: Vec = 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, IdxError> { @@ -785,9 +863,10 @@ fn collect_earnings( #[cfg(test)] mod tests { use super::{ - KeyRatios, MsnQuote, RawChartResponse, RawFinancialStatement, RawNewsFeed, - RawScreenerResponse, RawSentiment, parse_financial_statements, parse_fundamentals, - parse_history, parse_news, parse_quote, parse_screener_results, parse_sentiment, + KeyRatios, MsnQuote, NewsRelevance, RawChartResponse, RawFinancialStatement, RawNewsFeed, + RawScreenerResponse, RawSentiment, company_names, parse_financial_statements, + parse_fundamentals, parse_history, parse_news, parse_quote, parse_screener_results, + parse_sentiment, }; use crate::error::IdxError; @@ -814,7 +893,7 @@ mod tests { serde_json::from_str(include_str!("../../../tests/fixtures/msn_news_bbca.json")) .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[0].id, "news-1"); @@ -823,6 +902,84 @@ mod tests { assert_eq!(items[0].provider, "Contoso News"); assert_eq!(items[0].published_at, "2026-03-20T10:00:00Z"); 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 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] diff --git a/crates/idx-core/src/api/msn/mod.rs b/crates/idx-core/src/api/msn/mod.rs index 37bf2a3..021d5e1 100644 --- a/crates/idx-core/src/api/msn/mod.rs +++ b/crates/idx-core/src/api/msn/mod.rs @@ -19,9 +19,11 @@ use crate::error::IdxError; use client::MsnClient; use map::{ - parse_earnings, parse_financial_statements, parse_fundamentals, parse_history, parse_insights, - parse_news, parse_profile, parse_quote, parse_screener_results, parse_sentiment, + company_names, parse_earnings, parse_financial_statements, parse_fundamentals, parse_history, + parse_insights, parse_news, parse_profile, parse_quote, parse_screener_results, + parse_sentiment, }; +use symbols::resolve_msn_id; #[cfg(feature = "testing")] 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 { fn news(&self, symbol: &str, limit: usize) -> Result, IdxError> { + let id = + resolve_msn_id(symbol).ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?; 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) } } diff --git a/crates/idx-core/src/api/msn/raw_types.rs b/crates/idx-core/src/api/msn/raw_types.rs index ab2381d..ee3452e 100644 --- a/crates/idx-core/src/api/msn/raw_types.rs +++ b/crates/idx-core/src/api/msn/raw_types.rs @@ -11,6 +11,8 @@ pub(crate) struct MsnQuote { pub(crate) symbol: Option, #[serde(default)] pub(crate) short_name: Option, + #[serde(default)] + pub(crate) display_name: Option, pub(crate) price: Option, #[serde(default)] pub(crate) price_change: Option, @@ -281,6 +283,21 @@ pub(super) struct RawNewsItem { pub(super) provider: Option, pub(super) published_date_time: Option, pub(super) read_time_min: Option, + #[serde(default)] + pub(super) finance_metadata: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct RawNewsFinanceMetadata { + /// MSN instrument ids the article is tagged with. + #[serde(default)] + pub(super) stocks: Vec, +} + +#[derive(Debug, Deserialize)] +pub(super) struct RawNewsStock { + #[serde(rename = "stockId")] + pub(super) stock_id: Option, } #[derive(Debug, Deserialize)] diff --git a/crates/idx-core/src/api/types.rs b/crates/idx-core/src/api/types.rs index bf2552c..8949e8f 100644 --- a/crates/idx-core/src/api/types.rs +++ b/crates/idx-core/src/api/types.rs @@ -214,6 +214,27 @@ pub struct NewsItem { pub provider: String, pub published_at: String, pub read_time_min: Option, + /// 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)] diff --git a/crates/idx-core/src/financial.rs b/crates/idx-core/src/financial.rs index 19c2fb4..69486ea 100644 --- a/crates/idx-core/src/financial.rs +++ b/crates/idx-core/src/financial.rs @@ -122,7 +122,8 @@ impl Financial<'_> { policy: CachePolicy, ) -> Result>, IdxError> { 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 ttl = self.idx.config.quote_ttl; let fetched = self.fetch("news", &key, &resolved, ttl, policy, || { diff --git a/crates/idx-mcp/src/server.rs b/crates/idx-mcp/src/server.rs index eba79fa..558d0ed 100644 --- a/crates/idx-mcp/src/server.rs +++ b/crates/idx-mcp/src/server.rs @@ -162,8 +162,9 @@ for one or more IDX tickers." #[tool( 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." +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." )] async fn get_company(&self, params: Parameters) -> ToolResult { let CompanyParams { symbol, sections } = params.0; diff --git a/crates/idx-mcp/tests/golden/tools.json b/crates/idx-mcp/tests/golden/tools.json index ffd6b54..9287ed4 100644 --- a/crates/idx-mcp/tests/golden/tools.json +++ b/crates/idx-mcp/tests/golden/tools.json @@ -58,7 +58,7 @@ "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": { "$defs": { "CompanySection": { diff --git a/web/src/content/docs/cli/stocks.md b/web/src/content/docs/cli/stocks.md index 88c2b8c..3f366c4 100644 --- a/web/src/content/docs/cli/stocks.md +++ b/web/src/content/docs/cli/stocks.md @@ -26,7 +26,7 @@ them from stdin. `history` takes `--period` (`1d` to `5y`) and `--interval` | `earnings` | reported and estimated earnings | | `sentiment` | crowd sentiment | | `insights` | AI insights from the provider | -| `news` | recent news | +| `news` | recent news, this company's first (`ABOUT` = `company`), then general market news (`market`) | ## Analysis diff --git a/web/src/content/docs/mcp/tools.md b/web/src/content/docs/mcp/tools.md index 6d85ce7..16e57fa 100644 --- a/web/src/content/docs/mcp/tools.md +++ b/web/src/content/docs/mcp/tools.md @@ -29,7 +29,7 @@ Compare fundamental reports (growth, valuation, risk) across IDX tickers. Ticker ## `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 | | --- | --- | --- | --- | From 8c9c93339f418bf81c6bf3d74790353534a6ed06 Mon Sep 17 00:00:00 2001 From: hermes Date: Fri, 16 Oct 2026 08:31:11 +0000 Subject: [PATCH 15/21] fix(news): order news by instant, not timestamp string String order put 10:00:00Z before the newer 10:00:00.500Z and mixed time-zone offsets; with a small --limit the newest item could drop out. Compare parsed RFC 3339 instants; missing or unparseable dates sort last. From a Codex review. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- crates/idx-core/src/api/msn/map.rs | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/idx-core/src/api/msn/map.rs b/crates/idx-core/src/api/msn/map.rs index e94e594..b47f1a9 100644 --- a/crates/idx-core/src/api/msn/map.rs +++ b/crates/idx-core/src/api/msn/map.rs @@ -604,11 +604,13 @@ pub(super) fn parse_news( } }) .collect(); - // ISO 8601 UTC timestamps sort as strings; missing dates sort last. + // 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(|| b.published_at.cmp(&a.published_at)) + .then_with(|| instant(b).cmp(&instant(a))) }); items.truncate(limit); Ok(items) @@ -945,6 +947,24 @@ mod tests { 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!([ From d2ad5c142b97220c1bde8099bf3311cddb02dcfa Mon Sep 17 00:00:00 2001 From: hermes Date: Fri, 16 Oct 2026 08:45:34 +0000 Subject: [PATCH 16/21] docs: research IDX news and disclosures, propose a published feed MSN has no company news for many tickers; IDX's disclosures cover every issuer. idx.co.id is behind a Cloudflare challenge that plain curl and curl-impersonate fail from the maintainer host, while the local Camofox browser gets through. Record the working endpoints (per-issuer and all-issuer disclosures, exchange news), their shapes and quirks, the options, and a proposed design: a maintainer publisher writes static JSON to a GitHub release, as the ownership snapshot does, and idx-core merges it into news. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- AGENTS.md | 1 + ROADMAP.md | 2 + docs/IDX_NEWS.md | 115 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+) create mode 100644 docs/IDX_NEWS.md diff --git a/AGENTS.md b/AGENTS.md index 6eb9efa..ed234e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,7 @@ Docs: - `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/DESIGN.md`: idx-core / idx-mcp design and parity gates +- `docs/IDX_NEWS.md`: IDX news and disclosures research, proposed publisher design - `ROADMAP.md`: open work ## Rules diff --git a/ROADMAP.md b/ROADMAP.md index f4d7f89..51672e6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,6 +11,8 @@ history lives in git. - `screen presets` / `screen run `: built-in screener presets (graduate `screen` out of `stocks` only if these grow into a richer workflow) - Multi-period financial statements (today `financials` is single-period) +- IDX company disclosures and exchange news in `news` / `get_company`, through + a maintainer-published feed (`docs/IDX_NEWS.md`) ## Workflow - `watchlist`: named symbol sets usable by other commands diff --git a/docs/IDX_NEWS.md b/docs/IDX_NEWS.md new file mode 100644 index 0000000..9894fbb --- /dev/null +++ b/docs/IDX_NEWS.md @@ -0,0 +1,115 @@ +# IDX News and Disclosures + +Research notes and a proposed design for adding IDX's own news and company +disclosures next to MSN news. Nothing here ships yet; `idx stocks news` and the +`get_company` news section use MSN only (see `docs/ARCHITECTURE.md`). +Measured 2026-10-16. + +## 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 challenge. From the maintainer host +(a datacenter VM), every page and API below returns `403` to plain curl and to +`curl_chrome142` (curl-impersonate), the client ownership discovery uses. +Untested from residential networks. + +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: ""`: + `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/"}]}]}`. + +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) | Blocked from datacenter IPs; may work from homes, but unreliable. At most a best-effort fallback. | +| Ship a browser with idx | No: heavy, fragile, and out of place in a CLI and MCP server. | +| Hosted API that fetches IDX on request (e.g. a Cloudflare Worker) | Gets the same challenge; needs a browser anyway. | +| **Maintainer publisher → static JSON → users** | **Recommended.** Same model as the ownership snapshot. | + +## 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 dedicated GitHub release, `idx-news-current`, as a manifest + (stable URL, the commit point) plus content-addressed JSON, as + `docs/OWNERSHIP_PUBLISH.md` does. Run hourly on trading days; a freshness + check alerts when the manifest is stale. + +**Consumer** (idx-core): + +- A provider that fetches the manifest and JSON over plain HTTPS (no + Cloudflare on GitHub release assets), cached like other providers. +- `stocks news ` / `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. + +## Open questions + +- IDX terms of use for redistributing disclosure metadata and links; links to + IDX-hosted PDFs rather than copies keep the footprint small. +- Publisher reliability: Camofox is a single local service; the challenge + behavior can change. The freshness check and MSN fallback bound the impact. +- Whether `GetAnnouncement` per issuer is needed for backfill beyond the + rolling window. From 2d0bceade2f838ff4cc591df82d79be7391238d5 Mon Sep 17 00:00:00 2001 From: hermes Date: Fri, 16 Oct 2026 09:31:33 +0000 Subject: [PATCH 17/21] docs: news sources research: one publisher, Google News, greencloud test - Why not a browser per user: a full engine per install and fetch, each user facing the challenge, IDX seeing thousands of scrapers; one scheduled publisher serving static files instead (diagram). Hosting left open (GitHub release, Cloudflare Pages/R2, a Worker). - The IDX challenge is JavaScript-based: curl fails from a second datacenter (greencloud-vps, Singapore) too, so IP is not what curl lacks. - Other sources measured: Google News RSS covers companies MSN misses (BREN 75/100 titles naming the ticker in 7 days), plain HTTP; Yahoo search answers 429. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- AGENTS.md | 2 +- ROADMAP.md | 5 ++- docs/IDX_NEWS.md | 95 +++++++++++++++++++++++++++++++++++++----------- 3 files changed, 77 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ed234e9..36d9690 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ Docs: - `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/DESIGN.md`: idx-core / idx-mcp design and parity gates -- `docs/IDX_NEWS.md`: IDX news and disclosures research, proposed publisher design +- `docs/IDX_NEWS.md`: news sources research (IDX disclosures, Google News, Yahoo) and a proposed publisher design - `ROADMAP.md`: open work ## Rules diff --git a/ROADMAP.md b/ROADMAP.md index 51672e6..319318d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,8 +11,9 @@ history lives in git. - `screen presets` / `screen run `: built-in screener presets (graduate `screen` out of `stocks` only if these grow into a richer workflow) - Multi-period financial statements (today `financials` is single-period) -- IDX company disclosures and exchange news in `news` / `get_company`, through - a maintainer-published feed (`docs/IDX_NEWS.md`) +- 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 - `watchlist`: named symbol sets usable by other commands diff --git a/docs/IDX_NEWS.md b/docs/IDX_NEWS.md index 9894fbb..597a691 100644 --- a/docs/IDX_NEWS.md +++ b/docs/IDX_NEWS.md @@ -1,9 +1,9 @@ -# IDX News and Disclosures +# News Sources Research -Research notes and a proposed design for adding IDX's own news and company -disclosures next to MSN news. Nothing here ships yet; `idx stocks news` and the -`get_company` news section use MSN only (see `docs/ARCHITECTURE.md`). -Measured 2026-10-16. +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 @@ -16,10 +16,14 @@ news. ## What blocks direct access -`www.idx.co.id` is behind a Cloudflare challenge. From the maintainer host -(a datacenter VM), every page and API below returns `403` to plain curl and to -`curl_chrome142` (curl-impersonate), the client ownership discovery uses. -Untested from residential networks. +`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: @@ -71,10 +75,25 @@ Quirks: | Option | Verdict | | --- | --- | -| Users fetch IDX directly (curl-impersonate) | Blocked from datacenter IPs; may work from homes, but unreliable. At most a best-effort fallback. | -| Ship a browser with idx | No: heavy, fragile, and out of place in a CLI and MCP server. | +| 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. | -| **Maintainer publisher → static JSON → users** | **Recommended.** Same model as the ownership snapshot. | +| **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 @@ -90,26 +109,58 @@ Quirks: 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 dedicated GitHub release, `idx-news-current`, as a manifest - (stable URL, the commit point) plus content-addressed JSON, as - `docs/OWNERSHIP_PUBLISH.md` does. Run hourly on trading days; a freshness - check alerts when the manifest is stale. +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 manifest and JSON over plain HTTPS (no - Cloudflare on GitHub release assets), cached like other providers. +- A provider that fetches the JSON over plain HTTPS (no challenge on the + static host), cached like other providers. - `stocks news ` / `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=+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=.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 redistributing disclosure metadata and links; links to - IDX-hosted PDFs rather than copies keep the footprint small. -- Publisher reliability: Camofox is a single local service; the challenge - behavior can change. The freshness check and MSN fallback bound the impact. +- 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. From db7c90861cb1a7c77c186b9204cbbeaa1e36ffa5 Mon Sep 17 00:00:00 2001 From: hermes Date: Fri, 16 Oct 2026 08:13:30 +0000 Subject: [PATCH 18/21] feat(mcp): page get_ownership_changes and summarize all changes Between the two latest releases there are 1,568 holder changes. With only `limit` (up to 2000, ~200 KB), agents that wanted them all overflowed their tool output: Hermes then paged ticker by ticker (32 calls), and opencode 1 and 2 did the same and timed out. - `offset` pages through the rows; responses carry `offset` and `next_offset` (null after the last page). Pages cap at 300 rows (~40 KB); the over-limit error points to `offset` and `summary`. - Rows come largest |delta| first, so the first page holds the moves that matter rather than tickers starting with A. - `summary` covers every row: counts by change type, the number of tickers, and the ten tickers with the most changes. The CLI's `ownership changes` is unchanged. tools.json and the website's MCP pages are regenerated for the new description and parameter. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- crates/idx-mcp/src/params.rs | 5 +- crates/idx-mcp/src/server.rs | 72 +++++++++++++++++++++++--- crates/idx-mcp/tests/golden/tools.json | 14 ++++- crates/idx-mcp/tests/mcp.rs | 38 ++++++++++++++ web/src/content/docs/mcp/index.md | 2 +- web/src/content/docs/mcp/tools.md | 5 +- 6 files changed, 124 insertions(+), 12 deletions(-) diff --git a/crates/idx-mcp/src/params.rs b/crates/idx-mcp/src/params.rs index 9c4a1b8..4bab5c7 100644 --- a/crates/idx-mcp/src/params.rs +++ b/crates/idx-mcp/src/params.rs @@ -144,9 +144,12 @@ pub struct ChangesParams { /// Only this ticker's changes. #[serde(default)] pub ticker: Option, - /// 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)] pub limit: Option, + /// Rows to skip, for the next page: the previous response's `next_offset`. + #[serde(default)] + pub offset: Option, } #[derive(Debug, Deserialize, JsonSchema)] diff --git a/crates/idx-mcp/src/server.rs b/crates/idx-mcp/src/server.rs index 558d0ed..d66819b 100644 --- a/crates/idx-mcp/src/server.rs +++ b/crates/idx-mcp/src/server.rs @@ -2,12 +2,15 @@ //! `Idx` (as one CLI invocation would), so tools share the CLI's on-disk //! cache and ownership database. +use std::collections::BTreeMap; + use idx_core::analysis::fundamental::FundamentalReport; use idx_core::config::CoreConfig; use idx_core::error::IdxError; use idx_core::financial::{EarningsFilter, StatementFilter}; use idx_core::investor::{HolderSource, Investor}; use idx_core::market::{ScreenFilter, ScreenQuery, ScreenRegion}; +use idx_core::ownership::types::ChangeRow; use idx_core::{CachePolicy, Fetched, Idx}; use rmcp::handler::server::router::tool::ToolRouter; use rmcp::handler::server::wrapper::Parameters; @@ -32,7 +35,9 @@ const CHANGES_LIMIT: usize = 200; const MAX_SYMBOLS: usize = 20; const MAX_SCREEN_LIMIT: usize = 200; 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. static CALL_SLOTS: Semaphore = Semaphore::const_new(4); @@ -294,8 +299,10 @@ with concentration metrics (top1, top3, HHI, free float)." } #[tool( - 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." )] async fn get_ownership_changes(&self, params: Parameters) -> ToolResult { let ChangesParams { @@ -303,13 +310,39 @@ release dates, optionally for one ticker." to, ticker, limit, + offset, } = params.0; 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())?; + 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(); - changes.truncate(limit); - Ok(json!({ "from": from, "to": to, "total": total, "changes": changes })) + let summary = changes_summary(&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 } @@ -455,6 +488,33 @@ fn dedup_in_order(values: &mut Vec) { }); } +/// 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 = 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::>(), + }) +} + fn bounded_limit( field: &str, value: Option, diff --git a/crates/idx-mcp/tests/golden/tools.json b/crates/idx-mcp/tests/golden/tools.json index 9287ed4..8464d38 100644 --- a/crates/idx-mcp/tests/golden/tools.json +++ b/crates/idx-mcp/tests/golden/tools.json @@ -115,7 +115,7 @@ "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": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { @@ -125,7 +125,17 @@ }, "limit": { "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", "minimum": 0, "type": [ diff --git a/crates/idx-mcp/tests/mcp.rs b/crates/idx-mcp/tests/mcp.rs index e7d07b4..c35ef5a 100644 --- a/crates/idx-mcp/tests/mcp.rs +++ b/crates/idx-mcp/tests/mcp.rs @@ -346,6 +346,44 @@ fn ownership_tools_read_the_local_database() { ); assert_eq!(limited["total"], total); 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()); + 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::(), + total + ); + assert!(all["summary"]["tickers"].as_u64().unwrap() >= 1, "{all}"); + + 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( "get_ownership_changes", diff --git a/web/src/content/docs/mcp/index.md b/web/src/content/docs/mcp/index.md index d34a6e7..d4e06b1 100644 --- a/web/src/content/docs/mcp/index.md +++ b/web/src/content/docs/mcp/index.md @@ -45,7 +45,7 @@ body, so the agent can decide whether to retry: | tickers per call (`get_quotes`, `compare_stocks`) | 20, duplicates removed | | `screen_stocks` `limit` | 200 | | `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 | ## Ownership data diff --git a/web/src/content/docs/mcp/tools.md b/web/src/content/docs/mcp/tools.md index 16e57fa..55bf06b 100644 --- a/web/src/content/docs/mcp/tools.md +++ b/web/src/content/docs/mcp/tools.md @@ -46,12 +46,13 @@ Every IDX holding (above 1%) of one investor, matched by name. ## `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 | | --- | --- | --- | --- | | `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. | | `to` | string | yes | Later release date, YYYY-MM-DD. | From 01f181689e0dcc4d13b81a07365418b63c083ade Mon Sep 17 00:00:00 2001 From: hermes Date: Fri, 16 Oct 2026 09:00:44 +0000 Subject: [PATCH 19/21] test(mcp): pin paging past the end and the per-page summary From the Pullfrog review of #48: an offset at or past `total` returns an empty page with `next_offset: null`, and every page carries the same `summary` (it covers all rows). Assert both. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- crates/idx-mcp/tests/mcp.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/idx-mcp/tests/mcp.rs b/crates/idx-mcp/tests/mcp.rs index c35ef5a..906c87f 100644 --- a/crates/idx-mcp/tests/mcp.rs +++ b/crates/idx-mcp/tests/mcp.rs @@ -357,6 +357,8 @@ fn ownership_tools_read_the_local_database() { 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); @@ -377,6 +379,21 @@ fn ownership_tools_read_the_local_database() { ); 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}), From c6ec583edc23467d49aa298217a67b7ce6d9b1ca Mon Sep 17 00:00:00 2001 From: hermes Date: Fri, 16 Oct 2026 04:53:56 +0000 Subject: [PATCH 20/21] chore: release 0.3.2 Patch release for the npm launcher fix (#41, #42): `idx` and `idx-mcp` now install their binary on first run when the package manager skipped postinstall, as npm 12 does under `npx` with its `allow-scripts` policy (and pnpm 10+ and bun by default). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- Cargo.lock | 6 +++--- Cargo.toml | 4 ++-- README.md | 2 +- docs/INSTALL.md | 4 ++-- docs/NPM_DISTRIBUTION.md | 4 ++-- npm/idx-mcp/package.json | 2 +- package.json | 2 +- web/src/content/docs/start/install.mdx | 2 +- 8 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b5660f1..c3be9fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -901,7 +901,7 @@ dependencies = [ [[package]] name = "idx-cli" -version = "0.3.1" +version = "0.3.2" dependencies = [ "assert_cmd", "chrono", @@ -921,7 +921,7 @@ dependencies = [ [[package]] name = "idx-core" -version = "0.3.1" +version = "0.3.2" dependencies = [ "chrono", "clap", @@ -940,7 +940,7 @@ dependencies = [ [[package]] name = "idx-mcp" -version = "0.3.1" +version = "0.3.2" dependencies = [ "clap", "idx-core", diff --git a/Cargo.toml b/Cargo.toml index 30b7c40..59b3c94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/idx-core", "crates/idx-cli", "crates/idx-mcp"] [workspace.package] -version = "0.3.1" +version = "0.3.2" edition = "2024" rust-version = "1.89" license = "MIT" @@ -11,7 +11,7 @@ repository = "https://github.com/0xrsydn/idx-cli" homepage = "https://idx.rasyidanaf.com" [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"] } serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["raw_value"] } diff --git a/README.md b/README.md index f4bf29a..27089da 100644 --- a/README.md +++ b/README.md @@ -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, verifies it against the release `SHA256SUMS`, and installs it into `~/.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 diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 02d6506..fee5306 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -11,7 +11,7 @@ Each release also carries a copy of the script as a release asset, so a versioned URL stays stable: ```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 @@ -45,7 +45,7 @@ them yourself. Pass flags through the pipe with `sh -s --`: ```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 diff --git a/docs/NPM_DISTRIBUTION.md b/docs/NPM_DISTRIBUTION.md index 3d76548..d052bb6 100644 --- a/docs/NPM_DISTRIBUTION.md +++ b/docs/NPM_DISTRIBUTION.md @@ -38,7 +38,7 @@ npx idx-cli --help ``` 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 refuses to install a binary whose checksum does not match. @@ -105,7 +105,7 @@ URLs must stay aligned with `scripts/install.js`. To attach binaries to a tag that already exists, run the workflow manually: ```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 diff --git a/npm/idx-mcp/package.json b/npm/idx-mcp/package.json index f9a49d0..0617695 100644 --- a/npm/idx-mcp/package.json +++ b/npm/idx-mcp/package.json @@ -1,6 +1,6 @@ { "name": "idx-mcp", - "version": "0.3.1", + "version": "0.3.2", "description": "MCP server for Indonesian stock market (IDX) market, financial, and ownership data", "license": "MIT", "repository": { diff --git a/package.json b/package.json index 4752a30..45e2df7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "idx-cli", - "version": "0.3.1", + "version": "0.3.2", "description": "CLI tool for Indonesian stock market (IDX) analysis", "license": "MIT", "repository": { diff --git a/web/src/content/docs/start/install.mdx b/web/src/content/docs/start/install.mdx index 6a51ee5..6ea07a7 100644 --- a/web/src/content/docs/start/install.mdx +++ b/web/src/content/docs/start/install.mdx @@ -40,7 +40,7 @@ against the release `SHA256SUMS`, and installs it into `~/.local/bin` without sudo. Pin a version or change the directory: ```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 ``` From 4357a2ba7369399eb9c898d35a2e64ac611ce7d9 Mon Sep 17 00:00:00 2001 From: hermes Date: Fri, 16 Oct 2026 10:01:47 +0000 Subject: [PATCH 21/21] docs: release notes format in the release order Notes in the style of other open-source releases: an upgrade line when urgent, New Features / Bug Fixes / Improvements with one user-facing sentence and PR numbers per item, and a Full Changelog compare link. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- docs/NPM_DISTRIBUTION.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/NPM_DISTRIBUTION.md b/docs/NPM_DISTRIBUTION.md index d052bb6..e04345b 100644 --- a/docs/NPM_DISTRIBUTION.md +++ b/docs/NPM_DISTRIBUTION.md @@ -119,7 +119,14 @@ Release order: 2. Push the tag, or run the workflow for an existing tag. 3. Publish the crates from the tagged commit, dependency first: `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 `; 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/...`. 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 approval or `--otp=`.