diff --git a/.agent/skills/gate-cli-add-module/SKILL.md b/.agent/skills/gate-cli-add-module/SKILL.md index 5e5b6d7..5380749 100644 --- a/.agent/skills/gate-cli-add-module/SKILL.md +++ b/.agent/skills/gate-cli-add-module/SKILL.md @@ -83,10 +83,10 @@ Record exact field names and types — they often differ from what you'd guess ( ## Step 4 — Scaffold Files -Create `cmd//` with one file per subgroup: +Create `cmd/cex//` with one file per subgroup: ``` -cmd// +cmd/cex// .go — root Cmd only market.go — public endpoints (no auth) account.go — account/balance endpoints (auth) @@ -237,21 +237,23 @@ return &Client{ } ``` -**`cmd/root.go`** — register the command: +**`cmd/cex/cex.go`** — register the command under the `cex` group: ```go -import "github.com/gate/gate-cli/cmd/" -// ... -rootCmd.AddCommand(.Cmd) +import "github.com/gate/gate-cli/cmd/cex/" +// in init(): +Cmd.AddCommand(.Cmd) ``` +(`cmd/root.go` already adds `cex.Cmd`; do not register business modules on `rootCmd` directly.) + ## Step 6 — Build and Test ```bash go build -o gate-cli . go test ./... -./gate-cli --help # verify group structure -./gate-cli market --help # verify leaf commands +./gate-cli cex --help # verify group structure +./gate-cli cex market --help # verify leaf commands ``` Fix any field-name mismatches against actual SDK model files (the agent's guess about field names is often wrong — always verify). @@ -272,7 +274,7 @@ Fix all issues, re-review if needed. When SDK was upgraded in the same session, include `go.mod`/`go.sum` in the module's commit: ```bash -git add cmd// cmd/root.go internal/client/client.go go.mod go.sum +git add cmd/cex/cex.go cmd/cex// internal/client/client.go go.mod go.sum git commit -m "feat(): add module with full API coverage Adds N commands across M files: diff --git a/.agent/skills/playwright-cli/SKILL.md b/.agent/skills/playwright-cli/SKILL.md new file mode 100644 index 0000000..f648e92 --- /dev/null +++ b/.agent/skills/playwright-cli/SKILL.md @@ -0,0 +1,351 @@ +--- +name: playwright-cli +description: Automate browser interactions, test web pages and work with Playwright tests. +allowed-tools: Bash(playwright-cli:*) Bash(npx:*) Bash(npm:*) +--- + +# Browser Automation with playwright-cli + +## Quick start + +```bash +# open new browser +playwright-cli open +# navigate to a page +playwright-cli goto https://playwright.dev +# interact with the page using refs from the snapshot +playwright-cli click e15 +playwright-cli type "page.click" +playwright-cli press Enter +# take a screenshot (rarely used, as snapshot is more common) +playwright-cli screenshot +# close the browser +playwright-cli close +``` + +## Commands + +### Core + +```bash +playwright-cli open +# open and navigate right away +playwright-cli open https://example.com/ +playwright-cli goto https://playwright.dev +playwright-cli type "search query" +playwright-cli click e3 +playwright-cli dblclick e7 +# --submit presses Enter after filling the element +playwright-cli fill e5 "user@example.com" --submit +playwright-cli drag e2 e8 +playwright-cli hover e4 +playwright-cli select e9 "option-value" +playwright-cli upload ./document.pdf +playwright-cli check e12 +playwright-cli uncheck e12 +playwright-cli snapshot +playwright-cli eval "document.title" +playwright-cli eval "el => el.textContent" e5 +# get element id, class, or any attribute not visible in the snapshot +playwright-cli eval "el => el.id" e5 +playwright-cli eval "el => el.getAttribute('data-testid')" e5 +playwright-cli dialog-accept +playwright-cli dialog-accept "confirmation text" +playwright-cli dialog-dismiss +playwright-cli resize 1920 1080 +playwright-cli close +``` + +### Navigation + +```bash +playwright-cli go-back +playwright-cli go-forward +playwright-cli reload +``` + +### Keyboard + +```bash +playwright-cli press Enter +playwright-cli press ArrowDown +playwright-cli keydown Shift +playwright-cli keyup Shift +``` + +### Mouse + +```bash +playwright-cli mousemove 150 300 +playwright-cli mousedown +playwright-cli mousedown right +playwright-cli mouseup +playwright-cli mouseup right +playwright-cli mousewheel 0 100 +``` + +### Save as + +```bash +playwright-cli screenshot +playwright-cli screenshot e5 +playwright-cli screenshot --filename=page.png +playwright-cli pdf --filename=page.pdf +``` + +### Tabs + +```bash +playwright-cli tab-list +playwright-cli tab-new +playwright-cli tab-new https://example.com/page +playwright-cli tab-close +playwright-cli tab-close 2 +playwright-cli tab-select 0 +``` + +### Storage + +```bash +playwright-cli state-save +playwright-cli state-save auth.json +playwright-cli state-load auth.json + +# Cookies +playwright-cli cookie-list +playwright-cli cookie-list --domain=example.com +playwright-cli cookie-get session_id +playwright-cli cookie-set session_id abc123 +playwright-cli cookie-set session_id abc123 --domain=example.com --httpOnly --secure +playwright-cli cookie-delete session_id +playwright-cli cookie-clear + +# LocalStorage +playwright-cli localstorage-list +playwright-cli localstorage-get theme +playwright-cli localstorage-set theme dark +playwright-cli localstorage-delete theme +playwright-cli localstorage-clear + +# SessionStorage +playwright-cli sessionstorage-list +playwright-cli sessionstorage-get step +playwright-cli sessionstorage-set step 3 +playwright-cli sessionstorage-delete step +playwright-cli sessionstorage-clear +``` + +### Network + +```bash +playwright-cli route "**/*.jpg" --status=404 +playwright-cli route "https://api.example.com/**" --body='{"mock": true}' +playwright-cli route-list +playwright-cli unroute "**/*.jpg" +playwright-cli unroute +``` + +### DevTools + +```bash +playwright-cli console +playwright-cli console warning +playwright-cli network +playwright-cli run-code "async page => await page.context().grantPermissions(['geolocation'])" +playwright-cli run-code --filename=script.js +playwright-cli tracing-start +playwright-cli tracing-stop +playwright-cli video-start video.webm +playwright-cli video-chapter "Chapter Title" --description="Details" --duration=2000 +playwright-cli video-stop +``` + +## Raw output + +The global `--raw` option strips page status, generated code, and snapshot sections from the output, returning only the result value. Use it to pipe command output into other tools. Commands that don't produce output return nothing. + +```bash +playwright-cli --raw eval "JSON.stringify(performance.timing)" | jq '.loadEventEnd - .navigationStart' +playwright-cli --raw eval "JSON.stringify([...document.querySelectorAll('a')].map(a => a.href))" > links.json +playwright-cli --raw snapshot > before.yml +playwright-cli click e5 +playwright-cli --raw snapshot > after.yml +diff before.yml after.yml +TOKEN=$(playwright-cli --raw cookie-get session_id) +playwright-cli --raw localstorage-get theme +``` + +## Open parameters +```bash +# Use specific browser when creating session +playwright-cli open --browser=chrome +playwright-cli open --browser=firefox +playwright-cli open --browser=webkit +playwright-cli open --browser=msedge + +# Use persistent profile (by default profile is in-memory) +playwright-cli open --persistent +# Use persistent profile with custom directory +playwright-cli open --profile=/path/to/profile + +# Connect to browser via extension +playwright-cli attach --extension + +# Connect to a running Chrome or Edge by channel name +playwright-cli attach --cdp=chrome +playwright-cli attach --cdp=msedge + +# Connect to a running browser via CDP endpoint +playwright-cli attach --cdp=http://localhost:9222 + +# Start with config file +playwright-cli open --config=my-config.json + +# Close the browser +playwright-cli close +# Delete user data for the default session +playwright-cli delete-data +``` + +## Snapshots + +After each command, playwright-cli provides a snapshot of the current browser state. + +```bash +> playwright-cli goto https://example.com +### Page +- Page URL: https://example.com/ +- Page Title: Example Domain +### Snapshot +[Snapshot](.playwright-cli/page-2026-02-14T19-22-42-679Z.yml) +``` + +You can also take a snapshot on demand using `playwright-cli snapshot` command. All the options below can be combined as needed. + +```bash +# default - save to a file with timestamp-based name +playwright-cli snapshot + +# save to file, use when snapshot is a part of the workflow result +playwright-cli snapshot --filename=after-click.yaml + +# snapshot an element instead of the whole page +playwright-cli snapshot "#main" + +# limit snapshot depth for efficiency, take a partial snapshot afterwards +playwright-cli snapshot --depth=4 +playwright-cli snapshot e34 +``` + +## Targeting elements + +By default, use refs from the snapshot to interact with page elements. + +```bash +# get snapshot with refs +playwright-cli snapshot + +# interact using a ref +playwright-cli click e15 +``` + +You can also use css selectors or Playwright locators. + +```bash +# css selector +playwright-cli click "#main > button.submit" + +# role locator +playwright-cli click "getByRole('button', { name: 'Submit' })" + +# test id +playwright-cli click "getByTestId('submit-button')" +``` + +## Browser Sessions + +```bash +# create new browser session named "mysession" with persistent profile +playwright-cli -s=mysession open example.com --persistent +# same with manually specified profile directory (use when requested explicitly) +playwright-cli -s=mysession open example.com --profile=/path/to/profile +playwright-cli -s=mysession click e6 +playwright-cli -s=mysession close # stop a named browser +playwright-cli -s=mysession delete-data # delete user data for persistent session + +playwright-cli list +# Close all browsers +playwright-cli close-all +# Forcefully kill all browser processes +playwright-cli kill-all +``` + +## Installation + +If global `playwright-cli` command is not available, try a local version via `npx playwright-cli`: + +```bash +npx --no-install playwright-cli --version +``` + +When local version is available, use `npx playwright-cli` in all commands. Otherwise, install `playwright-cli` as a global command: + +```bash +npm install -g @playwright/cli@latest +``` + +## Example: Form submission + +```bash +playwright-cli open https://example.com/form +playwright-cli snapshot + +playwright-cli fill e1 "user@example.com" +playwright-cli fill e2 "password123" +playwright-cli click e3 +playwright-cli snapshot +playwright-cli close +``` + +## Example: Multi-tab workflow + +```bash +playwright-cli open https://example.com +playwright-cli tab-new https://example.com/other +playwright-cli tab-list +playwright-cli tab-select 0 +playwright-cli snapshot +playwright-cli close +``` + +## Example: Debugging with DevTools + +```bash +playwright-cli open https://example.com +playwright-cli click e4 +playwright-cli fill e7 "test" +playwright-cli console +playwright-cli network +playwright-cli close +``` + +```bash +playwright-cli open https://example.com +playwright-cli tracing-start +playwright-cli click e4 +playwright-cli fill e7 "test" +playwright-cli tracing-stop +playwright-cli close +``` + +## Specific tasks + +* **Running and Debugging Playwright tests** [references/playwright-tests.md](references/playwright-tests.md) +* **Request mocking** [references/request-mocking.md](references/request-mocking.md) +* **Running Playwright code** [references/running-code.md](references/running-code.md) +* **Browser session management** [references/session-management.md](references/session-management.md) +* **Storage state (cookies, localStorage)** [references/storage-state.md](references/storage-state.md) +* **Test generation** [references/test-generation.md](references/test-generation.md) +* **Tracing** [references/tracing.md](references/tracing.md) +* **Video recording** [references/video-recording.md](references/video-recording.md) +* **Inspecting element attributes** [references/element-attributes.md](references/element-attributes.md) diff --git a/.agent/skills/playwright-cli/references/element-attributes.md b/.agent/skills/playwright-cli/references/element-attributes.md new file mode 100644 index 0000000..4e9fa6b --- /dev/null +++ b/.agent/skills/playwright-cli/references/element-attributes.md @@ -0,0 +1,23 @@ +# Inspecting Element Attributes + +When the snapshot doesn't show an element's `id`, `class`, `data-*` attributes, or other DOM properties, use `eval` to inspect them. + +## Examples + +```bash +playwright-cli snapshot +# snapshot shows a button as e7 but doesn't reveal its id or data attributes + +# get the element's id +playwright-cli eval "el => el.id" e7 + +# get all CSS classes +playwright-cli eval "el => el.className" e7 + +# get a specific attribute +playwright-cli eval "el => el.getAttribute('data-testid')" e7 +playwright-cli eval "el => el.getAttribute('aria-label')" e7 + +# get a computed style property +playwright-cli eval "el => getComputedStyle(el).display" e7 +``` diff --git a/.agent/skills/playwright-cli/references/playwright-tests.md b/.agent/skills/playwright-cli/references/playwright-tests.md new file mode 100644 index 0000000..47627c2 --- /dev/null +++ b/.agent/skills/playwright-cli/references/playwright-tests.md @@ -0,0 +1,39 @@ +# Running Playwright Tests + +To run Playwright tests, use the `npx playwright test` command, or a package manager script. To avoid opening the interactive html report, use `PLAYWRIGHT_HTML_OPEN=never` environment variable. + +```bash +# Run all tests +PLAYWRIGHT_HTML_OPEN=never npx playwright test + +# Run all tests through a custom npm script +PLAYWRIGHT_HTML_OPEN=never npm run special-test-command +``` + +# Debugging Playwright Tests + +To debug a failing Playwright test, run it with `--debug=cli` option. This command will pause the test at the start and print the debugging instructions. + +**IMPORTANT**: run the command in the background and check the output until "Debugging Instructions" is printed. + +Once instructions containing a session name are printed, use `playwright-cli` to attach the session and explore the page. + +```bash +# Run the test +PLAYWRIGHT_HTML_OPEN=never npx playwright test --debug=cli +# ... +# ... debugging instructions for "tw-abcdef" session ... +# ... + +# Attach to the test +playwright-cli attach tw-abcdef +``` + +Keep the test running in the background while you explore and look for a fix. +The test is paused at the start, so you should step over or pause at a particular location +where the problem is most likely to be. + +Every action you perform with `playwright-cli` generates corresponding Playwright TypeScript code. +This code appears in the output and can be copied directly into the test. Most of the time, a specific locator or an expectation should be updated, but it could also be a bug in the app. Use your judgement. + +After fixing the test, stop the background test run. Rerun to check that test passes. diff --git a/.agent/skills/playwright-cli/references/request-mocking.md b/.agent/skills/playwright-cli/references/request-mocking.md new file mode 100644 index 0000000..9005fda --- /dev/null +++ b/.agent/skills/playwright-cli/references/request-mocking.md @@ -0,0 +1,87 @@ +# Request Mocking + +Intercept, mock, modify, and block network requests. + +## CLI Route Commands + +```bash +# Mock with custom status +playwright-cli route "**/*.jpg" --status=404 + +# Mock with JSON body +playwright-cli route "**/api/users" --body='[{"id":1,"name":"Alice"}]' --content-type=application/json + +# Mock with custom headers +playwright-cli route "**/api/data" --body='{"ok":true}' --header="X-Custom: value" + +# Remove headers from requests +playwright-cli route "**/*" --remove-header=cookie,authorization + +# List active routes +playwright-cli route-list + +# Remove a route or all routes +playwright-cli unroute "**/*.jpg" +playwright-cli unroute +``` + +## URL Patterns + +``` +**/api/users - Exact path match +**/api/*/details - Wildcard in path +**/*.{png,jpg,jpeg} - Match file extensions +**/search?q=* - Match query parameters +``` + +## Advanced Mocking with run-code + +For conditional responses, request body inspection, response modification, or delays: + +### Conditional Response Based on Request + +```bash +playwright-cli run-code "async page => { + await page.route('**/api/login', route => { + const body = route.request().postDataJSON(); + if (body.username === 'admin') { + route.fulfill({ body: JSON.stringify({ token: 'mock-token' }) }); + } else { + route.fulfill({ status: 401, body: JSON.stringify({ error: 'Invalid' }) }); + } + }); +}" +``` + +### Modify Real Response + +```bash +playwright-cli run-code "async page => { + await page.route('**/api/user', async route => { + const response = await route.fetch(); + const json = await response.json(); + json.isPremium = true; + await route.fulfill({ response, json }); + }); +}" +``` + +### Simulate Network Failures + +```bash +playwright-cli run-code "async page => { + await page.route('**/api/offline', route => route.abort('internetdisconnected')); +}" +# Options: connectionrefused, timedout, connectionreset, internetdisconnected +``` + +### Delayed Response + +```bash +playwright-cli run-code "async page => { + await page.route('**/api/slow', async route => { + await new Promise(r => setTimeout(r, 3000)); + route.fulfill({ body: JSON.stringify({ data: 'loaded' }) }); + }); +}" +``` diff --git a/.agent/skills/playwright-cli/references/running-code.md b/.agent/skills/playwright-cli/references/running-code.md new file mode 100644 index 0000000..8b35e9a --- /dev/null +++ b/.agent/skills/playwright-cli/references/running-code.md @@ -0,0 +1,231 @@ +# Running Custom Playwright Code + +Use `run-code` to execute arbitrary Playwright code for advanced scenarios not covered by CLI commands. + +## Syntax + +```bash +playwright-cli run-code "async page => { + // Your Playwright code here + // Access page.context() for browser context operations +}" +``` + +## Geolocation + +```bash +# Grant geolocation permission and set location +playwright-cli run-code "async page => { + await page.context().grantPermissions(['geolocation']); + await page.context().setGeolocation({ latitude: 37.7749, longitude: -122.4194 }); +}" + +# Set location to London +playwright-cli run-code "async page => { + await page.context().grantPermissions(['geolocation']); + await page.context().setGeolocation({ latitude: 51.5074, longitude: -0.1278 }); +}" + +# Clear geolocation override +playwright-cli run-code "async page => { + await page.context().clearPermissions(); +}" +``` + +## Permissions + +```bash +# Grant multiple permissions +playwright-cli run-code "async page => { + await page.context().grantPermissions([ + 'geolocation', + 'notifications', + 'camera', + 'microphone' + ]); +}" + +# Grant permissions for specific origin +playwright-cli run-code "async page => { + await page.context().grantPermissions(['clipboard-read'], { + origin: 'https://example.com' + }); +}" +``` + +## Media Emulation + +```bash +# Emulate dark color scheme +playwright-cli run-code "async page => { + await page.emulateMedia({ colorScheme: 'dark' }); +}" + +# Emulate light color scheme +playwright-cli run-code "async page => { + await page.emulateMedia({ colorScheme: 'light' }); +}" + +# Emulate reduced motion +playwright-cli run-code "async page => { + await page.emulateMedia({ reducedMotion: 'reduce' }); +}" + +# Emulate print media +playwright-cli run-code "async page => { + await page.emulateMedia({ media: 'print' }); +}" +``` + +## Wait Strategies + +```bash +# Wait for network idle +playwright-cli run-code "async page => { + await page.waitForLoadState('networkidle'); +}" + +# Wait for specific element +playwright-cli run-code "async page => { + await page.locator('.loading').waitFor({ state: 'hidden' }); +}" + +# Wait for function to return true +playwright-cli run-code "async page => { + await page.waitForFunction(() => window.appReady === true); +}" + +# Wait with timeout +playwright-cli run-code "async page => { + await page.locator('.result').waitFor({ timeout: 10000 }); +}" +``` + +## Frames and Iframes + +```bash +# Work with iframe +playwright-cli run-code "async page => { + const frame = page.locator('iframe#my-iframe').contentFrame(); + await frame.locator('button').click(); +}" + +# Get all frames +playwright-cli run-code "async page => { + const frames = page.frames(); + return frames.map(f => f.url()); +}" +``` + +## File Downloads + +```bash +# Handle file download +playwright-cli run-code "async page => { + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('link', { name: 'Download' }).click(); + const download = await downloadPromise; + await download.saveAs('./downloaded-file.pdf'); + return download.suggestedFilename(); +}" +``` + +## Clipboard + +```bash +# Read clipboard (requires permission) +playwright-cli run-code "async page => { + await page.context().grantPermissions(['clipboard-read']); + return await page.evaluate(() => navigator.clipboard.readText()); +}" + +# Write to clipboard +playwright-cli run-code "async page => { + await page.evaluate(text => navigator.clipboard.writeText(text), 'Hello clipboard!'); +}" +``` + +## Page Information + +```bash +# Get page title +playwright-cli run-code "async page => { + return await page.title(); +}" + +# Get current URL +playwright-cli run-code "async page => { + return page.url(); +}" + +# Get page content +playwright-cli run-code "async page => { + return await page.content(); +}" + +# Get viewport size +playwright-cli run-code "async page => { + return page.viewportSize(); +}" +``` + +## JavaScript Execution + +```bash +# Execute JavaScript and return result +playwright-cli run-code "async page => { + return await page.evaluate(() => { + return { + userAgent: navigator.userAgent, + language: navigator.language, + cookiesEnabled: navigator.cookieEnabled + }; + }); +}" + +# Pass arguments to evaluate +playwright-cli run-code "async page => { + const multiplier = 5; + return await page.evaluate(m => document.querySelectorAll('li').length * m, multiplier); +}" +``` + +## Error Handling + +```bash +# Try-catch in run-code +playwright-cli run-code "async page => { + try { + await page.getByRole('button', { name: 'Submit' }).click({ timeout: 1000 }); + return 'clicked'; + } catch (e) { + return 'element not found'; + } +}" +``` + +## Complex Workflows + +```bash +# Login and save state +playwright-cli run-code "async page => { + await page.goto('https://example.com/login'); + await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com'); + await page.getByRole('textbox', { name: 'Password' }).fill('secret'); + await page.getByRole('button', { name: 'Sign in' }).click(); + await page.waitForURL('**/dashboard'); + await page.context().storageState({ path: 'auth.json' }); + return 'Login successful'; +}" + +# Scrape data from multiple pages +playwright-cli run-code "async page => { + const results = []; + for (let i = 1; i <= 3; i++) { + await page.goto(\`https://example.com/page/\${i}\`); + const items = await page.locator('.item').allTextContents(); + results.push(...items); + } + return results; +}" +``` diff --git a/.agent/skills/playwright-cli/references/session-management.md b/.agent/skills/playwright-cli/references/session-management.md new file mode 100644 index 0000000..d1650ef --- /dev/null +++ b/.agent/skills/playwright-cli/references/session-management.md @@ -0,0 +1,209 @@ +# Browser Session Management + +Run multiple isolated browser sessions concurrently with state persistence. + +## Named Browser Sessions + +Use `-s` flag to isolate browser contexts: + +```bash +# Browser 1: Authentication flow +playwright-cli -s=auth open https://app.example.com/login + +# Browser 2: Public browsing (separate cookies, storage) +playwright-cli -s=public open https://example.com + +# Commands are isolated by browser session +playwright-cli -s=auth fill e1 "user@example.com" +playwright-cli -s=public snapshot +``` + +## Browser Session Isolation Properties + +Each browser session has independent: +- Cookies +- LocalStorage / SessionStorage +- IndexedDB +- Cache +- Browsing history +- Open tabs + +## Browser Session Commands + +```bash +# List all browser sessions +playwright-cli list + +# Stop a browser session (close the browser) +playwright-cli close # stop the default browser +playwright-cli -s=mysession close # stop a named browser + +# Stop all browser sessions +playwright-cli close-all + +# Forcefully kill all daemon processes (for stale/zombie processes) +playwright-cli kill-all + +# Delete browser session user data (profile directory) +playwright-cli delete-data # delete default browser data +playwright-cli -s=mysession delete-data # delete named browser data +``` + +## Environment Variable + +Set a default browser session name via environment variable: + +```bash +export PLAYWRIGHT_CLI_SESSION="mysession" +playwright-cli open example.com # Uses "mysession" automatically +``` + +## Common Patterns + +### Concurrent Scraping + +```bash +#!/bin/bash +# Scrape multiple sites concurrently + +# Start all browsers +playwright-cli -s=site1 open https://site1.com & +playwright-cli -s=site2 open https://site2.com & +playwright-cli -s=site3 open https://site3.com & +wait + +# Take snapshots from each +playwright-cli -s=site1 snapshot +playwright-cli -s=site2 snapshot +playwright-cli -s=site3 snapshot + +# Cleanup +playwright-cli close-all +``` + +### A/B Testing Sessions + +```bash +# Test different user experiences +playwright-cli -s=variant-a open "https://app.com?variant=a" +playwright-cli -s=variant-b open "https://app.com?variant=b" + +# Compare +playwright-cli -s=variant-a screenshot +playwright-cli -s=variant-b screenshot +``` + +### Persistent Profile + +By default, browser profile is kept in memory only. Use `--persistent` flag on `open` to persist the browser profile to disk: + +```bash +# Use persistent profile (auto-generated location) +playwright-cli open https://example.com --persistent + +# Use persistent profile with custom directory +playwright-cli open https://example.com --profile=/path/to/profile +``` + +## Attaching to a Running Browser + +Use `attach` to connect to a browser that is already running, instead of launching a new one. + +### Attach by channel name + +Connect to a running Chrome or Edge instance by its channel name. The browser must have remote debugging enabled — navigate to `chrome://inspect/#remote-debugging` in the target browser and check "Allow remote debugging for this browser instance". + +```bash +# Attach to Chrome +playwright-cli attach --cdp=chrome + +# Attach to Chrome Canary +playwright-cli attach --cdp=chrome-canary + +# Attach to Microsoft Edge +playwright-cli attach --cdp=msedge + +# Attach to Edge Dev +playwright-cli attach --cdp=msedge-dev +``` + +Supported channels: `chrome`, `chrome-beta`, `chrome-dev`, `chrome-canary`, `msedge`, `msedge-beta`, `msedge-dev`, `msedge-canary`. + +### Attach via CDP endpoint + +Connect to a browser that exposes a Chrome DevTools Protocol endpoint: + +```bash +playwright-cli attach --cdp=http://localhost:9222 +``` + +### Attach via browser extension + +Connect to a browser with the Playwright extension installed: + +```bash +playwright-cli attach --extension +``` + +## Default Browser Session + +When `-s` is omitted, commands use the default browser session: + +```bash +# These use the same default browser session +playwright-cli open https://example.com +playwright-cli snapshot +playwright-cli close # Stops default browser +``` + +## Browser Session Configuration + +Configure a browser session with specific settings when opening: + +```bash +# Open with config file +playwright-cli open https://example.com --config=.playwright/my-cli.json + +# Open with specific browser +playwright-cli open https://example.com --browser=firefox + +# Open in headed mode +playwright-cli open https://example.com --headed + +# Open with persistent profile +playwright-cli open https://example.com --persistent +``` + +## Best Practices + +### 1. Name Browser Sessions Semantically + +```bash +# GOOD: Clear purpose +playwright-cli -s=github-auth open https://github.com +playwright-cli -s=docs-scrape open https://docs.example.com + +# AVOID: Generic names +playwright-cli -s=s1 open https://github.com +``` + +### 2. Always Clean Up + +```bash +# Stop browsers when done +playwright-cli -s=auth close +playwright-cli -s=scrape close + +# Or stop all at once +playwright-cli close-all + +# If browsers become unresponsive or zombie processes remain +playwright-cli kill-all +``` + +### 3. Delete Stale Browser Data + +```bash +# Remove old browser data to free disk space +playwright-cli -s=oldsession delete-data +``` diff --git a/.agent/skills/playwright-cli/references/storage-state.md b/.agent/skills/playwright-cli/references/storage-state.md new file mode 100644 index 0000000..c856db5 --- /dev/null +++ b/.agent/skills/playwright-cli/references/storage-state.md @@ -0,0 +1,275 @@ +# Storage Management + +Manage cookies, localStorage, sessionStorage, and browser storage state. + +## Storage State + +Save and restore complete browser state including cookies and storage. + +### Save Storage State + +```bash +# Save to auto-generated filename (storage-state-{timestamp}.json) +playwright-cli state-save + +# Save to specific filename +playwright-cli state-save my-auth-state.json +``` + +### Restore Storage State + +```bash +# Load storage state from file +playwright-cli state-load my-auth-state.json + +# Reload page to apply cookies +playwright-cli open https://example.com +``` + +### Storage State File Format + +The saved file contains: + +```json +{ + "cookies": [ + { + "name": "session_id", + "value": "abc123", + "domain": "example.com", + "path": "/", + "expires": 1735689600, + "httpOnly": true, + "secure": true, + "sameSite": "Lax" + } + ], + "origins": [ + { + "origin": "https://example.com", + "localStorage": [ + { "name": "theme", "value": "dark" }, + { "name": "user_id", "value": "12345" } + ] + } + ] +} +``` + +## Cookies + +### List All Cookies + +```bash +playwright-cli cookie-list +``` + +### Filter Cookies by Domain + +```bash +playwright-cli cookie-list --domain=example.com +``` + +### Filter Cookies by Path + +```bash +playwright-cli cookie-list --path=/api +``` + +### Get Specific Cookie + +```bash +playwright-cli cookie-get session_id +``` + +### Set a Cookie + +```bash +# Basic cookie +playwright-cli cookie-set session abc123 + +# Cookie with options +playwright-cli cookie-set session abc123 --domain=example.com --path=/ --httpOnly --secure --sameSite=Lax + +# Cookie with expiration (Unix timestamp) +playwright-cli cookie-set remember_me token123 --expires=1735689600 +``` + +### Delete a Cookie + +```bash +playwright-cli cookie-delete session_id +``` + +### Clear All Cookies + +```bash +playwright-cli cookie-clear +``` + +### Advanced: Multiple Cookies or Custom Options + +For complex scenarios like adding multiple cookies at once, use `run-code`: + +```bash +playwright-cli run-code "async page => { + await page.context().addCookies([ + { name: 'session_id', value: 'sess_abc123', domain: 'example.com', path: '/', httpOnly: true }, + { name: 'preferences', value: JSON.stringify({ theme: 'dark' }), domain: 'example.com', path: '/' } + ]); +}" +``` + +## Local Storage + +### List All localStorage Items + +```bash +playwright-cli localstorage-list +``` + +### Get Single Value + +```bash +playwright-cli localstorage-get token +``` + +### Set Value + +```bash +playwright-cli localstorage-set theme dark +``` + +### Set JSON Value + +```bash +playwright-cli localstorage-set user_settings '{"theme":"dark","language":"en"}' +``` + +### Delete Single Item + +```bash +playwright-cli localstorage-delete token +``` + +### Clear All localStorage + +```bash +playwright-cli localstorage-clear +``` + +### Advanced: Multiple Operations + +For complex scenarios like setting multiple values at once, use `run-code`: + +```bash +playwright-cli run-code "async page => { + await page.evaluate(() => { + localStorage.setItem('token', 'jwt_abc123'); + localStorage.setItem('user_id', '12345'); + localStorage.setItem('expires_at', Date.now() + 3600000); + }); +}" +``` + +## Session Storage + +### List All sessionStorage Items + +```bash +playwright-cli sessionstorage-list +``` + +### Get Single Value + +```bash +playwright-cli sessionstorage-get form_data +``` + +### Set Value + +```bash +playwright-cli sessionstorage-set step 3 +``` + +### Delete Single Item + +```bash +playwright-cli sessionstorage-delete step +``` + +### Clear sessionStorage + +```bash +playwright-cli sessionstorage-clear +``` + +## IndexedDB + +### List Databases + +```bash +playwright-cli run-code "async page => { + return await page.evaluate(async () => { + const databases = await indexedDB.databases(); + return databases; + }); +}" +``` + +### Delete Database + +```bash +playwright-cli run-code "async page => { + await page.evaluate(() => { + indexedDB.deleteDatabase('myDatabase'); + }); +}" +``` + +## Common Patterns + +### Authentication State Reuse + +```bash +# Step 1: Login and save state +playwright-cli open https://app.example.com/login +playwright-cli snapshot +playwright-cli fill e1 "user@example.com" +playwright-cli fill e2 "password123" +playwright-cli click e3 + +# Save the authenticated state +playwright-cli state-save auth.json + +# Step 2: Later, restore state and skip login +playwright-cli state-load auth.json +playwright-cli open https://app.example.com/dashboard +# Already logged in! +``` + +### Save and Restore Roundtrip + +```bash +# Set up authentication state +playwright-cli open https://example.com +playwright-cli eval "() => { document.cookie = 'session=abc123'; localStorage.setItem('user', 'john'); }" + +# Save state to file +playwright-cli state-save my-session.json + +# ... later, in a new session ... + +# Restore state +playwright-cli state-load my-session.json +playwright-cli open https://example.com +# Cookies and localStorage are restored! +``` + +## Security Notes + +- Never commit storage state files containing auth tokens +- Add `*.auth-state.json` to `.gitignore` +- Delete state files after automation completes +- Use environment variables for sensitive data +- By default, sessions run in-memory mode which is safer for sensitive operations diff --git a/.agent/skills/playwright-cli/references/test-generation.md b/.agent/skills/playwright-cli/references/test-generation.md new file mode 100644 index 0000000..7a09df3 --- /dev/null +++ b/.agent/skills/playwright-cli/references/test-generation.md @@ -0,0 +1,88 @@ +# Test Generation + +Generate Playwright test code automatically as you interact with the browser. + +## How It Works + +Every action you perform with `playwright-cli` generates corresponding Playwright TypeScript code. +This code appears in the output and can be copied directly into your test files. + +## Example Workflow + +```bash +# Start a session +playwright-cli open https://example.com/login + +# Take a snapshot to see elements +playwright-cli snapshot +# Output shows: e1 [textbox "Email"], e2 [textbox "Password"], e3 [button "Sign In"] + +# Fill form fields - generates code automatically +playwright-cli fill e1 "user@example.com" +# Ran Playwright code: +# await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com'); + +playwright-cli fill e2 "password123" +# Ran Playwright code: +# await page.getByRole('textbox', { name: 'Password' }).fill('password123'); + +playwright-cli click e3 +# Ran Playwright code: +# await page.getByRole('button', { name: 'Sign In' }).click(); +``` + +## Building a Test File + +Collect the generated code into a Playwright test: + +```typescript +import { test, expect } from '@playwright/test'; + +test('login flow', async ({ page }) => { + // Generated code from playwright-cli session: + await page.goto('https://example.com/login'); + await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com'); + await page.getByRole('textbox', { name: 'Password' }).fill('password123'); + await page.getByRole('button', { name: 'Sign In' }).click(); + + // Add assertions + await expect(page).toHaveURL(/.*dashboard/); +}); +``` + +## Best Practices + +### 1. Use Semantic Locators + +The generated code uses role-based locators when possible, which are more resilient: + +```typescript +// Generated (good - semantic) +await page.getByRole('button', { name: 'Submit' }).click(); + +// Avoid (fragile - CSS selectors) +await page.locator('#submit-btn').click(); +``` + +### 2. Explore Before Recording + +Take snapshots to understand the page structure before recording actions: + +```bash +playwright-cli open https://example.com +playwright-cli snapshot +# Review the element structure +playwright-cli click e5 +``` + +### 3. Add Assertions Manually + +Generated code captures actions but not assertions. Add expectations in your test: + +```typescript +// Generated action +await page.getByRole('button', { name: 'Submit' }).click(); + +// Manual assertion +await expect(page.getByText('Success')).toBeVisible(); +``` diff --git a/.agent/skills/playwright-cli/references/tracing.md b/.agent/skills/playwright-cli/references/tracing.md new file mode 100644 index 0000000..7ce7bab --- /dev/null +++ b/.agent/skills/playwright-cli/references/tracing.md @@ -0,0 +1,139 @@ +# Tracing + +Capture detailed execution traces for debugging and analysis. Traces include DOM snapshots, screenshots, network activity, and console logs. + +## Basic Usage + +```bash +# Start trace recording +playwright-cli tracing-start + +# Perform actions +playwright-cli open https://example.com +playwright-cli click e1 +playwright-cli fill e2 "test" + +# Stop trace recording +playwright-cli tracing-stop +``` + +## Trace Output Files + +When you start tracing, Playwright creates a `traces/` directory with several files: + +### `trace-{timestamp}.trace` + +**Action log** - The main trace file containing: +- Every action performed (clicks, fills, navigations) +- DOM snapshots before and after each action +- Screenshots at each step +- Timing information +- Console messages +- Source locations + +### `trace-{timestamp}.network` + +**Network log** - Complete network activity: +- All HTTP requests and responses +- Request headers and bodies +- Response headers and bodies +- Timing (DNS, connect, TLS, TTFB, download) +- Resource sizes +- Failed requests and errors + +### `resources/` + +**Resources directory** - Cached resources: +- Images, fonts, stylesheets, scripts +- Response bodies for replay +- Assets needed to reconstruct page state + +## What Traces Capture + +| Category | Details | +|----------|---------| +| **Actions** | Clicks, fills, hovers, keyboard input, navigations | +| **DOM** | Full DOM snapshot before/after each action | +| **Screenshots** | Visual state at each step | +| **Network** | All requests, responses, headers, bodies, timing | +| **Console** | All console.log, warn, error messages | +| **Timing** | Precise timing for each operation | + +## Use Cases + +### Debugging Failed Actions + +```bash +playwright-cli tracing-start +playwright-cli open https://app.example.com + +# This click fails - why? +playwright-cli click e5 + +playwright-cli tracing-stop +# Open trace to see DOM state when click was attempted +``` + +### Analyzing Performance + +```bash +playwright-cli tracing-start +playwright-cli open https://slow-site.com +playwright-cli tracing-stop + +# View network waterfall to identify slow resources +``` + +### Capturing Evidence + +```bash +# Record a complete user flow for documentation +playwright-cli tracing-start + +playwright-cli open https://app.example.com/checkout +playwright-cli fill e1 "4111111111111111" +playwright-cli fill e2 "12/25" +playwright-cli fill e3 "123" +playwright-cli click e4 + +playwright-cli tracing-stop +# Trace shows exact sequence of events +``` + +## Trace vs Video vs Screenshot + +| Feature | Trace | Video | Screenshot | +|---------|-------|-------|------------| +| **Format** | .trace file | .webm video | .png/.jpeg image | +| **DOM inspection** | Yes | No | No | +| **Network details** | Yes | No | No | +| **Step-by-step replay** | Yes | Continuous | Single frame | +| **File size** | Medium | Large | Small | +| **Best for** | Debugging | Demos | Quick capture | + +## Best Practices + +### 1. Start Tracing Before the Problem + +```bash +# Trace the entire flow, not just the failing step +playwright-cli tracing-start +playwright-cli open https://example.com +# ... all steps leading to the issue ... +playwright-cli tracing-stop +``` + +### 2. Clean Up Old Traces + +Traces can consume significant disk space: + +```bash +# Remove traces older than 7 days +find .playwright-cli/traces -mtime +7 -delete +``` + +## Limitations + +- Traces add overhead to automation +- Large traces can consume significant disk space +- Some dynamic content may not replay perfectly diff --git a/.agent/skills/playwright-cli/references/video-recording.md b/.agent/skills/playwright-cli/references/video-recording.md new file mode 100644 index 0000000..ce9ad6a --- /dev/null +++ b/.agent/skills/playwright-cli/references/video-recording.md @@ -0,0 +1,143 @@ +# Video Recording + +Capture browser automation sessions as video for debugging, documentation, or verification. Produces WebM (VP8/VP9 codec). + +## Basic Recording + +```bash +# Open browser first +playwright-cli open + +# Start recording +playwright-cli video-start demo.webm + +# Add a chapter marker for section transitions +playwright-cli video-chapter "Getting Started" --description="Opening the homepage" --duration=2000 + +# Navigate and perform actions +playwright-cli goto https://example.com +playwright-cli snapshot +playwright-cli click e1 + +# Add another chapter +playwright-cli video-chapter "Filling Form" --description="Entering test data" --duration=2000 +playwright-cli fill e2 "test input" + +# Stop and save +playwright-cli video-stop +``` + +## Best Practices + +### 1. Use Descriptive Filenames + +```bash +# Include context in filename +playwright-cli video-start recordings/login-flow-2024-01-15.webm +playwright-cli video-start recordings/checkout-test-run-42.webm +``` + +### 2. Record entire hero scripts. + +When recording a video for the user or as a proof of work, it is best to create a code snippet and execute it with run-code. +It allows pulling appropriate pauses between the actions and annotating the video. There are new Playwright APIs for that. + +1) Perform scenario using CLI and take note of all locators and actions. You'll need those locators to request their bounding boxes for highlight. +2) Create a file with the intended script for video (below). Use pressSequentially w/ delay for nice typing, make reasonable pauses. +3) Use playwright-cli run-code --filename your-script.js + +**Important**: Overlays are `pointer-events: none` — they do not interfere with page interactions. You can safely keep sticky overlays visible while clicking, filling, or performing any actions on the page. + +```js +async page => { + await page.screencast.start({ path: 'video.webm', size: { width: 1280, height: 800 } }); + await page.goto('https://demo.playwright.dev/todomvc'); + + // Show a chapter card — blurs the page and shows a dialog. + // Blocks until duration expires, then auto-removes. + // Use this for simple use cases, but always feel free to hand-craft your own beautiful + // overlay via await page.screencast.showOverlay(). + await page.screencast.showChapter('Adding Todo Items', { + description: 'We will add several items to the todo list.', + duration: 2000, + }); + + // Perform action + await page.getByRole('textbox', { name: 'What needs to be done?' }).pressSequentially('Walk the dog', { delay: 60 }); + await page.getByRole('textbox', { name: 'What needs to be done?' }).press('Enter'); + await page.waitForTimeout(1000); + + // Show next chapter + await page.screencast.showChapter('Verifying Results', { + description: 'Checking the item appeared in the list.', + duration: 2000, + }); + + // Add a sticky annotation that stays while you perform actions. + // Overlays are pointer-events: none, so they won't block clicks. + const annotation = await page.screencast.showOverlay(` +
+ ✓ Item added successfully +
+ `); + + // Perform more actions while the annotation is visible + await page.getByRole('textbox', { name: 'What needs to be done?' }).pressSequentially('Buy groceries', { delay: 60 }); + await page.getByRole('textbox', { name: 'What needs to be done?' }).press('Enter'); + await page.waitForTimeout(1500); + + // Remove the annotation when done + await annotation.dispose(); + + // You can also highlight relevant locators and provide contextual annotations. + const bounds = await page.getByText('Walk the dog').boundingBox(); + await page.screencast.showOverlay(` +
+
+
Check it out, it is right above this text +
+ `, { duration: 2000 }); + + await page.screencast.stop(); +} +``` + +Embrace creativity, overlays are powerful. + +### Overlay API Summary + +| Method | Use Case | +|--------|----------| +| `page.screencast.showChapter(title, { description?, duration?, styleSheet? })` | Full-screen chapter card with blurred backdrop — ideal for section transitions | +| `page.screencast.showOverlay(html, { duration? })` | Custom HTML overlay — use for callouts, labels, highlights | +| `disposable.dispose()` | Remove a sticky overlay added without duration | +| `page.screencast.hideOverlays()` / `page.screencast.showOverlays()` | Temporarily hide/show all overlays | + +## Tracing vs Video + +| Feature | Video | Tracing | +|---------|-------|---------| +| Output | WebM file | Trace file (viewable in Trace Viewer) | +| Shows | Visual recording | DOM snapshots, network, console, actions | +| Use case | Demos, documentation | Debugging, analysis | +| Size | Larger | Smaller | + +## Limitations + +- Recording adds slight overhead to automation +- Large recordings can consume significant disk space diff --git a/.claude/skills b/.claude/skills deleted file mode 120000 index 9b05831..0000000 --- a/.claude/skills +++ /dev/null @@ -1 +0,0 @@ -../.agent/skills \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 1560cd6..d2d8859 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,9 +34,9 @@ - `FuturesTrade.Size` → `string` - `FuturesTrade.CreateTimeMs` → `float64` -## MCP / `tool`(信息与研究能力,规划中) -- 通过 MCP Streamable HTTP 对接 news / info / docs 服务;**规范主命令**为 `gate-cli tool`(`list` / `call` / `describe`),与交易 API(`gateapi-go`)**鉴权隔离**:勿用 `GATE_API_KEY` 充当 MCP Bearer。 -- 规格与待办:[`specs/README.md`](specs/README.md)、[`specs/open-items-and-dependencies.md`](specs/open-items-and-dependencies.md);**技术评审只读** [`specs/clidocs/gate-cli-intel-mcp-technical-solution-feishu.md`](specs/clidocs/gate-cli-intel-mcp-technical-solution-feishu.md);评审摘要:[`docs/plans/2026-04-10-gate-cli-tool-mcp-review.md`](docs/plans/2026-04-10-gate-cli-tool-mcp-review.md)。 +## MCP / Intel(`info` / `news` 已发布;`tool` 仍为规格命名) +- **用户 CLI**:`gate-cli info` / `gate-cli news`(50 个 MCP 叶子;`info list` / `news list`,`-h` 查 flag)。用户文档:根目录 [`README.md`](README.md)、[`docs/quickstart.md`](docs/quickstart.md)。 +- **规格中的统一主路径(规划)**:`gate-cli tool`(`list` / `call` / `describe`);与交易 API(`gateapi-go`)**鉴权隔离**:勿用 `GATE_API_KEY` 充当 MCP Bearer。规格与待办:[`specs/README.md`](specs/README.md)(**含「已对读」说明**)、[`specs/open-items-and-dependencies.md`](specs/open-items-and-dependencies.md);**技术评审只读** [`specs/clidocs/gate-cli-intel-mcp-technical-solution-feishu.md`](specs/clidocs/gate-cli-intel-mcp-technical-solution-feishu.md);评审摘要:[`docs/plans/2026-04-10-gate-cli-tool-mcp-review.md`](docs/plans/2026-04-10-gate-cli-tool-mcp-review.md)。 - **Cursor**:默认决策见 [`.cursor/rules/`](.cursor/rules/) — **`gate-cli-cli-layer-conventions.mdc`**(`GetPrinter`/`PrintError`、失败 **stderr** `{"error":...}`,与交易子命令一致;MCP 仅替换 `mcpclient`)、**`gate-cli-intel-mcp-specs.mdc`**、**`mcp-intel-curl-endpoints.mdc`**;Wire **`specs/cli/mcp-wire-appendix.md` v0.4**。 ## 架构约定 diff --git a/CHANGELOG.md b/CHANGELOG.md index 75bd92f..2609163 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,419 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [v0.7.9] + +### Changed + +- **News MCP baseline expansion** — News grows from **14** to **18** tools (**50** Intel tools total: 32 info + 18 news). The bundled MCP spec, CLI inventory, agent catalog/validation, README, and quick-start guides now include 24h mention bursts, 4h hot topics, and stored market-move report get/list queries. +- **News argument guardrails** — The four new leaves validate required symbols/coins, fixed windows (`24h` mention burst and `4h` hot topics), supported social platforms, hot-topic limits, market-report time ranges, and report-list limits before calling MCP. Report time filters accept ISO 8601 or `YYYY-MM-DD HH:MM:SS`; timezone-less values are interpreted as UTC0. +- **`toolargs` spec alignment** — `info macro get-economic-calendar` no longer requires both dates (zero-arg matches MCP default window); `info coin get-coin-rankings` rejects `time_range` unless `top_gainers`/`top_losers` and `listing_*` unless `new_listing`; `info platformmetrics get-defi-overview` stops rejecting unknown `category` (server pass-through). +- **Info MCP spec resync** — `specs/mcp/info-mcp-tools-inputs-logic.json` → `internal/mcpspec/bundled/`: logic/fields parity across all 37 tools; `info coin get-coin-rankings` adds `market_pulse_hot` to `--ranking-type`; `info platformmetrics get-yield-pools` adds `--scope` (`basic`|`full`). `toolargs` pre-checks updated for both enums. +- **`info platformmetrics get-chain-activity` MCP spec** — resync `specs/mcp/info-mcp-tools-inputs-logic.json` and bundled logic with upstream GAP-012: `staking_metrics.series[]` now documents full PRD fields (`eth_supply`, `staking_apr_7d`, `entry_wait_days`, `exit_wait_days`); removes `shelved_fields`; adds `errors` / `response_fields` / `series_fields` metadata. CLI flags and `toolargs` pre-check unchanged (pass-through response). **`--format pretty`** now renders staking query context, latest snapshot, and recent series (including the four enriched fields when present). + +### Fixed + +- **`+token-risk --symbol`** — align contract resolution with mcp-server `get_coin_info` output: `query`/`query_type=symbol`, prefer `items[0]`, parse `chain` as `string[]`, fallback `token_address[]`, multi-chain slug priority; native coins (e.g. BTC) return a clear error instead of a generic resolve failure. +- **Intel user-facing errors** — stderr JSON no longer includes MCP wire `tool_name` (`info_*_*`); shortcuts use paths like `info/+token-risk`, leaves use CLI command paths (e.g. `info coin get-coin-info`). Leaf `-h` text no longer embeds MCP tool names. **`info list` / `describe` / agent compact list** output uses CLI command paths only. +- **`agent-leaves` curated** — **31** intents (+ `info_token_risk_by_address`, `info_token_onchain_by_address` for contract-path agents). README/quickstart counts synced; **`specs/Shortcut/xuqiu.md` §3.9.2** call chains aligned with current CLI (`query`/`query_type`, scope fallbacks, partial shortcuts). +- **`agent-leaves` `mcp_catalog`** — `intent` fields use CLI path tokens (e.g. `coin-get-coin-info`), not MCP wire names. **`DeferredInfoShortcutPaths`** documents `+address-risk` until baseline ships. +- **`describe` / `invoke --name`** — accepts CLI command paths (e.g. `info coin get-coin-info`) in addition to MCP wire names; resolved before MCP RPC. Hidden `invoke` help examples use CLI paths. +- **Info shortcuts `get-coin-info`** — `+coin-overview`, `+coin-compare`, `+token-onchain`, `+token-risk` use explicit `query` + `query_type=symbol` (aligned with mcp-server); address identity lookup uses `query_type=address`. +- **Info/news shortcuts** — `get-coin-info` no longer forces `scope=full` (MCP default `basic`); `+token-risk --symbol` retries `scope=detailed` for contract resolution; market snapshot / token security fall back from `full` on MCP `isError`. Shared `internal/intelcmd/shortcut_call.go` maps `isError` to `GateErrorForIntelToolIsError`. Missing shortcut flags (`--symbol`, `--symbols`) emit JSON `{"error":…}` in agent/json mode. +- **Intel flag parse errors** — `InstallFlagErrorHook` emits `PrintError` JSON/pretty envelope for unknown-flag parse failures; `EmitExecuteErrorEnvelope` covers `MarkFlagRequired` / cobra validation errors (e.g. `info describe --format json` without `--name`). + +### Added + +- **News social-insight tools** — `news feed get-mention-burst` and `news feed get-hot-topics`, with short aliases `mention-burst` and `hot-topics`. +- **Stored market-move reports** — `news events get-market-move-report` (aliases `market-move-report`, `get-report`) and `news events list-market-move-reports` (aliases `market-move-reports`, `report-list`). Top-level path corrections guide misplaced report commands to the `news events` group, and `--coin` is corrected to the required `--symbol` flag. +- **Info shortcuts (partial on-chain)** — `+address-tracker` (`get-address-info` + `get-address-transactions`; `fund_flow_unavailable` until `trace-fund-flow` ships) and `+token-onchain` (`get-token-onchain`; `smart_money_unavailable` until `get-smart-money` ships). `+address-risk` remains deferred (`check-address-risk` not in baseline). **10** info/news shortcuts total for `agent-validate`. +- **`gate-cli info platformmetrics get-chain-activity`** — new intel leaf for `info_platformmetrics_get_chain_activity` (phase-1: Ethereum staking network activity — validator counts, entry/exit queues). Required `--metric-group staking`; optional `--chain` (eth/ethereum, default ethereum), `--start-date` / `--end-date` (UTC YYYY-MM-DD), `--lookback` (`30d`|`90d`|`1y`, default `30d` when dates omitted). Baseline **32** `info` + **14** `news` = **46** MCP tools total. +- **`specs/0413/intel-mcp-appendix-e-tool-catalog.md`** — E.1/E.2 对齐当前 **46** 叶 baseline(info **32** + news **14**);E.2 新增 `get-cex-orderbook-depth`、`get-institutional-metrics`、`get-chain-activity`;占位 tool 移至 E.3。 +- **`internal/mcpspec/bundled/info-mcp-tools-inputs-logic.json`** — resync onchain `get_address_info` / `get_address_transactions` logic with local QC spec (`TestBundledMatchesSpecs` parity). +- **`toolargs` sweep** — pre-MCP validation for `info_coin_get_coin_info`, `info_marketsnapshot_get_market_snapshot`, `news_feed_get_exchange_announcements`, `news_feed_get_social_sentiment`. +- **`agent-search`** — `matches[]` with `match_source` / `is_shortcut`; Agent mode adds `agent_resolve_hint`. +- **`agent-leaves`** — **27** curated intents (+ event detail, prediction orderbook/search). +- **Freshness** — `meta.freshness_status_cli` derived from MCP `freshness_status` counts or `newest_is_stale`. +- **`preflight` BLOCK** / **`doctor` fail** — stderr-only `GateError` with agent next-action hints; compact doctor JSON in agent mode. +- **PRD checklist** — `gate-ai-agent-cli-integration.md` § CLI 实现清单. +- **`toolargs` (batch 2)** — `get_indicator_history`, `marketdetail` orderbook/trades, `search_coins`, `search_platforms` pre-MCP validation. +- **`preflight` BLOCK** contract test (stderr-only, no stdout). +- **`release.yaml`** — runs `go test ./...` before goreleaser. +- **`toolargs` (batch 3)** — onchain 4、macro 2、platformmetrics 5(含 `get-chain-activity`)、`get_coin_rankings`;**46/46** baseline 均有预检。 +- **`agent-index`** — `leaves[]` with `match_source` / `is_shortcut`; agent `agent_resolve_hint`. +- **Intel MCP errors** — `ParseError` and `GateErrorForIntelToolIsError` call `FillAgentErrorConvergence` (`retryable`, `suggested_next_action`). +- **PRD appendix** — `GateAI CLI 调用成本与稳定性优化 PRD.md` §附录 A 与 `gate-ai-agent-cli-integration.md` 对读。 +- **Path aliases** — info `kline`/`coinanalysis`, news `search-news`/`explain-market-move` cobra aliases; stderr path fixes for `news search`, `info coin analysis`, etc. +- **`agent-leaves`** — **27** curated intents (institutional metrics, batch snapshot, exchange announcements, event detail, prediction, …). +- **Local Intel test script** — `./scripts/test-intel-scope.sh` for scoped `go test` / `go vet` during iteration (not wired in CI by default). +- **`gate-cli agent-leaves`** — JSON index of trace-backed high-frequency leaf commands for GateAI agents (replaces root `--help` crawl for the first batch). +- **`gate-cli agent-search --query`** — keyword search over runnable leaf commands (P1 discovery fallback); optional **`--domain`** (`cex` / `info` / `news` / `config`, aliases `trading` / `intel`) and query synonym expansion (e.g. `redeem` → `records`). +- **`gate-cli agent-index`** — export full runnable leaf catalog (`count`, `leaves`, `commands`) for offline Skill indexing; optional **`--domain`** filter. +- **`gate-cli agent-resolve --query`** — layer-2 intent resolution: curated `agent-leaves` matches plus `agent-search`-style leaf hits (`--domain info|news`). +- **`agent-leaves` (info/news only)** — curated intents plus **`mcp_catalog` (45 baseline tools)**; CEX via `agent-search --domain cex` (separate ownership). +- **`internal/toolargs`** — pre-MCP validation for `news_feed_search_news` (query/coin, `time_range`, `limit`≤100) and `news_events_explain_market_move` `time_range` enum. +- **Agent diagnostics** — `gate_cli_diagnostic` may include a matched `agent-leaves` command when `GATE_CLI_AGENT=1` (help block / early errors). +- **Agent Intel defaults** — `GATE_CLI_AGENT=1` injects safe `time_range` / `limit` / kline `size` defaults before MCP `tools/call` (news freshness + stdout bounds). +- **News freshness meta** — `news_*` success responses include `meta.freshness_hints` (and `agent_reminder` in agent mode); JSON stdout is `{data, meta}` when meta is present. +- **`info list` / `news list`** — agent + `--format json` returns compact `{name, path}` catalog instead of full tool objects. +- **`info`/`news` shortcuts** — output uses same stdout limits and `meta` (freshness / `EMPTY_RESULT`) as MCP leaves; internal tool calls run `PrepareToolArguments` (agent defaults + validation). +- **`agent-resolve`** — default search scope is **info + news only** (no cex/config noise). +- **Routing** — mistaken top-level `intel` / `intelligence` maps to `info`; `search-x` requires `query` or handle filters. +- **Agent discovery scope** — `agent-search` / `agent-index` / `agent-resolve` default to **info+news** leaves when `GATE_CLI_AGENT=1` and `--domain` is unset. +- **Freshness `meta.freshness_summary`** — aggregates `published_at` ages and `freshness_status` counts when present in MCP payloads. +- **`info describe` / `news describe`** — agent + `--format json` returns compact `{name, description, has_input_schema}`. +- **`agent-leaves`** — adds `count_curated`, `mcp_catalog` (45 tools), `scope`, `recommended_flow`; help crawl stderr adds `gate_cli_agent_resolve_hint`. +- **Agent JSON contract** — `GATE_CLI_AGENT=1` success stdout always `{"data","meta"}`; `freshness_status_cli=unknown` when no timestamps. +- **`preflight`** — compact JSON fields in agent mode. +- **`specs/clidocs/gate-ai-agent-cli-integration.md`** — Gate.AI ↔ gate-cli env, discovery commands, diagnostics contract. +- **`gate-cli agent-validate`** — CI helper: asserts all **45** `mcp_catalog` + **8** info/news shortcut paths exist in the live cobra tree (`mcp_catalog` / `shortcuts` sections, non-zero exit on drift). +- **`agent-resolve`** — `resolved_leaves` with `match_source` (`curated` | `mcp_catalog`); PRD adversarial golden tests (earn/markettrend/help/kline/news path). +- **`scripts/test-intel-scope.sh`** — includes `internal/cmdhint` and `internal/cmdindex`. +- **`cex earn uni` shortcuts** — `+redeem-records` and `+lends` for simple-earn agent flows (`--format json` recommended). +- **`cex spot/alpha` shortcuts** — `cex spot market +ticker`, `cex alpha market +tickers` for agent spot/alpha intents. +- **`info/news shortcut`** — add deterministic top-level shortcut commands under `gate-cli info` (`+coin-overview`, `+market-overview`, `+coin-compare`, `+trend-analysis`, `+token-risk`) and `gate-cli news` (`+brief`, `+event-explain`, `+community-scan`). Each shortcut uses fixed tools/call chains and returns stable aggregated sections in `pretty/json`. + +### Fixed + +- **`agent-resolve`** — `--domain` filters `resolved_leaves`; curated/MCP dedupe by CLI path (no duplicate `info … get-kline` entries). +- **Agent help** — allow `info|news list|describe -h`; shortcut `+…` unchanged; help-crawl diagnostics no longer overwritten by leaf enrich. +- **`doctor` fail** — stderr message includes first failing check; agent mode still stdout-empty on fail. +- **`migrate` fail** — aligned with `doctor`: agent JSON envelope, `MIGRATE_FAILED` stderr convergence, agent fail stdout-empty; non-agent still prints report then stderr. +- **Tests** — `doctor`/`migrate` agent-fail stderr-only contracts; `agent-resolve --domain` integration test. +- **`ResolveAgentIntent` / enrich** — skip leaf enrich on blocked path/top-level corrections; only fill `suggested_next_action` when empty. +- **`internal/toolargs`** — expand CLI pre-check before MCP `tools/call`: `news feed search-x` (handles XOR, `time_range` enum), `search-ugc` / `web-search` (enums and limit caps), `events get-latest-events` (time window rules), `prediction` rankings + orderbook unsupported params, `prediction search-events` (`page_token` base64/sort_by), `info platformmetrics get-cex-orderbook-depth` (`symbol`, enums, limit), `info marketsnapshot batch-market-snapshot` (`symbols` required, max 20). +- **`doc/tech/cli-first-technical-spec-v3.md`** — Intel baseline counts updated to **31** `info` + **14** `news` (**45** total). +- **`internal/toolargs`** — CLI pre-check before MCP `tools/call` for `info marketsnapshot get-institutional-metrics` (`asset` / `channel` / date window / `limit` bounds, including strict integer `limit` via `--params`). +- **`internal/toolargs`** — CLI pre-check before MCP `tools/call` for `info platformmetrics`: **get-stablecoin-info** (`sections`/`scope`/`dates`/`symbol`/`chain`/`limit`, including `usage_structure`); **get-exchange-reserves** (`include_history` + `history_window` + closed `asset`); **get-platform-info** (`include_oi_symbol_detail` + `oi_symbol_limit` vs `scope`). `limit<=0` defers to server default (no false 400). +- **`internal/toolargs` / README** — add local XOR validation for `info compliance check-token-security` (`token` or `address`, exactly one) and fix Intel README examples that previously omitted required filters. +- **`internal/toolrender`** — when MCP returns schema-shaped `structuredContent` with only null/empty values, fall back to parsing `content[].text` so `info platformmetrics get-stablecoin-info --sections usage_structure` does not render an all-null CLI payload when the text content contains the real response. +- **Docs / agents** — `README.md`, `docs/quickstart*.md`, and `gate-cli news mcp-spec` no longer point leaf `-h` routing text at `specs/mcp/`. **`news feed search-x` example** in README now includes `--query` and time flags (`time_range` 1h|24h|7d; longer lookback via `--days`). + +### Changed + +- **Agent cost defaults** — `GATE_CLI_AGENT=1` or `GATE_AI_AGENT=1` sets default `--max-output-bytes` to **65536** when `GATE_MAX_OUTPUT_BYTES` is unset (applies to **all** commands via `GetPrinter`). Parent-command `--help` is blocked in agent mode; use `agent-leaves` / `agent-search` / `agent-index`. Intel leaf `-h` is shortened in agent mode (full MCP field notes still via `GATE_INTEL_LEAF_HELP=full`). Wrong paths print `gate_cli_diagnostic=` (top-level prefix, path correction, fuzzy leaf, cobra flag did-you-mean, auth hint). `GateError.error_type` on API/Intel/validation stderr JSON (`INVALID_ARGS`, `AUTH_ERROR`, …) via `FillAgentErrorType`. +- **`info markettrend/marketdetail get-kline`** — baseline default **200**, maximum **500**, with CLI validation before MCP call. +- **Info MCP tool descriptions (release-only)** — English `description` (`[Read]` routing) on every tool in **`internal/mcpspec/bundled/`** only (embedded; leaf `-h` / `info mcp-spec` / agents). Local **`specs/mcp/info-mcp-tools-inputs-logic.json` is not shipped** and must not carry release descriptions (`scripts/patch-info-spec-descriptions.py` strips them from that path). Parity test compares spec vs bundled **excluding** description fields. +- **`internal/intelfacade/info_schema_baseline.go`** — richer English flag help for `get-stablecoin-info`, `get-cex-orderbook-depth`, `check-token-security`, and `batch-market-snapshot` (`symbols` max 20). +- **`internal/intelfacade/news_schema_baseline.go`** — clearer `-h` for `search-x` (`--query` vs `--coin`, `time_range` enum, platform-only `--limit`). +- **`specs/mcp/info-mcp-tools-inputs-logic.json`** & **`internal/mcpspec/bundled/`** — `info_marketsnapshot_get_institutional_metrics` logic resync to upstream `InstitutionalChannelMetricsRequest` / `GetInstitutionalChannelMetrics` / `institutionalChannelIndex` (CLI tool name and flat flags unchanged). +- **`specs/mcp/info-mcp-tools-inputs-logic.json`** (schema **1.1**) & **`internal/mcpspec/bundled/info-mcp-tools-inputs-logic.json`** — resync: `get-stablecoin-info` supports `scope` / `sections` / `start_date` / `end_date` with `issuance_flow` and `usage_structure` logic; limit default **10** max **400**. New leaf `info marketsnapshot get-institutional-metrics` (`info_marketsnapshot_get_institutional_metrics`; upstream `InstitutionalChannelMetricsRequest` / `GetInstitutionalChannelMetrics` / `institutionalChannelIndex`) for ETF/CME/CFTC metrics. MCP tool name `info_marketsnapshot_get_institutional_channel_metrics` is removed (use the new name in scripts and `tools/call`). Five **placeholder** tools remain in the MCP spec document only (not in the shipped **31** `info` baseline). +- **Info MCP spec parity** — mark the 5 placeholder tools as explicit `spec_only_tools`, record the 31-tool CLI baseline count in spec metadata, and fail tests if future spec-only tools drift without an allowlist update. +- **`internal/intelfacade/info_schema_baseline.go`** — flat flags for `info platformmetrics get-stablecoin-info` aligned with the new wire shape; `sections` now documents `issuance_flow` and `usage_structure`; add flat flags for `info marketsnapshot get-institutional-metrics`; drop stale `source`/`quote` on `get-indicator-history` (not in MCP spec). +- **`README.md`**, **`docs/quickstart.md`**, **`docs/quickstart_zh.md`** — Intel examples for `get-stablecoin-info` show `scope` / `sections` / date range for supply-flow and usage-structure queries; counts now show **45** total leaves (**31** `info` + **14** `news`). + +## [v0.7.3] - 2026-05-13 + +### Summary + +Intel-only **`flexBool`** fix: schema-derived boolean flags on `info` / `news` leaves now use `NoOptDefVal="true"` so bare `--flag` means **true** without swallowing the next argv token (for example `--with-indicators` then `--timeframe 4h`). Legacy spaced invocations `--flag true|false` are rewritten to `--flag=value` **before** Cobra parses, so old scripts keep working. **Flag error diagnostics** for `info`, `news`, `preflight`, `doctor`, and `migrate` print to stderr via a scoped `FlagErrorFunc`; **`cex` / `config`** parsing and error-print behavior are unchanged (Intel guardrail). + +### Changed — Intel boolean flags (`flexBool`) + +- **`internal/toolschema`** — JSON-schema `boolean` fields registered with `ApplyInputSchemaFlags` set `NoOptDefVal="true"` on the underlying `pflag` flag; documented interaction with argv rewriting. Exported **`FlexBoolTypeName`** so `internal/intelcmd` can detect flexBool flags via `Value.Type()` without string drift. +- **`specs/mcp/news-tools-args-and-logic.json`** (version **2026-05-20-rev2**) & **`internal/mcpspec/bundled/news-tools-args-and-logic.json`** — resync: `[Read]` descriptions, events OS errors as client `internal`, ranking `category` free-form + limit rules, prediction tool logic/errors; `get-event_signal` errors table drops duplicate `opensearch_query_failed` client row. +- **`internal/intelfacade/news_schema_baseline.go`** — flat-flag descriptions aligned with the new spec (feed/events/prediction), ranking `category` back to free-form string, search-events `status` without CLI/json default (wire omit → MCP coin-only `all`), orderbook `market_id` venue semantics, event-signal `include_orderbook_summary` deprecated. + +### Added + +- **`internal/intelcmd.RewriteFlexBoolSpaceArgs`** — For the resolved leaf command only, collapses `-- ` into `--=` when the next token is a boolean literal (`true` | `false` | `1` | `0` | `t` | `f`, case-insensitive); ignores `--` terminator, non-flexBool flags, and native `bool` flags. Wired from **`cmd/root.go`** via `SetArgs` when any rewrite occurs. +- **`internal/intelcmd.InstallFlagErrorHook`** — Recursively installs `FlagErrorFunc` on Intel subtrees so pflag parse errors are visible on stderr while `SilenceErrors` still suppresses duplicate Cobra banners. +- **Intel / `gate-cli news prediction`** — register three News MCP tools from `specs/mcp/news-tools-args-and-logic.json`: `news_prediction_get_market_orderbook` (`get-market-orderbook`), `news_prediction_search_events` (`search-events`), and `news_prediction_get_event_signal` (`get-event-signal`). Baseline count is now **44** (30 `info` + 14 `news`). + +### Fixed + +- **Bare flexBool + following flag** — Previously, combining `NoOptDefVal` with spaced values could mis-parse; bare `--with-indicators` followed by `--timeframe` now resolves correctly (see `cmd/info/aliases_flag_wiring_test.go`). +- **`internal/toolargs`** — CLI pre-check before MCP `tools/call` for News tools with strict or conditional required inputs: `search-ugc` (`query` or `coin`), `web-search` (`query`), `explain-market-move` (`query` + `coin`), `get-event-detail` (`event_id`), `search-events` (`query`, `coin`, or `category`), `get-market-orderbook` (`venue` + `market_id`), `get-event-signal` (`event_ref`). Prediction leaves also validate enums/ranges locally (`venue`, `category`, `status`, `sort_by`, `limit`, `depth`, `event_ref` shape, `window`, optional `venue[]` vs `event_ref`). +- **`internal/intelfacade/news_schema_baseline.go`** — align `news_feed_web_search` `lang` default (`zh`), `news_feed_search_x` `days` default (`1`), prediction `limit` minimum (`1`), and `search-events` `sort_by` default (`recently_listed`) with `news-tools-args-and-logic.json`. +- **`specs/qc/news-cli-commands.md`** & **`specs/qc/info-news-command-checklist.md`** — document `news prediction` leaves and `explain-market-move`. + +### Tests + +- **`internal/intelcmd/argv_rewrite_test.go`** — rewriter edge cases (equals form, `--`, unknown subcommand, native bool untouched, end-to-end parse). +- **`cmd/info/aliases_flag_wiring_test.go`** — `markettrend get-kline` flexBool wiring, bare flag, `=false`, and bool-then-next-flag ordering. + +### Unchanged + +- **`gateapi-go/v7 v7.2.78`** — no SDK bump. +- Tool inventory remains **41** (30 `info` + 11 `news`); no new MCP leaves in this release. + +## [v0.7.2] - 2026-05-12 + +### Summary + +Intel surface bump: **`gate-cli news`** gains one new MCP leaf (`news events explain-market-move`), taking the baseline to **41** tools (**30** `info` + **11** `news`). Also realigns the bundled MCP wire specs and `internal/intelfacade` baseline schemas for `info platformmetrics` (`get-platform-info` / `get-exchange-reserves` shape changes), adds a pre-`tools/call` static check for `info platformmetrics get-platform-history`, and fixes a stale top-level `required` array leaking from the gateway into XOR / `conditional_required` tools. SDK unchanged (`gateapi-go/v7 v7.2.78`); no `cex` command changes. **1 new command + static argument validation + MCP spec/baseline sync + 1 fix.** + +### Added — Intel (`gate-cli news`) + +- **`gate-cli news events explain-market-move`** — new MCP leaf (`news_events_explain_market_move`): synthesizes market-move evidence (Tavily search + internal event pool). Requires `--query` and `--coin`; optional `--time-range`, `--mode`, `--lang`. Baseline tool count is now **41** (30 `info` + 11 `news`); README, both quickstarts, and the module tables updated to match (e.g. `news events` group description now mentions "market-move evidence synthesis"). + +### Changed — Intel MCP wire specs & baseline schemas + +- **`info platformmetrics get-platform-info` / `get-exchange-reserves`** — bundled `internal/mcpspec/bundled/info-mcp-tools-inputs-logic.json` and the `internal/intelfacade` baseline JSON schemas updated to the new wire shapes: `get-platform-info` adds optional `include_oi_symbol_detail` / `oi_symbol_limit` plus richer merge logic; `get-exchange-reserves` replaces `period` with `scope` / `include_history` / `history_window` and a closed `asset` enum. README examples updated (`--scope full --include-oi-symbol-detail --oi-symbol-limit 20`, `--scope full --include-history --asset BTC`). +- **News baseline** — `news_prediction_get_*_ranking` flat-flag schema: `category` now uses the same closed enum as `specs/mcp/news-tools-args-and-logic.json`; `news_events_explain_market_move` Tavily `days` logic line aligned with `time_range` normalization (no stray `7d` branch). + +### Added — Static argument validation + +- **`internal/toolargs` + `internal/intelcmd`** — `info_platformmetrics_get_platform_history` is now validated before `tools/call`: `platform_name` and `exchange_slug` must not both be empty (matching the MCP spec `conditional_required`), returning **400 + INVALID_ARGUMENTS** locally instead of a gateway round-trip. + +### Fixed + +- **`internal/intelcmd`** — when merging static JSON Schema baselines into a cached `tools/list` input schema (and again before `MissingRequiredArguments` on `info` / `news` invoke), drop a stale top-level **`required`** array if the committed baseline intentionally omits `required` — so XOR / `conditional_required` tools such as `info_platformmetrics_get_platform_history` are not blocked by an outdated `required: ["platform_name"]` from the gateway when the caller supplies **`exchange_slug`** only. + +### Tests + +- `internal/intelcmd/merge_baseline_test.go`, `internal/intelfacade/info_schema_baseline_test.go`, `internal/intelfacade/news_schema_baseline_test.go`, `internal/intelfacade/inventory_test.go`, `internal/toolargs/validate_test.go` — cover the new `explain-market-move` leaf and the 41-tool baseline, the stale-`required` drop, the `platformmetrics` spec changes, and the `get-platform-history` XOR check. + +### Unchanged + +- `gateapi-go/v7 v7.2.78` (no SDK bump); the `v0.7.1` tag itself only trims a redundant line from `go.sum`. +- No `cex` command, flag, output-format, or exit-code changes; trading ↔ Intel auth isolation unchanged. + +## [v0.7.0] - 2026-05-06 + +### Summary + +Syncs gate-cli to **gateapi-go/v7 v7.2.78** (from v7.2.71) and ships a new top-level **`gate-cli cex bot`** module that exposes every method on `BotApiService` (10 AI Hub / quant-strategy endpoints — recommend, running, detail, stop + 4 grid + 2 martingale create flows). Targeted forward-compat tweaks cover the v7.2.78 wire-shape changes across `cross_ex`, `p2p`, and `spot`. **10 new commands + 1 new flag + 1 new column + 41 new unit tests + 1 breaking flag removal.** + +### Added — AI Hub (Quant Strategies) module + +- **`gate-cli cex bot`** — new top-level command group wrapping `BotApiService`. Previously the CLI had no quant/AI Hub coverage. SDK methods backing each subcommand: + - `cex bot recommend [--market] [--strategy-type] [--direction] [--invest-amount] [--scene] [--refresh-recommendation-id] [--limit] [--max-drawdown-lte] [--backtest-apr-gte]` → `BotAPI.GetAIHubStrategyRecommend` + - `cex bot running [--strategy-type] [--market] [--page] [--page-size]` → `BotAPI.GetAIHubPortfolioRunning` + - `cex bot detail --strategy-id --strategy-type ` → `BotAPI.GetAIHubPortfolioDetail` + - `cex bot stop --strategy-id --strategy-type ` → `BotAPI.PostAIHubPortfolioStop` + - `cex bot grid spot --json ''` → `BotAPI.PostAIHubSpotGridCreate` + - `cex bot grid margin --json ''` → `BotAPI.PostAIHubMarginGridCreate` + - `cex bot grid infinite --json ''` → `BotAPI.PostAIHubInfiniteGridCreate` + - `cex bot grid futures --json ''` → `BotAPI.PostAIHubFuturesGridCreate` + - `cex bot martingale spot --json ''` → `BotAPI.PostAIHubSpotMartingaleCreate` + - `cex bot martingale contract --json ''` → `BotAPI.PostAIHubContractMartingaleCreate` +- **`internal/client.Client`** — added `BotAPI` field (wrapping `gateapi.BotApiService`) so the new bot commands can invoke SDK methods via the standard client accessor. + +### Added — SDK v7.2.78 sync (CEX) + +- **`gate-cli cex cross-ex account book --statement-type `** — new optional filter exposing the v7.2.78 `ListCrossexAccountBookOpts.StatementType` query parameter (e.g. `TRANSACTION`, `TRADING_FEE`, `FUNDING_FEE`, `LIQUIDATION_FEE`, `TRANSFER_IN`, `TRANSFER_OUT`, `BANKRUPT_COMPENSATION`, `AUTO_REPAY`). +- **`gate-cli cex spot account book`** — added a `Code` column to the table output. v7.2.78 marks `SpotAccountBook.Type` as deprecated and exposes the new authoritative `Code` field; both columns now render so downstream tooling can migrate at its own pace. + +### Changed — User-visible behavior + +- **`gate-cli cex p2p chat list --txid 0`** — `--txid` help text now documents the v7.2.78 server contract: passing `0` returns the latest order with chat for the current user (the SDK added `omitempty` to `GetChatsListRequest.Txid`, so the field is elided on the wire when zero). The flag remains required at the cobra layer. +- **`gate-cli cex cross-ex account book`** — output table column header renamed `Type` → `Statement Type` to match the SDK rename `CrossexAccountBookRecord.Type` → `StatementType`. +- **`gate-cli cex p2p ads update-status`** — flag descriptions aligned with v7.2.78 SDK docs (`Ad number` → `Advertisement ID`, status enum reworded to `1=listed, 3=delisted, 4=closed`). +- **`gate-cli cex bot martingale contract`** — long help warns that v7.2.78 SDK still defines `create_params.stop_loss_price` for backward compatibility but the AIHub `contract_martingale` creation path does not map it; users should not include `stop_loss_price` in `--json`. + +### Changed — Wire-level SDK field renames (CLI flags unchanged) + +The CLI flags below are unchanged in name and semantics, but the JSON body sent to the Gate API now uses the v7.2.78 field names: + +| Command | Wire field | v7.2.71 → v7.2.78 | +|---------|-----------|--------------------| +| `cex p2p transaction confirm-payment` body | order ID | `trade_id` → `txid` | +| `cex p2p transaction confirm-receipt` body | order ID | `trade_id` → `txid` | +| `cex p2p transaction cancel` body | order ID | `trade_id` → `txid` | +| `cex cross-ex account book` row response | bill type | `type` → `statement_type` | + +### Removed — Breaking flag removal + +- **`gate-cli cex p2p ads update-status`** — removed `--trade-type` flag. v7.2.78 dropped the `TradeType` query parameter from `P2pMerchantBooksAdsUpdateStatus`. **Any scripts passing `--trade-type` must drop the flag.** + +### Fixed + +- **`internal/cmdutil/cmdutil_test.go::TestGetClient_NoCredentials`** — added missing `t.Setenv("HOME", t.TempDir())` so the test is isolated from a real `~/.gate-cli/config.yaml` on the developer's machine. Pre-existing bug surfaced while running the full suite; not a regression of this release. + +### Tests — 41 new unit tests covering v7.2.78 contracts + +- **`cmd/cex/bot/bot_test.go`** — 33 cases across 5 layers: command tree wiring (9), SDK type contracts (5: `StrategyType` enum stability, `SpotMartingaleCreateParams` new fields, `InfiniteGridCreateParams` omitempty, `AiHubPortfolioStopRequest` shape), RunE end-to-end with query/body capture (11), `RequireAuth` gate (10), and invalid-`--json` input validation (6). +- **`cmd/cex/p2p/sdk_v7_2_78_compat_test.go`** — locks the `TradeId → Txid` rename at both model layer (JSON tags) and wire level (mock-server captures); pins `omitempty` and the v7.2.78 `P2pTransactionActionResponse` shape (`Timestamp` `int32 → float32`, new `Method/Data/Version`); covers `PlaceBizPushOrder.HidePayment` silent drop and `team_payment_uid` forwarding for legacy `--json` blobs; protects `GetChatsListRequest.Txid` omitempty for `chat list --txid 0`. +- **`cmd/cex/cross_ex/sdk_v7_2_78_compat_test.go`** — pins the new `--statement-type` flag wiring, the `CrossexAccountBookRecord.StatementType` JSON tag, the rejected legacy `type` JSON key, and the renamed `Statement Type` table header. +- **`cmd/cex/spot/sdk_v7_2_78_compat_test.go`** — pins the new `Code` column rendering and the `SpotAccountBook.code` wire-tag binding. +- All 506 tests pass; zero failures across 42 test packages. + +### Unchanged + +- No new CLI flags on the existing cex modules beyond those listed above. +- Output formats, exit codes, profile/config layout, and Intel (`info`/`news`) baseline (40 tools) unchanged. +- No public API changes outside the `cmd/cex/bot/` namespace. + +## [v0.6.8] + +### Changed + +- **`docs/quickstart.md` & `docs/quickstart_zh.md`** — Intel (`info` & `news`) section updated to **40** MCP-style tools (**30** `info` + **10** `news`) and documents the **`prediction`** news subgroup (prediction-market venue rankings). + +### Fixed + +- **Intel / `gate-cli news prediction`** — `category` on `get-volume-delta-ranking` and `get-fastest-rising-ranking` is an optional free-form string filter in `internal/intelfacade/news_schema_baseline.go` (no closed enum); bundled `internal/mcpspec/bundled/news-tools-args-and-logic.json` validation and error tables aligned. + +## [v0.6.7] - 2026-04-30 + +### Added + +- **Intel / `gate-cli news`** — register two News MCP tools from `specs/mcp/news-tools-args-and-logic.json`: `news_prediction_get_volume_delta_ranking` and `news_prediction_get_fastest_rising_ranking` (CLI: `news prediction get-volume-delta-ranking`, `news prediction get-fastest-rising-ranking`). Baseline count is now **40** (30 `info` + 10 `news`). + +### Changed — Intel MCP HTTP client (`internal/mcpclient`) + +- Reject MCP JSON-RPC responses whose `id` does not match the outbound request (`tools/list`, `tools/call`, `initialize`, etc.). +- Require `initialize` to return a non-empty JSON `result` that unmarshals into a JSON object (not a bare scalar or missing `result`). +- While a warm `tools/list` cache exists, resolve tool names via an indexed map so repeated `DescribeTool` calls avoid scanning the whole tool list; the index tracks cache invalidation. + +### Fixed + +- **`internal/mcpclient`** — when `tools/list` temporarily fails after cache expiry but the client falls back to the last-good snapshot for that request, **`toolByName` is rebuilt** together with restored `listCache`, keeping describe-by-name lookups consistent with the fallback data. + +## [v0.6.6] - 2026-04-23 + +### Summary + +Docs-only refresh aligning user-facing documentation with the shipped Intel surface: `gate-cli info` / `gate-cli news` now documents **38** MCP-backed tools (**30** `info` + **8** `news`). No behavioural changes; no SDK bump; no new commands. + +### Changed — Documentation + +- **`README.md`** — New top-level **Intel (Info & News)** Features subsection listing all command groups (`coin`, `marketsnapshot`, `markettrend`, `onchain`, `platformmetrics`, `marketdetail`, `macro`, `compliance`, `feed`, `events`) with discovery hints (`info list`, `news list`, `-h`). Added one minimal `--format json` example per tool covering all 38 leaves. Modules table for `info` / `news` now shows tool counts and group lists; `Intel (info, news)` bottom section rewritten to point at the Features subsection and `specs/intel-config-and-security.md`. +- **`docs/quickstart.md` & `docs/quickstart_zh.md`** — New **Intel (`info` & `news`)** section between futures examples and multi-profile section, documenting groups, discovery commands, config (`intel:` block), and bearer isolation from trading `GATE_API_KEY`. Debugging section amended to note `--debug` / `--verbose` / `--max-output-bytes` behaviour for Intel MCP transport lines (stderr, unchanged stdout JSON shape). +- **`AGENTS.md` & `CLAUDE.md`** — MCP / Intel section rewritten to reflect that `info` / `news` are **published** (38 MCP leaves, discovery via `list` / `-h`). The planned unified `gate-cli tool` (`list` / `call` / `describe`) remains a spec-only path; auth-isolation guidance versus trading `GATE_API_KEY` reaffirmed. +- **`internal/intelfacade/inventory.go`** — Source-file comment updated from "Info: 29 tools on public gateway as of 2026-04; News: 8" to "Info: 30; News: 8; total 38. Keep in sync with BaselineToolCount tests." No code change; existing `TestBaselineToolCount` already asserts **38**. + +### Unchanged + +- `gateapi-go/v7 v7.2.71` (no SDK version bump). +- No new commands, flags, or breaking renames. +- No behavioural changes in any command; exit codes, output formats, and error surfaces are identical to v0.6.5. + +## [v0.6.5] - 2026-04-22 + +### Summary + +Syncs gate-cli to **gateapi-go/v7 v7.2.71** (from v7.2.57) and closes every remaining CLI ↔ SDK gap across the 11 core CEX modules plus the newly-surfaced `assetswap` module and `launch` Candy Drop / HODLer Airdrop V4 subtrees. **28 new commands + 1 parameter addition + 5 breaking renames + 66 new unit tests.** One existing dependency upgrade with zero behavioural regressions. + +### Added — MCP gap closure (assetswap + launch CandyDrop) + +- **`gate-cli cex assetswap`** — new top-level command group for Gate's Portfolio Optimization (asset-swap) APIs. Previously CLI had no assetswap coverage. SDK methods backing each subcommand: + - `cex assetswap assets` → `AssetswapAPI.ListAssetSwapAssets` + - `cex assetswap config` → `AssetswapAPI.GetAssetSwapConfig` + - `cex assetswap evaluate [--max-value] [--cursor] [--size]` → `AssetswapAPI.EvaluateAssetSwap` + - `cex assetswap order create --json ''` → `AssetswapAPI.CreateAssetSwapOrderV1` + - `cex assetswap order preview --json ''` → `AssetswapAPI.PreviewAssetSwapOrderV1` + - `cex assetswap order list [--from --to --status --offset --size --sort-mode --order-by]` → `AssetswapAPI.ListAssetSwapOrdersV1` + - `cex assetswap order get ` → `AssetswapAPI.GetAssetSwapOrderV1` +- **`gate-cli cex launch hodler`** — new subtree under the existing `launch` module exposing HODLer Airdrop V4: + - `cex launch hodler projects [--status --keyword --join --page --size]` → `LaunchAPI.GetHodlerAirdropProjectList` (public; logged-in users get extra info) + - `cex launch hodler order --hodler-id ` → `LaunchAPI.HodlerAirdropOrder` (participate in activity) + - `cex launch hodler order-records [--keyword --start-timest --end-timest --page --size]` → `LaunchAPI.GetHodlerAirdropUserOrderRecords` + - `cex launch hodler airdrop-records [--keyword --start-timest --end-timest --page --size]` → `LaunchAPI.GetHodlerAirdropUserAirdropRecords` +- **`gate-cli cex launch candy-drop`** — new subtree under the existing `launch` module exposing Candy Drop V4: + - `cex launch candy-drop activities` → `LaunchAPI.GetCandyDropActivityListV4` + - `cex launch candy-drop rules` → `LaunchAPI.GetCandyDropActivityRulesV4` + - `cex launch candy-drop register --currency [--activity-id]` → `LaunchAPI.RegisterCandyDropV4` + - `cex launch candy-drop progress` → `LaunchAPI.GetCandyDropTaskProgressV4` + - `cex launch candy-drop participations` → `LaunchAPI.GetCandyDropParticipationRecordsV4` + - `cex launch candy-drop airdrops` → `LaunchAPI.GetCandyDropAirdropRecordsV4` +- **`internal/client.Client`** — added `AssetswapAPI` field (wrapping `gateapi.AssetswapApiService`) so the new assetswap commands can invoke SDK methods via the standard client accessor. + +### Added — SDK v7.2.71 sync (CEX) + +- **`gate-cli cex earn dual refund-preview `** — preview early-redemption of a dual-investment order (`EarnAPI.GetDualOrderRefundPreview`, new in SDK v7.2.58+). +- **`gate-cli cex earn dual refund --order-id --req-id `** — execute early-redemption using the `req_id` obtained from `refund-preview` (`EarnAPI.PlaceDualOrderRefund`). +- **`gate-cli cex earn dual modify-reinvest --order-id --status <0|1> [--duration ]`** — toggle or adjust reinvest setting on a dual-investment order (`EarnAPI.ModifyDualOrderReinvest`). +- **`gate-cli cex earn dual recommend [--mode] [--coin] [--type] [--history-pids]`** — fetch recommended dual-investment projects (`EarnAPI.GetDualProjectRecommend`). +- **`gate-cli cex futures position get`** — get one-way (single-mode) position for a contract (`FuturesAPI.GetPosition`). Previously unavailable in the CLI. +- **`gate-cli cex futures position update-margin|update-leverage|update-cross-mode|update-risk-limit`** — new one-way position-mode update commands backed by SDK's bare `UpdatePosition*` methods. Previously unavailable in the CLI. +- **`gate-cli cex futures market risk-limit-table `** — query a specific futures risk-limit table (`FuturesAPI.GetFuturesRiskLimitTable`). Previously unavailable. +- **`gate-cli cex wallet sub --page --limit `** — added pagination flags to sub-account balance listing (SDK v7.2.71 extended `ListSubAccountBalancesOpts` with `Page`/`Limit`). + +### Changed — Futures dual-mode naming (breaking) + +Dual-mode (hedge) position commands have been renamed to free up the unprefixed names for the new one-way (single-mode) variants. **Any scripts using these commands must be updated.** + +| Old command | New command | Backing SDK method (unchanged) | +|-------------|-------------|-------------------------------| +| `cex futures position get` | `cex futures position get-dual` | `FuturesAPI.GetDualModePosition` | +| `cex futures position update-margin` | `cex futures position update-dual-margin` | `FuturesAPI.UpdateDualModePositionMargin` | +| `cex futures position update-leverage` | `cex futures position update-dual-leverage` | `FuturesAPI.UpdateDualModePositionLeverage` | +| `cex futures position update-cross-mode` | `cex futures position update-dual-cross-mode` | `FuturesAPI.UpdateDualCompPositionCrossMode` | +| `cex futures position update-risk-limit` | `cex futures position update-dual-risk-limit` | `FuturesAPI.UpdateDualModePositionRiskLimit` | + +`cex futures position update-contract-leverage` is unchanged (it maps to the contract-based `UpdateContractPositionLeverage`, a third mode that is distinct from both dual and one-way). + +### Dependencies + +- **`github.com/gate/gateapi-go/v7`**: `v7.2.57` → `v7.2.71`. Verified zero breaking changes to methods already in use; the upgrade unlocks the dual-investment refund/recommend methods and SDK-level pagination fields listed above. + +### Tests — 66 new unit tests, coverage lifted across every affected package + +Coverage strategy is three-layered: (1) cobra structural checks (subcommand tree, required flag annotations, positional-arg contracts), (2) direct RunE invocation with no credentials to exercise the `cmdutil.GetClient` / `RequireAuth` paths, (3) `httptest.NewServer` + `GATE_BASE_URL` redirection so no-auth public endpoints and downstream `opts` / JSON-unmarshal branches are covered end-to-end. + +Package-level coverage delta (statements): + +| Package | Before | After | +|---------|-------:|-------:| +| `cmd/cex/assetswap` | 17.4% | **59.4%** | +| `cmd/cex/launch` | 21.0% | **50.2%** | +| `cmd/cex/futures` | 22.7% | **26.6%** | +| `cmd/cex/earn` | 19.9% | **24.6%** | +| `cmd/cex/wallet` | 17.3% | **18.6%** | +| `internal/client` | 55.2% | **55.2%** (stable, guarded by new API-accessor non-nil test) | + +New test files: + +- `cmd/cex/assetswap/assetswap_test.go` (18 tests) — structural, RequireAuth, invalid-`--json`, httptest success + server-error branches +- `cmd/cex/launch/candy_drop_test.go` (11 tests), `cmd/cex/launch/hodler_airdrop_test.go` (9 tests) — structural, RequireAuth, httptest all-flags / no-flags matrices +- `cmd/cex/earn/dual_test.go` (10 tests) — structural, positional-args, RequireAuth, httptest for `runDualRecommend` +- `cmd/cex/futures/position_test.go` (4 tests) — full 17-command subtree guard + route-β rename regression via Short-field assertions +- `cmd/cex/futures/position_single_test.go` (7 tests) — RequireAuth for all single-mode + 2 dual-mode regression handlers +- `cmd/cex/futures/market_test.go` (3 tests) — `risk-limit-table` registration, Args contract, httptest success path +- `cmd/cex/wallet/balance_test.go` (4 tests) — new `--page` / `--limit` flags (non-zero + zero) + subcommand regression + +Updated tests: + +- `cmd/cex/launch/launch_test.go` — `candy-drop` and `hodler` added to the structural guard list +- `internal/client/client_test.go` — new `TestNewClientExposesAllSDKApis` asserts all 15 wrapped `*ApiService` fields (including the newly-added `AssetswapAPI`) are non-nil after `client.New` + +## [0.6.4] - 2026-04-22 + +### Added + +- **`gate-cli info platformmetrics get-cex-orderbook-depth`** — new intel leaf exposing the `info_platformmetrics_get_cex_orderbook_depth` MCP tool (CEX orderbook depth lookup). Inputs: required `symbol`; optional `market_type` (enum: `perp` [default], `spot`, `perps`, `futures`, `future`), `exchange`, `data_scope` (enum: `exchange`, `market`), `limit` (default `20`, max `100`). Registered in `internal/intelfacade/inventory.go` (`InfoToolBaseline`); schema added to `internal/intelfacade/info_schema_baseline.go`; bundled spec (`internal/mcpspec/bundled/info-mcp-tools-inputs-logic.json`) refreshed; `internal/intelfacade/inventory_test.go` baseline count updated (37 → 38). + +### Changed + +- **Info `platformmetrics_search_platforms.sort_by`** (`internal/intelfacade/info_schema_baseline.go`) — enum extended with `volume_perps_7d`, `volume_perps_30d`, `volume_perps_qtd` (in addition to existing `tvl` [default], `volume_24h`, `volume_spot_24h`, `volume_perps_24h`, `fees_24h`). +- **Info `platformmetrics_get_defi_overview.category`** — enum adds `dexs` alias alongside existing `dex` / `dexes`. +- **Info `platformmetrics_get_platform_history`** inputs gain `exchange_slug` (string) and `granularity` (enum: `day` [default], `week`, `month`, `quarter`). + +### Fixed + +- **`migration doctor` / `preflight`** — `MinDoctorVersion` bumped from `0.3.0` → `0.6.0` (`internal/migration/doctor.go`); `internal/migration/preflight_test.go` fixtures aligned to `0.6.0` so `version_ok` checks gate on the 0.6 line. + +## [0.6.2] - 2026-04-20 + +### Fixed + +- **CI**: `TestBundledMatchesSpecs` (`internal/mcpspec/spec_test.go`) now `t.Skip`s when `specs/mcp/*.json` is absent (CI / non-author machines) instead of failing with Fatal. The parity check still runs locally where the author keeps the source spec. + +## [0.6.1] - 2026-04-20 + +### Added + +- **`internal/mcpspec/`** — new package that embeds the Info/News MCP spec JSON (`info-mcp-tools-inputs-logic.json`, `news-tools-args-and-logic.json`) for offline agent/LLM consumption. Bundled JSON is validated at init and kept byte-identical to `specs/mcp/*.json` via new parity tests (`internal/intelfacade/spec_baseline_parity_test.go`, `news_spec_baseline_parity_test.go`, `internal/mcpspec/spec_test.go`). +- **`gate-cli info mcp-spec`** / **`gate-cli news mcp-spec`** — new leaf commands that print the embedded MCP inputs/spec document (tool names, fields, enums, bounds, logic text) with **no network call**. Table format is unsupported; use `--format json` or `pretty`. +- **`GATE_INTEL_LEAF_HELP`** — leaf `--help` `Long` text now appends MCP-spec narrative (description / policy / logic / errors) via `mcpspec.InfoLeafLongAppend` / `NewsLeafLongAppend`. Default stays compact (cobra already lists flag type/default/enum/max); set `GATE_INTEL_LEAF_HELP=full` (or `detailed`) to embed per-field notes. +- **`AnnotationIntelToolName`** (`gate-cli.intel.tool-name`) — cobra annotation attached to every intel leaf alias so tests and tooling can resolve the underlying MCP tool name. +- **Intel `isError` helpers** — new `internal/intelcmd/intel_result_error_classify.go` and `is_error_message.go` with unit tests classify tool-side failures and extract a human-readable message from the MCP payload. + +### Changed + +- **Info / News baseline schemas** (`internal/intelfacade/info_schema_baseline.go`, `news_schema_baseline.go`) — enrich flat-flag help with `enum`, `default`, `minimum`, `maximum`, `maxLength`, `maxItems`, and `pattern` mirrored from `specs/mcp/*.json` (e.g. Info `query_type` / `scope` / `asset_type` / `sort_by` / `ranking_type` / `time_range` / `timeframe` / `period` / `indicators` / `source`; News `platform` / `domain` / `quality_tier` / `time_range` / `sort_by` / `limit` / `page` / `days` / `allowed_handles` / `excluded_handles`). +- **`internal/toolschema`** — `ApplyInputSchemaFlags` now coerces `default` from `float64` / `int` / `int64` / `json.Number` for `integer` and `number` fields; flag usage strings append `min`, `max`, `minLen`, `maxLen`, `minItems`, `maxItems`, `pattern` when defined. `verify.valueMatchesType` accepts `int` / `int64` alongside `float64` for integer/number values. +- **`internal/toolrender/envelope`** — treat empty `structuredContent` (`{}`) as absent and fall back to `content[].text`; matches gateways that attach an empty structured payload. +- **`internal/intelcmd/leaf_alias.go`** — `LeafAliasConfig` gains `LongAppend`; the default `Long` block now documents `GATE_INTEL_LEAF_HELP=full` alongside the `--params` / `--args-json` / `--args-file` JSON-fallback flags. +- **`internal/intelfacade/inventory.go`** — comment documents the News baseline at 8 tools (Info: 29, public gateway as of 2026-04). + +### Fixed + +- **Intel `isError` classification** (`internal/intelcmd/run_tool_call.go` — `GateErrorForIntelToolIsError`) — tool-side argument / validation failures now surface as **HTTP 400 + `INVALID_ARGUMENTS`** (based on 4xx `http_status` / `status_code` fields, well-known error codes such as `INVALID_ARGUMENT` / `BAD_REQUEST` / `VALIDATION_ERROR` / `OUT_OF_RANGE`, or snake_case `" not supported"` wording). Transport / server errors stay at **502 + `INTEL_RESULT_ERROR`**, so scripts can distinguish caller-fixable input from backend outages. +- **Intel error messages** — surface a trimmed, redacted summary (≤ 2048 runes) from `structuredContent` / `content[].text` / raw payload instead of the generic `"tool returned isError=true"`; bearer tokens in the message are redacted to `Bearer [redacted]`. + ## [0.6.0] - 2026-04-20 ### Breaking @@ -14,6 +427,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **`cmd/cex/`** tree: `cex.go` wires every CEX domain package (`spot`, `futures`, `wallet`, `earn`, …) under **`gate-cli cex`**. Trading/account implementations now live under **`cmd/cex//`** (moved from the former top-level **`cmd//`** layout). - **Skill / MCP alignment docs** (for mapping Exchange skills and legacy MCP names to CLI): + - `cmd/cex/GATE_EXCHANGE_SKILLS_MCP_TO_GATE_CLI.md` — skill token → `gate-cli` invocation + - `cmd/cex/MCP_LEGACY_TOOL_RESOLUTION.md` — legacy `cex_*` tool resolution + - `cmd/cex/COMMAND_API_MAP.md` — API ↔ command reference +- **`bin/`** — prebuilt `gate-cli` binaries for **darwin / linux** (**arm64**, **x86_64**) for bundled or offline installs (e.g. skills / OpenClaw workflows). ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 444a376..2378bef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,9 +34,9 @@ - `FuturesTrade.Size` → `string` - `FuturesTrade.CreateTimeMs` → `float64` -## MCP / `tool`(信息与研究能力,规划中) -- 通过 MCP Streamable HTTP 对接 news / info / docs;**规范主命令** `gate-cli tool`(`list` / `call` / `describe`);与交易 API **鉴权隔离**(勿用 `GATE_API_KEY` 作 MCP Bearer)。 -- 见 [`specs/README.md`](specs/README.md)、[`specs/open-items-and-dependencies.md`](specs/open-items-and-dependencies.md)、[`docs/plans/2026-04-10-gate-cli-tool-mcp-review.md`](docs/plans/2026-04-10-gate-cli-tool-mcp-review.md)。 +## MCP / Intel(`info` / `news` 已发布;`tool` 仍为规格命名) +- **用户 CLI**:`gate-cli info` / `gate-cli news`(**46** 个 MCP 叶:`info list` / `news list`,`-h` 查 flag;README、quickstart)。**规划统一命令**:`gate-cli tool`(`list` / `call` / `describe`);与交易 API **鉴权隔离**(勿用 `GATE_API_KEY` 作 MCP Bearer)。 +- 见 [`specs/README.md`](specs/README.md)(含与实现对读)、[`specs/open-items-and-dependencies.md`](specs/open-items-and-dependencies.md)、[`docs/plans/2026-04-10-gate-cli-tool-mcp-review.md`](docs/plans/2026-04-10-gate-cli-tool-mcp-review.md)。 - **Cursor**:见 [`.cursor/rules/`](.cursor/rules/)(**`gate-cli-cli-layer-conventions.mdc`** + Intel 规则;`specs/cli/mcp-wire-appendix.md` **v0.4**)。 ## 架构约定 diff --git a/README.md b/README.md index 29b5e0a..57eb6e7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # gate-cli -A command-line interface for the [Gate](https://gate.com) API. Covers spot, futures, delivery, options, margin, unified account, earn, wallet, and 15+ more modules. Exchange API commands are grouped under `gate-cli cex …` (for example `gate-cli cex spot market ticker --pair BTC_USDT`); profile and credentials use `gate-cli config …` at the top level. Designed for developers, quants, and AI agents. For a full walkthrough, see the [English Quick Start](docs/quickstart.md) or [中文快速上手](docs/quickstart_zh.md). +A command-line interface for the [Gate](https://gate.com) API. Covers spot, futures, delivery, options, margin, unified account, earn, wallet, AI Hub quant strategies, and 15+ more modules. + +**Top-level layout:** CEX / trading APIs live under **`gate-cli cex …`** (for example `gate-cli cex spot market ticker --pair BTC_USDT`). Profiles and API credentials use **`gate-cli config …`**. **Intel** (market intelligence) uses **`gate-cli info`** and **`gate-cli news`** (**50** MCP-style tools: 32 + 18) plus deterministic top-level shortcut flows such as `gate-cli info +coin-overview` / `gate-cli news +brief`. Operational helpers: **`gate-cli doctor`** (local checks), **`gate-cli migrate`** (move legacy MCP provider configs toward CLI-first), **`gate-cli preflight`** (info/news readiness). Shell completion: **`gate-cli completion`**. Designed for developers, quants, and automation. For a full walkthrough, see the [English Quick Start](docs/quickstart.md) or [中文快速上手](docs/quickstart_zh.md). Per-release changes are tracked in [CHANGELOG.md](CHANGELOG.md). ## Installation @@ -25,6 +27,8 @@ irm https://raw.githubusercontent.com/gate/gate-cli/main/install.ps1 | iex gate-cli config init ``` +API keys and secrets for **trading** are stored per profile (for example `gate-cli config set api-key` / `gate-cli config set api-secret`) in `~/.gate-cli/config.yaml`. **Intel** endpoints and optional bearer tokens can use the same file under `intel:` or per-backend environment variables (see [Intel (`info`, `news`)](#intel-info-news) below). + ## Features ### Trading @@ -37,9 +41,11 @@ gate-cli config init - **Alpha** — alpha token market data, account, orders - **TradFi** — MT5 account, symbols, positions, orders, transactions - **Cross-Exchange** — cross-exchange trading, positions, orders, convert, margin +- **AI Hub (Bot)** — Gate's quant strategy engine: AI-recommended strategy discovery, 4 grid types (spot, margin, infinite, futures) and 2 martingale types (spot, contract), running portfolio listing, detail, and stop ### Finance -- **Earn** — dual investment, staking, fixed-term lending, auto-invest plans, uni simple earn +- **Earn** — dual investment (incl. early-redemption refund, reinvest modify, project recommend), staking, fixed-term lending, auto-invest plans, uni simple earn +- **Asset Swap** — portfolio optimization (valuation, recommended strategies, create/preview/list orders) - **Flash Swap** — instant token swaps, multi-currency many-to-one / one-to-many - **Multi-Collateral Loan** — multi-collateral borrowing, repayment, collateral management @@ -52,18 +58,48 @@ gate-cli config init ### Ecosystem - **P2P** — merchant ads, transactions, chat, payment methods - **Rebate** — partner/broker/agency commissions and transaction history -- **Launch** — launch pool projects, pledge, redeem, records +- **Launch** — launch pool projects/pledge/redeem, Candy Drop V4 activities, HODLer Airdrop V4 activities - **Activity** — platform activities and promotions - **Coupon** — user coupons and details - **Square** — AI search, live replay - **Welfare** — user identity, beginner tasks ### Architecture -- **Dual-position mode** — `add`, `remove`, `close` automatically detect position direction; single and dual (hedge) mode handled transparently via the `dual_comp` API +- **Futures position modes** — three orthogonal command groups under `gate-cli cex futures` expose every gateapi-go position flow: + - `cex futures position update-*` → **one-way (single)** mode — `UpdatePosition{Margin,Leverage,CrossMode,RiskLimit}` + `GetPosition` + - `cex futures position update-dual-*` → **dual (hedge)** mode — `UpdateDualModePosition*` + `GetDualModePosition` + - `cex futures position update-contract-leverage` → **contract** mode — `UpdateContractPositionLeverage` +- **Order helpers** — `cex futures order add`, `remove`, `close` automatically detect position direction for single/dual mode via the `dual_comp` API - **Output formats** — `--format pretty` (default for humans), `--format json` for scripts and agents, and `--format table` only where a command supports tabular list output - **Multiple profiles** — manage several API keys in one config file - **Credential priority** — `--api-key` flag > env var > config file +### Intel (Info & News) +- **Tool count** — **50** MCP-backed capabilities in the CLI baseline: **32** under `gate-cli info`, **18** under `gate-cli news` (grouped as ` ` leaves; counts follow the shipped tool list in the binary) +- **Info** — Each tool is `gate-cli info ` with **flat flags** for inputs. Optional JSON object args: `--params` / `--args-json` / `--args-file` when a field has no flag. +- **Info command groups** (the `` segment): + - **coin** — Coin profiles, multi-criteria search, and ranking boards. + - **marketsnapshot** — Single-symbol snapshots, batch snapshots, cross-asset market overview, and institutional ETF/CME/CFTC channel metrics. + - **markettrend** — OHLC-style klines, historical indicator series, and packaged technical analysis. + - **onchain** — Address balances and activity, transaction detail, and token-level on-chain metrics. + - **platformmetrics** — Protocol and CEX analytics: platform directory, DeFi overview, stablecoins (optional `scope=full` + `sections=issuance_flow|usage_structure` for supply flows and usage structure), bridges, order-book depth, yield pools, TVL/volume history, reserves, liquidation heatmaps, and chain activity (phase-1: Ethereum staking metrics). + - **marketdetail** — Live order book, recent trades, and klines for Gate trading symbols (spot/futures/etc.). + - **macro** — Macro indicators, economic calendar, and condensed macro summaries. + - **compliance** — Token security and risk screening for a given chain. +- **News** — Same pattern: `gate-cli news ` plus flat flags. +- **News command groups**: + - **feed** — Platform news index (`search-news`), UGC (`search-ugc`; needs `--query` or `--coin`), X/Twitter (`search-x`; needs `--query` for xAI), open-web synthesis (`web-search`), social sentiment, exchange announcements, 24h mention bursts (`get-mention-burst`; alias `mention-burst`), and 4h hot topics (`get-hot-topics`; alias `hot-topics`, limit 2–4). Both social-insight tools require `--coin`; `--platforms` accepts `all`, `gate_square`, `binance_square`, `twitter`, `telegram`, `youtube`, `reddit`, or `discord` (`all` cannot be combined). Tool routing hints: each leaf’s `-h` or `gate-cli news mcp-spec` (embedded English `description`; not `specs/mcp/`). + - **events** — Filtered event list with `event_id`, single-event detail, market-move evidence synthesis (`explain-market-move`), and stored market-move report queries: `get-market-move-report` (aliases `market-move-report`, `get-report`) requires `--symbol`; `--report-id` takes priority over `--event-id`, and omitting both returns the latest report for that symbol. `list-market-move-reports` (aliases `market-move-reports`, `report-list`) filters `updated_at` by required UTC0 `--start-time` / `--end-time` and accepts `--limit 0` for the default 20 (maximum 100). + - **prediction** — UTC daily rankings (`get-volume-delta-ranking`, `get-fastest-rising-ranking`; `predictionRankIndex`; `category` is a free-form rank-index term, not a closed enum). Event discovery: `search-events` on **`dws_prediction_event_signal_hf`** (collapse per `pk_id`; default `sort_by=recently_listed`; at least one of `--query`, `--coin`, `--category`). Per-event snapshot: `get-event-signal` on **`dws_external_event_signal_hf`** (`depth_summary` null in mapper—use `get-market-orderbook` for live CLOB). Live book: `get-market-orderbook` (`--venue` + `--market-id`; polymarket uses `predictionMarketIndex` + CLOB; predict.fun uses official numeric `market_id` and may return partial when API key missing). Unconfigured indices → `not_implemented`. See `gate-cli news prediction -h`. +- **Discovery** — `gate-cli info list`, `gate-cli news list` to print tool names; `gate-cli info -h` and `gate-cli news -h` for groups, flags, env vars, and top-level `+shortcut` commands +- **Schema booleans (`flexBool`)** — On `info` / `news` leaves, JSON boolean fields are exposed as flags that accept **`--flag`** (means true), **`--flag=false`**, or the legacy spaced form **`--flag false`**. The CLI normalizes spaced boolean literals before parsing so the next token (e.g. another flag) is not consumed by mistake. **`cex` / `config`** keep standard pflag `bool` behavior; this path is scoped to Intel commands only. + +### CLI diagnostics & migration + +- **`gate-cli doctor`** — Diagnose CLI version, config, connectivity to Intel backends, and legacy MCP registrations (`--check cli,version,config,connectivity,legacy-mcp` or `all`; `--strict` fails on warnings). +- **`gate-cli migrate`** — Scan and optionally rewrite Codex / Cursor / Claude Desktop configs that still reference legacy Gate MCP entries (`--dry-run`, `--apply`, `--provider`, `--backup-dir`). +- **`gate-cli preflight`** — CLI-first preflight for Gate info/news integrations (toggle MCP fallback with `--fallback-enabled`). + ## Usage examples ```bash @@ -101,10 +137,22 @@ gate-cli cex unified account get # Earn & staking gate-cli cex earn dual plans +gate-cli cex earn dual recommend --mode normal --coin BTC # recommended dual-investment projects +gate-cli cex earn dual refund-preview 12345 # preview early-redemption gate-cli cex earn uni currencies gate-cli cex earn fixed products gate-cli cex earn auto-invest coins +# Asset swap (portfolio optimization) +gate-cli cex assetswap assets +gate-cli cex assetswap config +gate-cli cex assetswap order list --size 20 + +# Launch pool / Candy Drop / HODLer Airdrop +gate-cli cex launch projects +gate-cli cex launch candy-drop activities --status active +gate-cli cex launch hodler projects --keyword BTC + # Flash swap gate-cli cex flash-swap pairs @@ -116,8 +164,89 @@ gate-cli cex mcl ltv gate-cli cex sub-account list gate-cli cex sub-account key list --user-id 12345 +# AI Hub (quant strategies) — 10 BotAPI methods wrapped under cex bot +gate-cli cex bot recommend --market BTC_USDT --strategy-type spot_grid # browse AI recommendations +gate-cli cex bot running --strategy-type spot_grid --page 1 --page-size 20 # list running strategies +gate-cli cex bot detail --strategy-id strat-001 --strategy-type spot_grid # detail by id+type +gate-cli cex bot stop --strategy-id strat-001 --strategy-type spot_grid # stop a running strategy +# Create flows take a JSON body matching the SDK's *CreateRequest shape; see -h on each leaf: +gate-cli cex bot grid spot --json '{"strategy_type":"spot_grid","market":"BTC_USDT","create_params":{"money":"100","low_price":"60000","high_price":"70000","grid_num":10,"price_type":0}}' +gate-cli cex bot grid infinite --json '{"strategy_type":"infinite_grid","market":"BTC_USDT","create_params":{"money":"100","price_floor":"60000","profit_per_grid":"0.005"}}' +gate-cli cex bot martingale spot --json '{"strategy_type":"spot_martingale","market":"BTC_USDT","create_params":{"invest_amount":"100","price_deviation":"0.02","max_orders":5,"take_profit_ratio":"0.01","stop_loss_per_cycle":"0.05"}}' + # JSON output for scripting gate-cli cex spot market ticker --pair BTC_USDT --format json | jq '.last' + +# Intel — 50 MCP tools (32 info + 18 news); list names: gate-cli info list / gate-cli news list +# Below: one minimal example per tool (flat flags; --format json). Arrays use a single JSON token, e.g. --indicators '["rsi"]'. + +# Info (32) +# coin — coin profiles, search, rankings +gate-cli info coin get-coin-info --query BTC --format json +# marketsnapshot — per-symbol and batch snapshots +gate-cli info marketsnapshot get-market-snapshot --symbol BTC_USDT --format json +# markettrend — klines, indicators, technical analysis +gate-cli info markettrend get-kline --symbol BTC_USDT --timeframe 1h --format json +gate-cli info markettrend get-indicator-history --symbol BTC_USDT --timeframe 1h --indicators '["rsi"]' --format json +gate-cli info markettrend get-technical-analysis --symbol BTC_USDT --format json +# onchain — addresses, transactions, token metrics +gate-cli info onchain get-address-info --address 0xd8dA6BF26964aF9D7eEd9e03E53415dA322193D --chain eth --format json +gate-cli info onchain get-address-info --address 0xd8dA6BF26964aF9D7eEd9e03E53415dA322193D --chain optimism --format pretty +gate-cli info onchain get-address-transactions --address 0xd8dA6BF26964aF9D7eEd9e03E53415dA322193D --format json +gate-cli info onchain get-transaction --tx-hash 0x88df016429689c079f1b2ea6911a4055630eac127461fbce8dcb82e83bdb12b4 --format json +gate-cli info onchain get-token-onchain --token USDT --chain eth --format json +# platformmetrics — DeFi/CEX platform and market-structure metrics +gate-cli info platformmetrics get-platform-info --platform-name uniswap --scope full --include-oi-symbol-detail --oi-symbol-limit 20 --format json +gate-cli info platformmetrics search-platforms --format json +gate-cli info platformmetrics get-defi-overview --format json +gate-cli info platformmetrics get-stablecoin-info --scope full --sections '["issuance_flow","usage_structure"]' --start-date 2026-04-01 --end-date 2026-05-01 --format json +gate-cli info platformmetrics get-bridge-metrics --format json +gate-cli info platformmetrics get-cex-orderbook-depth --symbol BTC_USDT --format json +gate-cli info platformmetrics get-yield-pools --format json +gate-cli info platformmetrics get-platform-history --platform-name uniswap --format json +gate-cli info platformmetrics get-exchange-reserves --scope full --include-history --asset BTC --format json +gate-cli info platformmetrics get-liquidation-heatmap --symbol BTC_USDT --format json +gate-cli info platformmetrics get-chain-activity --metric-group staking --lookback 30d --format json +# marketdetail — live book, trades, klines (Gate symbols) +gate-cli info marketdetail get-orderbook --symbol BTC_USDT --format json +gate-cli info marketdetail get-recent-trades --symbol BTC_USDT --format json +gate-cli info marketdetail get-kline --symbol BTC_USDT --timeframe 1h --format json +# macro — indicators, calendar, summary +gate-cli info macro get-macro-indicator --indicator CPI --format json +gate-cli info macro get-economic-calendar --format json +gate-cli info macro get-macro-summary --format json +# coin — search & rankings (same group as above) +gate-cli info coin search-coins --format json +gate-cli info coin get-coin-rankings --ranking-type popular --format json +# marketsnapshot — batch snapshot & overview +gate-cli info marketsnapshot batch-market-snapshot --symbols '["BTC_USDT"]' --format json +gate-cli info marketsnapshot get-market-overview --format json +gate-cli info marketsnapshot get-institutional-metrics --asset BTC --channel all --limit 30 --format json +# compliance — token security / risk +gate-cli info compliance check-token-security --chain eth --token USDT --format json + +# News (18) +# feed — search, web research, sentiment, announcements, mention bursts, hot topics (alias: search → search-news) +gate-cli news feed search-news --query bitcoin --format json +gate-cli news feed search-ugc --query bitcoin --format json +gate-cli news feed search-x --query bitcoin --coin BTC --time-range 7d --lang en --format json +gate-cli news feed web-search --query bitcoin --format json +gate-cli news feed get-social-sentiment --format json +gate-cli news feed get-exchange-announcements --format json +gate-cli news feed mention-burst --coin BTC --platforms all --format json +gate-cli news feed hot-topics --coin ETH --platforms twitter,reddit --limit 3 --format json +# events — latest events, detail by id, market-move evidence, stored report queries +gate-cli news events get-latest-events --format json +gate-cli news events get-event-detail --event-id example:event-1 --format json +gate-cli news events explain-market-move --query "Why did BTC move?" --coin BTC --format json +gate-cli news events get-report --symbol TAIKO --report-id _1782973063855 --format json +gate-cli news events report-list --symbol ETH --start-time "2026-07-09 22:00:00" --end-time "2026-07-10 03:15:00" --limit 10 --format json # filters updated_at in UTC0; sorts by event_time DESC; --limit 0 defaults to 20 +# prediction — rankings, signal-index search, external signal, live order book (see -h) +gate-cli news prediction get-volume-delta-ranking --format json +gate-cli news prediction get-fastest-rising-ranking --format json +gate-cli news prediction search-events --coin BTC --format json +gate-cli news prediction get-event-signal --event-ref polymarket:107711 --format json +gate-cli news prediction get-market-orderbook --venue polymarket --market-id 12345 --format json ``` ## Modules @@ -130,7 +259,9 @@ gate-cli cex spot market ticker --pair BTC_USDT --format json | jq '.last' | options | `gate-cli cex options` | Options trading | | margin | `gate-cli cex margin` | Margin trading & lending | | unified | `gate-cli cex unified` | Unified account management | -| earn | `gate-cli cex earn` | Earn, staking, dual investment, auto-invest | +| earn | `gate-cli cex earn` | Earn, staking, dual investment (incl. refund/recommend), auto-invest | +| bot | `gate-cli cex bot` | AI Hub quant strategies — recommend / running / detail / stop + 4 grid types + 2 martingale types | +| assetswap | `gate-cli cex assetswap` | Asset-swap / portfolio optimization | | flash-swap | `gate-cli cex flash-swap` | Instant token swaps | | mcl | `gate-cli cex mcl` | Multi-collateral loans | | cross-ex | `gate-cli cex cross-ex` | Cross-exchange trading | @@ -142,14 +273,17 @@ gate-cli cex spot market ticker --pair BTC_USDT --format json | jq '.last' | tradfi | `gate-cli cex tradfi` | TradFi (MT5) trading | | p2p | `gate-cli cex p2p` | P2P trading | | rebate | `gate-cli cex rebate` | Rebate & commissions | -| launch | `gate-cli cex launch` | Launch pool | +| launch | `gate-cli cex launch` | Launch pool + Candy Drop V4 + HODLer Airdrop V4 | | activity | `gate-cli cex activity` | Activities & promotions | | coupon | `gate-cli cex coupon` | Coupons | | square | `gate-cli cex square` | Gate Square | | welfare | `gate-cli cex welfare` | Welfare & tasks | -| config | `gate-cli config` | CLI configuration | -| info | `gate-cli info` | Market and intelligence info commands | -| news | `gate-cli news` | News and market intelligence commands | +| config | `gate-cli config` | CLI configuration (profiles, API keys, optional `intel:` block) | +| info | `gate-cli info` | **32** MCP tools under groups `coin`, `marketsnapshot`, `markettrend`, `onchain`, `platformmetrics`, `marketdetail`, `macro`, `compliance` (`info list`, top-level `+shortcut`, `info -h`; see Features) | +| news | `gate-cli news` | **18** MCP tools under `feed`, `events`, and `prediction` (`news list`, `news -h`; see Features) | +| doctor | `gate-cli doctor` | CLI + config + connectivity + legacy MCP diagnostics | +| migrate | `gate-cli migrate` | Migrate provider configs off legacy Gate MCP entries | +| preflight | `gate-cli preflight` | CLI-first preflight for info/news | ## Global flags @@ -157,12 +291,32 @@ gate-cli cex spot market ticker --pair BTC_USDT --format json | jq '.last' |------|---------|-------------| | `--format` | `pretty` | Output format: `pretty`, `json`, or `table` (only on tabular commands) | | `--profile` | `default` | Config profile to use | -| `--api-key` | — | API key (overrides env and config file) | -| `--api-secret` | — | API secret (overrides env and config file) | -| `--max-output-bytes` | `0` | Cap printed bytes for `info` / `news` results (`0` = unlimited; env `GATE_MAX_OUTPUT_BYTES`) | +| `--api-key` | — | Gate API key for **trading** (overrides env and config file; not used as Intel bearer) | +| `--api-secret` | — | Gate API secret for **trading** (overrides env and config file) | +| `--max-output-bytes` | `0` | Cap **printed** stdout for any command (`0` = unlimited; env `GATE_MAX_OUTPUT_BYTES`) | | `--verbose` | `false` | Print low-level Intel backend transport lines to stderr (`info` / `news`), prefixed `[verbose]`; stdout JSON unchanged | -| `--debug` | `false` | HTTP debug for Gate API clients; with `info` / `news`, backend transport uses `[debug]` on stderr (wins over `--verbose` when both are set) | +| `--debug` | `false` | HTTP debug for Gate **trading** clients; for `info` / `news`, Intel transport logs use `[debug]` on stderr (wins over `--verbose` when both are set) | ## Intel (`info`, `news`) -Behavior, flags, and environment variables: `gate-cli info -h`, `gate-cli news -h`. Optional defaults go under `intel:` in the same `config.yaml` as `profiles`; trading `GATE_API_KEY` / `--api-key` are not used as the Intel bearer (bearer is optional if your backend allows it). \ No newline at end of file +**50** MCP tools are wired as CLI leaves (32 `info`, 18 `news`). Command-group summaries (English) live under **Intel (Info & News)** in Features. Defaults can live under `intel:` in `~/.gate-cli/config.yaml` alongside `profiles`. **Do not** use trading `GATE_API_KEY` / `--api-key` as the Intel bearer; use the dedicated bearer env vars or `intel` config when your gateway requires auth. + +**Common environment variables** (override file when set; full detail in repo `specs/` if present): + +| Variable | Purpose | +|----------|---------| +| `GATE_INTEL_INFO_MCP_URL` / `GATE_INTEL_NEWS_MCP_URL` | JSON-RPC HTTP endpoint for each backend | +| `GATE_INTEL_INFO_BEARER_TOKEN` / `GATE_INTEL_NEWS_BEARER_TOKEN` | Per-backend bearer (optional) | +| `GATE_INTEL_BEARER_TOKEN` | Shared bearer when per-backend tokens are not set | +| `GATE_INTEL_HTTP_TIMEOUT` | HTTP client timeout (Go duration or seconds) | +| `GATE_INTEL_EXTRA_HEADERS` | JSON object of extra request headers (denylisted keys rejected) | +| `GATE_INTEL_MAX_RESPONSE_BYTES` | Max **HTTP response body** read for Intel JSON-RPC (default 16 MiB); distinct from `--max-output-bytes`, which only limits **stdout** | +| `GATE_MAX_OUTPUT_BYTES` | Default for `--max-output-bytes` when the flag is omitted | +| `GATE_INTEL_REFRESH_SCHEMA` | Set to `1` to force a one-off schema refresh (leaf flags / help) | +| `GATE_INTEL_LEAF_HELP` | `full` or `detailed` appends MCP-spec per-field notes to leaf `--help`. English `description` is shown on info/news leaf `-h` from the embedded bundled spec | + +Entry points: `gate-cli info list` / `gate-cli news list`, and `gate-cli info -h` / `gate-cli news -h`. Additional precedence and security notes may live under [`specs/README.md`](specs/README.md) / `specs/intel-config-and-security.md` depending on your checkout. + +## Development + +From a repository clone: `go build -o gate-cli .`. Prefer `./scripts/test-changed-go.sh` for local iteration; use `go test ./...` before wide merges / releases. Integration tests use build tag `integration` (see `testdata/integration.yaml.example`). diff --git a/cmd/cex/assetswap/assetswap.go b/cmd/cex/assetswap/assetswap.go new file mode 100644 index 0000000..6f106e1 --- /dev/null +++ b/cmd/cex/assetswap/assetswap.go @@ -0,0 +1,267 @@ +// Package assetswap exposes Gate's Portfolio Optimization (asset-swap) APIs +// via the `gate-cli cex assetswap ...` command group. Introduced when syncing +// with gateapi-go/v7 v7.2.71 to close the CLI gap against the assetswap MCP +// tool set. +package assetswap + +import ( + "encoding/json" + "fmt" + + "github.com/antihax/optional" + "github.com/spf13/cobra" + + "github.com/gate/gate-cli/internal/client" + "github.com/gate/gate-cli/internal/cmdutil" + gateapi "github.com/gate/gateapi-go/v7" +) + +// Cmd is the root command for the asset-swap module. +var Cmd = &cobra.Command{ + Use: "assetswap", + Short: "Asset-swap (portfolio optimization) commands", +} + +func init() { + assetsCmd := &cobra.Command{ + Use: "assets", + Short: "List supported asset-swap assets (public, no auth required)", + RunE: runAssets, + } + + configCmd := &cobra.Command{ + Use: "config", + Short: "Get asset-swap config and recommended strategies (public, no auth required)", + RunE: runConfig, + } + + evaluateCmd := &cobra.Command{ + Use: "evaluate", + Short: "Evaluate user portfolio for asset-swap (auth required)", + RunE: runEvaluate, + } + evaluateCmd.Flags().Int32("max-value", 0, "Maximum evaluate value") + evaluateCmd.Flags().String("cursor", "", "Pagination cursor") + evaluateCmd.Flags().Int32("size", 0, "Page size") + + orderCmd := &cobra.Command{ + Use: "order", + Short: "Asset-swap order commands", + } + + orderCreateCmd := &cobra.Command{ + Use: "create", + Short: "Create an asset-swap order", + RunE: runOrderCreate, + } + orderCreateCmd.Flags().String("json", "", `JSON body: {"from":[{"asset":"BTC","amount":"0.1"}],"to":[{"asset":"USDT","amount":"10000"}]} (required)`) + orderCreateCmd.MarkFlagRequired("json") + + orderPreviewCmd := &cobra.Command{ + Use: "preview", + Short: "Preview an asset-swap order (auth required)", + RunE: runOrderPreview, + } + orderPreviewCmd.Flags().String("json", "", `JSON body: {"from":[{"asset":"BTC","amount":"0.1"}],"to":[{"asset":"USDT","ratio":"0.5"}]} (required)`) + orderPreviewCmd.MarkFlagRequired("json") + + orderListCmd := &cobra.Command{ + Use: "list", + Short: "List asset-swap orders (auth required)", + RunE: runOrderList, + } + orderListCmd.Flags().Int32("from", 0, "Start time") + orderListCmd.Flags().Int32("to", 0, "End time") + orderListCmd.Flags().Int32("status", 0, "Order status") + orderListCmd.Flags().Int32("offset", 0, "Pagination offset") + orderListCmd.Flags().Int32("size", 0, "Page size") + orderListCmd.Flags().Int32("sort-mode", 0, "Sort mode") + orderListCmd.Flags().Int32("order-by", 0, "Order by") + + orderGetCmd := &cobra.Command{ + Use: "get ", + Short: "Get asset-swap order detail by ID (auth required)", + Args: cobra.ExactArgs(1), + RunE: runOrderGet, + } + + orderCmd.AddCommand(orderCreateCmd, orderPreviewCmd, orderListCmd, orderGetCmd) + Cmd.AddCommand(assetsCmd, configCmd, evaluateCmd, orderCmd) +} + +func runAssets(cmd *cobra.Command, args []string) error { + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + + result, httpResp, err := c.AssetswapAPI.ListAssetSwapAssets(c.Context()) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "GET", "/api/v4/asset-swap/asset/list", "")) + return nil + } + return p.Print(result) +} + +func runConfig(cmd *cobra.Command, args []string) error { + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + + result, httpResp, err := c.AssetswapAPI.GetAssetSwapConfig(c.Context()) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "GET", "/api/v4/asset-swap/config", "")) + return nil + } + return p.Print(result) +} + +func runEvaluate(cmd *cobra.Command, args []string) error { + maxValue, _ := cmd.Flags().GetInt32("max-value") + cursor, _ := cmd.Flags().GetString("cursor") + size, _ := cmd.Flags().GetInt32("size") + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + if err := c.RequireAuth(); err != nil { + return err + } + + opts := &gateapi.EvaluateAssetSwapOpts{} + if maxValue != 0 { + opts.MaxEvaluateValue = optional.NewInt32(maxValue) + } + if cursor != "" { + opts.Cursor = optional.NewString(cursor) + } + if size != 0 { + opts.Size = optional.NewInt32(size) + } + + result, httpResp, err := c.AssetswapAPI.EvaluateAssetSwap(c.Context(), opts) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "GET", "/api/v4/asset-swap/evaluate", "")) + return nil + } + return p.Print(result) +} + +func runOrderCreate(cmd *cobra.Command, args []string) error { + rawJSON, _ := cmd.Flags().GetString("json") + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + if err := c.RequireAuth(); err != nil { + return err + } + + var body gateapi.OrderCreateV1Req + if err := json.Unmarshal([]byte(rawJSON), &body); err != nil { + return fmt.Errorf("invalid --json body: %w", err) + } + + result, httpResp, err := c.AssetswapAPI.CreateAssetSwapOrderV1(c.Context(), body) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "POST", "/api/v4/asset-swap/order/v1", rawJSON)) + return nil + } + return p.Print(result) +} + +func runOrderPreview(cmd *cobra.Command, args []string) error { + rawJSON, _ := cmd.Flags().GetString("json") + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + if err := c.RequireAuth(); err != nil { + return err + } + + var body gateapi.OrderPreviewV1Req + if err := json.Unmarshal([]byte(rawJSON), &body); err != nil { + return fmt.Errorf("invalid --json body: %w", err) + } + + result, httpResp, err := c.AssetswapAPI.PreviewAssetSwapOrderV1(c.Context(), body) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "POST", "/api/v4/asset-swap/order/preview/v1", rawJSON)) + return nil + } + return p.Print(result) +} + +func runOrderList(cmd *cobra.Command, args []string) error { + from, _ := cmd.Flags().GetInt32("from") + to, _ := cmd.Flags().GetInt32("to") + status, _ := cmd.Flags().GetInt32("status") + offset, _ := cmd.Flags().GetInt32("offset") + size, _ := cmd.Flags().GetInt32("size") + sortMode, _ := cmd.Flags().GetInt32("sort-mode") + orderBy, _ := cmd.Flags().GetInt32("order-by") + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + if err := c.RequireAuth(); err != nil { + return err + } + + opts := &gateapi.ListAssetSwapOrdersV1Opts{} + if from != 0 { + opts.From = optional.NewInt32(from) + } + if to != 0 { + opts.To = optional.NewInt32(to) + } + if status != 0 { + opts.Status = optional.NewInt32(status) + } + if offset != 0 { + opts.Offset = optional.NewInt32(offset) + } + if size != 0 { + opts.Size = optional.NewInt32(size) + } + if sortMode != 0 { + opts.SortMode = optional.NewInt32(sortMode) + } + if orderBy != 0 { + opts.OrderBy = optional.NewInt32(orderBy) + } + + result, httpResp, err := c.AssetswapAPI.ListAssetSwapOrdersV1(c.Context(), opts) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "GET", "/api/v4/asset-swap/order/list/v1", "")) + return nil + } + return p.Print(result) +} + +func runOrderGet(cmd *cobra.Command, args []string) error { + orderID := args[0] + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + if err := c.RequireAuth(); err != nil { + return err + } + + result, httpResp, err := c.AssetswapAPI.GetAssetSwapOrderV1(c.Context(), orderID) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "GET", "/api/v4/asset-swap/order/detail/v1/"+orderID, "")) + return nil + } + return p.Print(result) +} diff --git a/cmd/cex/assetswap/assetswap_test.go b/cmd/cex/assetswap/assetswap_test.go new file mode 100644 index 0000000..ff96d8a --- /dev/null +++ b/cmd/cex/assetswap/assetswap_test.go @@ -0,0 +1,325 @@ +package assetswap + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newTestRoot provides a minimal cobra root so runXxx handlers can call +// cmdutil.GetClient/GetPrinter during tests. Env is isolated. +func newTestRoot(t *testing.T) *cobra.Command { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("GATE_API_KEY", "") + t.Setenv("GATE_API_SECRET", "") + root := &cobra.Command{Use: "gate-cli"} + root.PersistentFlags().String("format", "json", "") + root.PersistentFlags().String("profile", "default", "") + root.PersistentFlags().Bool("debug", false, "") + root.PersistentFlags().Bool("verbose", false, "") + root.PersistentFlags().String("api-key", "", "") + root.PersistentFlags().String("api-secret", "", "") + return root +} + +// authedTestRoot mirrors newTestRoot but seeds fake credentials so handlers +// pass the RequireAuth gate and exercise downstream branches (e.g. JSON parse). +func authedTestRoot(t *testing.T) *cobra.Command { + root := newTestRoot(t) + t.Setenv("GATE_API_KEY", "fake-key") + t.Setenv("GATE_API_SECRET", "fake-secret") + return root +} + +// mockGateServer stands up an httptest server returning fixed JSON for every +// request, points GATE_BASE_URL at it, and registers cleanup. Used for tests +// covering runXxx handlers that skip RequireAuth and go straight to the SDK. +func mockGateServer(t *testing.T, body string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + t.Setenv("GATE_BASE_URL", srv.URL) + return srv +} + +// silenceStdout swaps os.Stdout for /dev/null during the test so printer +// output does not pollute `go test` output. Tests using this must not run in +// parallel (t.Parallel() not called, which is the default). +func silenceStdout(t *testing.T) { + t.Helper() + oldOut := os.Stdout + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + require.NoError(t, err) + os.Stdout = devNull + t.Cleanup(func() { + os.Stdout = oldOut + _ = devNull.Close() + }) +} +var _ = io.Discard // keep the io import useful for future drain helpers + +const cobraBashCompOneRequiredFlag = "cobra_annotation_bash_completion_one_required_flag" + +func findSub(parent *cobra.Command, name string) *cobra.Command { + for _, c := range parent.Commands() { + if c.Name() == name { + return c + } + } + return nil +} + +func TestAssetswapRootCommand(t *testing.T) { + assert.Equal(t, "assetswap", Cmd.Name(), "module root command should be named assetswap") + assert.NotEmpty(t, Cmd.Short, "Cmd.Short should not be empty") +} + +func TestAssetswapFirstLevelSubcommands(t *testing.T) { + want := map[string]bool{ + "assets": false, + "config": false, + "evaluate": false, + "order": false, + } + for _, c := range Cmd.Commands() { + if _, ok := want[c.Name()]; ok { + want[c.Name()] = true + } + } + for name, found := range want { + assert.True(t, found, "assetswap should expose first-level %q", name) + } +} + +func TestAssetswapOrderSecondLevelSubcommands(t *testing.T) { + orderCmd := findSub(Cmd, "order") + require.NotNil(t, orderCmd, "assetswap.order should be registered") + + want := map[string]bool{ + "create": false, + "preview": false, + "list": false, + "get": false, + } + for _, c := range orderCmd.Commands() { + if _, ok := want[c.Name()]; ok { + want[c.Name()] = true + } + } + for name, found := range want { + assert.True(t, found, "assetswap order should expose %q", name) + } +} + +func TestAssetswapOrderCreateRequiresJSON(t *testing.T) { + orderCmd := findSub(Cmd, "order") + require.NotNil(t, orderCmd) + create := findSub(orderCmd, "create") + require.NotNil(t, create) + + j := create.Flag("json") + require.NotNil(t, j, "order create should have --json flag") + assert.NotEmpty(t, j.Annotations[cobraBashCompOneRequiredFlag], + "order create --json should be required") +} + +func TestAssetswapOrderPreviewRequiresJSON(t *testing.T) { + orderCmd := findSub(Cmd, "order") + require.NotNil(t, orderCmd) + preview := findSub(orderCmd, "preview") + require.NotNil(t, preview) + + j := preview.Flag("json") + require.NotNil(t, j, "order preview should have --json flag") + assert.NotEmpty(t, j.Annotations[cobraBashCompOneRequiredFlag], + "order preview --json should be required") +} + +func TestAssetswapOrderGetTakesPositionalArg(t *testing.T) { + orderCmd := findSub(Cmd, "order") + require.NotNil(t, orderCmd) + get := findSub(orderCmd, "get") + require.NotNil(t, get) + + err := get.Args(get, []string{}) + assert.Error(t, err, "order get should require 1 arg") + err = get.Args(get, []string{"id1", "id2"}) + assert.Error(t, err, "order get should reject 2 args") + err = get.Args(get, []string{"42"}) + assert.NoError(t, err, "order get should accept 1 arg") +} + +func TestAssetswapEvaluateOptionalFlags(t *testing.T) { + ev := findSub(Cmd, "evaluate") + require.NotNil(t, ev) + + for _, name := range []string{"max-value", "cursor", "size"} { + f := ev.Flag(name) + require.NotNil(t, f, "evaluate should expose --%s", name) + assert.Empty(t, f.Annotations[cobraBashCompOneRequiredFlag], + "evaluate --%s must be optional", name) + } +} + +// --- RunE error-path coverage --- + +func TestRunEvaluate_RequiresAuth(t *testing.T) { + root := newTestRoot(t) + cmd := &cobra.Command{Use: "evaluate"} + cmd.Flags().Int32("max-value", 0, "") + cmd.Flags().String("cursor", "", "") + cmd.Flags().Int32("size", 0, "") + root.AddCommand(cmd) + + err := runEvaluate(cmd, nil) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} + +func TestRunOrderCreate_RequiresAuth(t *testing.T) { + root := newTestRoot(t) + cmd := &cobra.Command{Use: "create"} + cmd.Flags().String("json", `{"from":[{"asset":"BTC","amount":"0.1"}],"to":[{"asset":"USDT","amount":"10000"}]}`, "") + root.AddCommand(cmd) + + err := runOrderCreate(cmd, nil) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} + +// With credentials seeded, an invalid --json body is reported by +// json.Unmarshal — covers the dedicated error-wrap branch. +func TestRunOrderCreate_InvalidJSON(t *testing.T) { + root := authedTestRoot(t) + cmd := &cobra.Command{Use: "create"} + cmd.Flags().String("json", "not-a-valid-json", "") + root.AddCommand(cmd) + + err := runOrderCreate(cmd, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid --json body", + "error should be wrapped with invalid --json body prefix") +} + +func TestRunOrderPreview_RequiresAuth(t *testing.T) { + root := newTestRoot(t) + cmd := &cobra.Command{Use: "preview"} + cmd.Flags().String("json", `{"from":[],"to":[]}`, "") + root.AddCommand(cmd) + + err := runOrderPreview(cmd, nil) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} + +func TestRunOrderPreview_InvalidJSON(t *testing.T) { + root := authedTestRoot(t) + cmd := &cobra.Command{Use: "preview"} + cmd.Flags().String("json", "][not-json[", "") + root.AddCommand(cmd) + + err := runOrderPreview(cmd, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid --json body") +} + +func TestRunOrderList_RequiresAuth(t *testing.T) { + root := newTestRoot(t) + cmd := &cobra.Command{Use: "list"} + for _, name := range []string{"from", "to", "status", "offset", "size", "sort-mode", "order-by"} { + cmd.Flags().Int32(name, 0, "") + } + root.AddCommand(cmd) + + err := runOrderList(cmd, nil) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} + +func TestRunOrderGet_RequiresAuth(t *testing.T) { + root := newTestRoot(t) + cmd := &cobra.Command{Use: "get"} + root.AddCommand(cmd) + + err := runOrderGet(cmd, []string{"order-123"}) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} + +// --- Public (no-auth) RunE coverage via httptest --- +// +// runAssets / runConfig do not call RequireAuth, so covering them requires a +// mock HTTP server. We return an empty JSON object which the SDK happily +// unmarshals into a zero-valued response struct. + +func TestRunAssets_Succeeds(t *testing.T) { + mockGateServer(t, `{}`) + silenceStdout(t) + root := newTestRoot(t) + cmd := &cobra.Command{Use: "assets"} + root.AddCommand(cmd) + + err := runAssets(cmd, nil) + assert.NoError(t, err, "runAssets should succeed against mock server") +} + +func TestRunConfig_Succeeds(t *testing.T) { + mockGateServer(t, `{}`) + silenceStdout(t) + root := newTestRoot(t) + cmd := &cobra.Command{Use: "config"} + root.AddCommand(cmd) + + err := runConfig(cmd, nil) + assert.NoError(t, err, "runConfig should succeed against mock server") +} + +// Server returning an HTTP error path exercises the PrintError branch. +// runAssets returns nil (error swallowed by PrintError) but still covers lines. +func TestRunAssets_ServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"label":"INTERNAL","message":"boom"}`)) + })) + defer srv.Close() + t.Setenv("GATE_BASE_URL", srv.URL) + // printer writes error JSON to stderr; silence stderr for cleanliness. + oldErr := os.Stderr + devNull, _ := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + os.Stderr = devNull + t.Cleanup(func() { os.Stderr = oldErr; _ = devNull.Close() }) + + root := newTestRoot(t) + cmd := &cobra.Command{Use: "assets"} + root.AddCommand(cmd) + + err := runAssets(cmd, nil) + assert.NoError(t, err, "runAssets swallows SDK errors via PrintError and returns nil") +} + +func TestAssetswapOrderListOptionalFlags(t *testing.T) { + orderCmd := findSub(Cmd, "order") + require.NotNil(t, orderCmd) + listCmd := findSub(orderCmd, "list") + require.NotNil(t, listCmd) + + for _, name := range []string{"from", "to", "status", "offset", "size", "sort-mode", "order-by"} { + f := listCmd.Flag(name) + require.NotNil(t, f, "order list should expose --%s", name) + assert.Empty(t, f.Annotations[cobraBashCompOneRequiredFlag], + "order list --%s must be optional", name) + } +} diff --git a/cmd/cex/bot/bot.go b/cmd/cex/bot/bot.go new file mode 100644 index 0000000..08cc380 --- /dev/null +++ b/cmd/cex/bot/bot.go @@ -0,0 +1,217 @@ +// Package bot wraps Gate AI Hub (BotAPI) endpoints under `gate-cli cex bot`. +// +// Coverage of the BotApiService surface in gateapi-go v7.2.78: +// +// bot recommend GetAIHubStrategyRecommend +// bot running GetAIHubPortfolioRunning +// bot detail GetAIHubPortfolioDetail +// bot stop PostAIHubPortfolioStop +// bot grid spot / margin / infinite / futures (Post*GridCreate) +// bot martingale spot / contract (Post*MartingaleCreate) +package bot + +import ( + "github.com/antihax/optional" + "github.com/spf13/cobra" + + "github.com/gate/gate-cli/internal/client" + "github.com/gate/gate-cli/internal/cmdutil" + gateapi "github.com/gate/gateapi-go/v7" +) + +// Cmd is the root command for the AI Hub (bot) module. +var Cmd = &cobra.Command{ + Use: "bot", + Short: "Gate AI Hub (quant strategies) commands", + Long: "Discover, create, query, and stop AI Hub strategies (spot/margin/infinite/futures grid + spot/contract martingale).", +} + +func init() { + recommendCmd := &cobra.Command{ + Use: "recommend", + Short: "List AI Hub recommended strategies", + RunE: runBotRecommend, + } + recommendCmd.Flags().String("market", "", "Trading pair, e.g. BTC_USDT") + recommendCmd.Flags().String("strategy-type", "", "Strategy type filter (e.g. spot_grid, futures_grid, spot_martingale)") + recommendCmd.Flags().String("direction", "", "Market direction") + recommendCmd.Flags().String("invest-amount", "", "Investment amount") + recommendCmd.Flags().String("scene", "", "Recommend scene: top1 / bundle / filter / refresh") + recommendCmd.Flags().String("refresh-recommendation-id", "", "Recommendation ID for scene=refresh; format strategy_type|market[|backtest_id]") + recommendCmd.Flags().Int32("limit", 0, "Max results returned (filter scene capped at 10)") + recommendCmd.Flags().String("max-drawdown-lte", "", "Max drawdown upper bound") + recommendCmd.Flags().String("backtest-apr-gte", "", "Backtest annualized return lower bound") + + runningCmd := &cobra.Command{ + Use: "running", + Short: "List currently-running strategies", + RunE: runBotRunning, + } + runningCmd.Flags().String("strategy-type", "", "Filter by strategy type") + runningCmd.Flags().String("market", "", "Filter by trading pair") + runningCmd.Flags().Int32("page", 0, "Page number") + runningCmd.Flags().Int32("page-size", 0, "Page size") + + detailCmd := &cobra.Command{ + Use: "detail", + Short: "Get strategy details", + RunE: runBotDetail, + } + detailCmd.Flags().String("strategy-id", "", "Strategy ID (required)") + detailCmd.MarkFlagRequired("strategy-id") + detailCmd.Flags().String("strategy-type", "", "Strategy type (required); used to dispatch to the underlying detail implementation") + detailCmd.MarkFlagRequired("strategy-type") + + stopCmd := &cobra.Command{ + Use: "stop", + Short: "Stop a running strategy", + RunE: runBotStop, + } + stopCmd.Flags().String("strategy-id", "", "Strategy ID (required)") + stopCmd.MarkFlagRequired("strategy-id") + stopCmd.Flags().String("strategy-type", "", "Strategy type (required)") + stopCmd.MarkFlagRequired("strategy-type") + + Cmd.AddCommand(recommendCmd, runningCmd, detailCmd, stopCmd) +} + +func runBotRecommend(cmd *cobra.Command, args []string) error { + market, _ := cmd.Flags().GetString("market") + strategyType, _ := cmd.Flags().GetString("strategy-type") + direction, _ := cmd.Flags().GetString("direction") + investAmount, _ := cmd.Flags().GetString("invest-amount") + scene, _ := cmd.Flags().GetString("scene") + refreshID, _ := cmd.Flags().GetString("refresh-recommendation-id") + limit, _ := cmd.Flags().GetInt32("limit") + maxDD, _ := cmd.Flags().GetString("max-drawdown-lte") + aprGte, _ := cmd.Flags().GetString("backtest-apr-gte") + + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + if err := c.RequireAuth(); err != nil { + return err + } + + opts := &gateapi.GetAIHubStrategyRecommendOpts{} + if market != "" { + opts.Market = optional.NewString(market) + } + if strategyType != "" { + opts.StrategyType = optional.NewString(strategyType) + } + if direction != "" { + opts.Direction = optional.NewString(direction) + } + if investAmount != "" { + opts.InvestAmount = optional.NewString(investAmount) + } + if scene != "" { + opts.Scene = optional.NewString(scene) + } + if refreshID != "" { + opts.RefreshRecommendationId = optional.NewString(refreshID) + } + if limit != 0 { + opts.Limit = optional.NewInt32(limit) + } + if maxDD != "" { + opts.MaxDrawdownLte = optional.NewString(maxDD) + } + if aprGte != "" { + opts.BacktestAprGte = optional.NewString(aprGte) + } + + result, httpResp, err := c.BotAPI.GetAIHubStrategyRecommend(c.Context(), opts) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "GET", "/api/v4/bot/strategy/recommend", "")) + return nil + } + return p.Print(result) +} + +func runBotRunning(cmd *cobra.Command, args []string) error { + strategyType, _ := cmd.Flags().GetString("strategy-type") + market, _ := cmd.Flags().GetString("market") + page, _ := cmd.Flags().GetInt32("page") + pageSize, _ := cmd.Flags().GetInt32("page-size") + + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + if err := c.RequireAuth(); err != nil { + return err + } + + opts := &gateapi.GetAIHubPortfolioRunningOpts{} + if strategyType != "" { + opts.StrategyType = optional.NewString(strategyType) + } + if market != "" { + opts.Market = optional.NewString(market) + } + if page != 0 { + opts.Page = optional.NewInt32(page) + } + if pageSize != 0 { + opts.PageSize = optional.NewInt32(pageSize) + } + + result, httpResp, err := c.BotAPI.GetAIHubPortfolioRunning(c.Context(), opts) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "GET", "/api/v4/bot/portfolio/running", "")) + return nil + } + return p.Print(result) +} + +func runBotDetail(cmd *cobra.Command, args []string) error { + strategyID, _ := cmd.Flags().GetString("strategy-id") + strategyType, _ := cmd.Flags().GetString("strategy-type") + + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + if err := c.RequireAuth(); err != nil { + return err + } + + result, httpResp, err := c.BotAPI.GetAIHubPortfolioDetail(c.Context(), strategyID, strategyType, nil) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "GET", "/api/v4/bot/portfolio/detail", "")) + return nil + } + return p.Print(result) +} + +func runBotStop(cmd *cobra.Command, args []string) error { + strategyID, _ := cmd.Flags().GetString("strategy-id") + strategyType, _ := cmd.Flags().GetString("strategy-type") + + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + if err := c.RequireAuth(); err != nil { + return err + } + + body := gateapi.AiHubPortfolioStopRequest{ + StrategyId: strategyID, + StrategyType: gateapi.StrategyType(strategyType), + } + + result, httpResp, err := c.BotAPI.PostAIHubPortfolioStop(c.Context(), body, nil) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "POST", "/api/v4/bot/portfolio/stop", "")) + return nil + } + return p.Print(result) +} diff --git a/cmd/cex/bot/bot_test.go b/cmd/cex/bot/bot_test.go new file mode 100644 index 0000000..65145d7 --- /dev/null +++ b/cmd/cex/bot/bot_test.go @@ -0,0 +1,651 @@ +package bot + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + gateapi "github.com/gate/gateapi-go/v7" +) + +// Test layout for the new cex bot module wired against gateapi-go v7.2.78. +// +// Layer 1 — command-tree wiring (10 commands across 3 trees). +// Layer 2 — SDK type contracts the bot module is locked to (StrategyType +// enum, v7.2.78 SpotMartingale field rename, InfiniteGrid omitempty). +// Layer 3 — RunE end-to-end against a mock Gate server, asserting the +// outbound query/body so wire-level regressions surface. +// Layer 4 — auth gate (all 10 commands gated by RequireAuth). +// Layer 5 — input-validation: invalid --json must error before hitting the SDK. + +const cobraRequiredFlagAnnotation = "cobra_annotation_bash_completion_one_required_flag" + +func newBotTestRoot(t *testing.T) *cobra.Command { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("GATE_API_KEY", "") + t.Setenv("GATE_API_SECRET", "") + root := &cobra.Command{Use: "gate-cli"} + root.PersistentFlags().String("format", "json", "") + root.PersistentFlags().String("profile", "default", "") + root.PersistentFlags().Bool("debug", false, "") + root.PersistentFlags().Bool("verbose", false, "") + root.PersistentFlags().String("api-key", "", "") + root.PersistentFlags().String("api-secret", "", "") + return root +} + +func authedBotTestRoot(t *testing.T) *cobra.Command { + root := newBotTestRoot(t) + t.Setenv("GATE_API_KEY", "fake-key") + t.Setenv("GATE_API_SECRET", "fake-secret") + return root +} + +func silenceBotStdout(t *testing.T) { + t.Helper() + oldOut := os.Stdout + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + require.NoError(t, err) + os.Stdout = devNull + t.Cleanup(func() { + os.Stdout = oldOut + _ = devNull.Close() + }) +} + +// captureRequest spins up a mock server that records the inbound request +// (URL + body) and returns 200 with `respBody`. Subtests inspect the +// captured fields after invoking RunE. +type captured struct { + URL *url.URL + Body string + Method string +} + +func captureRequest(t *testing.T, respBody string) *captured { + t.Helper() + cap := &captured{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cap.URL = r.URL + cap.Method = r.Method + if r.ContentLength > 0 { + buf := make([]byte, r.ContentLength) + _, _ = r.Body.Read(buf) + cap.Body = string(buf) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(respBody)) + })) + t.Cleanup(srv.Close) + t.Setenv("GATE_BASE_URL", srv.URL) + return cap +} + +func findBotSub(parent *cobra.Command, name string) *cobra.Command { + for _, c := range parent.Commands() { + if c.Name() == name { + return c + } + } + return nil +} + +// =========================================================================== +// Layer 1 — Command-tree wiring. +// =========================================================================== + +func TestBotRootCommand(t *testing.T) { + assert.Equal(t, "bot", Cmd.Name()) + assert.NotEmpty(t, Cmd.Short) +} + +func TestBotFirstLevelSubcommands(t *testing.T) { + want := map[string]bool{ + "recommend": false, + "running": false, + "detail": false, + "stop": false, + "grid": false, + "martingale": false, + } + for _, c := range Cmd.Commands() { + if _, ok := want[c.Name()]; ok { + want[c.Name()] = true + } + } + for name, found := range want { + assert.True(t, found, "bot must register %q", name) + } +} + +func TestBotGridSubcommands(t *testing.T) { + grid := findBotSub(Cmd, "grid") + require.NotNil(t, grid) + + want := map[string]bool{"spot": false, "margin": false, "infinite": false, "futures": false} + for _, c := range grid.Commands() { + if _, ok := want[c.Name()]; ok { + want[c.Name()] = true + } + } + for name, found := range want { + assert.True(t, found, "bot grid must register %q", name) + } +} + +func TestBotMartingaleSubcommands(t *testing.T) { + mart := findBotSub(Cmd, "martingale") + require.NotNil(t, mart) + + want := map[string]bool{"spot": false, "contract": false} + for _, c := range mart.Commands() { + if _, ok := want[c.Name()]; ok { + want[c.Name()] = true + } + } + for name, found := range want { + assert.True(t, found, "bot martingale must register %q", name) + } +} + +// Required-flag matrix. detail/stop need both --strategy-id and +// --strategy-type. All 6 create commands need --json. +func TestBotDetail_RequiredFlags(t *testing.T) { + detail := findBotSub(Cmd, "detail") + require.NotNil(t, detail) + for _, name := range []string{"strategy-id", "strategy-type"} { + f := detail.Flag(name) + require.NotNil(t, f, "detail must expose --%s", name) + assert.NotEmpty(t, f.Annotations[cobraRequiredFlagAnnotation], + "detail --%s must be required", name) + } +} + +func TestBotStop_RequiredFlags(t *testing.T) { + stop := findBotSub(Cmd, "stop") + require.NotNil(t, stop) + for _, name := range []string{"strategy-id", "strategy-type"} { + f := stop.Flag(name) + require.NotNil(t, f) + assert.NotEmpty(t, f.Annotations[cobraRequiredFlagAnnotation]) + } +} + +func TestBotRecommend_AllFlagsOptional(t *testing.T) { + rec := findBotSub(Cmd, "recommend") + require.NotNil(t, rec) + for _, name := range []string{"market", "strategy-type", "direction", "invest-amount", "scene", "refresh-recommendation-id", "limit", "max-drawdown-lte", "backtest-apr-gte"} { + f := rec.Flag(name) + require.NotNil(t, f, "recommend must expose --%s", name) + assert.Empty(t, f.Annotations[cobraRequiredFlagAnnotation], + "recommend --%s must be optional", name) + } +} + +func TestBotRunning_AllFlagsOptional(t *testing.T) { + run := findBotSub(Cmd, "running") + require.NotNil(t, run) + for _, name := range []string{"strategy-type", "market", "page", "page-size"} { + f := run.Flag(name) + require.NotNil(t, f) + assert.Empty(t, f.Annotations[cobraRequiredFlagAnnotation]) + } +} + +func TestBotCreateCommands_RequireJSON(t *testing.T) { + cases := []struct { + group, leaf string + }{ + {"grid", "spot"}, {"grid", "margin"}, {"grid", "infinite"}, {"grid", "futures"}, + {"martingale", "spot"}, {"martingale", "contract"}, + } + for _, tc := range cases { + grp := findBotSub(Cmd, tc.group) + require.NotNil(t, grp) + leaf := findBotSub(grp, tc.leaf) + require.NotNil(t, leaf, "%s %s must be registered", tc.group, tc.leaf) + f := leaf.Flag("json") + require.NotNil(t, f, "%s %s must expose --json", tc.group, tc.leaf) + assert.NotEmpty(t, f.Annotations[cobraRequiredFlagAnnotation], + "%s %s --json must be required", tc.group, tc.leaf) + } +} + +// =========================================================================== +// Layer 2 — SDK type contracts the bot module relies on. +// =========================================================================== + +// StrategyType enum constants must keep their string values, otherwise the +// `stop` command's gateapi.StrategyType(strategyType) cast would silently +// drift away from server expectations. +func TestStrategyType_EnumStableValues(t *testing.T) { + assert.Equal(t, "spot_grid", string(gateapi.SPOT_GRID)) + assert.Equal(t, "margin_grid", string(gateapi.MARGIN_GRID)) + assert.Equal(t, "infinite_grid", string(gateapi.INFINITE_GRID)) + assert.Equal(t, "futures_grid", string(gateapi.FUTURES_GRID)) + assert.Equal(t, "spot_martingale", string(gateapi.SPOT_MARTINGALE)) + assert.Equal(t, "contract_martingale", string(gateapi.CONTRACT_MARTINGALE)) +} + +// v7.2.78 spot martingale wire shape: stop_loss_per_cycle replaces the +// old stop_loss_price; trigger_price is new and optional. +func TestSpotMartingaleCreateParams_NewWireShape(t *testing.T) { + params := gateapi.SpotMartingaleCreateParams{ + InvestAmount: "100", + PriceDeviation: "0.02", + MaxOrders: 5, + TakeProfitRatio: "0.01", + StopLossPerCycle: "0.05", + TriggerPrice: "70000", + } + raw, err := json.Marshal(params) + require.NoError(t, err) + s := string(raw) + + assert.Contains(t, s, `"stop_loss_per_cycle":"0.05"`, + "v7.2.78 spot martingale uses stop_loss_per_cycle") + assert.Contains(t, s, `"trigger_price":"70000"`, + "v7.2.78 added trigger_price") + assert.NotContains(t, s, `"stop_loss_price"`, + "v7.2.78 removed stop_loss_price from SpotMartingaleCreateParams") +} + +// v7.2.78 InfiniteGridCreateParams: grid_num and price_type became omitempty. +// When the user only supplies money/price_floor/profit_per_grid (the +// documented minimum), the optional ints must not show up as 0 on the wire. +func TestInfiniteGridCreateParams_OmitemptyForOptionals(t *testing.T) { + params := gateapi.InfiniteGridCreateParams{ + Money: "100", + PriceFloor: "60000", + ProfitPerGrid: "0.005", + } + raw, err := json.Marshal(params) + require.NoError(t, err) + s := string(raw) + + assert.NotContains(t, s, `"grid_num"`, + "v7.2.78 made grid_num omitempty; zero must not be sent") + assert.NotContains(t, s, `"price_type"`, + "v7.2.78 made price_type omitempty; zero must not be sent") + assert.Contains(t, s, `"money":"100"`) + assert.Contains(t, s, `"price_floor":"60000"`) + assert.Contains(t, s, `"profit_per_grid":"0.005"`) +} + +// Conversely, non-zero values still serialize so explicit user choices reach +// the server. +func TestInfiniteGridCreateParams_NonZeroValuesSerialize(t *testing.T) { + params := gateapi.InfiniteGridCreateParams{ + Money: "100", PriceFloor: "60000", ProfitPerGrid: "0.005", + GridNum: 50, PriceType: 1, + } + raw, err := json.Marshal(params) + require.NoError(t, err) + s := string(raw) + assert.Contains(t, s, `"grid_num":50`) + assert.Contains(t, s, `"price_type":1`) +} + +// AiHubPortfolioStopRequest shape lock; the bot stop CLI builds this struct. +func TestAiHubPortfolioStopRequest_FieldShape(t *testing.T) { + body := gateapi.AiHubPortfolioStopRequest{ + StrategyId: "abc-123", + StrategyType: gateapi.SPOT_GRID, + } + raw, err := json.Marshal(body) + require.NoError(t, err) + s := string(raw) + assert.Contains(t, s, `"strategy_id":"abc-123"`) + assert.Contains(t, s, `"strategy_type":"spot_grid"`) +} + +// =========================================================================== +// Layer 3 — RunE end-to-end (query + body capture). +// =========================================================================== + +func TestRunBotRecommend_AllFlags_FlowToQuery(t *testing.T) { + cap := captureRequest(t, `{}`) + silenceBotStdout(t) + root := authedBotTestRoot(t) + cmd := &cobra.Command{Use: "recommend"} + cmd.Flags().String("market", "BTC_USDT", "") + cmd.Flags().String("strategy-type", "spot_grid", "") + cmd.Flags().String("direction", "long", "") + cmd.Flags().String("invest-amount", "1000", "") + cmd.Flags().String("scene", "filter", "") + cmd.Flags().String("refresh-recommendation-id", "spot_grid|BTC_USDT", "") + cmd.Flags().Int32("limit", 5, "") + cmd.Flags().String("max-drawdown-lte", "0.3", "") + cmd.Flags().String("backtest-apr-gte", "0.1", "") + root.AddCommand(cmd) + + err := runBotRecommend(cmd, nil) + require.NoError(t, err) + + require.NotNil(t, cap.URL) + q := cap.URL.Query() + assert.Equal(t, "BTC_USDT", q.Get("market")) + assert.Equal(t, "spot_grid", q.Get("strategy_type")) + assert.Equal(t, "long", q.Get("direction")) + assert.Equal(t, "1000", q.Get("invest_amount")) + assert.Equal(t, "filter", q.Get("scene")) + assert.Equal(t, "spot_grid|BTC_USDT", q.Get("refresh_recommendation_id")) + assert.Equal(t, "5", q.Get("limit")) + assert.Equal(t, "0.3", q.Get("max_drawdown_lte")) + assert.Equal(t, "0.1", q.Get("backtest_apr_gte")) +} + +func TestRunBotRecommend_NoFlags_LeavesQueryEmpty(t *testing.T) { + cap := captureRequest(t, `{}`) + silenceBotStdout(t) + root := authedBotTestRoot(t) + cmd := &cobra.Command{Use: "recommend"} + cmd.Flags().String("market", "", "") + cmd.Flags().String("strategy-type", "", "") + cmd.Flags().String("direction", "", "") + cmd.Flags().String("invest-amount", "", "") + cmd.Flags().String("scene", "", "") + cmd.Flags().String("refresh-recommendation-id", "", "") + cmd.Flags().Int32("limit", 0, "") + cmd.Flags().String("max-drawdown-lte", "", "") + cmd.Flags().String("backtest-apr-gte", "", "") + root.AddCommand(cmd) + + require.NoError(t, runBotRecommend(cmd, nil)) + + require.NotNil(t, cap.URL) + q := cap.URL.Query() + for _, k := range []string{"market", "strategy_type", "direction", "invest_amount", "scene", "refresh_recommendation_id", "limit", "max_drawdown_lte", "backtest_apr_gte"} { + assert.Empty(t, q.Get(k), "zero/empty flag must not leak as %q", k) + } +} + +func TestRunBotRunning_FlowToQuery(t *testing.T) { + cap := captureRequest(t, `{}`) + silenceBotStdout(t) + root := authedBotTestRoot(t) + cmd := &cobra.Command{Use: "running"} + cmd.Flags().String("strategy-type", "spot_martingale", "") + cmd.Flags().String("market", "ETH_USDT", "") + cmd.Flags().Int32("page", 2, "") + cmd.Flags().Int32("page-size", 50, "") + root.AddCommand(cmd) + + require.NoError(t, runBotRunning(cmd, nil)) + + q := cap.URL.Query() + assert.Equal(t, "spot_martingale", q.Get("strategy_type")) + assert.Equal(t, "ETH_USDT", q.Get("market")) + assert.Equal(t, "2", q.Get("page")) + assert.Equal(t, "50", q.Get("page_size")) +} + +func TestRunBotDetail_PathAndQuery(t *testing.T) { + cap := captureRequest(t, `{}`) + silenceBotStdout(t) + root := authedBotTestRoot(t) + cmd := &cobra.Command{Use: "detail"} + cmd.Flags().String("strategy-id", "strat-001", "") + cmd.Flags().String("strategy-type", "spot_grid", "") + root.AddCommand(cmd) + + require.NoError(t, runBotDetail(cmd, nil)) + + require.NotNil(t, cap.URL) + assert.Equal(t, "GET", cap.Method) + q := cap.URL.Query() + assert.Equal(t, "strat-001", q.Get("strategy_id"), + "strategy_id must be sent as a query param on GET /bot/portfolio/detail") + assert.Equal(t, "spot_grid", q.Get("strategy_type")) +} + +func TestRunBotStop_BodyShape(t *testing.T) { + cap := captureRequest(t, `{}`) + silenceBotStdout(t) + root := authedBotTestRoot(t) + cmd := &cobra.Command{Use: "stop"} + cmd.Flags().String("strategy-id", "strat-stop-1", "") + cmd.Flags().String("strategy-type", "futures_grid", "") + root.AddCommand(cmd) + + require.NoError(t, runBotStop(cmd, nil)) + + assert.Equal(t, "POST", cap.Method) + assert.Contains(t, cap.Body, `"strategy_id":"strat-stop-1"`) + assert.Contains(t, cap.Body, `"strategy_type":"futures_grid"`) +} + +// All 6 create commands share the same JSON-passthrough pattern. The +// table-driven test below avoids 6 near-identical functions while still +// asserting the body actually reaches the server intact. +func TestRunBotCreate_JSONFlowsToBody(t *testing.T) { + cases := []struct { + name string + fn func(cmd *cobra.Command, args []string) error + jsonBody string + wantKeys []string + }{ + { + name: "grid_spot", + fn: runBotGridSpotCreate, + jsonBody: `{"strategy_type":"spot_grid","market":"BTC_USDT","create_params":{"money":"100","low_price":"60000","high_price":"70000","grid_num":10,"price_type":0}}`, + wantKeys: []string{`"strategy_type":"spot_grid"`, `"market":"BTC_USDT"`, `"money":"100"`, `"grid_num":10`}, + }, + { + name: "grid_margin", + fn: runBotGridMarginCreate, + jsonBody: `{"strategy_type":"margin_grid","market":"BTC_USDT","create_params":{"money":"100","low_price":"60000","high_price":"70000","grid_num":10,"price_type":0,"leverage":"3","direction":"long"}}`, + wantKeys: []string{`"strategy_type":"margin_grid"`, `"leverage":"3"`, `"direction":"long"`}, + }, + { + name: "grid_infinite", + fn: runBotGridInfiniteCreate, + jsonBody: `{"strategy_type":"infinite_grid","market":"BTC_USDT","create_params":{"money":"100","price_floor":"60000","profit_per_grid":"0.005"}}`, + wantKeys: []string{`"strategy_type":"infinite_grid"`, `"price_floor":"60000"`, `"profit_per_grid":"0.005"`}, + }, + { + name: "grid_futures", + fn: runBotGridFuturesCreate, + jsonBody: `{"strategy_type":"futures_grid","market":"BTC_USDT","create_params":{"money":"100","low_price":"60000","high_price":"70000","grid_num":10,"price_type":0,"leverage":"5"}}`, + wantKeys: []string{`"strategy_type":"futures_grid"`, `"leverage":"5"`}, + }, + { + name: "martingale_spot", + fn: runBotMartingaleSpotCreate, + jsonBody: `{"strategy_type":"spot_martingale","market":"BTC_USDT","create_params":{"invest_amount":"100","price_deviation":"0.02","max_orders":5,"take_profit_ratio":"0.01","stop_loss_per_cycle":"0.05","trigger_price":"70000"}}`, + wantKeys: []string{`"strategy_type":"spot_martingale"`, `"stop_loss_per_cycle":"0.05"`, `"trigger_price":"70000"`}, + }, + { + name: "martingale_contract", + fn: runBotMartingaleContractCreate, + jsonBody: `{"strategy_type":"contract_martingale","market":"BTC_USDT","create_params":{"invest_amount":"100","price_deviation":"0.02","max_orders":5,"take_profit_ratio":"0.01","direction":"buy","leverage":"3"}}`, + wantKeys: []string{`"strategy_type":"contract_martingale"`, `"direction":"buy"`, `"leverage":"3"`}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cap := captureRequest(t, `{}`) + silenceBotStdout(t) + root := authedBotTestRoot(t) + cmd := &cobra.Command{Use: tc.name} + cmd.Flags().String("json", tc.jsonBody, "") + root.AddCommand(cmd) + + require.NoError(t, tc.fn(cmd, nil)) + + assert.Equal(t, "POST", cap.Method) + for _, want := range tc.wantKeys { + assert.Contains(t, cap.Body, want, "case %s expected %s in outbound body", tc.name, want) + } + }) + } +} + +// Specific guard for the v7.2.78 spot martingale wire-shape change: even +// though Layer 2 already pinned the model, this RunE-level test proves +// the rename survives the full CLI → SDK → HTTP path. +func TestRunBotMartingaleSpot_StopLossPerCycle_OnWire(t *testing.T) { + cap := captureRequest(t, `{}`) + silenceBotStdout(t) + root := authedBotTestRoot(t) + cmd := &cobra.Command{Use: "spot"} + cmd.Flags().String("json", + `{"strategy_type":"spot_martingale","market":"BTC_USDT","create_params":{"invest_amount":"100","price_deviation":"0.02","max_orders":5,"take_profit_ratio":"0.01","stop_loss_per_cycle":"0.05"}}`, "") + root.AddCommand(cmd) + + require.NoError(t, runBotMartingaleSpotCreate(cmd, nil)) + + assert.Contains(t, cap.Body, `"stop_loss_per_cycle":"0.05"`, + "runBotMartingaleSpotCreate must transmit the v7.2.78 stop_loss_per_cycle field") + assert.NotContains(t, cap.Body, `"stop_loss_price"`, + "the legacy stop_loss_price field is gone from SpotMartingaleCreateParams in v7.2.78") +} + +// Specific guard for InfiniteGrid omitempty: the user-supplied minimal +// JSON (without grid_num/price_type) must reach the server without +// auto-zero injection. +func TestRunBotGridInfinite_MinimalJSON_OmitsOptionalInts(t *testing.T) { + cap := captureRequest(t, `{}`) + silenceBotStdout(t) + root := authedBotTestRoot(t) + cmd := &cobra.Command{Use: "infinite"} + cmd.Flags().String("json", + `{"strategy_type":"infinite_grid","market":"BTC_USDT","create_params":{"money":"100","price_floor":"60000","profit_per_grid":"0.005"}}`, "") + root.AddCommand(cmd) + + require.NoError(t, runBotGridInfiniteCreate(cmd, nil)) + + assert.NotContains(t, cap.Body, `"grid_num"`, + "v7.2.78 omitempty: minimal JSON must not auto-inject grid_num=0") + assert.NotContains(t, cap.Body, `"price_type"`) +} + +// =========================================================================== +// Layer 4 — RequireAuth gate. All 10 RunE handlers must trip without creds. +// =========================================================================== + +func TestRunBotRecommend_RequiresAuth(t *testing.T) { + root := newBotTestRoot(t) + cmd := &cobra.Command{Use: "recommend"} + for _, n := range []string{"market", "strategy-type", "direction", "invest-amount", "scene", "refresh-recommendation-id", "max-drawdown-lte", "backtest-apr-gte"} { + cmd.Flags().String(n, "", "") + } + cmd.Flags().Int32("limit", 0, "") + root.AddCommand(cmd) + + err := runBotRecommend(cmd, nil) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} + +func TestRunBotRunning_RequiresAuth(t *testing.T) { + root := newBotTestRoot(t) + cmd := &cobra.Command{Use: "running"} + cmd.Flags().String("strategy-type", "", "") + cmd.Flags().String("market", "", "") + cmd.Flags().Int32("page", 0, "") + cmd.Flags().Int32("page-size", 0, "") + root.AddCommand(cmd) + + err := runBotRunning(cmd, nil) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} + +func TestRunBotDetail_RequiresAuth(t *testing.T) { + root := newBotTestRoot(t) + cmd := &cobra.Command{Use: "detail"} + cmd.Flags().String("strategy-id", "x", "") + cmd.Flags().String("strategy-type", "spot_grid", "") + root.AddCommand(cmd) + + err := runBotDetail(cmd, nil) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} + +func TestRunBotStop_RequiresAuth(t *testing.T) { + root := newBotTestRoot(t) + cmd := &cobra.Command{Use: "stop"} + cmd.Flags().String("strategy-id", "x", "") + cmd.Flags().String("strategy-type", "spot_grid", "") + root.AddCommand(cmd) + + err := runBotStop(cmd, nil) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} + +// All 6 create commands are gated by RequireAuth; cover them table-style. +func TestRunBotCreateCommands_RequireAuth(t *testing.T) { + cases := []struct { + name string + fn func(cmd *cobra.Command, args []string) error + }{ + {"grid_spot", runBotGridSpotCreate}, + {"grid_margin", runBotGridMarginCreate}, + {"grid_infinite", runBotGridInfiniteCreate}, + {"grid_futures", runBotGridFuturesCreate}, + {"martingale_spot", runBotMartingaleSpotCreate}, + {"martingale_contract", runBotMartingaleContractCreate}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + root := newBotTestRoot(t) + cmd := &cobra.Command{Use: tc.name} + cmd.Flags().String("json", `{}`, "") + root.AddCommand(cmd) + + err := tc.fn(cmd, nil) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") + }) + } +} + +// =========================================================================== +// Layer 5 — Input validation. Invalid --json must fail before any HTTP. +// Auth must pass first; otherwise the RequireAuth path returns early and +// we cannot exercise the JSON parse branch. +// =========================================================================== + +func TestRunBotCreateCommands_InvalidJSON_Errors(t *testing.T) { + // No mock server registered — if the JSON parse path were skipped + // (regression), the SDK would panic-style fail trying to dial. The + // test asserts a clean parse error. + cases := []struct { + name string + fn func(cmd *cobra.Command, args []string) error + }{ + {"grid_spot", runBotGridSpotCreate}, + {"grid_margin", runBotGridMarginCreate}, + {"grid_infinite", runBotGridInfiniteCreate}, + {"grid_futures", runBotGridFuturesCreate}, + {"martingale_spot", runBotMartingaleSpotCreate}, + {"martingale_contract", runBotMartingaleContractCreate}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + root := authedBotTestRoot(t) + cmd := &cobra.Command{Use: tc.name} + cmd.Flags().String("json", `{"strategy_type": not-a-string}`, "") + root.AddCommand(cmd) + + err := tc.fn(cmd, nil) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "invalid --json", + "invalid JSON must surface as a clear --json error, not a network failure") + }) + } +} diff --git a/cmd/cex/bot/grid.go b/cmd/cex/bot/grid.go new file mode 100644 index 0000000..c82fdd9 --- /dev/null +++ b/cmd/cex/bot/grid.go @@ -0,0 +1,154 @@ +package bot + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + + "github.com/gate/gate-cli/internal/client" + "github.com/gate/gate-cli/internal/cmdutil" + gateapi "github.com/gate/gateapi-go/v7" +) + +var gridCmd = &cobra.Command{ + Use: "grid", + Short: "Create AI Hub grid strategies (spot/margin/infinite/futures)", +} + +func init() { + spotCmd := &cobra.Command{ + Use: "spot", + Short: "Create a spot grid strategy", + Long: "Wraps PostAIHubSpotGridCreate. Required JSON shape: {strategy_type, market, create_params:{money, low_price, high_price, grid_num, price_type, ...}}", + RunE: runBotGridSpotCreate, + } + spotCmd.Flags().String("json", "", "JSON body for SpotGridCreateRequest (required)") + spotCmd.MarkFlagRequired("json") + + marginCmd := &cobra.Command{ + Use: "margin", + Short: "Create a margin (leverage) grid strategy", + Long: "Wraps PostAIHubMarginGridCreate. Required JSON shape: {strategy_type, market, create_params:{money, low_price, high_price, grid_num, price_type, leverage, direction?, ...}}", + RunE: runBotGridMarginCreate, + } + marginCmd.Flags().String("json", "", "JSON body for MarginGridCreateRequest (required)") + marginCmd.MarkFlagRequired("json") + + infiniteCmd := &cobra.Command{ + Use: "infinite", + Short: "Create an infinite grid strategy", + Long: "Wraps PostAIHubInfiniteGridCreate. v7.2.78 makes grid_num/price_type optional; required JSON keys are money, price_floor, profit_per_grid.", + RunE: runBotGridInfiniteCreate, + } + infiniteCmd.Flags().String("json", "", "JSON body for InfiniteGridCreateRequest (required)") + infiniteCmd.MarkFlagRequired("json") + + futuresCmd := &cobra.Command{ + Use: "futures", + Short: "Create a futures (contract) grid strategy", + Long: "Wraps PostAIHubFuturesGridCreate. Required JSON shape: {strategy_type, market, create_params:{money, low_price, high_price, grid_num, price_type, leverage, direction?, ...}}", + RunE: runBotGridFuturesCreate, + } + futuresCmd.Flags().String("json", "", "JSON body for FuturesGridCreateRequest (required)") + futuresCmd.MarkFlagRequired("json") + + gridCmd.AddCommand(spotCmd, marginCmd, infiniteCmd, futuresCmd) + Cmd.AddCommand(gridCmd) +} + +func runBotGridSpotCreate(cmd *cobra.Command, args []string) error { + jsonStr, _ := cmd.Flags().GetString("json") + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + if err := c.RequireAuth(); err != nil { + return err + } + + var body gateapi.SpotGridCreateRequest + if err := json.Unmarshal([]byte(jsonStr), &body); err != nil { + return fmt.Errorf("invalid --json: %w", err) + } + + result, httpResp, err := c.BotAPI.PostAIHubSpotGridCreate(c.Context(), body, nil) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "POST", "/api/v4/bot/spot/grid/create", jsonStr)) + return nil + } + return p.Print(result) +} + +func runBotGridMarginCreate(cmd *cobra.Command, args []string) error { + jsonStr, _ := cmd.Flags().GetString("json") + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + if err := c.RequireAuth(); err != nil { + return err + } + + var body gateapi.MarginGridCreateRequest + if err := json.Unmarshal([]byte(jsonStr), &body); err != nil { + return fmt.Errorf("invalid --json: %w", err) + } + + result, httpResp, err := c.BotAPI.PostAIHubMarginGridCreate(c.Context(), body, nil) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "POST", "/api/v4/bot/margin/grid/create", jsonStr)) + return nil + } + return p.Print(result) +} + +func runBotGridInfiniteCreate(cmd *cobra.Command, args []string) error { + jsonStr, _ := cmd.Flags().GetString("json") + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + if err := c.RequireAuth(); err != nil { + return err + } + + var body gateapi.InfiniteGridCreateRequest + if err := json.Unmarshal([]byte(jsonStr), &body); err != nil { + return fmt.Errorf("invalid --json: %w", err) + } + + result, httpResp, err := c.BotAPI.PostAIHubInfiniteGridCreate(c.Context(), body, nil) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "POST", "/api/v4/bot/infinite/grid/create", jsonStr)) + return nil + } + return p.Print(result) +} + +func runBotGridFuturesCreate(cmd *cobra.Command, args []string) error { + jsonStr, _ := cmd.Flags().GetString("json") + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + if err := c.RequireAuth(); err != nil { + return err + } + + var body gateapi.FuturesGridCreateRequest + if err := json.Unmarshal([]byte(jsonStr), &body); err != nil { + return fmt.Errorf("invalid --json: %w", err) + } + + result, httpResp, err := c.BotAPI.PostAIHubFuturesGridCreate(c.Context(), body, nil) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "POST", "/api/v4/bot/futures/grid/create", jsonStr)) + return nil + } + return p.Print(result) +} diff --git a/cmd/cex/bot/martingale.go b/cmd/cex/bot/martingale.go new file mode 100644 index 0000000..a954d3f --- /dev/null +++ b/cmd/cex/bot/martingale.go @@ -0,0 +1,101 @@ +package bot + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + + "github.com/gate/gate-cli/internal/client" + "github.com/gate/gate-cli/internal/cmdutil" + gateapi "github.com/gate/gateapi-go/v7" +) + +var martingaleCmd = &cobra.Command{ + Use: "martingale", + Short: "Create AI Hub martingale strategies (spot/contract)", +} + +func init() { + spotCmd := &cobra.Command{ + Use: "spot", + Short: "Create a spot martingale strategy", + Long: `Wraps PostAIHubSpotMartingaleCreate. + +v7.2.78 contract: stop-loss is expressed via create_params.stop_loss_per_cycle +(per-round ratio); the legacy create_params.stop_loss_price is no longer +mapped on this path. Optional create_params.trigger_price is also accepted.`, + RunE: runBotMartingaleSpotCreate, + } + spotCmd.Flags().String("json", "", "JSON body for SpotMartingaleCreateRequest (required)") + spotCmd.MarkFlagRequired("json") + + contractCmd := &cobra.Command{ + Use: "contract", + Short: "Create a contract martingale strategy", + Long: `Wraps PostAIHubContractMartingaleCreate. + +Required JSON shape: {strategy_type, market, create_params:{invest_amount, +price_deviation, max_orders, take_profit_ratio, direction(buy/sell), leverage, +...}}. + +v7.2.78 note: the SDK still defines create_params.stop_loss_price for backward +compatibility, but per upstream SDK docs the AIHub contract_martingale path +does not map this field. Do not include stop_loss_price in --json; follow the +contract martingale rules of the underlying API instead.`, + RunE: runBotMartingaleContractCreate, + } + contractCmd.Flags().String("json", "", "JSON body for ContractMartingaleCreateRequest (required)") + contractCmd.MarkFlagRequired("json") + + martingaleCmd.AddCommand(spotCmd, contractCmd) + Cmd.AddCommand(martingaleCmd) +} + +func runBotMartingaleSpotCreate(cmd *cobra.Command, args []string) error { + jsonStr, _ := cmd.Flags().GetString("json") + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + if err := c.RequireAuth(); err != nil { + return err + } + + var body gateapi.SpotMartingaleCreateRequest + if err := json.Unmarshal([]byte(jsonStr), &body); err != nil { + return fmt.Errorf("invalid --json: %w", err) + } + + result, httpResp, err := c.BotAPI.PostAIHubSpotMartingaleCreate(c.Context(), body, nil) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "POST", "/api/v4/bot/spot/martingale/create", jsonStr)) + return nil + } + return p.Print(result) +} + +func runBotMartingaleContractCreate(cmd *cobra.Command, args []string) error { + jsonStr, _ := cmd.Flags().GetString("json") + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + if err := c.RequireAuth(); err != nil { + return err + } + + var body gateapi.ContractMartingaleCreateRequest + if err := json.Unmarshal([]byte(jsonStr), &body); err != nil { + return fmt.Errorf("invalid --json: %w", err) + } + + result, httpResp, err := c.BotAPI.PostAIHubContractMartingaleCreate(c.Context(), body, nil) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "POST", "/api/v4/bot/contract/martingale/create", jsonStr)) + return nil + } + return p.Print(result) +} diff --git a/cmd/cex/cex.go b/cmd/cex/cex.go index 0c294c4..ddcff85 100644 --- a/cmd/cex/cex.go +++ b/cmd/cex/cex.go @@ -8,6 +8,8 @@ import ( "github.com/gate/gate-cli/cmd/cex/account" "github.com/gate/gate-cli/cmd/cex/activity" "github.com/gate/gate-cli/cmd/cex/alpha" + "github.com/gate/gate-cli/cmd/cex/assetswap" + "github.com/gate/gate-cli/cmd/cex/bot" "github.com/gate/gate-cli/cmd/cex/coupon" crossex "github.com/gate/gate-cli/cmd/cex/cross_ex" "github.com/gate/gate-cli/cmd/cex/delivery" @@ -62,4 +64,6 @@ func init() { Cmd.AddCommand(launch.Cmd) Cmd.AddCommand(square.Cmd) Cmd.AddCommand(welfare.Cmd) + Cmd.AddCommand(assetswap.Cmd) + Cmd.AddCommand(bot.Cmd) } diff --git a/cmd/cex/config/config.go b/cmd/cex/config/config.go index f993341..4881738 100644 --- a/cmd/cex/config/config.go +++ b/cmd/cex/config/config.go @@ -53,6 +53,7 @@ type profileEntry struct { type fileLayout struct { DefaultProfile string `yaml:"default_profile"` DefaultSettle string `yaml:"default_settle"` + Intel config.IntelFile `yaml:"intel,omitempty"` Profiles map[string]profileEntry `yaml:"profiles"` } @@ -82,6 +83,11 @@ func runInit(cmd *cobra.Command, args []string) error { fc := fileLayout{ DefaultProfile: profileName, DefaultSettle: "usdt", + // Defaults come from internal/config constants (CR-1010); bump those when public QC URLs move. + Intel: config.IntelFile{ + InfoMCPURL: config.DefaultIntelInfoMCPURL, + NewsMCPURL: config.DefaultIntelNewsMCPURL, + }, Profiles: map[string]profileEntry{ profileName: {APIKey: apiKey, APISecret: apiSecret}, }, @@ -102,7 +108,9 @@ func maskSecrets(content string) string { lines := strings.Split(content, "\n") for i, line := range lines { trimmed := strings.TrimLeft(line, " \t") - if strings.HasPrefix(trimmed, "api_key:") || strings.HasPrefix(trimmed, "api_secret:") { + if strings.HasPrefix(trimmed, "api_key:") || strings.HasPrefix(trimmed, "api_secret:") || + strings.HasPrefix(trimmed, "bearer_token:") || strings.HasPrefix(trimmed, "news_bearer_token:") || + strings.HasPrefix(trimmed, "info_bearer_token:") { indent := line[:len(line)-len(trimmed)] field := trimmed[:strings.Index(trimmed, ":")] lines[i] = indent + field + ": ****" diff --git a/cmd/cex/cross_ex/account.go b/cmd/cex/cross_ex/account.go index a3761a6..ce239d2 100644 --- a/cmd/cex/cross_ex/account.go +++ b/cmd/cex/cross_ex/account.go @@ -41,6 +41,7 @@ func init() { bookCmd.Flags().Int32("page", 0, "Page number") bookCmd.Flags().Int32("limit", 0, "Max records to return") bookCmd.Flags().String("coin", "", "Filter by currency") + bookCmd.Flags().String("statement-type", "", "Bill entry type filter (e.g. TRANSACTION, TRADING_FEE, FUNDING_FEE, LIQUIDATION_FEE, TRANSFER_IN, TRANSFER_OUT, BANKRUPT_COMPENSATION, AUTO_REPAY)") bookCmd.Flags().Int32("from", 0, "Start millisecond timestamp") bookCmd.Flags().Int32("to", 0, "End millisecond timestamp") @@ -117,6 +118,7 @@ func runAccountBook(cmd *cobra.Command, args []string) error { page, _ := cmd.Flags().GetInt32("page") limit, _ := cmd.Flags().GetInt32("limit") coin, _ := cmd.Flags().GetString("coin") + statementType, _ := cmd.Flags().GetString("statement-type") from, _ := cmd.Flags().GetInt32("from") to, _ := cmd.Flags().GetInt32("to") p := cmdutil.GetPrinter(cmd) @@ -138,6 +140,9 @@ func runAccountBook(cmd *cobra.Command, args []string) error { if coin != "" { opts.Coin = optional.NewString(coin) } + if statementType != "" { + opts.StatementType = optional.NewString(statementType) + } if from != 0 { opts.From = optional.NewInt32(from) } @@ -155,7 +160,7 @@ func runAccountBook(cmd *cobra.Command, args []string) error { } rows := make([][]string, len(result)) for i, r := range result { - rows[i] = []string{r.Id, r.Type, r.Coin, r.ExchangeType, r.Change, r.Balance, r.CreateTime} + rows[i] = []string{r.Id, r.StatementType, r.Coin, r.ExchangeType, r.Change, r.Balance, r.CreateTime} } - return p.Table([]string{"ID", "Type", "Coin", "Exchange", "Change", "Balance", "Created"}, rows) + return p.Table([]string{"ID", "Statement Type", "Coin", "Exchange", "Change", "Balance", "Created"}, rows) } diff --git a/cmd/cex/cross_ex/sdk_v7_2_78_compat_test.go b/cmd/cex/cross_ex/sdk_v7_2_78_compat_test.go new file mode 100644 index 0000000..9a614f2 --- /dev/null +++ b/cmd/cex/cross_ex/sdk_v7_2_78_compat_test.go @@ -0,0 +1,326 @@ +package crossex + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" + + "github.com/antihax/optional" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + gateapi "github.com/gate/gateapi-go/v7" +) + +// Tests in this file lock in the gateapi-go SDK upgrade from v7.2.71 to +// v7.2.78 for the cross_ex module. Coverage: +// 1. The new --statement-type flag on `account book`. +// 2. JSON wire-tag for CrossexAccountBookRecord (Type → StatementType). +// 3. End-to-end RunE: query param plumbing and the new field rendering. +// 4. RequireAuth gate still trips when no credentials are configured. + +const cobraRequiredFlagAnnotation = "cobra_annotation_bash_completion_one_required_flag" + +func newCrossexTestRoot(t *testing.T) *cobra.Command { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("GATE_API_KEY", "") + t.Setenv("GATE_API_SECRET", "") + root := &cobra.Command{Use: "gate-cli"} + root.PersistentFlags().String("format", "json", "") + root.PersistentFlags().String("profile", "default", "") + root.PersistentFlags().Bool("debug", false, "") + root.PersistentFlags().Bool("verbose", false, "") + root.PersistentFlags().String("api-key", "", "") + root.PersistentFlags().String("api-secret", "", "") + return root +} + +func authedCrossexTestRoot(t *testing.T) *cobra.Command { + root := newCrossexTestRoot(t) + t.Setenv("GATE_API_KEY", "fake-key") + t.Setenv("GATE_API_SECRET", "fake-secret") + return root +} + +func silenceCrossexStdout(t *testing.T) { + t.Helper() + oldOut := os.Stdout + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + require.NoError(t, err) + os.Stdout = devNull + t.Cleanup(func() { + os.Stdout = oldOut + _ = devNull.Close() + }) +} + +func findCrossexSub(parent *cobra.Command, name string) *cobra.Command { + for _, c := range parent.Commands() { + if c.Name() == name { + return c + } + } + return nil +} + +// --------------------------------------------------------------------------- +// Layer 1 — Flag wiring on `account book`. +// v7.2.78 added an optional StatementType filter. The CLI exposes it as +// --statement-type. Below pins both the existence and the optional-ness. +// --------------------------------------------------------------------------- + +func TestAccountBook_HasStatementTypeFlag_AfterV7_2_78(t *testing.T) { + book := findCrossexSub(accountCmd, "book") + require.NotNil(t, book, "account book command must be registered") + + f := book.Flag("statement-type") + require.NotNil(t, f, + "--statement-type was added in v7.2.78 to expose the new ListCrossexAccountBookOpts.StatementType field") + assert.Empty(t, f.Annotations[cobraRequiredFlagAnnotation], + "--statement-type must remain optional") +} + +func TestAccountBook_AllFlagsOptional(t *testing.T) { + book := findCrossexSub(accountCmd, "book") + require.NotNil(t, book) + + for _, name := range []string{"page", "limit", "coin", "statement-type", "from", "to"} { + f := book.Flag(name) + require.NotNil(t, f, "account book must expose --%s", name) + assert.Empty(t, f.Annotations[cobraRequiredFlagAnnotation], + "account book --%s must be optional", name) + } +} + +// --------------------------------------------------------------------------- +// Layer 2 — SDK model field rename (CrossexAccountBookRecord.Type → +// .StatementType). Lock JSON wire shape to catch silent regressions. +// --------------------------------------------------------------------------- + +func TestCrossexAccountBookRecord_StatementTypeJSONTag(t *testing.T) { + payload := `{ + "id": "rec-1", + "user_id": "uid-1", + "business_id": "biz-1", + "statement_type": "TRADING_FEE", + "exchange_type": "BINANCE", + "coin": "USDT", + "change": "1.23", + "balance": "100.00", + "create_time": "2026-01-01T00:00:00Z" + }` + var rec gateapi.CrossexAccountBookRecord + require.NoError(t, json.Unmarshal([]byte(payload), &rec)) + + assert.Equal(t, "TRADING_FEE", rec.StatementType, + "v7.2.78 renamed Type → StatementType (json: statement_type)") + assert.Equal(t, "rec-1", rec.Id) + assert.Equal(t, "BINANCE", rec.ExchangeType) +} + +// The legacy `type` JSON key must no longer bind to any field on the +// struct, otherwise the rename was incomplete. +func TestCrossexAccountBookRecord_LegacyTypeKeyDoesNotBind(t *testing.T) { + payload := `{"id": "rec-2", "type": "LEGACY_VALUE"}` + var rec gateapi.CrossexAccountBookRecord + require.NoError(t, json.Unmarshal([]byte(payload), &rec)) + + assert.Equal(t, "rec-2", rec.Id) + assert.Empty(t, rec.StatementType, + "legacy `type` key must not populate StatementType under v7.2.78") +} + +// ListCrossexAccountBookOpts must accept StatementType and serialize it as +// the `statement_type` query-param. We can only directly inspect the struct, +// not the URL it produces, so couple the struct check with the RunE +// integration test below. +func TestListCrossexAccountBookOpts_StatementTypeField(t *testing.T) { + opts := gateapi.ListCrossexAccountBookOpts{ + StatementType: optional.NewString("TRANSACTION"), + } + require.True(t, opts.StatementType.IsSet(), + "StatementType must be a settable optional.String on v7.2.78") + assert.Equal(t, "TRANSACTION", opts.StatementType.Value()) +} + +// --------------------------------------------------------------------------- +// Layer 3 — RunE end-to-end. Cover three branches: +// - all flags including the new --statement-type +// - no flags at all (zero-value branch) +// - --statement-type only (proves the new opt reaches the wire) +// --------------------------------------------------------------------------- + +func TestRunAccountBook_AllFlags_Succeeds(t *testing.T) { + var capturedURL *url.URL + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedURL = r.URL + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`[{"id":"rec-1","statement_type":"TRADING_FEE","coin":"USDT","exchange_type":"BINANCE","change":"1","balance":"100","create_time":"now"}]`)) + })) + t.Cleanup(srv.Close) + t.Setenv("GATE_BASE_URL", srv.URL) + + silenceCrossexStdout(t) + root := authedCrossexTestRoot(t) + cmd := &cobra.Command{Use: "book"} + cmd.Flags().Int32("page", 2, "") + cmd.Flags().Int32("limit", 50, "") + cmd.Flags().String("coin", "USDT", "") + cmd.Flags().String("statement-type", "TRADING_FEE", "") + cmd.Flags().Int32("from", 1700000000, "") + cmd.Flags().Int32("to", 1700001000, "") + root.AddCommand(cmd) + + err := runAccountBook(cmd, nil) + require.NoError(t, err) + + require.NotNil(t, capturedURL) + q := capturedURL.Query() + assert.Equal(t, "TRADING_FEE", q.Get("statement_type"), + "--statement-type must reach the SDK as the statement_type query param") + assert.Equal(t, "USDT", q.Get("coin")) + assert.Equal(t, "2", q.Get("page")) + assert.Equal(t, "50", q.Get("limit")) + assert.Equal(t, "1700000000", q.Get("from")) + assert.Equal(t, "1700001000", q.Get("to")) +} + +func TestRunAccountBook_NoFlags_Succeeds(t *testing.T) { + var capturedURL *url.URL + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedURL = r.URL + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`[]`)) + })) + t.Cleanup(srv.Close) + t.Setenv("GATE_BASE_URL", srv.URL) + + silenceCrossexStdout(t) + root := authedCrossexTestRoot(t) + cmd := &cobra.Command{Use: "book"} + cmd.Flags().Int32("page", 0, "") + cmd.Flags().Int32("limit", 0, "") + cmd.Flags().String("coin", "", "") + cmd.Flags().String("statement-type", "", "") + cmd.Flags().Int32("from", 0, "") + cmd.Flags().Int32("to", 0, "") + root.AddCommand(cmd) + + err := runAccountBook(cmd, nil) + require.NoError(t, err) + + require.NotNil(t, capturedURL) + q := capturedURL.Query() + // Empty / zero flag values must not become "" query params; verifies + // the .IsSet() guards in runAccountBook. + assert.Empty(t, q.Get("statement_type")) + assert.Empty(t, q.Get("coin")) + assert.Empty(t, q.Get("page")) + assert.Empty(t, q.Get("limit")) +} + +// Set --statement-type only and verify nothing else leaks into the URL. +func TestRunAccountBook_StatementTypeOnly_Succeeds(t *testing.T) { + var capturedURL *url.URL + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedURL = r.URL + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`[]`)) + })) + t.Cleanup(srv.Close) + t.Setenv("GATE_BASE_URL", srv.URL) + + silenceCrossexStdout(t) + root := authedCrossexTestRoot(t) + cmd := &cobra.Command{Use: "book"} + cmd.Flags().Int32("page", 0, "") + cmd.Flags().Int32("limit", 0, "") + cmd.Flags().String("coin", "", "") + cmd.Flags().String("statement-type", "FUNDING_FEE", "") + cmd.Flags().Int32("from", 0, "") + cmd.Flags().Int32("to", 0, "") + root.AddCommand(cmd) + + err := runAccountBook(cmd, nil) + require.NoError(t, err) + + require.NotNil(t, capturedURL) + q := capturedURL.Query() + assert.Equal(t, "FUNDING_FEE", q.Get("statement_type")) + assert.Empty(t, q.Get("coin")) + assert.Empty(t, q.Get("page")) +} + +// Drives the JSON output branch, which now reads StatementType (was Type). +// Asserts the rename did not break the table-rendering path used in non-JSON +// mode — both code paths should accept the new field. +func TestRunAccountBook_TableRendering_UsesStatementType(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`[{"id":"r1","statement_type":"AUTO_REPAY","coin":"USDT","exchange_type":"GATE","change":"-0.01","balance":"42","create_time":"now"}]`)) + })) + t.Cleanup(srv.Close) + t.Setenv("GATE_BASE_URL", srv.URL) + + // Capture stdout to assert the table contents under the new field. + r, w, _ := os.Pipe() + oldOut := os.Stdout + os.Stdout = w + t.Cleanup(func() { os.Stdout = oldOut }) + + root := authedCrossexTestRoot(t) + // Default --format=text triggers the table path; switch away from json. + require.NoError(t, root.PersistentFlags().Set("format", "text")) + + cmd := &cobra.Command{Use: "book"} + cmd.Flags().Int32("page", 0, "") + cmd.Flags().Int32("limit", 0, "") + cmd.Flags().String("coin", "", "") + cmd.Flags().String("statement-type", "", "") + cmd.Flags().Int32("from", 0, "") + cmd.Flags().Int32("to", 0, "") + root.AddCommand(cmd) + + err := runAccountBook(cmd, nil) + _ = w.Close() + captured := make([]byte, 4096) + n, _ := r.Read(captured) + output := string(captured[:n]) + + require.NoError(t, err) + assert.Contains(t, output, "AUTO_REPAY", + "table output must render the new StatementType field value") + assert.Contains(t, output, "Statement Type", + "v7.2.78 column header must use 'Statement Type', not legacy 'Type'") +} + +// --------------------------------------------------------------------------- +// Layer 4 — Auth gate. Account book reads private data; RequireAuth must +// continue to fence unauthenticated callers. +// --------------------------------------------------------------------------- + +func TestRunAccountBook_RequiresAuth(t *testing.T) { + root := newCrossexTestRoot(t) + cmd := &cobra.Command{Use: "book"} + cmd.Flags().Int32("page", 0, "") + cmd.Flags().Int32("limit", 0, "") + cmd.Flags().String("coin", "", "") + cmd.Flags().String("statement-type", "", "") + cmd.Flags().Int32("from", 0, "") + cmd.Flags().Int32("to", 0, "") + root.AddCommand(cmd) + + err := runAccountBook(cmd, nil) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} diff --git a/cmd/cex/earn/dual.go b/cmd/cex/earn/dual.go index 8a351a4..7f04113 100644 --- a/cmd/cex/earn/dual.go +++ b/cmd/cex/earn/dual.go @@ -52,7 +52,46 @@ func init() { RunE: runDualBalance, } - dualCmd.AddCommand(plansCmd, ordersCmd, placeCmd, balanceCmd) + refundPreviewCmd := &cobra.Command{ + Use: "refund-preview ", + Short: "Preview early-redemption of a dual investment order", + Args: cobra.ExactArgs(1), + RunE: runDualRefundPreview, + } + + refundCmd := &cobra.Command{ + Use: "refund", + Short: "Execute early-redemption of a dual investment order", + RunE: runDualRefund, + } + refundCmd.Flags().String("order-id", "", "Order ID (required)") + refundCmd.Flags().String("req-id", "", "Request ID returned by refund-preview (required)") + refundCmd.MarkFlagRequired("order-id") + refundCmd.MarkFlagRequired("req-id") + + modifyReinvestCmd := &cobra.Command{ + Use: "modify-reinvest", + Short: "Modify reinvest setting of a dual investment order", + RunE: runDualModifyReinvest, + } + modifyReinvestCmd.Flags().Int64("order-id", 0, "Order ID (required)") + modifyReinvestCmd.Flags().Int32("status", 0, "0=off, 1=on (required)") + modifyReinvestCmd.Flags().Int64("duration", 0, "Effective duration in seconds; default 86400 (1 day) if omitted") + modifyReinvestCmd.MarkFlagRequired("order-id") + modifyReinvestCmd.MarkFlagRequired("status") + + recommendCmd := &cobra.Command{ + Use: "recommend", + Short: "Get recommended dual investment projects (public, no auth required)", + RunE: runDualRecommend, + } + recommendCmd.Flags().String("mode", "", "Sort mode: normal / senior / apy_up etc.") + recommendCmd.Flags().String("coin", "", "Filter by invest currency, e.g. BTC, USDT") + recommendCmd.Flags().String("type", "", "Filter by type: call (sell high) / put (buy low)") + recommendCmd.Flags().String("history-pids", "", "Comma-separated product IDs already held by the user") + + dualCmd.AddCommand(plansCmd, ordersCmd, placeCmd, balanceCmd, + refundPreviewCmd, refundCmd, modifyReinvestCmd, recommendCmd) } func runDualPlans(cmd *cobra.Command, args []string) error { @@ -165,5 +204,109 @@ func runDualBalance(cmd *cobra.Command, args []string) error { ) } +func runDualRefundPreview(cmd *cobra.Command, args []string) error { + orderID := args[0] + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + if err := c.RequireAuth(); err != nil { + return err + } + + result, httpResp, err := c.EarnAPI.GetDualOrderRefundPreview(c.Context(), orderID) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "GET", "/api/v4/earn/dual/order-refund-preview", "")) + return nil + } + return p.Print(result) +} + +func runDualRefund(cmd *cobra.Command, args []string) error { + orderID, _ := cmd.Flags().GetString("order-id") + reqID, _ := cmd.Flags().GetString("req-id") + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + if err := c.RequireAuth(); err != nil { + return err + } + + body := gateapi.DualOrderRefundParams{ + OrderId: orderID, + ReqId: reqID, + } + bodyJSON, _ := json.Marshal(body) + httpResp, err := c.EarnAPI.PlaceDualOrderRefund(c.Context(), body) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "POST", "/api/v4/earn/dual/order-refund", string(bodyJSON))) + return nil + } + return p.Print(map[string]string{"status": "ok", "order_id": orderID, "req_id": reqID}) +} + +func runDualModifyReinvest(cmd *cobra.Command, args []string) error { + orderID, _ := cmd.Flags().GetInt64("order-id") + status, _ := cmd.Flags().GetInt32("status") + duration, _ := cmd.Flags().GetInt64("duration") + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + if err := c.RequireAuth(); err != nil { + return err + } + + body := gateapi.DualModifyOrderReinvestParams{ + OrderId: orderID, + Status: status, + EffectiveTimeDuration: duration, + } + bodyJSON, _ := json.Marshal(body) + httpResp, err := c.EarnAPI.ModifyDualOrderReinvest(c.Context(), body) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "POST", "/api/v4/earn/dual/modify-order-reinvest", string(bodyJSON))) + return nil + } + return p.Print(map[string]any{"status": "ok", "order_id": orderID, "reinvest": status}) +} + +func runDualRecommend(cmd *cobra.Command, args []string) error { + mode, _ := cmd.Flags().GetString("mode") + coin, _ := cmd.Flags().GetString("coin") + typ, _ := cmd.Flags().GetString("type") + historyPids, _ := cmd.Flags().GetString("history-pids") + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + + opts := &gateapi.GetDualProjectRecommendOpts{} + if mode != "" { + opts.Mode = optional.NewString(mode) + } + if coin != "" { + opts.Coin = optional.NewString(coin) + } + if typ != "" { + opts.Type_ = optional.NewString(typ) + } + if historyPids != "" { + opts.HistoryPids = optional.NewString(historyPids) + } + + result, httpResp, err := c.EarnAPI.GetDualProjectRecommend(c.Context(), opts) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "GET", "/api/v4/earn/dual/project-recommend", "")) + return nil + } + return p.Print(result) +} + // Ensure fmt is used (for potential future table formatting). var _ = fmt.Sprintf diff --git a/cmd/cex/earn/dual_test.go b/cmd/cex/earn/dual_test.go new file mode 100644 index 0000000..72129ba --- /dev/null +++ b/cmd/cex/earn/dual_test.go @@ -0,0 +1,258 @@ +package earn + +import ( + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockGateServer spins up an httptest server returning fixed JSON for every +// request, redirects GATE_BASE_URL at it, and registers cleanup. +func mockGateServer(t *testing.T, body string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + t.Setenv("GATE_BASE_URL", srv.URL) + return srv +} + +// silenceStdout swaps os.Stdout for /dev/null to keep `go test` output clean. +func silenceStdout(t *testing.T) { + t.Helper() + oldOut := os.Stdout + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + require.NoError(t, err) + os.Stdout = devNull + t.Cleanup(func() { + os.Stdout = oldOut + _ = devNull.Close() + }) +} + +// newTestRoot builds a minimal root with persistent flags matching cmd/root.go +// so runXxx handlers can call cmdutil.GetClient/GetPrinter without panicking. +// env is isolated so no real API key leaks in. +func newTestRoot(t *testing.T) *cobra.Command { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("GATE_API_KEY", "") + t.Setenv("GATE_API_SECRET", "") + root := &cobra.Command{Use: "gate-cli"} + root.PersistentFlags().String("format", "json", "") + root.PersistentFlags().String("profile", "default", "") + root.PersistentFlags().Bool("debug", false, "") + root.PersistentFlags().Bool("verbose", false, "") + root.PersistentFlags().String("api-key", "", "") + root.PersistentFlags().String("api-secret", "", "") + return root +} + +func TestDualSubcommands(t *testing.T) { + want := map[string]bool{ + "plans": false, + "orders": false, + "place": false, + "balance": false, + "refund-preview": false, + "refund": false, + "modify-reinvest": false, + "recommend": false, + } + + dualFound := false + for _, c := range Cmd.Commands() { + if c.Name() == "dual" { + dualFound = true + for _, sub := range c.Commands() { + if _, ok := want[sub.Name()]; ok { + want[sub.Name()] = true + } + } + break + } + } + require.True(t, dualFound, "dual command should be registered on earn.Cmd") + + for name, found := range want { + assert.True(t, found, "dual should expose subcommand %q", name) + } +} + +func TestDualRefundPreviewTakesPositionalArg(t *testing.T) { + for _, c := range Cmd.Commands() { + if c.Name() != "dual" { + continue + } + for _, sub := range c.Commands() { + if sub.Name() != "refund-preview" { + continue + } + // Args must be set to ExactArgs(1); invoke it with wrong arity to prove. + err := sub.Args(sub, []string{}) + assert.Error(t, err, "refund-preview requires exactly 1 arg") + err = sub.Args(sub, []string{"id1", "id2"}) + assert.Error(t, err, "refund-preview should reject 2 args") + err = sub.Args(sub, []string{"42"}) + assert.NoError(t, err, "refund-preview should accept 1 arg") + return + } + } + t.Fatal("dual refund-preview subcommand not found") +} + +func TestDualRefundRequiredFlags(t *testing.T) { + for _, c := range Cmd.Commands() { + if c.Name() != "dual" { + continue + } + for _, sub := range c.Commands() { + if sub.Name() != "refund" { + continue + } + for _, name := range []string{"order-id", "req-id"} { + f := sub.Flag(name) + require.NotNil(t, f, "refund should have --%s flag", name) + ann := f.Annotations[cobraBashCompOneRequiredFlag] + assert.NotEmpty(t, ann, "--%s should be marked required", name) + } + return + } + } + t.Fatal("dual refund subcommand not found") +} + +func TestDualModifyReinvestRequiredFlags(t *testing.T) { + for _, c := range Cmd.Commands() { + if c.Name() != "dual" { + continue + } + for _, sub := range c.Commands() { + if sub.Name() != "modify-reinvest" { + continue + } + for _, name := range []string{"order-id", "status"} { + f := sub.Flag(name) + require.NotNil(t, f, "modify-reinvest should have --%s flag", name) + ann := f.Annotations[cobraBashCompOneRequiredFlag] + assert.NotEmpty(t, ann, "--%s should be marked required", name) + } + // duration is optional + f := sub.Flag("duration") + require.NotNil(t, f, "modify-reinvest should have --duration flag") + assert.Empty(t, f.Annotations[cobraBashCompOneRequiredFlag], "--duration must not be required") + return + } + } + t.Fatal("dual modify-reinvest subcommand not found") +} + +func TestDualRecommendOptionalFlags(t *testing.T) { + for _, c := range Cmd.Commands() { + if c.Name() != "dual" { + continue + } + for _, sub := range c.Commands() { + if sub.Name() != "recommend" { + continue + } + for _, name := range []string{"mode", "coin", "type", "history-pids"} { + f := sub.Flag(name) + require.NotNil(t, f, "recommend should have --%s flag", name) + assert.Empty(t, f.Annotations[cobraBashCompOneRequiredFlag], "--%s must not be required", name) + } + return + } + } + t.Fatal("dual recommend subcommand not found") +} + +// cobra uses this annotation key internally to mark MarkFlagRequired; we probe +// via the Annotations map so we don't have to import cobra's internals. +const cobraBashCompOneRequiredFlag = "cobra_annotation_bash_completion_one_required_flag" + +// runDualRecommend does not call RequireAuth, so covering it needs a mock +// server. The optional flags feed four `if != ""` branches that we light up +// by passing non-empty values. +func TestRunDualRecommend_MockServer_AllFlags(t *testing.T) { + mockGateServer(t, `[]`) + silenceStdout(t) + root := newTestRoot(t) + cmd := &cobra.Command{Use: "recommend"} + cmd.Flags().String("mode", "normal", "") + cmd.Flags().String("coin", "BTC", "") + cmd.Flags().String("type", "call", "") + cmd.Flags().String("history-pids", "1,2,3", "") + root.AddCommand(cmd) + + err := runDualRecommend(cmd, nil) + assert.NoError(t, err, "runDualRecommend should succeed against mock server") +} + +// Same handler but all optional flags empty: exercises the non-set branches +// of the opts builder. +func TestRunDualRecommend_MockServer_NoFlags(t *testing.T) { + mockGateServer(t, `[]`) + silenceStdout(t) + root := newTestRoot(t) + cmd := &cobra.Command{Use: "recommend"} + cmd.Flags().String("mode", "", "") + cmd.Flags().String("coin", "", "") + cmd.Flags().String("type", "", "") + cmd.Flags().String("history-pids", "", "") + root.AddCommand(cmd) + + err := runDualRecommend(cmd, nil) + assert.NoError(t, err) +} + +// --- RunE error-path coverage --- +// +// The four dual runXxx helpers all require auth before touching the SDK. With +// no credentials configured, they return an error that mentions "API key". +// Driving the function directly keeps coverage counters accurate without +// starting an HTTP server. + +func TestRunDualRefundPreview_RequiresAuth(t *testing.T) { + root := newTestRoot(t) + cmd := &cobra.Command{Use: "refund-preview"} + root.AddCommand(cmd) + + err := runDualRefundPreview(cmd, []string{"order-42"}) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} + +func TestRunDualRefund_RequiresAuth(t *testing.T) { + root := newTestRoot(t) + cmd := &cobra.Command{Use: "refund"} + cmd.Flags().String("order-id", "42", "") + cmd.Flags().String("req-id", "req-abc", "") + root.AddCommand(cmd) + + err := runDualRefund(cmd, []string{}) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} + +func TestRunDualModifyReinvest_RequiresAuth(t *testing.T) { + root := newTestRoot(t) + cmd := &cobra.Command{Use: "modify-reinvest"} + cmd.Flags().Int64("order-id", 42, "") + cmd.Flags().Int32("status", 1, "") + cmd.Flags().Int64("duration", 86400, "") + root.AddCommand(cmd) + + err := runDualModifyReinvest(cmd, []string{}) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} diff --git a/cmd/cex/futures/market.go b/cmd/cex/futures/market.go index 7c705a8..16c8202 100644 --- a/cmd/cex/futures/market.go +++ b/cmd/cex/futures/market.go @@ -137,6 +137,14 @@ func init() { riskLimitTiersCmd.Flags().Int32("limit", 0, "Number of records to return") addSettleFlag(riskLimitTiersCmd) + riskLimitTableCmd := &cobra.Command{ + Use: "risk-limit-table ", + Short: "Query a specific risk limit table (public, no authentication required)", + Args: cobra.ExactArgs(1), + RunE: runFuturesRiskLimitTable, + } + addSettleFlag(riskLimitTableCmd) + liquidationsCmd := &cobra.Command{ Use: "liquidations", Short: "List recently liquidated orders (public, no authentication required)", @@ -164,7 +172,7 @@ func init() { addSettleFlag(batchFundingRatesCmd) marketCmd.AddCommand(tickerCmd, tickersCmd, orderbookCmd, tradesCmd, candlesticksCmd, fundingRateCmd, - contractsCmd, contractCmd, premiumCmd, statsCmd, indexCmd, riskLimitTiersCmd, + contractsCmd, contractCmd, premiumCmd, statsCmd, indexCmd, riskLimitTiersCmd, riskLimitTableCmd, liquidationsCmd, insuranceCmd, batchFundingRatesCmd) Cmd.AddCommand(marketCmd) } @@ -492,6 +500,30 @@ func runFuturesRiskLimitTiers(cmd *cobra.Command, args []string) error { return p.Table([]string{"Tier", "Risk Limit", "Initial Rate", "Maintenance Rate"}, rows) } +func runFuturesRiskLimitTable(cmd *cobra.Command, args []string) error { + tableID := args[0] + settle := cmdutil.GetSettle(cmd) + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + + result, httpResp, err := c.FuturesAPI.GetFuturesRiskLimitTable(c.Context(), settle, tableID) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "GET", "/api/v4/futures/"+settle+"/risk_limit_table/"+tableID, "")) + return nil + } + if p.IsJSON() { + return p.Print(result) + } + rows := make([][]string, len(result)) + for i, r := range result { + rows[i] = []string{fmt.Sprintf("%d", r.Tier), r.RiskLimit, r.InitialRate, r.MaintenanceRate} + } + return p.Table([]string{"Tier", "Risk Limit", "Initial Rate", "Maintenance Rate"}, rows) +} + func runFuturesLiquidations(cmd *cobra.Command, args []string) error { contract, _ := cmd.Flags().GetString("contract") limit, _ := cmd.Flags().GetInt32("limit") diff --git a/cmd/cex/futures/market_test.go b/cmd/cex/futures/market_test.go new file mode 100644 index 0000000..662be75 --- /dev/null +++ b/cmd/cex/futures/market_test.go @@ -0,0 +1,107 @@ +package futures + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockGateServer returns fixed JSON for every request and points GATE_BASE_URL +// at itself. Used for covering runXxx handlers that bypass RequireAuth. +func mockGateServer(t *testing.T, body string) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + t.Setenv("GATE_BASE_URL", srv.URL) +} + +func silenceStdout(t *testing.T) { + t.Helper() + oldOut := os.Stdout + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + require.NoError(t, err) + os.Stdout = devNull + t.Cleanup(func() { + os.Stdout = oldOut + _ = devNull.Close() + }) +} + +// cobra uses this annotation key internally to mark MarkFlagRequired; tests +// probe via the Annotations map so we don't depend on cobra's internals. +const cobraBashCompOneRequiredFlag = "cobra_annotation_bash_completion_one_required_flag" + +func TestFuturesMarketRiskLimitTableRegistered(t *testing.T) { + found := false + for _, c := range Cmd.Commands() { + if c.Name() != "market" { + continue + } + for _, sub := range c.Commands() { + if sub.Name() == "risk-limit-table" { + found = true + // Verify it takes exactly one positional arg (the table-id). + err := sub.Args(sub, []string{}) + assert.Error(t, err, "risk-limit-table should require 1 arg") + err = sub.Args(sub, []string{"1", "2"}) + assert.Error(t, err, "risk-limit-table should reject 2 args") + err = sub.Args(sub, []string{"42"}) + assert.NoError(t, err, "risk-limit-table should accept 1 arg") + return + } + } + } + require.True(t, found, "market should expose risk-limit-table subcommand") +} + +// runFuturesRiskLimitTable is public (no auth). Mock a minimal JSON response +// so the handler executes end-to-end against the httptest server. +func TestRunFuturesRiskLimitTable_MockServer(t *testing.T) { + mockGateServer(t, `[]`) + silenceStdout(t) + root := newTestRoot(t) + cmd := &cobra.Command{Use: "risk-limit-table"} + cmd.Flags().String("settle", "usdt", "") + root.AddCommand(cmd) + + err := runFuturesRiskLimitTable(cmd, []string{"42"}) + assert.NoError(t, err, "runFuturesRiskLimitTable should succeed against mock server") +} + +func TestFuturesMarketExistingSubcommandsStillRegistered(t *testing.T) { + // Guard against accidental regressions while adding new commands. + want := map[string]bool{ + "ticker": false, + "contracts": false, + "risk-limit-tiers": false, + "risk-limit-table": false, + "candlesticks": false, + "orderbook": false, + "premium": false, + "funding-rate": false, + "index-constituents": false, + "batch-funding-rates": false, + } + for _, c := range Cmd.Commands() { + if c.Name() != "market" { + continue + } + for _, sub := range c.Commands() { + if _, ok := want[sub.Name()]; ok { + want[sub.Name()] = true + } + } + } + for name, found := range want { + assert.True(t, found, "market should still expose %q", name) + } +} diff --git a/cmd/cex/futures/position.go b/cmd/cex/futures/position.go index 9af592d..456e210 100644 --- a/cmd/cex/futures/position.go +++ b/cmd/cex/futures/position.go @@ -25,14 +25,14 @@ func init() { } addSettleFlag(listCmd) - getCmd := &cobra.Command{ - Use: "get", - Short: "Get position(s) for a contract (works in both single and dual mode)", - RunE: runFuturesPositionGet, + getDualCmd := &cobra.Command{ + Use: "get-dual", + Short: "Get dual-mode (hedge) position(s) for a contract", + RunE: runFuturesPositionGetDual, } - getCmd.Flags().String("contract", "", "Contract name, e.g. BTC_USDT (required)") - getCmd.MarkFlagRequired("contract") - addSettleFlag(getCmd) + getDualCmd.Flags().String("contract", "", "Contract name, e.g. BTC_USDT (required)") + getDualCmd.MarkFlagRequired("contract") + addSettleFlag(getDualCmd) listTimerangeCmd := &cobra.Command{ Use: "list-timerange", @@ -57,28 +57,28 @@ func init() { leverageCmd.MarkFlagRequired("contract") addSettleFlag(leverageCmd) - updateMarginCmd := &cobra.Command{ - Use: "update-margin", - Short: "Update position margin", - RunE: runFuturesUpdatePositionMargin, - } - updateMarginCmd.Flags().String("contract", "", "Contract name (required)") - updateMarginCmd.Flags().String("change", "", "Margin change amount (required)") - updateMarginCmd.Flags().String("dual-side", "", "Position side for dual mode: dual_long or dual_short (omit for single mode)") - updateMarginCmd.MarkFlagRequired("contract") - updateMarginCmd.MarkFlagRequired("change") - addSettleFlag(updateMarginCmd) - - updateLeverageCmd := &cobra.Command{ - Use: "update-leverage", - Short: "Update position leverage", - RunE: runFuturesUpdatePositionLeverage, - } - updateLeverageCmd.Flags().String("contract", "", "Contract name (required)") - updateLeverageCmd.Flags().String("leverage", "", "New leverage (required)") - updateLeverageCmd.MarkFlagRequired("contract") - updateLeverageCmd.MarkFlagRequired("leverage") - addSettleFlag(updateLeverageCmd) + updateDualMarginCmd := &cobra.Command{ + Use: "update-dual-margin", + Short: "Update dual-mode (hedge) position margin", + RunE: runFuturesUpdateDualPositionMargin, + } + updateDualMarginCmd.Flags().String("contract", "", "Contract name (required)") + updateDualMarginCmd.Flags().String("change", "", "Margin change amount (required)") + updateDualMarginCmd.Flags().String("dual-side", "", "Position side: dual_long or dual_short (required)") + updateDualMarginCmd.MarkFlagRequired("contract") + updateDualMarginCmd.MarkFlagRequired("change") + addSettleFlag(updateDualMarginCmd) + + updateDualLeverageCmd := &cobra.Command{ + Use: "update-dual-leverage", + Short: "Update dual-mode (hedge) position leverage", + RunE: runFuturesUpdateDualPositionLeverage, + } + updateDualLeverageCmd.Flags().String("contract", "", "Contract name (required)") + updateDualLeverageCmd.Flags().String("leverage", "", "New leverage (required)") + updateDualLeverageCmd.MarkFlagRequired("contract") + updateDualLeverageCmd.MarkFlagRequired("leverage") + addSettleFlag(updateDualLeverageCmd) updateContractLeverageCmd := &cobra.Command{ Use: "update-contract-leverage", @@ -93,27 +93,27 @@ func init() { updateContractLeverageCmd.MarkFlagRequired("margin-mode") addSettleFlag(updateContractLeverageCmd) - updateCrossCmd := &cobra.Command{ - Use: "update-cross-mode", - Short: "Update position cross/isolated margin mode (works in both single and dual mode)", - RunE: runFuturesUpdatePositionCrossMode, + updateDualCrossCmd := &cobra.Command{ + Use: "update-dual-cross-mode", + Short: "Update dual-mode (hedge) position cross/isolated margin mode", + RunE: runFuturesUpdateDualPositionCrossMode, } - updateCrossCmd.Flags().String("contract", "", "Contract name (required)") - updateCrossCmd.Flags().String("mode", "", "Margin mode: ISOLATED or CROSS (required)") - updateCrossCmd.MarkFlagRequired("contract") - updateCrossCmd.MarkFlagRequired("mode") - addSettleFlag(updateCrossCmd) + updateDualCrossCmd.Flags().String("contract", "", "Contract name (required)") + updateDualCrossCmd.Flags().String("mode", "", "Margin mode: ISOLATED or CROSS (required)") + updateDualCrossCmd.MarkFlagRequired("contract") + updateDualCrossCmd.MarkFlagRequired("mode") + addSettleFlag(updateDualCrossCmd) - updateRiskLimitCmd := &cobra.Command{ - Use: "update-risk-limit", - Short: "Update position risk limit", - RunE: runFuturesUpdatePositionRiskLimit, + updateDualRiskLimitCmd := &cobra.Command{ + Use: "update-dual-risk-limit", + Short: "Update dual-mode (hedge) position risk limit", + RunE: runFuturesUpdateDualPositionRiskLimit, } - updateRiskLimitCmd.Flags().String("contract", "", "Contract name (required)") - updateRiskLimitCmd.Flags().String("risk-limit", "", "New risk limit (required)") - updateRiskLimitCmd.MarkFlagRequired("contract") - updateRiskLimitCmd.MarkFlagRequired("risk-limit") - addSettleFlag(updateRiskLimitCmd) + updateDualRiskLimitCmd.Flags().String("contract", "", "Contract name (required)") + updateDualRiskLimitCmd.Flags().String("risk-limit", "", "New risk limit (required)") + updateDualRiskLimitCmd.MarkFlagRequired("contract") + updateDualRiskLimitCmd.MarkFlagRequired("risk-limit") + addSettleFlag(updateDualRiskLimitCmd) closeHistoryCmd := &cobra.Command{ Use: "close-history", @@ -144,11 +144,12 @@ func init() { adlCmd.Flags().Int32("limit", 0, "Number of records to return") addSettleFlag(adlCmd) - positionCmd.AddCommand(listCmd, getCmd, + positionCmd.AddCommand(listCmd, getDualCmd, listTimerangeCmd, leverageCmd, - updateMarginCmd, updateLeverageCmd, updateContractLeverageCmd, - updateCrossCmd, updateRiskLimitCmd, + updateDualMarginCmd, updateDualLeverageCmd, updateContractLeverageCmd, + updateDualCrossCmd, updateDualRiskLimitCmd, closeHistoryCmd, liquidatesCmd, adlCmd) + registerSinglePositionCommands(positionCmd) Cmd.AddCommand(positionCmd) } @@ -184,7 +185,7 @@ func runFuturesPositionList(cmd *cobra.Command, args []string) error { return p.Table([]string{"Contract", "Size", "Entry Price", "Mark Price", "Unrealised PNL", "Leverage"}, rows) } -func runFuturesPositionGet(cmd *cobra.Command, args []string) error { +func runFuturesPositionGetDual(cmd *cobra.Command, args []string) error { contract, _ := cmd.Flags().GetString("contract") settle := cmdutil.GetSettle(cmd) p := cmdutil.GetPrinter(cmd) @@ -289,7 +290,7 @@ func runFuturesPositionLeverage(cmd *cobra.Command, args []string) error { ) } -func runFuturesUpdatePositionMargin(cmd *cobra.Command, args []string) error { +func runFuturesUpdateDualPositionMargin(cmd *cobra.Command, args []string) error { contract, _ := cmd.Flags().GetString("contract") change, _ := cmd.Flags().GetString("change") dualSide, _ := cmd.Flags().GetString("dual-side") @@ -314,7 +315,7 @@ func runFuturesUpdatePositionMargin(cmd *cobra.Command, args []string) error { return p.Print(result) } -func runFuturesUpdatePositionLeverage(cmd *cobra.Command, args []string) error { +func runFuturesUpdateDualPositionLeverage(cmd *cobra.Command, args []string) error { contract, _ := cmd.Flags().GetString("contract") leverage, _ := cmd.Flags().GetString("leverage") settle := cmdutil.GetSettle(cmd) @@ -357,7 +358,7 @@ func runFuturesUpdateContractPositionLeverage(cmd *cobra.Command, args []string) return p.Print(result) } -func runFuturesUpdatePositionCrossMode(cmd *cobra.Command, args []string) error { +func runFuturesUpdateDualPositionCrossMode(cmd *cobra.Command, args []string) error { contract, _ := cmd.Flags().GetString("contract") mode, _ := cmd.Flags().GetString("mode") settle := cmdutil.GetSettle(cmd) @@ -380,7 +381,7 @@ func runFuturesUpdatePositionCrossMode(cmd *cobra.Command, args []string) error return p.Print(result) } -func runFuturesUpdatePositionRiskLimit(cmd *cobra.Command, args []string) error { +func runFuturesUpdateDualPositionRiskLimit(cmd *cobra.Command, args []string) error { contract, _ := cmd.Flags().GetString("contract") riskLimit, _ := cmd.Flags().GetString("risk-limit") settle := cmdutil.GetSettle(cmd) diff --git a/cmd/cex/futures/position_single.go b/cmd/cex/futures/position_single.go new file mode 100644 index 0000000..59b4601 --- /dev/null +++ b/cmd/cex/futures/position_single.go @@ -0,0 +1,193 @@ +package futures + +import ( + "encoding/json" + + "github.com/antihax/optional" + "github.com/spf13/cobra" + + "github.com/gate/gate-cli/internal/client" + "github.com/gate/gate-cli/internal/cmdutil" + gateapi "github.com/gate/gateapi-go/v7" +) + +// registerSinglePositionCommands attaches single (one-way) position-mode +// commands. These mirror the dual (hedge) variants and are introduced +// alongside SDK v7.2.71 adoption to expose the bare UpdatePosition* / +// GetPosition methods that were previously unavailable in the CLI. +func registerSinglePositionCommands(parent *cobra.Command) { + getCmd := &cobra.Command{ + Use: "get", + Short: "Get one-way (single-mode) position for a contract", + RunE: runFuturesPositionGetSingle, + } + getCmd.Flags().String("contract", "", "Contract name, e.g. BTC_USDT (required)") + getCmd.MarkFlagRequired("contract") + addSettleFlag(getCmd) + + updateMarginCmd := &cobra.Command{ + Use: "update-margin", + Short: "Update one-way (single-mode) position margin", + RunE: runFuturesUpdateSinglePositionMargin, + } + updateMarginCmd.Flags().String("contract", "", "Contract name (required)") + updateMarginCmd.Flags().String("change", "", "Margin change amount (required)") + updateMarginCmd.MarkFlagRequired("contract") + updateMarginCmd.MarkFlagRequired("change") + addSettleFlag(updateMarginCmd) + + updateLeverageCmd := &cobra.Command{ + Use: "update-leverage", + Short: "Update one-way (single-mode) position leverage", + RunE: runFuturesUpdateSinglePositionLeverage, + } + updateLeverageCmd.Flags().String("contract", "", "Contract name (required)") + updateLeverageCmd.Flags().String("leverage", "", "New leverage (required)") + updateLeverageCmd.Flags().String("cross-leverage-limit", "", "Cross margin leverage limit (cross mode only)") + updateLeverageCmd.MarkFlagRequired("contract") + updateLeverageCmd.MarkFlagRequired("leverage") + addSettleFlag(updateLeverageCmd) + + updateCrossCmd := &cobra.Command{ + Use: "update-cross-mode", + Short: "Update one-way (single-mode) position cross/isolated margin mode", + RunE: runFuturesUpdateSinglePositionCrossMode, + } + updateCrossCmd.Flags().String("contract", "", "Contract name (required)") + updateCrossCmd.Flags().String("mode", "", "Margin mode: ISOLATED or CROSS (required)") + updateCrossCmd.MarkFlagRequired("contract") + updateCrossCmd.MarkFlagRequired("mode") + addSettleFlag(updateCrossCmd) + + updateRiskLimitCmd := &cobra.Command{ + Use: "update-risk-limit", + Short: "Update one-way (single-mode) position risk limit", + RunE: runFuturesUpdateSinglePositionRiskLimit, + } + updateRiskLimitCmd.Flags().String("contract", "", "Contract name (required)") + updateRiskLimitCmd.Flags().String("risk-limit", "", "New risk limit (required)") + updateRiskLimitCmd.MarkFlagRequired("contract") + updateRiskLimitCmd.MarkFlagRequired("risk-limit") + addSettleFlag(updateRiskLimitCmd) + + parent.AddCommand(getCmd, updateMarginCmd, updateLeverageCmd, updateCrossCmd, updateRiskLimitCmd) +} + +func runFuturesPositionGetSingle(cmd *cobra.Command, args []string) error { + contract, _ := cmd.Flags().GetString("contract") + settle := cmdutil.GetSettle(cmd) + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + if err := c.RequireAuth(); err != nil { + return err + } + + result, httpResp, err := c.FuturesAPI.GetPosition(c.Context(), settle, contract) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "GET", "/api/v4/futures/"+settle+"/positions/"+contract, "")) + return nil + } + if p.IsJSON() { + return p.Print(result) + } + return p.Table( + []string{"Contract", "Mode", "Size", "Entry Price", "Mark Price", "Unrealised PNL", "Leverage", "Liq Price"}, + [][]string{{result.Contract, result.Mode, result.Size, result.EntryPrice, result.MarkPrice, result.UnrealisedPnl, result.Leverage, result.LiqPrice}}, + ) +} + +func runFuturesUpdateSinglePositionMargin(cmd *cobra.Command, args []string) error { + contract, _ := cmd.Flags().GetString("contract") + change, _ := cmd.Flags().GetString("change") + settle := cmdutil.GetSettle(cmd) + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + if err := c.RequireAuth(); err != nil { + return err + } + + result, httpResp, err := c.FuturesAPI.UpdatePositionMargin(c.Context(), settle, contract, change) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "POST", "/api/v4/futures/"+settle+"/positions/"+contract+"/margin", "")) + return nil + } + return p.Print(result) +} + +func runFuturesUpdateSinglePositionLeverage(cmd *cobra.Command, args []string) error { + contract, _ := cmd.Flags().GetString("contract") + leverage, _ := cmd.Flags().GetString("leverage") + crossLeverageLimit, _ := cmd.Flags().GetString("cross-leverage-limit") + settle := cmdutil.GetSettle(cmd) + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + if err := c.RequireAuth(); err != nil { + return err + } + + var opts *gateapi.UpdatePositionLeverageOpts + if crossLeverageLimit != "" { + opts = &gateapi.UpdatePositionLeverageOpts{ + CrossLeverageLimit: optional.NewString(crossLeverageLimit), + } + } + result, httpResp, err := c.FuturesAPI.UpdatePositionLeverage(c.Context(), settle, contract, leverage, opts) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "POST", "/api/v4/futures/"+settle+"/positions/"+contract+"/leverage", "")) + return nil + } + return p.Print(result) +} + +func runFuturesUpdateSinglePositionCrossMode(cmd *cobra.Command, args []string) error { + contract, _ := cmd.Flags().GetString("contract") + mode, _ := cmd.Flags().GetString("mode") + settle := cmdutil.GetSettle(cmd) + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + if err := c.RequireAuth(); err != nil { + return err + } + + req := gateapi.FuturesPositionCrossMode{Contract: contract, Mode: mode} + body, _ := json.Marshal(req) + result, httpResp, err := c.FuturesAPI.UpdatePositionCrossMode(c.Context(), settle, req) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "POST", "/api/v4/futures/"+settle+"/positions/cross_mode", string(body))) + return nil + } + return p.Print(result) +} + +func runFuturesUpdateSinglePositionRiskLimit(cmd *cobra.Command, args []string) error { + contract, _ := cmd.Flags().GetString("contract") + riskLimit, _ := cmd.Flags().GetString("risk-limit") + settle := cmdutil.GetSettle(cmd) + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + if err := c.RequireAuth(); err != nil { + return err + } + + result, httpResp, err := c.FuturesAPI.UpdatePositionRiskLimit(c.Context(), settle, contract, riskLimit) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "POST", "/api/v4/futures/"+settle+"/positions/"+contract+"/risk_limit", "")) + return nil + } + return p.Print(result) +} diff --git a/cmd/cex/futures/position_single_test.go b/cmd/cex/futures/position_single_test.go new file mode 100644 index 0000000..b8db8eb --- /dev/null +++ b/cmd/cex/futures/position_single_test.go @@ -0,0 +1,124 @@ +package futures + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newTestRoot builds a minimal cobra root mirroring cmd/root.go's persistent +// flags so runXxx handlers can invoke cmdutil.GetClient/GetPrinter/GetSettle. +// Environment is scrubbed so no real credentials leak in. +func newTestRoot(t *testing.T) *cobra.Command { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("GATE_API_KEY", "") + t.Setenv("GATE_API_SECRET", "") + root := &cobra.Command{Use: "gate-cli"} + root.PersistentFlags().String("format", "json", "") + root.PersistentFlags().String("profile", "default", "") + root.PersistentFlags().Bool("debug", false, "") + root.PersistentFlags().Bool("verbose", false, "") + root.PersistentFlags().String("api-key", "", "") + root.PersistentFlags().String("api-secret", "", "") + return root +} + +// singleModeLeaf builds a standalone child command with the default flag set +// expected by the position single-mode runners (contract is always present). +func singleModeLeaf(use string, flagSetup func(*cobra.Command)) *cobra.Command { + cmd := &cobra.Command{Use: use} + cmd.Flags().String("contract", "BTC_USDT", "") + cmd.Flags().String("settle", "usdt", "") + if flagSetup != nil { + flagSetup(cmd) + } + return cmd +} + +func TestRunFuturesPositionGetSingle_RequiresAuth(t *testing.T) { + root := newTestRoot(t) + cmd := singleModeLeaf("get", nil) + root.AddCommand(cmd) + + err := runFuturesPositionGetSingle(cmd, nil) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} + +func TestRunFuturesUpdateSinglePositionMargin_RequiresAuth(t *testing.T) { + root := newTestRoot(t) + cmd := singleModeLeaf("update-margin", func(c *cobra.Command) { + c.Flags().String("change", "10", "") + }) + root.AddCommand(cmd) + + err := runFuturesUpdateSinglePositionMargin(cmd, nil) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} + +func TestRunFuturesUpdateSinglePositionLeverage_RequiresAuth(t *testing.T) { + root := newTestRoot(t) + cmd := singleModeLeaf("update-leverage", func(c *cobra.Command) { + c.Flags().String("leverage", "5", "") + c.Flags().String("cross-leverage-limit", "", "") + }) + root.AddCommand(cmd) + + err := runFuturesUpdateSinglePositionLeverage(cmd, nil) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} + +func TestRunFuturesUpdateSinglePositionCrossMode_RequiresAuth(t *testing.T) { + root := newTestRoot(t) + cmd := singleModeLeaf("update-cross-mode", func(c *cobra.Command) { + c.Flags().String("mode", "CROSS", "") + }) + root.AddCommand(cmd) + + err := runFuturesUpdateSinglePositionCrossMode(cmd, nil) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} + +func TestRunFuturesUpdateSinglePositionRiskLimit_RequiresAuth(t *testing.T) { + root := newTestRoot(t) + cmd := singleModeLeaf("update-risk-limit", func(c *cobra.Command) { + c.Flags().String("risk-limit", "1000000", "") + }) + root.AddCommand(cmd) + + err := runFuturesUpdateSinglePositionRiskLimit(cmd, nil) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} + +// Dual-mode handlers follow the same RequireAuth pattern; covering them here +// protects against regressions in the shared auth gate. + +func TestRunFuturesPositionGetDual_RequiresAuth(t *testing.T) { + root := newTestRoot(t) + cmd := singleModeLeaf("get-dual", nil) + root.AddCommand(cmd) + + err := runFuturesPositionGetDual(cmd, nil) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} + +func TestRunFuturesUpdateDualPositionLeverage_RequiresAuth(t *testing.T) { + root := newTestRoot(t) + cmd := singleModeLeaf("update-dual-leverage", func(c *cobra.Command) { + c.Flags().String("leverage", "5", "") + }) + root.AddCommand(cmd) + + err := runFuturesUpdateDualPositionLeverage(cmd, nil) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} diff --git a/cmd/cex/futures/position_test.go b/cmd/cex/futures/position_test.go new file mode 100644 index 0000000..da0e506 --- /dev/null +++ b/cmd/cex/futures/position_test.go @@ -0,0 +1,198 @@ +package futures + +import ( + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// positionCmd returns the `position` subcommand registered on futures.Cmd. +func findPositionCmd(t *testing.T) *cobra.Command { + t.Helper() + for _, c := range Cmd.Commands() { + if c.Name() == "position" { + return c + } + } + t.Fatal("position subcommand not found on futures.Cmd") + return nil +} + +// subcommandByName walks a cobra parent and returns the matching subcommand. +func subcommandByName(parent *cobra.Command, name string) *cobra.Command { + for _, c := range parent.Commands() { + if c.Name() == name { + return c + } + } + return nil +} + +func TestFuturesPositionFullSubcommandTree(t *testing.T) { + // All commands expected after SDK v7.2.71 sync (dual rename + single-mode additions). + want := map[string]bool{ + // Shared / read-only + "list": false, + "list-timerange": false, + "leverage": false, + "close-history": false, + "liquidates": false, + "adl": false, + // Dual-mode (renamed in route-β) + "get-dual": false, + "update-dual-margin": false, + "update-dual-leverage": false, + "update-dual-cross-mode": false, + "update-dual-risk-limit": false, + // Contract-mode (unchanged) + "update-contract-leverage": false, + // One-way / single-mode (newly added) + "get": false, + "update-margin": false, + "update-leverage": false, + "update-cross-mode": false, + "update-risk-limit": false, + } + + pos := findPositionCmd(t) + for _, sub := range pos.Commands() { + if _, ok := want[sub.Name()]; ok { + want[sub.Name()] = true + } + } + for name, ok := range want { + assert.True(t, ok, "position should expose %q", name) + } +} + +func TestFuturesPositionDualCommandsRequireFlags(t *testing.T) { + pos := findPositionCmd(t) + + // get-dual: --contract required + getDual := subcommandByName(pos, "get-dual") + require.NotNil(t, getDual, "get-dual should be registered") + require.NotNil(t, getDual.Flag("contract")) + assert.NotEmpty(t, + getDual.Flag("contract").Annotations[cobraBashCompOneRequiredFlag], + "get-dual --contract should be required") + + // update-dual-margin: --contract, --change, --dual-side required + updDualMargin := subcommandByName(pos, "update-dual-margin") + require.NotNil(t, updDualMargin) + for _, name := range []string{"contract", "change"} { + f := updDualMargin.Flag(name) + require.NotNil(t, f, "update-dual-margin should have --%s", name) + assert.NotEmpty(t, f.Annotations[cobraBashCompOneRequiredFlag], + "update-dual-margin --%s should be required", name) + } + // dual-side exists but is not required (only required when in dual mode) + require.NotNil(t, updDualMargin.Flag("dual-side")) + + // update-dual-leverage: --contract, --leverage required + updDualLev := subcommandByName(pos, "update-dual-leverage") + require.NotNil(t, updDualLev) + for _, name := range []string{"contract", "leverage"} { + f := updDualLev.Flag(name) + require.NotNil(t, f) + assert.NotEmpty(t, f.Annotations[cobraBashCompOneRequiredFlag]) + } + + // update-dual-cross-mode: --contract, --mode required + updDualCross := subcommandByName(pos, "update-dual-cross-mode") + require.NotNil(t, updDualCross) + for _, name := range []string{"contract", "mode"} { + f := updDualCross.Flag(name) + require.NotNil(t, f) + assert.NotEmpty(t, f.Annotations[cobraBashCompOneRequiredFlag]) + } + + // update-dual-risk-limit: --contract, --risk-limit required + updDualRisk := subcommandByName(pos, "update-dual-risk-limit") + require.NotNil(t, updDualRisk) + for _, name := range []string{"contract", "risk-limit"} { + f := updDualRisk.Flag(name) + require.NotNil(t, f) + assert.NotEmpty(t, f.Annotations[cobraBashCompOneRequiredFlag]) + } +} + +func TestFuturesPositionSingleCommandsRequireFlags(t *testing.T) { + pos := findPositionCmd(t) + + // get (single-mode): --contract required + getSingle := subcommandByName(pos, "get") + require.NotNil(t, getSingle, "get (single-mode) should be registered") + f := getSingle.Flag("contract") + require.NotNil(t, f) + assert.NotEmpty(t, f.Annotations[cobraBashCompOneRequiredFlag]) + + // update-margin: --contract, --change required + updMargin := subcommandByName(pos, "update-margin") + require.NotNil(t, updMargin) + for _, name := range []string{"contract", "change"} { + f := updMargin.Flag(name) + require.NotNil(t, f) + assert.NotEmpty(t, f.Annotations[cobraBashCompOneRequiredFlag]) + } + + // update-leverage: --contract, --leverage required; --cross-leverage-limit optional + updLev := subcommandByName(pos, "update-leverage") + require.NotNil(t, updLev) + for _, name := range []string{"contract", "leverage"} { + f := updLev.Flag(name) + require.NotNil(t, f) + assert.NotEmpty(t, f.Annotations[cobraBashCompOneRequiredFlag]) + } + crossLim := updLev.Flag("cross-leverage-limit") + require.NotNil(t, crossLim, "update-leverage should expose --cross-leverage-limit") + assert.Empty(t, crossLim.Annotations[cobraBashCompOneRequiredFlag], + "--cross-leverage-limit must remain optional") + + // update-cross-mode: --contract, --mode required + updCross := subcommandByName(pos, "update-cross-mode") + require.NotNil(t, updCross) + for _, name := range []string{"contract", "mode"} { + f := updCross.Flag(name) + require.NotNil(t, f) + assert.NotEmpty(t, f.Annotations[cobraBashCompOneRequiredFlag]) + } + + // update-risk-limit: --contract, --risk-limit required + updRisk := subcommandByName(pos, "update-risk-limit") + require.NotNil(t, updRisk) + for _, name := range []string{"contract", "risk-limit"} { + f := updRisk.Flag(name) + require.NotNil(t, f) + assert.NotEmpty(t, f.Annotations[cobraBashCompOneRequiredFlag]) + } +} + +// Ensures route-β naming has been applied: the unprefixed `update-leverage` +// now belongs to single-mode (UpdatePositionLeverage), NOT the old dual-mode +// alias. We detect this via the Short description because RunE values are +// unexported. +func TestFuturesPositionRouteBetaRenaming(t *testing.T) { + pos := findPositionCmd(t) + + // get is single-mode + getCmd := subcommandByName(pos, "get") + require.NotNil(t, getCmd) + assert.Contains(t, getCmd.Short, "one-way", "get should be single/one-way mode") + + // get-dual is dual-mode + getDual := subcommandByName(pos, "get-dual") + require.NotNil(t, getDual) + assert.Contains(t, getDual.Short, "dual-mode", "get-dual should be dual/hedge mode") + + // update-leverage is single-mode + updLev := subcommandByName(pos, "update-leverage") + require.NotNil(t, updLev) + assert.Contains(t, updLev.Short, "one-way") + + // update-dual-leverage is dual-mode + updDualLev := subcommandByName(pos, "update-dual-leverage") + require.NotNil(t, updDualLev) + assert.Contains(t, updDualLev.Short, "dual-mode") +} diff --git a/cmd/cex/launch/candy_drop.go b/cmd/cex/launch/candy_drop.go new file mode 100644 index 0000000..895b8cd --- /dev/null +++ b/cmd/cex/launch/candy_drop.go @@ -0,0 +1,288 @@ +package launch + +import ( + "encoding/json" + + "github.com/antihax/optional" + "github.com/spf13/cobra" + + "github.com/gate/gate-cli/internal/client" + "github.com/gate/gate-cli/internal/cmdutil" + gateapi "github.com/gate/gateapi-go/v7" +) + +// registerCandyDropCommands attaches the Candy Drop V4 sub-tree under +// `cex launch candy-drop ...`. Introduced alongside SDK v7.2.71 adoption to +// close the CLI gap against the launch-module MCP tools. +func registerCandyDropCommands(parent *cobra.Command) { + candyDropCmd := &cobra.Command{ + Use: "candy-drop", + Short: "Candy Drop V4 commands", + } + + activitiesCmd := &cobra.Command{ + Use: "activities", + Short: "List Candy Drop V4 activities (public)", + RunE: runCandyDropActivities, + } + activitiesCmd.Flags().String("status", "", "Filter by activity status") + activitiesCmd.Flags().String("rule-name", "", "Filter by rule name") + activitiesCmd.Flags().String("register-status", "", "Filter by register status") + activitiesCmd.Flags().String("currency", "", "Filter by reward currency") + activitiesCmd.Flags().Int32("limit", 0, "Page size") + activitiesCmd.Flags().Int32("offset", 0, "Pagination offset") + + rulesCmd := &cobra.Command{ + Use: "rules", + Short: "Query Candy Drop V4 activity rules (public)", + RunE: runCandyDropRules, + } + rulesCmd.Flags().Int64("activity-id", 0, "Activity ID") + rulesCmd.Flags().String("currency", "", "Currency") + + registerCmd := &cobra.Command{ + Use: "register", + Short: "Register for a Candy Drop V4 activity (auth required)", + RunE: runCandyDropRegister, + } + registerCmd.Flags().String("currency", "", "Project/currency name (required)") + registerCmd.Flags().Int64("activity-id", 0, "Activity ID (optional, used with currency)") + registerCmd.MarkFlagRequired("currency") + + progressCmd := &cobra.Command{ + Use: "progress", + Short: "Query Candy Drop V4 task completion progress (auth required)", + RunE: runCandyDropProgress, + } + progressCmd.Flags().Int64("activity-id", 0, "Activity ID") + progressCmd.Flags().String("currency", "", "Currency") + + participationsCmd := &cobra.Command{ + Use: "participations", + Short: "List Candy Drop V4 participation records (auth required)", + RunE: runCandyDropParticipations, + } + participationsCmd.Flags().String("currency", "", "Currency") + participationsCmd.Flags().String("status", "", "Participation status") + participationsCmd.Flags().Int64("start-time", 0, "Start timestamp") + participationsCmd.Flags().Int64("end-time", 0, "End timestamp") + participationsCmd.Flags().Int32("page", 0, "Page number") + participationsCmd.Flags().Int32("limit", 0, "Page size") + + airdropsCmd := &cobra.Command{ + Use: "airdrops", + Short: "List Candy Drop V4 airdrop records (auth required)", + RunE: runCandyDropAirdrops, + } + airdropsCmd.Flags().String("currency", "", "Currency") + airdropsCmd.Flags().Int64("start-time", 0, "Start timestamp") + airdropsCmd.Flags().Int64("end-time", 0, "End timestamp") + airdropsCmd.Flags().Int32("page", 0, "Page number") + airdropsCmd.Flags().Int32("limit", 0, "Page size") + + candyDropCmd.AddCommand(activitiesCmd, rulesCmd, registerCmd, progressCmd, participationsCmd, airdropsCmd) + parent.AddCommand(candyDropCmd) +} + +func runCandyDropActivities(cmd *cobra.Command, args []string) error { + status, _ := cmd.Flags().GetString("status") + ruleName, _ := cmd.Flags().GetString("rule-name") + registerStatus, _ := cmd.Flags().GetString("register-status") + currency, _ := cmd.Flags().GetString("currency") + limit, _ := cmd.Flags().GetInt32("limit") + offset, _ := cmd.Flags().GetInt32("offset") + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + + opts := &gateapi.GetCandyDropActivityListV4Opts{} + if status != "" { + opts.Status = optional.NewString(status) + } + if ruleName != "" { + opts.RuleName = optional.NewString(ruleName) + } + if registerStatus != "" { + opts.RegisterStatus = optional.NewString(registerStatus) + } + if currency != "" { + opts.Currency = optional.NewString(currency) + } + if limit != 0 { + opts.Limit = optional.NewInt32(limit) + } + if offset != 0 { + opts.Offset = optional.NewInt32(offset) + } + + result, httpResp, err := c.LaunchAPI.GetCandyDropActivityListV4(c.Context(), opts) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "GET", "/api/v4/candy_drop/v4/activities", "")) + return nil + } + return p.Print(result) +} + +func runCandyDropRules(cmd *cobra.Command, args []string) error { + activityID, _ := cmd.Flags().GetInt64("activity-id") + currency, _ := cmd.Flags().GetString("currency") + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + + opts := &gateapi.GetCandyDropActivityRulesV4Opts{} + if activityID != 0 { + opts.ActivityId = optional.NewInt64(activityID) + } + if currency != "" { + opts.Currency = optional.NewString(currency) + } + + result, httpResp, err := c.LaunchAPI.GetCandyDropActivityRulesV4(c.Context(), opts) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "GET", "/api/v4/candy_drop/v4/rules", "")) + return nil + } + return p.Print(result) +} + +func runCandyDropRegister(cmd *cobra.Command, args []string) error { + currency, _ := cmd.Flags().GetString("currency") + activityID, _ := cmd.Flags().GetInt64("activity-id") + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + if err := c.RequireAuth(); err != nil { + return err + } + + req := gateapi.CandyDropV4RegisterReqCd02{ + Currency: currency, + ActivityId: activityID, + } + body, _ := json.Marshal(req) + result, httpResp, err := c.LaunchAPI.RegisterCandyDropV4(c.Context(), req) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "POST", "/api/v4/candy_drop/v4/register", string(body))) + return nil + } + return p.Print(result) +} + +func runCandyDropProgress(cmd *cobra.Command, args []string) error { + activityID, _ := cmd.Flags().GetInt64("activity-id") + currency, _ := cmd.Flags().GetString("currency") + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + if err := c.RequireAuth(); err != nil { + return err + } + + opts := &gateapi.GetCandyDropTaskProgressV4Opts{} + if activityID != 0 { + opts.ActivityId = optional.NewInt64(activityID) + } + if currency != "" { + opts.Currency = optional.NewString(currency) + } + + result, httpResp, err := c.LaunchAPI.GetCandyDropTaskProgressV4(c.Context(), opts) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "GET", "/api/v4/candy_drop/v4/progress", "")) + return nil + } + return p.Print(result) +} + +func runCandyDropParticipations(cmd *cobra.Command, args []string) error { + currency, _ := cmd.Flags().GetString("currency") + status, _ := cmd.Flags().GetString("status") + startTime, _ := cmd.Flags().GetInt64("start-time") + endTime, _ := cmd.Flags().GetInt64("end-time") + page, _ := cmd.Flags().GetInt32("page") + limit, _ := cmd.Flags().GetInt32("limit") + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + if err := c.RequireAuth(); err != nil { + return err + } + + opts := &gateapi.GetCandyDropParticipationRecordsV4Opts{} + if currency != "" { + opts.Currency = optional.NewString(currency) + } + if status != "" { + opts.Status = optional.NewString(status) + } + if startTime != 0 { + opts.StartTime = optional.NewInt64(startTime) + } + if endTime != 0 { + opts.EndTime = optional.NewInt64(endTime) + } + if page != 0 { + opts.Page = optional.NewInt32(page) + } + if limit != 0 { + opts.Limit = optional.NewInt32(limit) + } + + result, httpResp, err := c.LaunchAPI.GetCandyDropParticipationRecordsV4(c.Context(), opts) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "GET", "/api/v4/candy_drop/v4/participations", "")) + return nil + } + return p.Print(result) +} + +func runCandyDropAirdrops(cmd *cobra.Command, args []string) error { + currency, _ := cmd.Flags().GetString("currency") + startTime, _ := cmd.Flags().GetInt64("start-time") + endTime, _ := cmd.Flags().GetInt64("end-time") + page, _ := cmd.Flags().GetInt32("page") + limit, _ := cmd.Flags().GetInt32("limit") + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + if err := c.RequireAuth(); err != nil { + return err + } + + opts := &gateapi.GetCandyDropAirdropRecordsV4Opts{} + if currency != "" { + opts.Currency = optional.NewString(currency) + } + if startTime != 0 { + opts.StartTime = optional.NewInt64(startTime) + } + if endTime != 0 { + opts.EndTime = optional.NewInt64(endTime) + } + if page != 0 { + opts.Page = optional.NewInt32(page) + } + if limit != 0 { + opts.Limit = optional.NewInt32(limit) + } + + result, httpResp, err := c.LaunchAPI.GetCandyDropAirdropRecordsV4(c.Context(), opts) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "GET", "/api/v4/candy_drop/v4/airdrops", "")) + return nil + } + return p.Print(result) +} diff --git a/cmd/cex/launch/candy_drop_test.go b/cmd/cex/launch/candy_drop_test.go new file mode 100644 index 0000000..24ffe40 --- /dev/null +++ b/cmd/cex/launch/candy_drop_test.go @@ -0,0 +1,255 @@ +package launch + +import ( + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockGateServer stands up an httptest server returning fixed JSON for every +// request and points GATE_BASE_URL at it. +func mockGateServer(t *testing.T, body string) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + t.Setenv("GATE_BASE_URL", srv.URL) +} + +func silenceStdout(t *testing.T) { + t.Helper() + oldOut := os.Stdout + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + require.NoError(t, err) + os.Stdout = devNull + t.Cleanup(func() { + os.Stdout = oldOut + _ = devNull.Close() + }) +} + +// newTestRoot provides a minimal cobra root so runXxx handlers can call +// cmdutil.GetClient/GetPrinter during tests. Env is isolated so no real +// credentials leak in. +func newTestRoot(t *testing.T) *cobra.Command { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("GATE_API_KEY", "") + t.Setenv("GATE_API_SECRET", "") + root := &cobra.Command{Use: "gate-cli"} + root.PersistentFlags().String("format", "json", "") + root.PersistentFlags().String("profile", "default", "") + root.PersistentFlags().Bool("debug", false, "") + root.PersistentFlags().Bool("verbose", false, "") + root.PersistentFlags().String("api-key", "", "") + root.PersistentFlags().String("api-secret", "", "") + return root +} + +const cobraBashCompOneRequiredFlag = "cobra_annotation_bash_completion_one_required_flag" + +// findSubcommand walks a cobra parent and returns the named child. +func findSubcommand(parent *cobra.Command, name string) *cobra.Command { + for _, c := range parent.Commands() { + if c.Name() == name { + return c + } + } + return nil +} + +// findLaunchChild returns Cmd. or nil. +func findLaunchChild(name string) *cobra.Command { + return findSubcommand(Cmd, name) +} + +func TestCandyDropSubtreeRegistered(t *testing.T) { + candy := findLaunchChild("candy-drop") + require.NotNil(t, candy, "launch.Cmd should expose candy-drop subtree") + + want := map[string]bool{ + "activities": false, + "rules": false, + "register": false, + "progress": false, + "participations": false, + "airdrops": false, + } + for _, sub := range candy.Commands() { + if _, ok := want[sub.Name()]; ok { + want[sub.Name()] = true + } + } + for name, found := range want { + assert.True(t, found, "candy-drop should expose %q", name) + } +} + +func TestCandyDropRegisterRequiresCurrency(t *testing.T) { + candy := findLaunchChild("candy-drop") + require.NotNil(t, candy) + + register := findSubcommand(candy, "register") + require.NotNil(t, register) + + curFlag := register.Flag("currency") + require.NotNil(t, curFlag, "register should have --currency flag") + assert.NotEmpty(t, curFlag.Annotations[cobraBashCompOneRequiredFlag], + "register --currency should be required") + + // activity-id is optional per SDK contract (ActivityId int64 `json:"activity_id,omitempty"`). + actID := register.Flag("activity-id") + require.NotNil(t, actID) + assert.Empty(t, actID.Annotations[cobraBashCompOneRequiredFlag], + "register --activity-id must be optional") +} + +func TestCandyDropActivitiesOptionalFlags(t *testing.T) { + candy := findLaunchChild("candy-drop") + require.NotNil(t, candy) + + activities := findSubcommand(candy, "activities") + require.NotNil(t, activities) + + for _, name := range []string{"status", "rule-name", "register-status", "currency", "limit", "offset"} { + f := activities.Flag(name) + require.NotNil(t, f, "activities should expose --%s", name) + assert.Empty(t, f.Annotations[cobraBashCompOneRequiredFlag], + "--%s must be optional", name) + } +} + +// --- Public (no-auth) RunE coverage via httptest --- + +// runCandyDropActivities skips RequireAuth; exercise the full opts-builder +// branch matrix by passing every optional flag. +func TestRunCandyDropActivities_MockServer_AllFlags(t *testing.T) { + mockGateServer(t, `[]`) + silenceStdout(t) + root := newTestRoot(t) + cmd := &cobra.Command{Use: "activities"} + cmd.Flags().String("status", "active", "") + cmd.Flags().String("rule-name", "alpha", "") + cmd.Flags().String("register-status", "registered", "") + cmd.Flags().String("currency", "BTC", "") + cmd.Flags().Int32("limit", 20, "") + cmd.Flags().Int32("offset", 5, "") + root.AddCommand(cmd) + + err := runCandyDropActivities(cmd, nil) + assert.NoError(t, err) +} + +// Same handler with all optional flags at their zero value. +func TestRunCandyDropActivities_MockServer_NoFlags(t *testing.T) { + mockGateServer(t, `[]`) + silenceStdout(t) + root := newTestRoot(t) + cmd := &cobra.Command{Use: "activities"} + cmd.Flags().String("status", "", "") + cmd.Flags().String("rule-name", "", "") + cmd.Flags().String("register-status", "", "") + cmd.Flags().String("currency", "", "") + cmd.Flags().Int32("limit", 0, "") + cmd.Flags().Int32("offset", 0, "") + root.AddCommand(cmd) + + err := runCandyDropActivities(cmd, nil) + assert.NoError(t, err) +} + +func TestRunCandyDropRules_MockServer(t *testing.T) { + mockGateServer(t, `{}`) + silenceStdout(t) + root := newTestRoot(t) + cmd := &cobra.Command{Use: "rules"} + cmd.Flags().Int64("activity-id", 100, "") + cmd.Flags().String("currency", "USDT", "") + root.AddCommand(cmd) + + err := runCandyDropRules(cmd, nil) + assert.NoError(t, err) +} + +// --- RunE error-path coverage (auth-required subset) --- + +func TestRunCandyDropRegister_RequiresAuth(t *testing.T) { + root := newTestRoot(t) + cmd := &cobra.Command{Use: "register"} + cmd.Flags().String("currency", "BTC", "") + cmd.Flags().Int64("activity-id", 12345, "") + root.AddCommand(cmd) + + err := runCandyDropRegister(cmd, nil) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} + +func TestRunCandyDropProgress_RequiresAuth(t *testing.T) { + root := newTestRoot(t) + cmd := &cobra.Command{Use: "progress"} + cmd.Flags().Int64("activity-id", 0, "") + cmd.Flags().String("currency", "", "") + root.AddCommand(cmd) + + err := runCandyDropProgress(cmd, nil) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} + +func TestRunCandyDropParticipations_RequiresAuth(t *testing.T) { + root := newTestRoot(t) + cmd := &cobra.Command{Use: "participations"} + cmd.Flags().String("currency", "", "") + cmd.Flags().String("status", "", "") + cmd.Flags().Int64("start-time", 0, "") + cmd.Flags().Int64("end-time", 0, "") + cmd.Flags().Int32("page", 0, "") + cmd.Flags().Int32("limit", 0, "") + root.AddCommand(cmd) + + err := runCandyDropParticipations(cmd, nil) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} + +func TestRunCandyDropAirdrops_RequiresAuth(t *testing.T) { + root := newTestRoot(t) + cmd := &cobra.Command{Use: "airdrops"} + cmd.Flags().String("currency", "", "") + cmd.Flags().Int64("start-time", 0, "") + cmd.Flags().Int64("end-time", 0, "") + cmd.Flags().Int32("page", 0, "") + cmd.Flags().Int32("limit", 0, "") + root.AddCommand(cmd) + + err := runCandyDropAirdrops(cmd, nil) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} + +func TestCandyDropParticipationsAndAirdropsSymmetric(t *testing.T) { + // Both participation and airdrop records share the same shape of optional filters. + candy := findLaunchChild("candy-drop") + require.NotNil(t, candy) + + for _, name := range []string{"participations", "airdrops"} { + sub := findSubcommand(candy, name) + require.NotNil(t, sub, "candy-drop %s missing", name) + for _, flagName := range []string{"currency", "start-time", "end-time", "page", "limit"} { + f := sub.Flag(flagName) + require.NotNil(t, f, "%s should expose --%s", name, flagName) + assert.Empty(t, f.Annotations[cobraBashCompOneRequiredFlag], + "%s --%s must be optional", name, flagName) + } + } +} diff --git a/cmd/cex/launch/hodler_airdrop.go b/cmd/cex/launch/hodler_airdrop.go new file mode 100644 index 0000000..a7dd9d4 --- /dev/null +++ b/cmd/cex/launch/hodler_airdrop.go @@ -0,0 +1,205 @@ +package launch + +import ( + "encoding/json" + + "github.com/antihax/optional" + "github.com/spf13/cobra" + + "github.com/gate/gate-cli/internal/client" + "github.com/gate/gate-cli/internal/cmdutil" + gateapi "github.com/gate/gateapi-go/v7" +) + +// registerHodlerAirdropCommands attaches the Hodler Airdrop V4 sub-tree under +// `cex launch hodler ...`. These mirror the CandyDrop V4 subtree and close the +// remaining LaunchAPI gap surfaced when cross-referencing gateapi-go v7.2.71 +// against the CLI. +func registerHodlerAirdropCommands(parent *cobra.Command) { + hodlerCmd := &cobra.Command{ + Use: "hodler", + Short: "HODLer Airdrop V4 commands", + } + + projectsCmd := &cobra.Command{ + Use: "projects", + Short: "List HODLer Airdrop activities (public; logged-in users get extra participation info)", + RunE: runHodlerProjects, + } + projectsCmd.Flags().String("status", "", "Filter by activity status") + projectsCmd.Flags().String("keyword", "", "Filter by currency/project name (fuzzy)") + projectsCmd.Flags().Int32("join", 0, "Filter by participation status") + projectsCmd.Flags().Int32("page", 0, "Page number") + projectsCmd.Flags().Int32("size", 0, "Page size") + + orderCmd := &cobra.Command{ + Use: "order", + Short: "Participate in a HODLer Airdrop activity (auth required)", + RunE: runHodlerOrder, + } + orderCmd.Flags().Int32("hodler-id", 0, "Activity ID (required)") + orderCmd.MarkFlagRequired("hodler-id") + + orderRecordsCmd := &cobra.Command{ + Use: "order-records", + Short: "Query user's HODLer Airdrop participation records (auth required)", + RunE: runHodlerOrderRecords, + } + orderRecordsCmd.Flags().String("keyword", "", "Filter by currency/project name") + orderRecordsCmd.Flags().Int32("start-timest", 0, "Start timestamp (seconds)") + orderRecordsCmd.Flags().Int32("end-timest", 0, "End timestamp (seconds)") + orderRecordsCmd.Flags().Int32("page", 0, "Page number") + orderRecordsCmd.Flags().Int32("size", 0, "Page size") + + airdropRecordsCmd := &cobra.Command{ + Use: "airdrop-records", + Short: "Query HODLer Airdrop distribution records received by the user (auth required)", + RunE: runHodlerAirdropRecords, + } + airdropRecordsCmd.Flags().String("keyword", "", "Filter by currency/project name") + airdropRecordsCmd.Flags().Int32("start-timest", 0, "Start timestamp (seconds)") + airdropRecordsCmd.Flags().Int32("end-timest", 0, "End timestamp (seconds)") + airdropRecordsCmd.Flags().Int32("page", 0, "Page number") + airdropRecordsCmd.Flags().Int32("size", 0, "Page size") + + hodlerCmd.AddCommand(projectsCmd, orderCmd, orderRecordsCmd, airdropRecordsCmd) + parent.AddCommand(hodlerCmd) +} + +func runHodlerProjects(cmd *cobra.Command, args []string) error { + status, _ := cmd.Flags().GetString("status") + keyword, _ := cmd.Flags().GetString("keyword") + join, _ := cmd.Flags().GetInt32("join") + page, _ := cmd.Flags().GetInt32("page") + size, _ := cmd.Flags().GetInt32("size") + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + + opts := &gateapi.GetHodlerAirdropProjectListOpts{} + if status != "" { + opts.Status = optional.NewString(status) + } + if keyword != "" { + opts.Keyword = optional.NewString(keyword) + } + if join != 0 { + opts.Join = optional.NewInt32(join) + } + if page != 0 { + opts.Page = optional.NewInt32(page) + } + if size != 0 { + opts.Size = optional.NewInt32(size) + } + + result, httpResp, err := c.LaunchAPI.GetHodlerAirdropProjectList(c.Context(), opts) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "GET", "/api/v4/hodler_airdrop/v4/projects", "")) + return nil + } + return p.Print(result) +} + +func runHodlerOrder(cmd *cobra.Command, args []string) error { + hodlerID, _ := cmd.Flags().GetInt32("hodler-id") + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + if err := c.RequireAuth(); err != nil { + return err + } + + req := gateapi.HodlerAirdropV4OrderRequest{HodlerId: hodlerID} + body, _ := json.Marshal(req) + result, httpResp, err := c.LaunchAPI.HodlerAirdropOrder(c.Context(), req) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "POST", "/api/v4/hodler_airdrop/v4/order", string(body))) + return nil + } + return p.Print(result) +} + +func runHodlerOrderRecords(cmd *cobra.Command, args []string) error { + keyword, _ := cmd.Flags().GetString("keyword") + startTimest, _ := cmd.Flags().GetInt32("start-timest") + endTimest, _ := cmd.Flags().GetInt32("end-timest") + page, _ := cmd.Flags().GetInt32("page") + size, _ := cmd.Flags().GetInt32("size") + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + if err := c.RequireAuth(); err != nil { + return err + } + + opts := &gateapi.GetHodlerAirdropUserOrderRecordsOpts{} + if keyword != "" { + opts.Keyword = optional.NewString(keyword) + } + if startTimest != 0 { + opts.StartTimest = optional.NewInt32(startTimest) + } + if endTimest != 0 { + opts.EndTimest = optional.NewInt32(endTimest) + } + if page != 0 { + opts.Page = optional.NewInt32(page) + } + if size != 0 { + opts.Size = optional.NewInt32(size) + } + + result, httpResp, err := c.LaunchAPI.GetHodlerAirdropUserOrderRecords(c.Context(), opts) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "GET", "/api/v4/hodler_airdrop/v4/user/order_records", "")) + return nil + } + return p.Print(result) +} + +func runHodlerAirdropRecords(cmd *cobra.Command, args []string) error { + keyword, _ := cmd.Flags().GetString("keyword") + startTimest, _ := cmd.Flags().GetInt32("start-timest") + endTimest, _ := cmd.Flags().GetInt32("end-timest") + page, _ := cmd.Flags().GetInt32("page") + size, _ := cmd.Flags().GetInt32("size") + p := cmdutil.GetPrinter(cmd) + c, err := cmdutil.GetClient(cmd) + if err != nil { + return err + } + if err := c.RequireAuth(); err != nil { + return err + } + + opts := &gateapi.GetHodlerAirdropUserAirdropRecordsOpts{} + if keyword != "" { + opts.Keyword = optional.NewString(keyword) + } + if startTimest != 0 { + opts.StartTimest = optional.NewInt32(startTimest) + } + if endTimest != 0 { + opts.EndTimest = optional.NewInt32(endTimest) + } + if page != 0 { + opts.Page = optional.NewInt32(page) + } + if size != 0 { + opts.Size = optional.NewInt32(size) + } + + result, httpResp, err := c.LaunchAPI.GetHodlerAirdropUserAirdropRecords(c.Context(), opts) + if err != nil { + p.PrintError(client.ParseGateError(err, httpResp, "GET", "/api/v4/hodler_airdrop/v4/user/airdrop_records", "")) + return nil + } + return p.Print(result) +} diff --git a/cmd/cex/launch/hodler_airdrop_test.go b/cmd/cex/launch/hodler_airdrop_test.go new file mode 100644 index 0000000..20c4290 --- /dev/null +++ b/cmd/cex/launch/hodler_airdrop_test.go @@ -0,0 +1,156 @@ +package launch + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- Public (no-auth) RunE coverage via httptest --- + +// runHodlerProjects is public (no auth required); cover both "all-flags-set" +// and "all-flags-zero" branch matrices of the opts builder. +func TestRunHodlerProjects_MockServer_AllFlags(t *testing.T) { + mockGateServer(t, `[]`) + silenceStdout(t) + root := newTestRoot(t) + cmd := &cobra.Command{Use: "projects"} + cmd.Flags().String("status", "active", "") + cmd.Flags().String("keyword", "BTC", "") + cmd.Flags().Int32("join", 1, "") + cmd.Flags().Int32("page", 1, "") + cmd.Flags().Int32("size", 10, "") + root.AddCommand(cmd) + + err := runHodlerProjects(cmd, nil) + assert.NoError(t, err) +} + +func TestRunHodlerProjects_MockServer_NoFlags(t *testing.T) { + mockGateServer(t, `[]`) + silenceStdout(t) + root := newTestRoot(t) + cmd := &cobra.Command{Use: "projects"} + cmd.Flags().String("status", "", "") + cmd.Flags().String("keyword", "", "") + cmd.Flags().Int32("join", 0, "") + cmd.Flags().Int32("page", 0, "") + cmd.Flags().Int32("size", 0, "") + root.AddCommand(cmd) + + err := runHodlerProjects(cmd, nil) + assert.NoError(t, err) +} + +// --- RunE error-path coverage (auth-required subset) --- + +func TestRunHodlerOrder_RequiresAuth(t *testing.T) { + root := newTestRoot(t) + cmd := &cobra.Command{Use: "order"} + cmd.Flags().Int32("hodler-id", 42, "") + root.AddCommand(cmd) + + err := runHodlerOrder(cmd, nil) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} + +func TestRunHodlerOrderRecords_RequiresAuth(t *testing.T) { + root := newTestRoot(t) + cmd := &cobra.Command{Use: "order-records"} + cmd.Flags().String("keyword", "", "") + cmd.Flags().Int32("start-timest", 0, "") + cmd.Flags().Int32("end-timest", 0, "") + cmd.Flags().Int32("page", 0, "") + cmd.Flags().Int32("size", 0, "") + root.AddCommand(cmd) + + err := runHodlerOrderRecords(cmd, nil) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} + +func TestRunHodlerAirdropRecords_RequiresAuth(t *testing.T) { + root := newTestRoot(t) + cmd := &cobra.Command{Use: "airdrop-records"} + cmd.Flags().String("keyword", "", "") + cmd.Flags().Int32("start-timest", 0, "") + cmd.Flags().Int32("end-timest", 0, "") + cmd.Flags().Int32("page", 0, "") + cmd.Flags().Int32("size", 0, "") + root.AddCommand(cmd) + + err := runHodlerAirdropRecords(cmd, nil) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} + +func TestHodlerSubtreeRegistered(t *testing.T) { + hodler := findLaunchChild("hodler") + require.NotNil(t, hodler, "launch.Cmd should expose hodler subtree") + + want := map[string]bool{ + "projects": false, + "order": false, + "order-records": false, + "airdrop-records": false, + } + for _, sub := range hodler.Commands() { + if _, ok := want[sub.Name()]; ok { + want[sub.Name()] = true + } + } + for name, found := range want { + assert.True(t, found, "hodler should expose %q", name) + } +} + +func TestHodlerOrderRequiresHodlerID(t *testing.T) { + hodler := findLaunchChild("hodler") + require.NotNil(t, hodler) + + order := findSubcommand(hodler, "order") + require.NotNil(t, order) + + hf := order.Flag("hodler-id") + require.NotNil(t, hf, "hodler order should have --hodler-id flag") + assert.Equal(t, "int32", hf.Value.Type(), "--hodler-id should be int32") + assert.NotEmpty(t, hf.Annotations[cobraBashCompOneRequiredFlag], + "--hodler-id should be required") +} + +func TestHodlerProjectsOptionalFlags(t *testing.T) { + hodler := findLaunchChild("hodler") + require.NotNil(t, hodler) + + projects := findSubcommand(hodler, "projects") + require.NotNil(t, projects) + + for _, name := range []string{"status", "keyword", "join", "page", "size"} { + f := projects.Flag(name) + require.NotNil(t, f, "hodler projects should expose --%s", name) + assert.Empty(t, f.Annotations[cobraBashCompOneRequiredFlag], + "--%s must be optional", name) + } +} + +func TestHodlerRecordsSymmetric(t *testing.T) { + hodler := findLaunchChild("hodler") + require.NotNil(t, hodler) + + // order-records and airdrop-records share identical pagination/filtering flags + // (per SDK Opts: Keyword, StartTimest, EndTimest, Page, Size). + for _, name := range []string{"order-records", "airdrop-records"} { + sub := findSubcommand(hodler, name) + require.NotNil(t, sub, "hodler %s missing", name) + for _, flagName := range []string{"keyword", "start-timest", "end-timest", "page", "size"} { + f := sub.Flag(flagName) + require.NotNil(t, f, "%s should expose --%s", name, flagName) + assert.Empty(t, f.Annotations[cobraBashCompOneRequiredFlag], + "%s --%s must be optional", name, flagName) + } + } +} diff --git a/cmd/cex/launch/launch.go b/cmd/cex/launch/launch.go index d4a98a3..2341f15 100644 --- a/cmd/cex/launch/launch.go +++ b/cmd/cex/launch/launch.go @@ -72,6 +72,8 @@ func init() { rewardRecordsCmd.Flags().String("coin", "", "Reward currency") Cmd.AddCommand(projectsCmd, pledgeCmd, redeemCmd, pledgeRecordsCmd, rewardRecordsCmd) + registerCandyDropCommands(Cmd) + registerHodlerAirdropCommands(Cmd) } func runProjects(cmd *cobra.Command, args []string) error { diff --git a/cmd/cex/launch/launch_test.go b/cmd/cex/launch/launch_test.go index 4dadbf4..790c8c0 100644 --- a/cmd/cex/launch/launch_test.go +++ b/cmd/cex/launch/launch_test.go @@ -13,7 +13,11 @@ func TestLaunchCommandStructure(t *testing.T) { subCmds[c.Name()] = true } - expected := []string{"projects", "pledge", "redeem", "pledge-records", "reward-records"} + expected := []string{ + "projects", "pledge", "redeem", "pledge-records", "reward-records", + // Added alongside SDK v7.2.71 sync: + "candy-drop", "hodler", + } for _, name := range expected { assert.True(t, subCmds[name], "missing subcommand: %s", name) } diff --git a/cmd/cex/p2p/ads.go b/cmd/cex/p2p/ads.go index 248e439..65759a8 100644 --- a/cmd/cex/p2p/ads.go +++ b/cmd/cex/p2p/ads.go @@ -46,11 +46,10 @@ func init() { Short: "Update ad status", RunE: runAdsUpdateStatus, } - updateStatusCmd.Flags().Int32("adv-no", 0, "Ad number (required)") + updateStatusCmd.Flags().Int32("adv-no", 0, "Advertisement ID (required)") updateStatusCmd.MarkFlagRequired("adv-no") - updateStatusCmd.Flags().Int32("adv-status", 0, "Status: 1=Active, 3=Inactive, 4=Closed (required)") + updateStatusCmd.Flags().Int32("adv-status", 0, "Ad status: 1=listed, 3=delisted, 4=closed (required)") updateStatusCmd.MarkFlagRequired("adv-status") - updateStatusCmd.Flags().String("trade-type", "", "Trade type (optional)") adsCmd.AddCommand(listCmd, myListCmd, detailCmd, updateStatusCmd) Cmd.AddCommand(adsCmd) @@ -136,7 +135,6 @@ func runAdsDetail(cmd *cobra.Command, args []string) error { func runAdsUpdateStatus(cmd *cobra.Command, args []string) error { advNo, _ := cmd.Flags().GetInt32("adv-no") advStatus, _ := cmd.Flags().GetInt32("adv-status") - tradeType, _ := cmd.Flags().GetString("trade-type") p := cmdutil.GetPrinter(cmd) c, err := cmdutil.GetClient(cmd) if err != nil { @@ -151,14 +149,7 @@ func runAdsUpdateStatus(cmd *cobra.Command, args []string) error { AdvStatus: advStatus, } - var opts *gateapi.P2pMerchantBooksAdsUpdateStatusOpts - if tradeType != "" { - opts = &gateapi.P2pMerchantBooksAdsUpdateStatusOpts{ - TradeType: optional.NewString(tradeType), - } - } - - result, httpResp, err := c.P2pAPI.P2pMerchantBooksAdsUpdateStatus(c.Context(), body, opts) + result, httpResp, err := c.P2pAPI.P2pMerchantBooksAdsUpdateStatus(c.Context(), body) if err != nil { p.PrintError(client.ParseGateError(err, httpResp, "POST", "/api/v4/p2p/merchant/books/ads_update_status", "")) return nil diff --git a/cmd/cex/p2p/chat.go b/cmd/cex/p2p/chat.go index 3b22a74..6a5dfd2 100644 --- a/cmd/cex/p2p/chat.go +++ b/cmd/cex/p2p/chat.go @@ -20,12 +20,18 @@ func init() { listCmd := &cobra.Command{ Use: "list", Short: "List chat messages for an order", - RunE: runChatList, + Long: `List chat messages for an order. + +v7.2.78 contract: --txid is required by the CLI but the value 0 is treated by +the server as 'omit' — pass --txid 0 to fetch the latest order with chat for +the current user. Pass --lastreceived / --firstreceived to paginate forward +or backward from a known timestamp.`, + RunE: runChatList, } - listCmd.Flags().Int32("txid", 0, "Order ID (required)") + listCmd.Flags().Int32("txid", 0, "Order ID (required; pass 0 to return the latest order with chat)") listCmd.MarkFlagRequired("txid") - listCmd.Flags().Int32("lastreceived", 0, "Pagination timestamp (forward)") - listCmd.Flags().Int32("firstreceived", 0, "Pagination timestamp (backward)") + listCmd.Flags().Int32("lastreceived", 0, "Timestamp of last received message; backward incremental fetch") + listCmd.Flags().Int32("firstreceived", 0, "Timestamp of first received message; forward paging") sendCmd := &cobra.Command{ Use: "send", diff --git a/cmd/cex/p2p/sdk_v7_2_78_compat_test.go b/cmd/cex/p2p/sdk_v7_2_78_compat_test.go new file mode 100644 index 0000000..dd0fe5b --- /dev/null +++ b/cmd/cex/p2p/sdk_v7_2_78_compat_test.go @@ -0,0 +1,685 @@ +package p2p + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + gateapi "github.com/gate/gateapi-go/v7" +) + +// Tests in this file lock in the gateapi-go SDK upgrade from v7.2.71 to +// v7.2.78. They cover three layers: +// 1. Cobra command/flag wiring updated to match the new SDK surface. +// 2. SDK model JSON tags so the wire-level rename TradeId → Txid is +// observable, not just a Go field rename. +// 3. RunE behavior end-to-end against a mock Gate server, exercising the +// new return types and removed opts arguments. + +const cobraRequiredFlagAnnotation = "cobra_annotation_bash_completion_one_required_flag" + +func newP2pTestRoot(t *testing.T) *cobra.Command { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("GATE_API_KEY", "") + t.Setenv("GATE_API_SECRET", "") + root := &cobra.Command{Use: "gate-cli"} + root.PersistentFlags().String("format", "json", "") + root.PersistentFlags().String("profile", "default", "") + root.PersistentFlags().Bool("debug", false, "") + root.PersistentFlags().Bool("verbose", false, "") + root.PersistentFlags().String("api-key", "", "") + root.PersistentFlags().String("api-secret", "", "") + return root +} + +func authedP2pTestRoot(t *testing.T) *cobra.Command { + root := newP2pTestRoot(t) + t.Setenv("GATE_API_KEY", "fake-key") + t.Setenv("GATE_API_SECRET", "fake-secret") + return root +} + +func mockP2pGateServer(t *testing.T, body string) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + t.Setenv("GATE_BASE_URL", srv.URL) +} + +func silenceP2pStdout(t *testing.T) { + t.Helper() + oldOut := os.Stdout + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + require.NoError(t, err) + os.Stdout = devNull + t.Cleanup(func() { + os.Stdout = oldOut + _ = devNull.Close() + }) +} + +func findP2pSub(parent *cobra.Command, name string) *cobra.Command { + for _, c := range parent.Commands() { + if c.Name() == name { + return c + } + } + return nil +} + +// --------------------------------------------------------------------------- +// Layer 1 — Command / flag wiring after SDK v7.2.78 upgrade. +// --------------------------------------------------------------------------- + +// v7.2.78 dropped the optional TradeType param from +// P2pMerchantBooksAdsUpdateStatus, so the CLI must no longer expose it. +// Regression guard against accidentally re-introducing the flag. +func TestAdsUpdateStatus_NoTradeTypeFlag_AfterV7_2_78(t *testing.T) { + updateStatus := findP2pSub(adsCmd, "update-status") + require.NotNil(t, updateStatus, "ads update-status command must be registered") + + assert.Nil(t, updateStatus.Flag("trade-type"), + "--trade-type was removed in v7.2.78 (P2pMerchantBooksAdsUpdateStatusOpts deleted) and must not reappear") +} + +func TestAdsUpdateStatus_RequiredFlags(t *testing.T) { + updateStatus := findP2pSub(adsCmd, "update-status") + require.NotNil(t, updateStatus) + + for _, name := range []string{"adv-no", "adv-status"} { + f := updateStatus.Flag(name) + require.NotNil(t, f, "update-status must expose --%s", name) + assert.NotEmpty(t, f.Annotations[cobraRequiredFlagAnnotation], + "update-status --%s must be required", name) + } +} + +func TestTransactionConfirmPayment_RequiredFlags(t *testing.T) { + confirm := findP2pSub(transactionCmd, "confirm-payment") + require.NotNil(t, confirm, "confirm-payment must be registered") + + for _, name := range []string{"trade-id", "payment-method"} { + f := confirm.Flag(name) + require.NotNil(t, f, "confirm-payment must expose --%s", name) + assert.NotEmpty(t, f.Annotations[cobraRequiredFlagAnnotation], + "confirm-payment --%s must be required", name) + } +} + +func TestTransactionConfirmReceipt_RequiredFlags(t *testing.T) { + confirm := findP2pSub(transactionCmd, "confirm-receipt") + require.NotNil(t, confirm, "confirm-receipt must be registered") + + f := confirm.Flag("trade-id") + require.NotNil(t, f) + assert.NotEmpty(t, f.Annotations[cobraRequiredFlagAnnotation], + "confirm-receipt --trade-id must be required") +} + +func TestTransactionCancel_FlagShape(t *testing.T) { + cancel := findP2pSub(transactionCmd, "cancel") + require.NotNil(t, cancel) + + tradeID := cancel.Flag("trade-id") + require.NotNil(t, tradeID) + assert.NotEmpty(t, tradeID.Annotations[cobraRequiredFlagAnnotation], + "cancel --trade-id must be required") + + for _, name := range []string{"reason-id", "reason-memo"} { + f := cancel.Flag(name) + require.NotNil(t, f, "cancel must expose --%s", name) + assert.Empty(t, f.Annotations[cobraRequiredFlagAnnotation], + "cancel --%s must remain optional", name) + } +} + +// --------------------------------------------------------------------------- +// Layer 2 — SDK model JSON tags. The on-the-wire rename TradeId → Txid is the +// breaking change of this upgrade; freeze it via JSON serialization so future +// regenerations cannot silently undo it. +// --------------------------------------------------------------------------- + +func TestConfirmPayment_TxidJSONTag(t *testing.T) { + body := gateapi.ConfirmPayment{ + Txid: "12345", + PaymentMethod: "bank", + } + raw, err := json.Marshal(body) + require.NoError(t, err) + + s := string(raw) + assert.Contains(t, s, `"txid":"12345"`, + "ConfirmPayment must serialize order id under wire field `txid` (v7.2.78)") + assert.NotContains(t, s, `"trade_id"`, + "v7.2.78 renamed trade_id → txid; the legacy tag must be gone") + assert.Contains(t, s, `"payment_method":"bank"`) +} + +func TestConfirmPayment_PaymentMethodOmitemptyOnEmpty(t *testing.T) { + // v7.2.78 made payment_method optional (omitempty); empty value should + // be elided so the server applies its default behavior. + raw, err := json.Marshal(gateapi.ConfirmPayment{Txid: "12345"}) + require.NoError(t, err) + assert.NotContains(t, string(raw), `"payment_method"`, + "empty payment_method should be omitted under v7.2.78 omitempty tag") +} + +func TestConfirmReceipt_TxidJSONTag(t *testing.T) { + raw, err := json.Marshal(gateapi.ConfirmReceipt{Txid: "67890"}) + require.NoError(t, err) + + s := string(raw) + assert.Equal(t, `{"txid":"67890"}`, s, + "ConfirmReceipt only carries txid under v7.2.78") + assert.NotContains(t, s, `"trade_id"`) +} + +func TestCancelOrder_TxidJSONTag(t *testing.T) { + body := gateapi.CancelOrder{ + Txid: "99999", + ReasonId: "9", + ReasonMemo: "buyer no longer wants", + } + raw, err := json.Marshal(body) + require.NoError(t, err) + + s := string(raw) + assert.Contains(t, s, `"txid":"99999"`, + "CancelOrder must serialize the order id as `txid` (v7.2.78)") + assert.NotContains(t, s, `"trade_id"`) + assert.Contains(t, s, `"reason_id":"9"`) + assert.Contains(t, s, `"reason_memo":"buyer no longer wants"`) +} + +func TestCancelOrder_ReasonFieldsOmitempty(t *testing.T) { + raw, err := json.Marshal(gateapi.CancelOrder{Txid: "1"}) + require.NoError(t, err) + s := string(raw) + assert.NotContains(t, s, `"reason_id"`, + "reason_id is optional; empty value must be omitted") + assert.NotContains(t, s, `"reason_memo"`) +} + +// --------------------------------------------------------------------------- +// Layer 3 — RunE end-to-end against a mock Gate server. +// Exercises the full handler including the SDK call, locking in: +// - the new Txid wire field (handlers must not regress on the rename) +// - the new return type for P2pMerchantBooksPlaceBizPushOrder +// - the removed opts argument for P2pMerchantBooksAdsUpdateStatus +// --------------------------------------------------------------------------- + +func TestRunConfirmPayment_Succeeds_AgainstMockServer(t *testing.T) { + // Capture the request body to verify the wire payload uses `txid`. + var captured string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + buf := make([]byte, r.ContentLength) + _, _ = r.Body.Read(buf) + captured = string(buf) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"code":0,"message":"ok"}`)) + })) + t.Cleanup(srv.Close) + t.Setenv("GATE_BASE_URL", srv.URL) + + silenceP2pStdout(t) + root := authedP2pTestRoot(t) + cmd := &cobra.Command{Use: "confirm-payment"} + cmd.Flags().String("trade-id", "55555", "") + cmd.Flags().String("payment-method", "bank", "") + root.AddCommand(cmd) + + err := runConfirmPayment(cmd, nil) + require.NoError(t, err, "runConfirmPayment must succeed against a mock 200 response") + + // Wire-level proof that the v7.2.78 field rename reaches the server. + assert.Contains(t, captured, `"txid":"55555"`, + "runConfirmPayment must POST txid (not trade_id) under v7.2.78") + assert.NotContains(t, captured, `"trade_id"`) + assert.Contains(t, captured, `"payment_method":"bank"`) +} + +func TestRunConfirmReceipt_Succeeds_AgainstMockServer(t *testing.T) { + var captured string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + buf := make([]byte, r.ContentLength) + _, _ = r.Body.Read(buf) + captured = string(buf) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"code":0,"message":"ok"}`)) + })) + t.Cleanup(srv.Close) + t.Setenv("GATE_BASE_URL", srv.URL) + + silenceP2pStdout(t) + root := authedP2pTestRoot(t) + cmd := &cobra.Command{Use: "confirm-receipt"} + cmd.Flags().String("trade-id", "77777", "") + root.AddCommand(cmd) + + err := runConfirmReceipt(cmd, nil) + require.NoError(t, err) + + assert.Contains(t, captured, `"txid":"77777"`) + assert.NotContains(t, captured, `"trade_id"`) +} + +func TestRunTransactionCancel_Succeeds_AgainstMockServer(t *testing.T) { + var captured string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + buf := make([]byte, r.ContentLength) + _, _ = r.Body.Read(buf) + captured = string(buf) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"code":0,"message":"ok"}`)) + })) + t.Cleanup(srv.Close) + t.Setenv("GATE_BASE_URL", srv.URL) + + silenceP2pStdout(t) + root := authedP2pTestRoot(t) + cmd := &cobra.Command{Use: "cancel"} + cmd.Flags().String("trade-id", "11111", "") + cmd.Flags().String("reason-id", "9", "") + cmd.Flags().String("reason-memo", "test reason", "") + root.AddCommand(cmd) + + err := runTransactionCancel(cmd, nil) + require.NoError(t, err) + + assert.Contains(t, captured, `"txid":"11111"`) + assert.NotContains(t, captured, `"trade_id"`) + assert.Contains(t, captured, `"reason_id":"9"`) + assert.Contains(t, captured, `"reason_memo":"test reason"`) +} + +// runAdsUpdateStatus dropped the third opts argument in v7.2.78. Locking the +// happy path proves the handler compiles against the new two-arg signature +// and exercises the body it sends. +func TestRunAdsUpdateStatus_Succeeds_AgainstMockServer(t *testing.T) { + var capturedQuery, capturedBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedQuery = r.URL.RawQuery + buf := make([]byte, r.ContentLength) + _, _ = r.Body.Read(buf) + capturedBody = string(buf) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"code":0,"message":"ok","data":{"status":1}}`)) + })) + t.Cleanup(srv.Close) + t.Setenv("GATE_BASE_URL", srv.URL) + + silenceP2pStdout(t) + root := authedP2pTestRoot(t) + cmd := &cobra.Command{Use: "update-status"} + cmd.Flags().Int32("adv-no", 12345, "") + cmd.Flags().Int32("adv-status", 1, "") + root.AddCommand(cmd) + + err := runAdsUpdateStatus(cmd, nil) + require.NoError(t, err) + + // v7.2.78 removed the trade_type query param; verify it never gets sent. + assert.NotContains(t, capturedQuery, "trade_type", + "trade_type query param was removed in v7.2.78 and must not appear") + assert.Contains(t, capturedBody, `"adv_no":12345`) + assert.Contains(t, capturedBody, `"adv_status":1`) +} + +// runPushOrder's underlying SDK method changed return type from +// map[string]interface{} to P2pMerchantBooksPlaceBizPushOrderResponse in +// v7.2.78. A successful round-trip proves the new struct type unmarshals. +func TestRunPushOrder_Succeeds_AgainstMockServer(t *testing.T) { + mockP2pGateServer(t, `{"code":0,"message":"ok","timestamp":1700000000.5,"data":{}}`) + silenceP2pStdout(t) + root := authedP2pTestRoot(t) + cmd := &cobra.Command{Use: "push-order"} + cmd.Flags().String("json", `{"currencyType":"USDT","exchangeType":"CNY","type":"0","unitPrice":"7.0","number":"100","payType":"bank","minAmount":"100","maxAmount":"10000"}`, "") + root.AddCommand(cmd) + + err := runPushOrder(cmd, nil) + assert.NoError(t, err, + "push-order must accept the new P2pMerchantBooksPlaceBizPushOrderResponse return type") +} + +// --------------------------------------------------------------------------- +// Layer 4 — Auth gate. The RequireAuth wall must still trigger when no +// credentials are configured. Guards against accidental auth removal during +// future refactors of these handlers. +// --------------------------------------------------------------------------- + +func TestRunConfirmPayment_RequiresAuth(t *testing.T) { + root := newP2pTestRoot(t) + cmd := &cobra.Command{Use: "confirm-payment"} + cmd.Flags().String("trade-id", "1", "") + cmd.Flags().String("payment-method", "bank", "") + root.AddCommand(cmd) + + err := runConfirmPayment(cmd, nil) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} + +func TestRunConfirmReceipt_RequiresAuth(t *testing.T) { + root := newP2pTestRoot(t) + cmd := &cobra.Command{Use: "confirm-receipt"} + cmd.Flags().String("trade-id", "1", "") + root.AddCommand(cmd) + + err := runConfirmReceipt(cmd, nil) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} + +func TestRunTransactionCancel_RequiresAuth(t *testing.T) { + root := newP2pTestRoot(t) + cmd := &cobra.Command{Use: "cancel"} + cmd.Flags().String("trade-id", "1", "") + cmd.Flags().String("reason-id", "", "") + cmd.Flags().String("reason-memo", "", "") + root.AddCommand(cmd) + + err := runTransactionCancel(cmd, nil) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} + +func TestRunAdsUpdateStatus_RequiresAuth(t *testing.T) { + root := newP2pTestRoot(t) + cmd := &cobra.Command{Use: "update-status"} + cmd.Flags().Int32("adv-no", 1, "") + cmd.Flags().Int32("adv-status", 1, "") + root.AddCommand(cmd) + + err := runAdsUpdateStatus(cmd, nil) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} + +func TestRunPushOrder_RequiresAuth(t *testing.T) { + root := newP2pTestRoot(t) + cmd := &cobra.Command{Use: "push-order"} + cmd.Flags().String("json", `{}`, "") + root.AddCommand(cmd) + + err := runPushOrder(cmd, nil) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} + +// --------------------------------------------------------------------------- +// Layer 5 — Adjacent v7.2.78 model surface used by --json input. Lock the +// wire-shape changes so they cannot regress without test failure. +// --------------------------------------------------------------------------- + +// PlaceBizPushOrder dropped HidePayment and added TeamPaymentUid in v7.2.78. +func TestPlaceBizPushOrder_FieldShape(t *testing.T) { + body := gateapi.PlaceBizPushOrder{ + CurrencyType: "USDT", + ExchangeType: "CNY", + Type: "0", + UnitPrice: "7.0", + Number: "100", + PayType: "bank", + MinAmount: "100", + MaxAmount: "10000", + TeamPaymentUid: "team-uid-001", + } + raw, err := json.Marshal(body) + require.NoError(t, err) + s := string(raw) + + assert.Contains(t, s, `"team_payment_uid":"team-uid-001"`, + "v7.2.78 added team_payment_uid") + assert.NotContains(t, s, `"hide_payment"`, + "v7.2.78 removed HidePayment from PlaceBizPushOrder") +} + +// P2pTransactionActionResponse changed Timestamp from int32 → float32 in +// v7.2.78 and added Method/Data/Version. Verify the new fields parse so +// runConfirm* output is correctly decoded. +// +// The timestamp value below is deliberately small: float32 has only 23 mantissa +// bits, so 17-billion-class timestamps lose seconds-level precision and +// fractional comparison becomes flaky. The point of the test is the type and +// the new field set, not real-world clock values. +func TestP2pTransactionActionResponse_NewShape(t *testing.T) { + payload := `{"timestamp":12345.5,"method":"confirm","code":0,"message":"ok","data":{},"version":"v4"}` + var resp gateapi.P2pTransactionActionResponse + require.NoError(t, json.Unmarshal([]byte(payload), &resp)) + + assert.InDelta(t, 12345.5, resp.Timestamp, 0.001, + "Timestamp is now float32 in v7.2.78") + assert.Equal(t, "confirm", resp.Method) + assert.Equal(t, int32(0), resp.Code) + assert.Equal(t, "ok", resp.Message) + assert.Equal(t, "v4", resp.Version) +} + +// --------------------------------------------------------------------------- +// Layer 6 — v7.2.78 silent-behavior changes that gate-cli does not modify +// directly but inherits via the SDK upgrade. +// +// (b) GetChatsListRequest.Txid: int32 with omitempty. +// v7.2.71 always serialized "txid":0; v7.2.78 omits it entirely. Per the +// server contract, an omitted/zero txid means "return the latest order +// with chat for the user". Locking this here protects users whose +// scripts pass --txid 0 from a silent server-side semantics shift. +// +// (d) PlaceBizPushOrder.HidePayment was deleted in v7.2.78. +// User --json blobs that still include "hide_payment" must be silently +// dropped (Go's encoding/json ignores unknown fields by default), and +// must NOT reach the server in the outbound POST body. The new +// team_payment_uid field, conversely, must be passed through. +// --------------------------------------------------------------------------- + +// (b1) Wire-shape check at the model layer: zero-valued Txid must not +// appear in the encoded payload under v7.2.78. +func TestGetChatsListRequest_TxidOmitemptyOnZero(t *testing.T) { + body := gateapi.GetChatsListRequest{Txid: 0} + raw, err := json.Marshal(body) + require.NoError(t, err) + + assert.NotContains(t, string(raw), `"txid"`, + "v7.2.78 added omitempty to GetChatsListRequest.Txid; zero value must not be sent so the server can apply its 'latest order' fallback") +} + +// (b2) Non-zero Txid must still be sent, otherwise we just broke a user's +// ability to query a specific order. +func TestGetChatsListRequest_TxidPreservedWhenSet(t *testing.T) { + body := gateapi.GetChatsListRequest{Txid: 42} + raw, err := json.Marshal(body) + require.NoError(t, err) + assert.Contains(t, string(raw), `"txid":42`, + "non-zero txid must still serialize; omitempty only elides zero") +} + +// (b3) Lastreceived/Firstreceived already had omitempty in v7.2.71 — pin +// here so the trio behaves consistently and a future regen does not flip +// one tag without the others. +func TestGetChatsListRequest_PaginationOmitemptyOnZero(t *testing.T) { + raw, err := json.Marshal(gateapi.GetChatsListRequest{Txid: 1}) + require.NoError(t, err) + s := string(raw) + assert.NotContains(t, s, `"lastreceived"`) + assert.NotContains(t, s, `"firstreceived"`) +} + +// (b4) End-to-end: when the CLI is invoked with --txid=0 (the documented +// "give me the latest order" shortcut), the outbound POST body must not +// carry a `txid` key. Captures the actual wire payload. +// +// Note on `MarkFlagRequired`: required only checks whether the flag was set +// during parsing, not whether the value is non-zero, so `--txid 0` is a +// valid, accepted CLI invocation. The fact that v7.2.78 also turns this +// into a meaningful request (rather than a hard-coded txid=0 lookup) is +// what we lock down below. +func TestRunChatList_TxidZero_OmitsField_AgainstMockServer(t *testing.T) { + var captured string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + buf := make([]byte, r.ContentLength) + _, _ = r.Body.Read(buf) + captured = string(buf) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + })) + t.Cleanup(srv.Close) + t.Setenv("GATE_BASE_URL", srv.URL) + + silenceP2pStdout(t) + root := authedP2pTestRoot(t) + cmd := &cobra.Command{Use: "list"} + cmd.Flags().Int32("txid", 0, "") + cmd.Flags().Int32("lastreceived", 0, "") + cmd.Flags().Int32("firstreceived", 0, "") + root.AddCommand(cmd) + + err := runChatList(cmd, nil) + require.NoError(t, err) + + assert.NotContains(t, captured, `"txid"`, + "runChatList with --txid 0 must POST a body without txid (v7.2.78 'latest order' contract)") +} + +// (b5) End-to-end happy path: with a real txid, the field reaches the +// server. Guards against an over-eager omitempty regression on non-zero. +func TestRunChatList_TxidNonZero_IsSent_AgainstMockServer(t *testing.T) { + var captured string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + buf := make([]byte, r.ContentLength) + _, _ = r.Body.Read(buf) + captured = string(buf) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + })) + t.Cleanup(srv.Close) + t.Setenv("GATE_BASE_URL", srv.URL) + + silenceP2pStdout(t) + root := authedP2pTestRoot(t) + cmd := &cobra.Command{Use: "list"} + cmd.Flags().Int32("txid", 9876, "") + cmd.Flags().Int32("lastreceived", 0, "") + cmd.Flags().Int32("firstreceived", 0, "") + root.AddCommand(cmd) + + err := runChatList(cmd, nil) + require.NoError(t, err) + assert.Contains(t, captured, `"txid":9876`) +} + +// (d1) Reverse direction: a legacy --json blob carrying the now-removed +// `hide_payment` key must unmarshal cleanly (Go's encoding/json silently +// drops unknown keys), and the deserialized struct must not contain that +// data — so it cannot leak back onto the wire. +// +// This is the user-script protection: someone may have CI scripts built +// against the v7.2.71 wire shape; we want them to get a graceful no-op on +// hide_payment, not a parse error. +func TestPlaceBizPushOrder_LegacyHidePaymentSilentlyDropped(t *testing.T) { + legacyJSON := `{ + "currencyType": "USDT", + "exchangeType": "CNY", + "type": "0", + "unitPrice": "7.0", + "number": "100", + "payType": "bank", + "minAmount": "100", + "maxAmount": "10000", + "hide_payment": "1" + }` + var body gateapi.PlaceBizPushOrder + require.NoError(t, json.Unmarshal([]byte(legacyJSON), &body), + "legacy blob with hide_payment must still unmarshal — Go drops unknown keys") + + // Round-trip: re-encode and prove hide_payment did not survive the + // trip into the struct (it has no field to bind to). + reencoded, err := json.Marshal(body) + require.NoError(t, err) + assert.NotContains(t, string(reencoded), `"hide_payment"`, + "hide_payment was dropped by the v7.2.78 model and must not reappear after re-encoding") + + // Sanity: the supported fields survived. + assert.Equal(t, "USDT", body.CurrencyType) + assert.Equal(t, "bank", body.PayType) +} + +// (d2) End-to-end: users posting a v7.2.71-style JSON containing +// `hide_payment` get a silent drop, not a hard failure. The request still +// goes out, just without that field. This is the CLI-level user contract. +func TestRunPushOrder_LegacyHidePayment_DroppedOnWire(t *testing.T) { + var captured string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + buf := make([]byte, r.ContentLength) + _, _ = r.Body.Read(buf) + captured = string(buf) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"code":0,"message":"ok","data":{}}`)) + })) + t.Cleanup(srv.Close) + t.Setenv("GATE_BASE_URL", srv.URL) + + silenceP2pStdout(t) + root := authedP2pTestRoot(t) + cmd := &cobra.Command{Use: "push-order"} + cmd.Flags().String("json", `{"currencyType":"USDT","exchangeType":"CNY","type":"0","unitPrice":"7.0","number":"100","payType":"bank","minAmount":"100","maxAmount":"10000","hide_payment":"1"}`, "") + root.AddCommand(cmd) + + err := runPushOrder(cmd, nil) + require.NoError(t, err, + "push-order with a legacy hide_payment field must still succeed (graceful migration for user scripts)") + + assert.NotContains(t, captured, `"hide_payment"`, + "v7.2.78 dropped hide_payment; it must not be relayed to the server even if the user supplies it") + // The other flags must still reach the server — proves the legacy + // field is the only thing that disappears. + assert.Contains(t, captured, `"currencyType":"USDT"`) + assert.Contains(t, captured, `"payType":"bank"`) +} + +// (d3) Forward direction: users passing the v7.2.78-only `team_payment_uid` +// field must see it pass through to the server. Pairs with (d1) to lock +// "drops the deprecated field, forwards the new one" behavior. +func TestRunPushOrder_TeamPaymentUid_ForwardedOnWire(t *testing.T) { + var captured string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + buf := make([]byte, r.ContentLength) + _, _ = r.Body.Read(buf) + captured = string(buf) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"code":0,"message":"ok","data":{}}`)) + })) + t.Cleanup(srv.Close) + t.Setenv("GATE_BASE_URL", srv.URL) + + silenceP2pStdout(t) + root := authedP2pTestRoot(t) + cmd := &cobra.Command{Use: "push-order"} + cmd.Flags().String("json", `{"currencyType":"USDT","exchangeType":"CNY","type":"0","unitPrice":"7.0","number":"100","payType":"bank","minAmount":"100","maxAmount":"10000","team_payment_uid":"team-001"}`, "") + root.AddCommand(cmd) + + err := runPushOrder(cmd, nil) + require.NoError(t, err) + + assert.Contains(t, captured, `"team_payment_uid":"team-001"`, + "v7.2.78 added team_payment_uid; user-supplied value must round-trip into the request") +} diff --git a/cmd/cex/p2p/transaction.go b/cmd/cex/p2p/transaction.go index 0a259b1..ecff30f 100644 --- a/cmd/cex/p2p/transaction.go +++ b/cmd/cex/p2p/transaction.go @@ -170,7 +170,7 @@ func runConfirmPayment(cmd *cobra.Command, args []string) error { } body := gateapi.ConfirmPayment{ - TradeId: tradeID, + Txid: tradeID, PaymentMethod: paymentMethod, } @@ -194,7 +194,7 @@ func runConfirmReceipt(cmd *cobra.Command, args []string) error { } body := gateapi.ConfirmReceipt{ - TradeId: tradeID, + Txid: tradeID, } result, httpResp, err := c.P2pAPI.P2pMerchantTransactionConfirmReceipt(c.Context(), body) @@ -219,7 +219,7 @@ func runTransactionCancel(cmd *cobra.Command, args []string) error { } body := gateapi.CancelOrder{ - TradeId: tradeID, + Txid: tradeID, } if reasonID != "" { body.ReasonId = reasonID diff --git a/cmd/cex/spot/account.go b/cmd/cex/spot/account.go index f8b3e46..28e31c7 100644 --- a/cmd/cex/spot/account.go +++ b/cmd/cex/spot/account.go @@ -222,7 +222,10 @@ func runSpotAccountBook(cmd *cobra.Command, args []string) error { } rows := make([][]string, len(result)) for i, r := range result { - rows[i] = []string{r.Id, fmt.Sprintf("%d", r.Time), r.Currency, r.Change, r.Balance, r.Type} + rows[i] = []string{r.Id, fmt.Sprintf("%d", r.Time), r.Currency, r.Change, r.Balance, r.Type, r.Code} } - return p.Table([]string{"ID", "Time(ms)", "Currency", "Change", "Balance", "Type"}, rows) + // SDK v7.2.78 marks Type as deprecated; Code is the authoritative + // account-change identifier. Keep Type for backward visibility while + // surfacing Code so downstream tooling can migrate. + return p.Table([]string{"ID", "Time(ms)", "Currency", "Change", "Balance", "Type", "Code"}, rows) } diff --git a/cmd/cex/spot/sdk_v7_2_78_compat_test.go b/cmd/cex/spot/sdk_v7_2_78_compat_test.go new file mode 100644 index 0000000..ea15611 --- /dev/null +++ b/cmd/cex/spot/sdk_v7_2_78_compat_test.go @@ -0,0 +1,103 @@ +package spot + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + gateapi "github.com/gate/gateapi-go/v7" +) + +// SDK v7.2.78 marks SpotAccountBook.Type as deprecated and points users to +// the new authoritative `code` field. The CLI now renders both columns; this +// file pins: +// 1. The Code field exists on the SDK model. +// 2. Wire JSON binds `code` to that Go field. +// 3. The `cex spot account book` table includes a Code column. + +func newSpotTestRoot(t *testing.T) *cobra.Command { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("GATE_API_KEY", "fake-key") + t.Setenv("GATE_API_SECRET", "fake-secret") + root := &cobra.Command{Use: "gate-cli"} + root.PersistentFlags().String("format", "text", "") + root.PersistentFlags().String("profile", "default", "") + root.PersistentFlags().Bool("debug", false, "") + root.PersistentFlags().Bool("verbose", false, "") + root.PersistentFlags().String("api-key", "", "") + root.PersistentFlags().String("api-secret", "", "") + return root +} + +// (1) Compile-time + JSON-tag pin: SpotAccountBook must expose a Code +// field bound to the v7.2.78 `code` wire key. This is the field the +// table-rendering path now reads alongside the deprecated Type. +func TestSpotAccountBook_CodeFieldBindsToWireKey(t *testing.T) { + payload := `{"id":"r1","time":1700000000000,"currency":"USDT","change":"1.23","balance":"100","type":"trade","code":"301"}` + var rec gateapi.SpotAccountBook + require.NoError(t, json.Unmarshal([]byte(payload), &rec)) + + assert.Equal(t, "301", rec.Code, + "v7.2.78 added Code as the authoritative account-change identifier; CLI table renders it") + // Type must still bind so the CLI can keep rendering it for backward + // visibility (deprecated, not removed). + assert.Equal(t, "trade", rec.Type) +} + +// (2) End-to-end: invoke runSpotAccountBook against a mock server and +// assert the rendered table includes both the legacy Type column and the +// new Code column. Captures stdout so the assertion is on what the user +// actually sees. +func TestRunSpotAccountBook_TableIncludesCodeColumn_v7_2_78(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`[{"id":"r1","time":1700000000000,"currency":"USDT","change":"-0.01","balance":"99.99","type":"fee","code":"401"}]`)) + })) + t.Cleanup(srv.Close) + t.Setenv("GATE_BASE_URL", srv.URL) + + // Capture stdout. Using io.Copy + a goroutine prevents pipe-buffer + // stalls when the CLI writes more than the default pipe size. + r, w, _ := os.Pipe() + oldOut := os.Stdout + os.Stdout = w + t.Cleanup(func() { os.Stdout = oldOut }) + done := make(chan []byte, 1) + go func() { + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + done <- buf.Bytes() + }() + + root := newSpotTestRoot(t) + cmd := &cobra.Command{Use: "book"} + cmd.Flags().String("currency", "", "") + cmd.Flags().Int64("from", 0, "") + cmd.Flags().Int64("to", 0, "") + cmd.Flags().Int32("limit", 0, "") + root.AddCommand(cmd) + + err := runSpotAccountBook(cmd, nil) + _ = w.Close() + output := string(<-done) + + require.NoError(t, err) + assert.Contains(t, output, "Type", + "deprecated Type column must remain for backward compatibility") + assert.Contains(t, output, "Code", + "v7.2.78 added Code column; CLI must render the new authoritative field") + assert.Contains(t, output, "401", + "the row's Code value must be rendered alongside Type") + assert.Contains(t, output, "fee", + "the row's Type value must still be rendered for backward visibility") +} diff --git a/cmd/cex/wallet/balance.go b/cmd/cex/wallet/balance.go index 5031d19..0407049 100644 --- a/cmd/cex/wallet/balance.go +++ b/cmd/cex/wallet/balance.go @@ -31,6 +31,8 @@ func init() { RunE: runWalletSubBalances, } subCmd.Flags().String("sub-uid", "", "Filter by sub-account user IDs (comma-separated)") + subCmd.Flags().Int32("page", 0, "Page number (default 1)") + subCmd.Flags().Int32("limit", 0, "Page size, max 100 (default 100)") subMarginCmd := &cobra.Command{ Use: "sub-margin", @@ -122,6 +124,8 @@ func runWalletTotalBalance(cmd *cobra.Command, args []string) error { func runWalletSubBalances(cmd *cobra.Command, args []string) error { subUID, _ := cmd.Flags().GetString("sub-uid") + page, _ := cmd.Flags().GetInt32("page") + limit, _ := cmd.Flags().GetInt32("limit") p := cmdutil.GetPrinter(cmd) c, err := cmdutil.GetClient(cmd) if err != nil { @@ -135,6 +139,12 @@ func runWalletSubBalances(cmd *cobra.Command, args []string) error { if subUID != "" { opts.SubUid = optional.NewString(subUID) } + if page != 0 { + opts.Page = optional.NewInt32(page) + } + if limit != 0 { + opts.Limit = optional.NewInt32(limit) + } result, httpResp, err := c.WalletAPI.ListSubAccountBalances(c.Context(), opts) if err != nil { diff --git a/cmd/cex/wallet/balance_test.go b/cmd/cex/wallet/balance_test.go new file mode 100644 index 0000000..6f7a099 --- /dev/null +++ b/cmd/cex/wallet/balance_test.go @@ -0,0 +1,105 @@ +package wallet + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newTestRoot builds a minimal cobra root mirroring cmd/root.go's persistent +// flags so runXxx handlers can invoke cmdutil.GetClient/GetPrinter. +func newTestRoot(t *testing.T) *cobra.Command { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("GATE_API_KEY", "") + t.Setenv("GATE_API_SECRET", "") + root := &cobra.Command{Use: "gate-cli"} + root.PersistentFlags().String("format", "json", "") + root.PersistentFlags().String("profile", "default", "") + root.PersistentFlags().Bool("debug", false, "") + root.PersistentFlags().Bool("verbose", false, "") + root.PersistentFlags().String("api-key", "", "") + root.PersistentFlags().String("api-secret", "", "") + return root +} + +const cobraBashCompOneRequiredFlag = "cobra_annotation_bash_completion_one_required_flag" + +// findBalanceSub returns the wallet.balance. leaf or nil. +func findBalanceSub(name string) (found bool, hasFlag func(string) bool, flagRequired func(string) bool) { + for _, c := range Cmd.Commands() { + if c.Name() != "balance" { + continue + } + for _, sub := range c.Commands() { + if sub.Name() != name { + continue + } + return true, + func(f string) bool { return sub.Flag(f) != nil }, + func(f string) bool { + ff := sub.Flag(f) + if ff == nil { + return false + } + return len(ff.Annotations[cobraBashCompOneRequiredFlag]) > 0 + } + } + } + return false, nil, nil +} + +func TestWalletBalanceSubHasPaginationFlags(t *testing.T) { + // After SDK v7.2.71 sync, `wallet balance sub` gained --page and --limit flags + // backing the ListSubAccountBalancesOpts.Page/Limit fields. + found, hasFlag, flagRequired := findBalanceSub("sub") + require.True(t, found, "wallet balance sub subcommand should be registered") + + for _, name := range []string{"sub-uid", "page", "limit"} { + assert.True(t, hasFlag(name), "wallet balance sub should expose --%s", name) + assert.False(t, flagRequired(name), "--%s must remain optional", name) + } +} + +// TestRunWalletSubBalances_RequiresAuth drives the runner directly so the new +// --page / --limit flag plumbing is exercised before the auth gate trips, but +// still fails with a clear API-key error under an empty credential env. +func TestRunWalletSubBalances_RequiresAuth(t *testing.T) { + root := newTestRoot(t) + cmd := &cobra.Command{Use: "sub"} + cmd.Flags().String("sub-uid", "12345", "") + cmd.Flags().Int32("page", 2, "") + cmd.Flags().Int32("limit", 50, "") + root.AddCommand(cmd) + + err := runWalletSubBalances(cmd, nil) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} + +// Zero-value page/limit must not cause the handler to panic or misconfigure +// opts; the auth gate should still be reached and returns the API-key error. +func TestRunWalletSubBalances_ZeroPaginationFlags(t *testing.T) { + root := newTestRoot(t) + cmd := &cobra.Command{Use: "sub"} + cmd.Flags().String("sub-uid", "", "") + cmd.Flags().Int32("page", 0, "") + cmd.Flags().Int32("limit", 0, "") + root.AddCommand(cmd) + + err := runWalletSubBalances(cmd, nil) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "api key") +} + +func TestWalletBalanceOtherSubcommandsStillPresent(t *testing.T) { + // Guard against accidental regressions while adding new flags. + want := []string{"sub", "sub-margin", "sub-futures", "sub-cross-margin", "small", "small-history"} + for _, name := range want { + found, _, _ := findBalanceSub(name) + assert.True(t, found, "wallet balance should still expose %q", name) + } +} diff --git a/cmd/doctor/doctor.go b/cmd/doctor/doctor.go index 6fae618..7eac82d 100644 --- a/cmd/doctor/doctor.go +++ b/cmd/doctor/doctor.go @@ -1,12 +1,14 @@ package doctor import ( - "errors" + "strings" "github.com/spf13/cobra" + "github.com/gate/gate-cli/internal/cmdhint" "github.com/gate/gate-cli/internal/cmdutil" "github.com/gate/gate-cli/internal/exitcode" + "github.com/gate/gate-cli/internal/intelcmd" "github.com/gate/gate-cli/internal/migration" "github.com/gate/gate-cli/internal/output" ) @@ -29,7 +31,7 @@ func runDoctor(cmd *cobra.Command, args []string) error { p := cmdutil.GetPrinter(cmd) if p.IsTable() { p.PrintError(output.UnsupportedTableFormatError()) - return exitcode.New(exitcode.RenderOrInternal, errors.New("unsupported format")) + return exitcode.New(exitcode.RenderOrInternal, intelcmd.ErrSilenced) } checkRaw, _ := cmd.Flags().GetString("check") strict, _ := cmd.Flags().GetBool("strict") @@ -38,7 +40,7 @@ func runDoctor(cmd *cobra.Command, args []string) error { infoURL, newsURL, err := cmdutil.IntelMCPBaseURLs(cmd) if err != nil { p.PrintError(&output.GateError{Status: 500, Label: "CONFIG_ERROR", Message: err.Error()}) - return exitcode.New(exitcode.RenderOrInternal, err) + return exitcode.New(exitcode.RenderOrInternal, intelcmd.ErrSilenced) } report := migration.BuildDoctorReport(migration.DoctorOptions{ @@ -49,13 +51,31 @@ func runDoctor(cmd *cobra.Command, args []string) error { NewsURL: newsURL, }) - if err := p.Print(report); err != nil { - return exitcode.New(exitcode.RenderOrInternal, err) + payload := interface{}(report) + if p.IsJSON() && cmdhint.AgentModeEnabled() { + payload = map[string]interface{}{ + "status": report.Status, + "summary": report.Summary, + "checks": report.Checks, + "recommended_actions": report.RecommendedActions, + "suggested_next_action": cmdhint.AgentDoctorNextAction(report.Status), + "agent_resolve_hint": cmdhint.AgentResolveHint("intel doctor"), + } } - if report.Status == "fail" { - p.PrintError(&output.GateError{Status: 422, Label: "DOCTOR_FAILED", Message: "doctor checks failed"}) - return exitcode.New(migration.DoctorExitCode(report), errors.New("doctor failed")) + ge := &output.GateError{Status: 422, Label: "DOCTOR_FAILED", Message: doctorFailMessage(report)} + output.FillAgentErrorConvergence(ge) + if cmdhint.AgentModeEnabled() { + ge.SuggestedNextAction = cmdhint.AgentDoctorNextAction(report.Status) + } else if err := p.Print(payload); err != nil { + return exitcode.New(exitcode.RenderOrInternal, err) + } + p.PrintError(ge) + return exitcode.New(migration.DoctorExitCode(report), intelcmd.ErrSilenced) + } + + if err := p.Print(payload); err != nil { + return exitcode.New(exitcode.RenderOrInternal, err) } if report.Status == "warn" { return exitcode.New(migration.DoctorExitCode(report), nil) @@ -63,3 +83,17 @@ func runDoctor(cmd *cobra.Command, args []string) error { return nil } +func doctorFailMessage(report migration.DoctorReport) string { + for _, c := range report.Checks { + if strings.TrimSpace(c.Status) != "fail" { + continue + } + if msg := strings.TrimSpace(c.Message); msg != "" { + return "doctor checks failed: " + msg + } + if id := strings.TrimSpace(c.ID); id != "" { + return "doctor checks failed: " + id + } + } + return "doctor checks failed" +} diff --git a/cmd/doctor/doctor_agent_fail_test.go b/cmd/doctor/doctor_agent_fail_test.go new file mode 100644 index 0000000..a12be26 --- /dev/null +++ b/cmd/doctor/doctor_agent_fail_test.go @@ -0,0 +1,88 @@ +//go:build agent + +package doctor + +import ( + "bytes" + "encoding/json" + "io" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/gate/gate-cli/internal/migration" + "github.com/gate/gate-cli/internal/version" +) + +func TestDoctorAgentFailStderrOnlyJSON(t *testing.T) { + prev := version.Version + version.Version = "0.0.1" + t.Cleanup(func() { version.Version = prev }) + + t.Setenv("GATE_CLI_AGENT", "1") + t.Cleanup(func() { _ = os.Unsetenv("GATE_CLI_AGENT") }) + + root := &cobra.Command{Use: "gate-cli"} + root.PersistentFlags().String("format", "json", "") + root.PersistentFlags().String("profile", "default", "") + root.PersistentFlags().String("api-key", "", "") + root.PersistentFlags().String("api-secret", "", "") + root.PersistentFlags().Int64("max-output-bytes", 0, "") + root.AddCommand(Cmd) + + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetArgs([]string{"doctor", "--check", "version", "--format", "json"}) + + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + oldStderr := os.Stderr + os.Stderr = w + t.Cleanup(func() { + os.Stderr = oldStderr + _ = w.Close() + }) + + if err := root.Execute(); err == nil { + t.Fatal("expected error exit") + } + _ = w.Close() + stderrBytes, _ := io.ReadAll(r) + if stdout.Len() > 0 { + t.Fatalf("agent fail must not write stdout, got %q", stdout.String()) + } + if !strings.Contains(string(stderrBytes), `"error"`) { + t.Fatalf("expected stderr GateError JSON, got %q", stderrBytes) + } + var wrap map[string]interface{} + if err := json.Unmarshal(stderrBytes, &wrap); err != nil { + t.Fatalf("stderr JSON: %v body=%q", err, stderrBytes) + } + errObj, _ := wrap["error"].(map[string]interface{}) + if errObj["label"] != "DOCTOR_FAILED" { + t.Fatalf("expected DOCTOR_FAILED, got %#v", errObj) + } + if retry, _ := errObj["retryable"].(bool); retry { + t.Fatalf("DOCTOR_FAILED must not be retryable: %#v", errObj) + } + msg, _ := errObj["message"].(string) + if !strings.Contains(msg, "doctor checks failed") { + t.Fatalf("expected check detail in message, got %q", msg) + } +} + +func TestDoctorFailMessageUsesFirstFailCheck(t *testing.T) { + t.Parallel() + msg := doctorFailMessage(migration.DoctorReport{ + Checks: []migration.DoctorCheck{ + {ID: "cli.version", Status: "fail", Message: "cli version below minimum requirement"}, + }, + }) + if !strings.Contains(msg, "cli version below minimum requirement") { + t.Fatalf("got %q", msg) + } +} diff --git a/cmd/info/aliases.go b/cmd/info/aliases.go index b424b5e..c66e760 100644 --- a/cmd/info/aliases.go +++ b/cmd/info/aliases.go @@ -5,6 +5,7 @@ import ( "github.com/gate/gate-cli/internal/intelcmd" "github.com/gate/gate-cli/internal/intelfacade" + "github.com/gate/gate-cli/internal/mcpspec" "github.com/gate/gate-cli/internal/toolschema" ) @@ -13,6 +14,7 @@ func makeInfoAliasCommand(use, toolName string) *cobra.Command { BackendCLI: "info", Use: use, ToolName: toolName, + LongAppend: mcpspec.InfoLeafLongAppend(toolName), RunE: func(cmd *cobra.Command, args []string) error { return runInfoCallByName(cmd, toolName, intelcmd.ReservedMCPJSONFallbackFlags()) }, @@ -54,8 +56,10 @@ func loadInfoToolSchemas() map[string]toolschema.ToolSummary { } var infoBusinessAliases = map[string][]string{ - "info_coin_get_coin_info": {"coin-info"}, - "info_marketsnapshot_get_market_overview": {"overview", "market-overview"}, - "info_markettrend_get_technical_analysis": {"ta", "trend-analysis"}, - "info_compliance_check_token_security": {"token-risk"}, + "info_coin_get_coin_info": {"coin-info", "coinanalysis", "coin-analysis"}, + "info_marketsnapshot_get_market_overview": {"overview", "market-overview"}, + "info_marketsnapshot_get_institutional_metrics": {"institutional-metrics", "institutional"}, + "info_markettrend_get_kline": {"kline"}, + "info_markettrend_get_technical_analysis": {"ta", "trend-analysis"}, + "info_compliance_check_token_security": {"token-risk"}, } diff --git a/cmd/info/aliases_flag_wiring_test.go b/cmd/info/aliases_flag_wiring_test.go index ed06348..c39ad8b 100644 --- a/cmd/info/aliases_flag_wiring_test.go +++ b/cmd/info/aliases_flag_wiring_test.go @@ -13,14 +13,15 @@ func TestMarkettrendGetKlineUsesFlexBoolForWithIndicators(t *testing.T) { require.NoError(t, err) fl := cmd.Flags().Lookup("with-indicators") require.NotNil(t, fl, "with-indicators flag should exist") - assert.Equal(t, "flexBool", fl.Value.Type(), "native bool breaks --with-indicators true (spaced)") + assert.Equal(t, "flexBool", fl.Value.Type(), "flexBool keeps custom ParseBool behavior") + assert.Equal(t, "true", fl.NoOptDefVal, "bare --with-indicators must mean true so the flag does not consume the next argv token") } -func TestMarkettrendGetKlineParseFlags_SpacedBoolValue(t *testing.T) { +func TestMarkettrendGetKlineParseFlags_BareBoolMeansTrue(t *testing.T) { leaf, _, err := Cmd.Find([]string{"markettrend", "get-kline"}) require.NoError(t, err) require.NoError(t, leaf.ParseFlags([]string{ - "--symbol", "ETH", "--timeframe", "4h", "--with-indicators", "true", + "--symbol", "ETH", "--timeframe", "4h", "--with-indicators", })) fl := leaf.Flags().Lookup("with-indicators") require.NotNil(t, fl) @@ -28,3 +29,33 @@ func TestMarkettrendGetKlineParseFlags_SpacedBoolValue(t *testing.T) { require.NoError(t, err) assert.True(t, v) } + +func TestMarkettrendGetKlineParseFlags_EqualsBoolValue(t *testing.T) { + leaf, _, err := Cmd.Find([]string{"markettrend", "get-kline"}) + require.NoError(t, err) + require.NoError(t, leaf.ParseFlags([]string{ + "--symbol", "ETH", "--timeframe", "4h", "--with-indicators=false", + })) + fl := leaf.Flags().Lookup("with-indicators") + require.NotNil(t, fl) + v, err := strconv.ParseBool(fl.Value.String()) + require.NoError(t, err) + assert.False(t, v) +} + +func TestMarkettrendGetKlineParseFlags_BoolThenNextFlagDoesNotConsume(t *testing.T) { + leaf, _, err := Cmd.Find([]string{"markettrend", "get-kline"}) + require.NoError(t, err) + require.NoError(t, leaf.ParseFlags([]string{ + "--symbol", "ETH", "--with-indicators", "--timeframe", "4h", + })) + fl := leaf.Flags().Lookup("with-indicators") + require.NotNil(t, fl) + v, err := strconv.ParseBool(fl.Value.String()) + require.NoError(t, err) + assert.True(t, v, "bare --with-indicators followed by --next-flag must resolve to true, not error") + + tf := leaf.Flags().Lookup("timeframe") + require.NotNil(t, tf) + assert.Equal(t, "4h", tf.Value.String(), "the following --timeframe value must still be parsed") +} diff --git a/cmd/info/aliases_test.go b/cmd/info/aliases_test.go index 5c852e5..40f043f 100644 --- a/cmd/info/aliases_test.go +++ b/cmd/info/aliases_test.go @@ -6,7 +6,9 @@ import ( "github.com/spf13/cobra" + "github.com/gate/gate-cli/internal/intelcmd" "github.com/gate/gate-cli/internal/intelfacade" + "github.com/gate/gate-cli/internal/mcpspec" "github.com/gate/gate-cli/internal/toolschema" ) @@ -65,3 +67,114 @@ func TestInfoGetCoinInfoHasStaticFlatFlagsWhenLoaderEmpty(t *testing.T) { } } } + +func TestInfoIntelLeafToolAnnotation(t *testing.T) { + for _, tool := range intelfacade.InfoToolBaseline { + parts := strings.Split(tool, "_") + if len(parts) < 3 { + t.Fatalf("invalid tool %q", tool) + } + group := parts[1] + leaf := strings.Join(parts[2:], "-") + leafCmd, _, err := Cmd.Find([]string{group, leaf}) + if err != nil || leafCmd == nil { + t.Fatalf("find %s/%s for %q: %v", group, leaf, tool, err) + } + if got := leafCmd.Annotations[intelcmd.AnnotationIntelToolName]; got != tool { + t.Fatalf("%s/%s: annotation %q want %q", group, leaf, got, tool) + } + } +} + +// TestInfoEachLeafRegistersAllBaselineFlags ensures every baseline JSON-schema property +// is wired as a cobra flag on the matching leaf (static wiring; empty schema tools skip). +func TestInfoEachLeafRegistersAllBaselineFlags(t *testing.T) { + oldLoader := infoSchemaLoader + infoSchemaLoader = func() map[string]toolschema.ToolSummary { return map[string]toolschema.ToolSummary{} } + t.Cleanup(func() { infoSchemaLoader = oldLoader }) + + cmd := &cobra.Command{Use: "info"} + orig := Cmd + Cmd = cmd + t.Cleanup(func() { Cmd = orig }) + buildInfoAliases() + + for _, tool := range intelfacade.InfoToolBaseline { + parts := strings.Split(tool, "_") + group := parts[1] + leaf := strings.Join(parts[2:], "-") + leafCmd, _, err := cmd.Find([]string{group, leaf}) + if err != nil || leafCmd == nil { + t.Fatalf("find %s/%s for %q: %v", group, leaf, tool, err) + } + schema := intelfacade.InfoBaselineInputSchema(tool) + if schema == nil { + t.Fatalf("nil baseline schema for %q", tool) + } + props, ok := schema["properties"].(map[string]interface{}) + if !ok { + t.Fatalf("%q: missing properties", tool) + } + for k := range props { + flagName := strings.ReplaceAll(k, "_", "-") + if leafCmd.Flags().Lookup(flagName) == nil { + t.Errorf("tool %q missing flag --%s (baseline key %q)", tool, flagName, k) + } + } + } +} + +// TestInfoEachLeafRegistersAllSpecFields ensures embedded MCP spec fields are present as flags +// (belt-and-suspenders on top of intelfacade.TestInfoBaselineCoversSpecInputFields). +func TestInfoEachLeafRegistersAllSpecFields(t *testing.T) { + doc, err := mcpspec.InfoInputsLogic() + if err != nil { + t.Fatal(err) + } + root := doc.(map[string]interface{}) + raw := root["tools"].([]interface{}) + specByTool := make(map[string][]string, len(raw)) + for _, item := range raw { + tm := item.(map[string]interface{}) + name, _ := tm["tool_name"].(string) + if name == "" { + continue + } + fields, _ := tm["fields"].([]interface{}) + for _, f := range fields { + fm := f.(map[string]interface{}) + if n, _ := fm["name"].(string); n != "" { + specByTool[name] = append(specByTool[name], n) + } + } + } + + oldLoader := infoSchemaLoader + infoSchemaLoader = func() map[string]toolschema.ToolSummary { return map[string]toolschema.ToolSummary{} } + t.Cleanup(func() { infoSchemaLoader = oldLoader }) + cmd := &cobra.Command{Use: "info"} + orig := Cmd + Cmd = cmd + t.Cleanup(func() { Cmd = orig }) + buildInfoAliases() + + for _, tool := range intelfacade.InfoToolBaseline { + names := specByTool[tool] + if len(names) == 0 { + continue + } + parts := strings.Split(tool, "_") + group := parts[1] + leaf := strings.Join(parts[2:], "-") + leafCmd, _, err := cmd.Find([]string{group, leaf}) + if err != nil || leafCmd == nil { + t.Fatalf("find %s/%s for %q: %v", group, leaf, tool, err) + } + for _, field := range names { + flagName := strings.ReplaceAll(field, "_", "-") + if leafCmd.Flags().Lookup(flagName) == nil { + t.Errorf("tool %q missing flag for spec field %q (--%s)", tool, field, flagName) + } + } + } +} diff --git a/cmd/info/call_describe_test.go b/cmd/info/call_describe_test.go index 15bf0b4..56ee62d 100644 --- a/cmd/info/call_describe_test.go +++ b/cmd/info/call_describe_test.go @@ -16,15 +16,17 @@ import ( ) type fakeInfoService struct { - describe *intelfacade.ToolSummary - call *mcpclient.CallResult - err error + describe *intelfacade.ToolSummary + call *mcpclient.CallResult + err error + describeOf string } func (f *fakeInfoService) ListTools(ctx context.Context) ([]intelfacade.ToolSummary, *http.Response, error) { return nil, nil, nil } func (f *fakeInfoService) DescribeTool(ctx context.Context, name string) (*intelfacade.ToolSummary, *http.Response, error) { + f.describeOf = name return f.describe, nil, f.err } func (f *fakeInfoService) CallTool(ctx context.Context, name string, arguments map[string]interface{}) (*mcpclient.CallResult, *http.Response, error) { @@ -47,7 +49,27 @@ func TestRunInfoDescribeJSON(t *testing.T) { cmd.Flags().String("name", "", "") _ = cmd.Flags().Set("name", "info_coin_get_coin_info") require.NoError(t, runInfoDescribe(cmd, nil)) - assert.Contains(t, out.String(), "info_coin_get_coin_info") + assert.Contains(t, out.String(), `"command":"info coin get-coin-info"`) + assert.NotContains(t, out.String(), "info_coin_get_coin_info") +} + +func TestRunInfoDescribeCLIPathName(t *testing.T) { + oldFactory, oldPrinter := newInfoService, getPrinter + t.Cleanup(func() { newInfoService = oldFactory; getPrinter = oldPrinter }) + + svc := &fakeInfoService{describe: &intelfacade.ToolSummary{Name: "info_coin_get_coin_info"}} + newInfoService = func(cmd *cobra.Command) (infoService, error) { return svc, nil } + var out, errOut bytes.Buffer + getPrinter = func(cmd *cobra.Command) *output.Printer { + return output.NewWithStderr(&out, &errOut, output.FormatJSON) + } + + cmd := &cobra.Command{Use: "describe"} + cmd.Flags().String("name", "", "") + _ = cmd.Flags().Set("name", "info coin get-coin-info") + require.NoError(t, runInfoDescribe(cmd, nil)) + assert.Equal(t, "info_coin_get_coin_info", svc.describeOf) + assert.Contains(t, out.String(), `"command":"info coin get-coin-info"`) } func TestRunInfoDescribePrettySections(t *testing.T) { @@ -78,6 +100,8 @@ func TestRunInfoDescribePrettySections(t *testing.T) { _ = cmd.Flags().Set("name", "info_coin_get_coin_info") require.NoError(t, runInfoDescribe(cmd, nil)) assert.Contains(t, out.String(), "Overview") + assert.Contains(t, out.String(), "info coin get-coin-info") + assert.NotContains(t, out.String(), "info_coin_get_coin_info") assert.Contains(t, out.String(), "Parameters") assert.Contains(t, out.String(), "symbol") assert.NotContains(t, out.String(), "input_schema") diff --git a/cmd/info/call_output_test.go b/cmd/info/call_output_test.go index 5e346bb..af8d2c9 100644 --- a/cmd/info/call_output_test.go +++ b/cmd/info/call_output_test.go @@ -22,6 +22,7 @@ import ( type fakeInfoCallService struct { result *mcpclient.CallResult callHTTP *http.Response + callName string } func (f *fakeInfoCallService) ListTools(ctx context.Context) ([]intelfacade.ToolSummary, *http.Response, error) { @@ -31,9 +32,21 @@ func (f *fakeInfoCallService) DescribeTool(ctx context.Context, name string) (*i return &intelfacade.ToolSummary{Name: name}, nil, nil } func (f *fakeInfoCallService) CallTool(ctx context.Context, name string, arguments map[string]interface{}) (*mcpclient.CallResult, *http.Response, error) { + f.callName = name return f.result, f.callHTTP, nil } +func infoCallTestCmd() *cobra.Command { + root := &cobra.Command{Use: "gate-cli"} + root.PersistentFlags().Int64("max-output-bytes", 0, "") + cmd := &cobra.Command{Use: "call"} + cmd.Flags().String("params", "", "") + cmd.Flags().String("args-json", `{"query":"BTC"}`, "") + cmd.Flags().String("args-file", "", "") + root.AddCommand(cmd) + return cmd +} + func TestRunInfoCall_JSONEnvelope(t *testing.T) { oldFactory, oldPrinter := newInfoService, getPrinter t.Cleanup(func() { newInfoService = oldFactory; getPrinter = oldPrinter }) @@ -48,10 +61,7 @@ func TestRunInfoCall_JSONEnvelope(t *testing.T) { getPrinter = func(cmd *cobra.Command) *output.Printer { return output.NewWithStderr(&out, &errOut, output.FormatJSON) } - cmd := &cobra.Command{Use: "call"} - cmd.Flags().String("params", "", "") - cmd.Flags().String("args-json", "", "") - cmd.Flags().String("args-file", "", "") + cmd := infoCallTestCmd() require.NoError(t, runInfoCallByName(cmd, "info_coin_get_coin_info", map[string]struct{}{})) assert.NotContains(t, out.String(), "tool_name") assert.NotContains(t, out.String(), "data_source") @@ -74,10 +84,7 @@ func TestRunInfoCall_IsErrorPrintsStderrOnly(t *testing.T) { getPrinter = func(cmd *cobra.Command) *output.Printer { return output.NewWithStderr(&out, &errOut, output.FormatJSON) } - cmd := &cobra.Command{Use: "call"} - cmd.Flags().String("params", "", "") - cmd.Flags().String("args-json", "", "") - cmd.Flags().String("args-file", "", "") + cmd := infoCallTestCmd() err := runInfoCallByName(cmd, "info_coin_get_coin_info", map[string]struct{}{}) require.Error(t, err) @@ -88,7 +95,28 @@ func TestRunInfoCall_IsErrorPrintsStderrOnly(t *testing.T) { assert.Empty(t, out.String()) assert.Contains(t, errOut.String(), `"error":`) assert.Contains(t, errOut.String(), `"label":"INTEL_RESULT_ERROR"`) - assert.Contains(t, errOut.String(), `"tool_name":"info_coin_get_coin_info"`) + assert.NotContains(t, errOut.String(), `"tool_name"`) + assert.Contains(t, errOut.String(), `"url":"info coin get-coin-info"`) +} + +func TestRunInfoCall_CLIPathName(t *testing.T) { + oldFactory, oldPrinter := newInfoService, getPrinter + t.Cleanup(func() { newInfoService = oldFactory; getPrinter = oldPrinter }) + + svc := &fakeInfoCallService{result: &mcpclient.CallResult{ + ContentRaw: []interface{}{map[string]interface{}{"type": "text", "text": `{"ok":true}`}}, + }} + newInfoService = func(cmd *cobra.Command) (infoService, error) { return svc, nil } + + var out, errOut bytes.Buffer + getPrinter = func(cmd *cobra.Command) *output.Printer { + return output.NewWithStderr(&out, &errOut, output.FormatJSON) + } + cmd := infoCallTestCmd() + require.NoError(t, runInfoCallByName(cmd, "info coin get-coin-info", map[string]struct{}{})) + assert.Equal(t, "info_coin_get_coin_info", svc.callName) + assert.Contains(t, out.String(), `"ok":true`) + assert.Empty(t, errOut.String()) } func TestRunInfoCall_IsErrorUnaffectedByMaxOutputBytes(t *testing.T) { @@ -111,7 +139,7 @@ func TestRunInfoCall_IsErrorUnaffectedByMaxOutputBytes(t *testing.T) { root.PersistentFlags().Int64("max-output-bytes", 8, "") cmd := &cobra.Command{Use: "call"} cmd.Flags().String("params", "", "") - cmd.Flags().String("args-json", "", "") + cmd.Flags().String("args-json", `{"query":"BTC"}`, "") cmd.Flags().String("args-file", "", "") root.AddCommand(cmd) @@ -140,10 +168,7 @@ func TestRunInfoCall_PrettyIsErrorPrintsReadableStderrOnly(t *testing.T) { getPrinter = func(cmd *cobra.Command) *output.Printer { return output.NewWithStderr(&out, &errOut, output.FormatPretty) } - cmd := &cobra.Command{Use: "call"} - cmd.Flags().String("params", "", "") - cmd.Flags().String("args-json", "", "") - cmd.Flags().String("args-file", "", "") + cmd := infoCallTestCmd() err := runInfoCallByName(cmd, "info_coin_get_coin_info", map[string]struct{}{}) require.Error(t, err) @@ -151,8 +176,10 @@ func TestRunInfoCall_PrettyIsErrorPrintsReadableStderrOnly(t *testing.T) { require.True(t, errors.As(err, &coded)) assert.Equal(t, 1, coded.Code) assert.Empty(t, out.String()) - assert.Contains(t, errOut.String(), "Error [502 INTEL_RESULT_ERROR]: tool returned isError=true") - assert.Contains(t, errOut.String(), "Tool: info_coin_get_coin_info") + assert.Contains(t, errOut.String(), "INTEL_RESULT_ERROR") + assert.Contains(t, errOut.String(), "tool returned isError=true") + assert.NotContains(t, errOut.String(), "Tool: info_coin_get_coin_info") + assert.Contains(t, errOut.String(), "Request: POST info coin get-coin-info") } func TestRunInfoCall_IsErrorIncludesTraceIDJSON(t *testing.T) { @@ -173,10 +200,7 @@ func TestRunInfoCall_IsErrorIncludesTraceIDJSON(t *testing.T) { getPrinter = func(cmd *cobra.Command) *output.Printer { return output.NewWithStderr(&out, &errOut, output.FormatJSON) } - cmd := &cobra.Command{Use: "call"} - cmd.Flags().String("params", "", "") - cmd.Flags().String("args-json", "", "") - cmd.Flags().String("args-file", "", "") + cmd := infoCallTestCmd() err := runInfoCallByName(cmd, "info_coin_get_coin_info", map[string]struct{}{}) require.Error(t, err) diff --git a/cmd/info/describe.go b/cmd/info/describe.go index 1ae1944..4f46945 100644 --- a/cmd/info/describe.go +++ b/cmd/info/describe.go @@ -4,17 +4,17 @@ import ( "github.com/spf13/cobra" "github.com/gate/gate-cli/internal/intelcmd" - "github.com/gate/gate-cli/internal/intelfacade" ) var describeCmd = &cobra.Command{ - Use: "describe --name ", - Short: "Describe one Info capability", - RunE: runInfoDescribe, + Use: "describe --name ", + Short: "Describe one Info capability", + Example: " gate-cli info describe --name \"info coin get-coin-info\" --format json", + RunE: runInfoDescribe, } func init() { - describeCmd.Flags().String("name", "", "Info tool name") + describeCmd.Flags().String("name", "", "Info MCP tool name or CLI command (e.g. info coin get-coin-info)") _ = describeCmd.MarkFlagRequired("name") Cmd.AddCommand(describeCmd) } @@ -30,12 +30,10 @@ func runInfoDescribe(cmd *cobra.Command, args []string) error { } name, _ := cmd.Flags().GetString("name") + name = intelcmd.ResolveMCPToolName("info", name) tool, httpResp, err := svc.DescribeTool(cmd.Context(), name) if err != nil { return intelcmd.FailDescribeTransport(p, err, httpResp, "info", name) } - if p.IsJSON() { - return p.Print(tool) - } - return p.WritePretty(intelfacade.DescribePrettyText(tool)) + return intelcmd.RenderDescribeTool(p, "info", tool) } diff --git a/cmd/info/info.go b/cmd/info/info.go index bdd58de..0a6d608 100644 --- a/cmd/info/info.go +++ b/cmd/info/info.go @@ -6,6 +6,13 @@ import "github.com/spf13/cobra" var Cmd = &cobra.Command{ Use: "info", Short: "Market and intelligence info commands", + Long: `Market and intelligence info commands. + +Agent shortcuts (+ prefix): +coin-overview, +market-overview, +coin-compare, +trend-analysis, +token-risk, +address-tracker, +token-onchain. ++address-risk remains deferred until info_compliance_check_address_risk ships. + +Discovery: gate-cli info list --format table (CLI command paths, not MCP wire names). +Describe: gate-cli info describe --name "info coin get-coin-info" (CLI path or MCP wire name).`, } func init() { diff --git a/cmd/info/info_test.go b/cmd/info/info_test.go index e256fa6..a486a38 100644 --- a/cmd/info/info_test.go +++ b/cmd/info/info_test.go @@ -12,6 +12,7 @@ func TestInfoCommandStructure(t *testing.T) { subCmds[c.Name()] = true } assert.True(t, subCmds["list"], "missing info list subcommand") + assert.True(t, subCmds["+coin-overview"], "missing info +coin-overview subcommand") assert.True(t, subCmds["verify-schema"], "missing info verify-schema subcommand") for _, c := range Cmd.Commands() { if c.Name() == "invoke" { diff --git a/cmd/info/invoke.go b/cmd/info/invoke.go index bc9d8cb..ffc0958 100644 --- a/cmd/info/invoke.go +++ b/cmd/info/invoke.go @@ -10,15 +10,16 @@ import ( ) var invokeCmd = &cobra.Command{ - Use: "invoke --name [flags]", - Short: "Run one Info capability by tool name (flat flags when --name is on the command line)", + Use: "invoke --name [flags]", + Short: "Run one Info capability (MCP wire name or CLI command path)", + Example: " gate-cli info invoke --name \"info coin get-coin-info\" --query BTC --format json\n gate-cli info invoke --name info_coin_get_coin_info --query BTC --format json", Hidden: true, Aliases: []string{"call"}, RunE: runInfoInvoke, } func init() { - invokeCmd.Flags().String("name", "", "Info tool name") + invokeCmd.Flags().String("name", "", "Info MCP tool name or CLI command (e.g. info coin get-coin-info)") invokeCmd.Flags().String("params", "", "JSON object arguments (fallback)") invokeCmd.Flags().String("args-json", "", "JSON object arguments (alias of --params)") invokeCmd.Flags().String("args-file", "", "Path to JSON file containing arguments object") @@ -41,6 +42,7 @@ func runInfoInvoke(cmd *cobra.Command, args []string) error { func runInfoCallByName(cmd *cobra.Command, name string, reserved map[string]struct{}) error { p := getPrinter(cmd) maxOutputBytes, _ := cmd.Root().PersistentFlags().GetInt64("max-output-bytes") + name = intelcmd.ResolveMCPToolName("info", name) svc, err := newInfoService(cmd) if err != nil { return intelcmd.FailIntelClientInit(p, err, "info", "invoke", name) diff --git a/cmd/info/list.go b/cmd/info/list.go index eff5758..942d0fa 100644 --- a/cmd/info/list.go +++ b/cmd/info/list.go @@ -33,7 +33,7 @@ func runInfoList(cmd *cobra.Command, args []string) error { } _ = saveInfoSchemaCache("info", toInfoSchemaSummaries(items)) - return intelcmd.RenderToolList(p, items) + return intelcmd.RenderToolList(p, "info", items) } func toInfoSchemaSummaries(items []intelfacade.ToolSummary) []toolschema.ToolSummary { diff --git a/cmd/info/list_test.go b/cmd/info/list_test.go index 2b342ae..a8abe4a 100644 --- a/cmd/info/list_test.go +++ b/cmd/info/list_test.go @@ -75,7 +75,8 @@ func TestRunInfoListJSON(t *testing.T) { err := runInfoList(cmd, nil) require.NoError(t, err) - assert.Contains(t, out.String(), "info_coin_get_coin_info") + assert.Contains(t, out.String(), `"command":"info coin get-coin-info"`) + assert.NotContains(t, out.String(), "info_coin_get_coin_info") assert.Empty(t, errOut.String()) } @@ -107,7 +108,8 @@ func TestRunInfoListSaveCacheFailureIgnored(t *testing.T) { err := runInfoList(cmd, nil) require.NoError(t, err) - assert.Contains(t, out.String(), "info_coin_get_coin_info") + assert.Contains(t, out.String(), `"command":"info coin get-coin-info"`) + assert.NotContains(t, out.String(), "info_coin_get_coin_info") assert.Empty(t, errOut.String()) } @@ -173,7 +175,8 @@ func TestRunInfoListPrettySegmented(t *testing.T) { require.NoError(t, runInfoList(cmd, nil)) assert.Contains(t, out.String(), "Capabilities") - assert.Contains(t, out.String(), "info_coin_get_coin_info") + assert.Contains(t, out.String(), "info coin get-coin-info") + assert.NotContains(t, out.String(), "info_coin_get_coin_info") assert.Contains(t, out.String(), "Accepts parameters: yes") assert.NotContains(t, out.String(), "HasInputSchema") assert.Empty(t, errOut.String()) @@ -207,6 +210,7 @@ func TestRunInfoListTableColumns(t *testing.T) { require.NoError(t, runInfoList(cmd, nil)) assert.Contains(t, out.String(), "Accepts parameters") - assert.Contains(t, out.String(), "info_coin_get_coin_info") + assert.Contains(t, out.String(), "info coin get-coin-info") + assert.NotContains(t, out.String(), "info_coin_get_coin_info") assert.Empty(t, errOut.String()) } diff --git a/cmd/info/mcp_spec.go b/cmd/info/mcp_spec.go new file mode 100644 index 0000000..b2320df --- /dev/null +++ b/cmd/info/mcp_spec.go @@ -0,0 +1,34 @@ +package info + +import ( + "github.com/spf13/cobra" + + "github.com/gate/gate-cli/internal/intelcmd" + "github.com/gate/gate-cli/internal/mcpspec" +) + +var mcpSpecCmd = &cobra.Command{ + Use: "mcp-spec", + Short: "Print embedded Info MCP inputs/spec JSON (offline, for agents and LLMs)", + Long: "Prints the embedded Info MCP inputs/spec JSON shipped inside gate-cli (English description per tool, fields, logic). " + + "No MCP network call; use --format json or pretty. Leaf -h reads description from this same embedded document. " + + "Maintainers sync logic/fields from gate/mcp-server into internal/mcpspec/bundled/; routing descriptions are updated via scripts/patch-info-spec-descriptions.py (not from specs/mcp/, which is local QC only and not part of releases).", + Args: cobra.NoArgs, + RunE: runInfoMCPSpec, +} + +func init() { + Cmd.AddCommand(mcpSpecCmd) +} + +func runInfoMCPSpec(cmd *cobra.Command, args []string) error { + p := getPrinter(cmd) + if p.IsTable() { + return intelcmd.FailLeafUnsupportedTable(p, "info") + } + doc, err := mcpspec.InfoInputsLogic() + if err != nil { + return err + } + return p.Print(doc) +} diff --git a/cmd/info/mcp_spec_test.go b/cmd/info/mcp_spec_test.go new file mode 100644 index 0000000..9b095ba --- /dev/null +++ b/cmd/info/mcp_spec_test.go @@ -0,0 +1,13 @@ +package info + +import "testing" + +func TestInfoMCPSpecCommandRegistered(t *testing.T) { + if Cmd == nil { + t.Fatal("Cmd is nil") + } + sub, _, err := Cmd.Find([]string{"mcp-spec"}) + if err != nil || sub == nil || sub.Name() != "mcp-spec" { + t.Fatalf("mcp-spec subcommand: err=%v cmd=%v", err, sub) + } +} diff --git a/cmd/info/shortcut.go b/cmd/info/shortcut.go new file mode 100644 index 0000000..9c090c1 --- /dev/null +++ b/cmd/info/shortcut.go @@ -0,0 +1,618 @@ +package info + +import ( + "context" + "errors" + "strings" + "sync" + + "github.com/spf13/cobra" + + "github.com/gate/gate-cli/internal/cmdutil" + "github.com/gate/gate-cli/internal/intelcmd" + "github.com/gate/gate-cli/internal/output" + "github.com/gate/gate-cli/internal/toolrender" +) + +func init() { + buildInfoShortcuts() +} + +func buildInfoShortcuts() { + Cmd.AddCommand( + newInfoCoinOverviewCmd(), + newInfoMarketOverviewCmd(), + newInfoCoinCompareCmd(), + newInfoTrendAnalysisCmd(), + newInfoTokenRiskCmd(), + newInfoAddressTrackerCmd(), + newInfoTokenOnchainCmd(), + ) +} + +func newInfoCoinOverviewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "+coin-overview --symbol ", + Aliases: []string{"coin-overview"}, + RunE: func(cmd *cobra.Command, args []string) error { + symbol, err := requireShortcutSymbol(cmd, "symbol") + if err != nil { + return err + } + return runInfoShortcut(cmd, "info/+coin-overview", func(ctx context.Context, svc infoService) (map[string]interface{}, error) { + coin, err := callCoinInfoForShortcut(ctx, svc, symbol) + if err != nil { + return nil, err + } + out := map[string]interface{}{ + "summary": map[string]interface{}{"symbol": symbol}, + "basic_info": coin, + "market_snapshot": map[string]interface{}{}, + "technical_view": map[string]interface{}{}, + "risk_flags": []interface{}{}, + } + var missing []string + var mu sync.Mutex + _ = intelcmd.RunParallel(intelcmd.DefaultShortcutParallelism, []func() error{ + func() error { + if v, err := callMarketSnapshotForShortcut(ctx, svc, symbol); err == nil { + mu.Lock() + out["market_snapshot"] = v + mu.Unlock() + } else { + mu.Lock() + missing = append(missing, "market_snapshot") + mu.Unlock() + } + return nil + }, + func() error { + if v, err := callInfoShortcutTool(ctx, svc, "info_markettrend_get_technical_analysis", map[string]interface{}{"symbol": symbol}); err == nil { + mu.Lock() + out["technical_view"] = v + mu.Unlock() + } else { + mu.Lock() + missing = append(missing, "technical_view") + mu.Unlock() + } + return nil + }, + }) + if len(missing) > 0 { + out["partial"] = true + out["missing_sections"] = missing + } + return out, nil + }) + }, + } + cmd.Flags().String("symbol", "", "Coin symbol (required)") + return cmd +} + +func newInfoMarketOverviewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "+market-overview", + Aliases: []string{"market-overview"}, + RunE: func(cmd *cobra.Command, args []string) error { + rawBench, _ := cmd.Flags().GetString("benchmark") + benchmarks := splitCSVOrDefault(rawBench, []string{"BTC", "ETH", "SOL"}) + if len(benchmarks) > 5 { + benchmarks = benchmarks[:5] + } + return runInfoShortcut(cmd, "info/+market-overview", func(ctx context.Context, svc infoService) (map[string]interface{}, error) { + summary, err := callInfoShortcutTool(ctx, svc, "info_marketsnapshot_get_market_overview", map[string]interface{}{}) + if err != nil { + return nil, err + } + out := map[string]interface{}{ + "market_summary": summary, + "benchmark_snapshots": []interface{}{}, + "trend_anchor": []interface{}{}, + "risk_watchlist": []interface{}{}, + "requested_benchmarks": benchmarks, + "partial": false, + "missing_sections": []string{}, + } + var snapshots []interface{} + var anchors []interface{} + var mu sync.Mutex + snapshotTasks := make([]func() error, 0, len(benchmarks)) + for _, symbol := range benchmarks { + symbol := symbol + snapshotTasks = append(snapshotTasks, func() error { + if v, err := callMarketSnapshotForShortcut(ctx, svc, symbol); err == nil { + mu.Lock() + snapshots = append(snapshots, map[string]interface{}{"symbol": symbol, "data": v}) + mu.Unlock() + } + return nil + }) + } + _ = intelcmd.RunParallel(intelcmd.DefaultShortcutParallelism, snapshotTasks) + out["benchmark_snapshots"] = snapshots + anchorLimit := minInt(2, len(benchmarks)) + anchorTasks := make([]func() error, 0, anchorLimit) + for i := 0; i < anchorLimit; i++ { + symbol := benchmarks[i] + anchorTasks = append(anchorTasks, func() error { + if v, err := callInfoShortcutTool(ctx, svc, "info_markettrend_get_technical_analysis", map[string]interface{}{"symbol": symbol}); err == nil { + mu.Lock() + anchors = append(anchors, map[string]interface{}{"symbol": symbol, "data": v}) + mu.Unlock() + } + return nil + }) + } + _ = intelcmd.RunParallel(intelcmd.DefaultShortcutParallelism, anchorTasks) + out["trend_anchor"] = anchors + var missing []string + if len(snapshots) < len(benchmarks) { + missing = append(missing, "benchmark_snapshots") + } + if len(anchors) < minInt(2, len(benchmarks)) { + missing = append(missing, "trend_anchor") + } + if len(missing) > 0 { + out["partial"] = true + out["missing_sections"] = missing + } + return out, nil + }) + }, + } + cmd.Flags().String("benchmark", "BTC,ETH,SOL", "Comma-separated benchmark symbols") + return cmd +} + +func newInfoCoinCompareCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "+coin-compare --symbols BTC,ETH", + Aliases: []string{"coin-compare"}, + RunE: func(cmd *cobra.Command, args []string) error { + raw, _ := cmd.Flags().GetString("symbols") + if strings.TrimSpace(raw) == "" { + return intelcmd.FailAfterPrintError(getPrinter(cmd), output.InvalidArgsError("missing required flag: symbols")) + } + symbols := splitCSVOrDefault(raw, nil) + if len(symbols) < 2 || len(symbols) > 5 { + return intelcmd.FailAfterPrintError(getPrinter(cmd), output.InvalidArgsError("symbols must contain 2 to 5 items")) + } + return runInfoShortcut(cmd, "info/+coin-compare", func(ctx context.Context, svc infoService) (map[string]interface{}, error) { + var matrix []interface{} + var dropped []string + for _, symbol := range symbols { + row := map[string]interface{}{"symbol": symbol} + okCount := 0 + var rowMu sync.Mutex + _ = intelcmd.RunParallel(intelcmd.DefaultShortcutParallelism, []func() error{ + func() error { + if v, err := callCoinInfoForShortcut(ctx, svc, symbol); err == nil { + rowMu.Lock() + row["basic_info"] = v + okCount++ + rowMu.Unlock() + } + return nil + }, + func() error { + if v, err := callMarketSnapshotForShortcut(ctx, svc, symbol); err == nil { + rowMu.Lock() + row["market_snapshot"] = v + okCount++ + rowMu.Unlock() + } + return nil + }, + func() error { + if v, err := callInfoShortcutTool(ctx, svc, "info_markettrend_get_technical_analysis", map[string]interface{}{"symbol": symbol}); err == nil { + rowMu.Lock() + row["technical_view"] = v + okCount++ + rowMu.Unlock() + } + return nil + }, + }) + if okCount >= 2 { + matrix = append(matrix, row) + } else { + dropped = append(dropped, symbol) + } + } + return map[string]interface{}{ + "compare_matrix": matrix, + "ranking_view": map[string]interface{}{}, + "technical_diff": map[string]interface{}{}, + "key_deltas": map[string]interface{}{}, + "dropped_symbols": dropped, + }, nil + }) + }, + } + cmd.Flags().String("symbols", "", "Comma-separated 2-5 symbols (required)") + return cmd +} + +func newInfoTrendAnalysisCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "+trend-analysis --symbol ", + Aliases: []string{"trend-analysis"}, + RunE: func(cmd *cobra.Command, args []string) error { + symbol, err := requireShortcutSymbol(cmd, "symbol") + if err != nil { + return err + } + return runInfoShortcut(cmd, "info/+trend-analysis", func(ctx context.Context, svc infoService) (map[string]interface{}, error) { + ta, err := callInfoShortcutTool(ctx, svc, "info_markettrend_get_technical_analysis", map[string]interface{}{"symbol": symbol}) + if err != nil { + return nil, err + } + out := map[string]interface{}{ + "trend_summary": ta, + "indicator_snapshot": ta, + "price_context": map[string]interface{}{}, + "watch_levels": []interface{}{}, + } + if snapshot, err := callMarketSnapshotForShortcut(ctx, svc, symbol); err == nil { + out["price_context"] = snapshot + } else { + out["price_context_unavailable"] = true + } + return out, nil + }) + }, + } + cmd.Flags().String("symbol", "", "Coin symbol (required)") + return cmd +} + +func newInfoTokenRiskCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "+token-risk", + Aliases: []string{"token-risk"}, + RunE: func(cmd *cobra.Command, args []string) error { + symbol, _ := cmd.Flags().GetString("symbol") + address, _ := cmd.Flags().GetString("address") + chain, _ := cmd.Flags().GetString("chain") + return runInfoShortcut(cmd, "info/+token-risk", func(ctx context.Context, svc infoService) (map[string]interface{}, error) { + symbol = strings.ToUpper(strings.TrimSpace(symbol)) + address = strings.TrimSpace(address) + chain = strings.TrimSpace(chain) + if address == "" && symbol == "" { + return nil, intelcmd.ShortcutArgsError("either symbol or address is required") + } + if address != "" && symbol != "" { + return nil, intelcmd.ShortcutArgsError("provide either symbol or address, not both") + } + if address != "" { + if chain == "" { + return nil, intelcmd.ShortcutArgsError("chain is required when address is provided") + } + sec, err := callTokenSecurityForShortcut(ctx, svc, address, chain) + if err != nil { + return nil, err + } + tokenIdentity := map[string]interface{}{} + partial := false + missing := []string{} + if coin, err := callInfoShortcutTool(ctx, svc, "info_coin_get_coin_info", map[string]interface{}{ + "query": address, + "query_type": "address", + "scope": "basic", + }); err == nil { + tokenIdentity = coin + } else { + partial = true + missing = append(missing, "token_identity") + } + out := map[string]interface{}{ + "risk_level": sec, + "risk_items": sec, + "token_identity": tokenIdentity, + "coverage_note": "shortcut", + } + if partial { + out["partial"] = true + out["missing_sections"] = missing + } + return out, nil + } + coin, err := callCoinInfoForTokenRisk(ctx, svc, symbol) + if err != nil { + return nil, err + } + resolvedAddress, resolvedChain := resolveTokenAddressAndChain(coin, symbol) + if resolvedAddress == "" || resolvedChain == "" { + if coinInfoIndicatesNativeAsset(coin, symbol) { + return nil, intelcmd.ShortcutArgsError("native coin has no contract address for token security; use +coin-overview or pass --address and --chain for a wrapped token") + } + return nil, intelcmd.ShortcutArgsError("failed to resolve canonical contract or chain from symbol; try --address and --chain") + } + sec, err := callTokenSecurityForShortcut(ctx, svc, resolvedAddress, resolvedChain) + if err != nil { + return nil, err + } + return map[string]interface{}{ + "risk_level": sec, + "risk_items": sec, + "token_identity": coin, + "coverage_note": "shortcut", + }, nil + }) + }, + } + cmd.Flags().String("symbol", "", "Token symbol") + cmd.Flags().String("address", "", "Token address") + cmd.Flags().String("chain", "", "Chain") + return cmd +} + +func newInfoAddressTrackerCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "+address-tracker --address
--chain ", + Aliases: []string{"address-tracker"}, + RunE: func(cmd *cobra.Command, args []string) error { + address, chain, err := requireShortcutAddressChain(cmd) + if err != nil { + return err + } + minValue, _ := cmd.Flags().GetFloat64("min-value") + if minValue < 0 { + return intelcmd.FailAfterPrintError(getPrinter(cmd), output.InvalidArgsError("min-value must be non-negative")) + } + return runInfoShortcut(cmd, "info/+address-tracker", func(ctx context.Context, svc infoService) (map[string]interface{}, error) { + profile, err := callInfoShortcutTool(ctx, svc, "info_onchain_get_address_info", map[string]interface{}{ + "address": address, + "chain": chain, + "scope": "with_defi", + }) + if err != nil { + return nil, err + } + out := map[string]interface{}{ + "entity_guess": profile, + "defi_context": profile, + "recent_activity": map[string]interface{}{}, + "fund_flow_graph": map[string]interface{}{}, + "fund_flow_unavailable": true, + "watch_items": []interface{}{}, + "coverage_note": "partial: trace_fund_flow unavailable", + } + txArgs := map[string]interface{}{ + "address": address, + "chain": chain, + } + if minValue > 0 { + txArgs["min_value_usd"] = minValue + } + if recent, err := callInfoShortcutTool(ctx, svc, "info_onchain_get_address_transactions", txArgs); err == nil { + out["recent_activity"] = recent + } else { + out["recent_activity_unavailable"] = true + } + return out, nil + }) + }, + } + cmd.Flags().String("address", "", "Wallet address (required)") + cmd.Flags().String("chain", "", "Chain (required)") + cmd.Flags().Float64("min-value", 100000, "Minimum transaction value in USD for recent activity") + cmd.Flags().Int("depth", 3, "Fund-flow depth (reserved; trace-fund-flow not yet available)") + return cmd +} + +func newInfoTokenOnchainCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "+token-onchain", + Aliases: []string{"token-onchain"}, + RunE: func(cmd *cobra.Command, args []string) error { + symbol, _ := cmd.Flags().GetString("symbol") + address, _ := cmd.Flags().GetString("address") + chain, _ := cmd.Flags().GetString("chain") + return runInfoShortcut(cmd, "info/+token-onchain", func(ctx context.Context, svc infoService) (map[string]interface{}, error) { + symbol = strings.ToUpper(strings.TrimSpace(symbol)) + address = strings.TrimSpace(address) + chain = strings.TrimSpace(chain) + if address == "" && symbol == "" { + return nil, intelcmd.ShortcutArgsError("either symbol or address is required") + } + if address != "" && chain == "" { + return nil, intelcmd.ShortcutArgsError("chain is required when address is provided") + } + if symbol != "" && address != "" { + return nil, intelcmd.ShortcutArgsError("provide either symbol or address, not both") + } + var coin map[string]interface{} + token := symbol + onchainChain := chain + if symbol != "" { + if v, err := callCoinInfoForTokenRisk(ctx, svc, symbol); err == nil { + coin = v + if resolvedAddress, resolvedChain := resolveTokenAddressAndChain(v, symbol); resolvedAddress != "" { + token = resolvedAddress + if onchainChain == "" { + onchainChain = resolvedChain + } + } + } + } else { + token = address + } + onchain, err := callTokenOnchainForShortcut(ctx, svc, token, onchainChain) + if err != nil { + return nil, err + } + out := map[string]interface{}{ + "token_distribution": onchain, + "holder_structure": onchain, + "transfer_summary": onchain, + "activity_snapshot": onchain, + "smart_money_view": map[string]interface{}{}, + "smart_money_unavailable": true, + "coverage_note": "partial: get_smart_money unavailable", + } + if coin != nil { + out["coin_context"] = coin + } + return out, nil + }) + }, + } + cmd.Flags().String("symbol", "", "Token symbol") + cmd.Flags().String("address", "", "Token contract address") + cmd.Flags().String("chain", "", "Chain (required with address)") + return cmd +} + +func runInfoShortcut(cmd *cobra.Command, path string, runner func(ctx context.Context, svc infoService) (map[string]interface{}, error)) error { + p := getPrinter(cmd) + if p.IsTable() { + return intelcmd.FailLeafUnsupportedTable(p, "info") + } + svc, err := newInfoService(cmd) + if err != nil { + return intelcmd.FailIntelClientInit(p, err, "info", "shortcut", "") + } + ctx, cancel := intelcmd.WithShortcutBudget(cmd.Context()) + defer cancel() + out, err := runner(ctx, svc) + if err != nil { + ge := intelcmd.GateErrorFromShortcutErr(err, path) + output.FillAgentErrorConvergence(ge) + return intelcmd.FailAfterPrintError(p, ge) + } + return toolrender.RenderIntelPayload(p, path, out, cmdutil.GetMaxOutputBytes(cmd)) +} + +func callInfoShortcutTool(ctx context.Context, svc infoService, name string, args map[string]interface{}) (map[string]interface{}, error) { + return intelcmd.CallShortcutTool(ctx, svc, name, args) +} + +func callCoinInfoForShortcut(ctx context.Context, svc infoService, symbol string) (map[string]interface{}, error) { + return callInfoShortcutTool(ctx, svc, "info_coin_get_coin_info", map[string]interface{}{ + "query": symbol, + "query_type": "symbol", + }) +} + +func callCoinInfoForTokenRisk(ctx context.Context, svc infoService, symbol string) (map[string]interface{}, error) { + coin, err := callCoinInfoForShortcut(ctx, svc, symbol) + if err != nil { + return nil, err + } + if addr, chain := resolveTokenAddressAndChain(coin, symbol); addr != "" && chain != "" { + return coin, nil + } + detailed, err := callInfoShortcutTool(ctx, svc, "info_coin_get_coin_info", map[string]interface{}{ + "query": symbol, + "query_type": "symbol", + "scope": "detailed", + }) + if err != nil { + return coin, nil + } + return detailed, nil +} + +func callMarketSnapshotForShortcut(ctx context.Context, svc infoService, symbol string) (map[string]interface{}, error) { + snapshot, err := callInfoShortcutTool(ctx, svc, "info_marketsnapshot_get_market_snapshot", map[string]interface{}{ + "symbol": symbol, + "scope": "full", + }) + if err == nil { + return snapshot, nil + } + var isErr *intelcmd.ShortcutToolIsError + if !errors.As(err, &isErr) { + return nil, err + } + return callInfoShortcutTool(ctx, svc, "info_marketsnapshot_get_market_snapshot", map[string]interface{}{"symbol": symbol}) +} + +func callTokenOnchainForShortcut(ctx context.Context, svc infoService, token, chain string) (map[string]interface{}, error) { + args := map[string]interface{}{ + "token": token, + "scope": "full", + } + if chain != "" { + args["chain"] = chain + } + return callInfoShortcutTool(ctx, svc, "info_onchain_get_token_onchain", args) +} + +func callTokenSecurityForShortcut(ctx context.Context, svc infoService, address, chain string) (map[string]interface{}, error) { + sec, err := callInfoShortcutTool(ctx, svc, "info_compliance_check_token_security", map[string]interface{}{ + "address": address, + "chain": chain, + "scope": "full", + }) + if err == nil { + return sec, nil + } + var isErr *intelcmd.ShortcutToolIsError + if !errors.As(err, &isErr) { + return nil, err + } + return callInfoShortcutTool(ctx, svc, "info_compliance_check_token_security", map[string]interface{}{ + "address": address, + "chain": chain, + }) +} + +func requireShortcutAddressChain(cmd *cobra.Command) (address, chain string, err error) { + address, _ = cmd.Flags().GetString("address") + chain, _ = cmd.Flags().GetString("chain") + address = strings.TrimSpace(address) + chain = strings.TrimSpace(chain) + if address == "" { + return "", "", intelcmd.FailAfterPrintError(getPrinter(cmd), output.InvalidArgsError("missing required flag: address")) + } + if chain == "" { + return "", "", intelcmd.FailAfterPrintError(getPrinter(cmd), output.InvalidArgsError("missing required flag: chain")) + } + return address, chain, nil +} + +func requireShortcutSymbol(cmd *cobra.Command, flag string) (string, error) { + raw, _ := cmd.Flags().GetString(flag) + symbol := strings.ToUpper(strings.TrimSpace(raw)) + if symbol == "" { + return "", intelcmd.FailAfterPrintError(getPrinter(cmd), output.InvalidArgsError("missing required flag: "+flag)) + } + return symbol, nil +} + +func splitCSVOrDefault(raw string, fallback []string) []string { + if strings.TrimSpace(raw) == "" { + if fallback == nil { + return nil + } + return append([]string(nil), fallback...) + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if t := strings.ToUpper(strings.TrimSpace(p)); t != "" { + out = append(out, t) + } + } + return out +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func firstStringByKeys(m map[string]interface{}, keys ...string) string { + for _, k := range keys { + if v, ok := m[k].(string); ok && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} diff --git a/cmd/info/shortcut_onchain_test.go b/cmd/info/shortcut_onchain_test.go new file mode 100644 index 0000000..4afe056 --- /dev/null +++ b/cmd/info/shortcut_onchain_test.go @@ -0,0 +1,71 @@ +package info + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gate/gate-cli/internal/output" +) + +func runOnchainShortcut(t *testing.T, cmd *cobra.Command, setFlags func(*cobra.Command)) (stdout, stderr string, err error) { + t.Helper() + oldFactory, oldPrinter := newInfoService, getPrinter + t.Cleanup(func() { newInfoService = oldFactory; getPrinter = oldPrinter }) + newInfoService = func(cmd *cobra.Command) (infoService, error) { return &fakeInfoShortcutService{}, nil } + + var out, errOut bytes.Buffer + getPrinter = func(cmd *cobra.Command) *output.Printer { + return output.NewWithStderr(&out, &errOut, output.FormatJSON) + } + setFlags(cmd) + err = cmd.RunE(cmd, nil) + return out.String(), errOut.String(), err +} + +func TestInfoShortcutAddressTrackerMissingAddress(t *testing.T) { + cmd := newInfoAddressTrackerCmd() + _, stderr, err := runOnchainShortcut(t, cmd, func(c *cobra.Command) { + require.NoError(t, c.Flags().Set("chain", "eth")) + }) + require.Error(t, err) + assert.Contains(t, stderr, "missing required flag: address") +} + +func TestInfoShortcutAddressTrackerDegradedFundFlow(t *testing.T) { + cmd := newInfoAddressTrackerCmd() + stdout, _, err := runOnchainShortcut(t, cmd, func(c *cobra.Command) { + require.NoError(t, c.Flags().Set("address", "0xabc")) + require.NoError(t, c.Flags().Set("chain", "eth")) + }) + require.NoError(t, err) + + var payload map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(stdout), &payload)) + assert.Equal(t, true, payload["fund_flow_unavailable"]) + assert.Contains(t, payload["coverage_note"], "trace_fund_flow") +} + +func TestInfoShortcutTokenOnchainMissingInput(t *testing.T) { + cmd := newInfoTokenOnchainCmd() + _, stderr, err := runOnchainShortcut(t, cmd, func(c *cobra.Command) {}) + require.Error(t, err) + assert.Contains(t, stderr, "either symbol or address is required") +} + +func TestInfoShortcutTokenOnchainDegradedSmartMoney(t *testing.T) { + cmd := newInfoTokenOnchainCmd() + stdout, _, err := runOnchainShortcut(t, cmd, func(c *cobra.Command) { + require.NoError(t, c.Flags().Set("symbol", "USDT")) + }) + require.NoError(t, err) + + var payload map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(stdout), &payload)) + assert.Equal(t, true, payload["smart_money_unavailable"]) + assert.Contains(t, payload["coverage_note"], "get_smart_money") +} diff --git a/cmd/info/shortcut_test.go b/cmd/info/shortcut_test.go new file mode 100644 index 0000000..7c8783a --- /dev/null +++ b/cmd/info/shortcut_test.go @@ -0,0 +1,367 @@ +package info + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gate/gate-cli/internal/intelfacade" + "github.com/gate/gate-cli/internal/mcpclient" + "github.com/gate/gate-cli/internal/output" +) + +// tokenRiskRecordingService records tools/call order for +token-risk contract tests. +type tokenRiskRecordingService struct { + calls []string + failSecurity bool + failCoin bool + nativeCoin bool +} + +func (s *tokenRiskRecordingService) ListTools(ctx context.Context) ([]intelfacade.ToolSummary, *http.Response, error) { + return nil, nil, nil +} +func (s *tokenRiskRecordingService) DescribeTool(ctx context.Context, name string) (*intelfacade.ToolSummary, *http.Response, error) { + return &intelfacade.ToolSummary{Name: name}, nil, nil +} +func (s *tokenRiskRecordingService) CallTool(ctx context.Context, name string, arguments map[string]interface{}) (*mcpclient.CallResult, *http.Response, error) { + s.calls = append(s.calls, name) + switch name { + case "info_compliance_check_token_security": + if s.failSecurity { + return nil, nil, errors.New("security check failed") + } + return &mcpclient.CallResult{StructuredContent: map[string]interface{}{"risk_level": "low"}}, nil, nil + case "info_coin_get_coin_info": + if s.failCoin { + return nil, nil, errors.New("coin lookup failed") + } + if s.nativeCoin { + return &mcpclient.CallResult{StructuredContent: map[string]interface{}{ + "query": "BTC", + "items": []interface{}{ + map[string]interface{}{ + "symbol": "BTC", + "contract_address": "", + "chain": []interface{}{"botanix"}, + }, + }, + }}, nil, nil + } + content := map[string]interface{}{"tool": name} + q, _ := arguments["query"].(string) + if strings.TrimSpace(q) == "" { + q, _ = arguments["symbol"].(string) + } + if strings.TrimSpace(q) != "" { + content["canonical_contract"] = "0xresolved" + content["canonical_chain"] = "eth" + } + return &mcpclient.CallResult{StructuredContent: content}, nil, nil + default: + return &mcpclient.CallResult{StructuredContent: map[string]interface{}{"tool": name}}, nil, nil + } +} + +func runTokenRiskShortcut(t *testing.T, svc infoService, setFlags func(*cobra.Command)) (stdout, stderr string, err error) { + t.Helper() + oldFactory, oldPrinter := newInfoService, getPrinter + t.Cleanup(func() { newInfoService = oldFactory; getPrinter = oldPrinter }) + newInfoService = func(cmd *cobra.Command) (infoService, error) { return svc, nil } + + var out, errOut bytes.Buffer + getPrinter = func(cmd *cobra.Command) *output.Printer { + return output.NewWithStderr(&out, &errOut, output.FormatJSON) + } + cmd := newInfoTokenRiskCmd() + setFlags(cmd) + err = cmd.RunE(cmd, nil) + return out.String(), errOut.String(), err +} + +type fakeInfoShortcutService struct{} + +func (f *fakeInfoShortcutService) ListTools(ctx context.Context) ([]intelfacade.ToolSummary, *http.Response, error) { + return nil, nil, nil +} +func (f *fakeInfoShortcutService) DescribeTool(ctx context.Context, name string) (*intelfacade.ToolSummary, *http.Response, error) { + return &intelfacade.ToolSummary{Name: name}, nil, nil +} +func (f *fakeInfoShortcutService) CallTool(ctx context.Context, name string, arguments map[string]interface{}) (*mcpclient.CallResult, *http.Response, error) { + return &mcpclient.CallResult{StructuredContent: map[string]interface{}{"tool": name}}, nil, nil +} + +func TestInfoShortcutCoinOverviewMissingSymbolJSON(t *testing.T) { + oldFactory, oldPrinter := newInfoService, getPrinter + t.Cleanup(func() { newInfoService = oldFactory; getPrinter = oldPrinter }) + newInfoService = func(cmd *cobra.Command) (infoService, error) { return &fakeInfoShortcutService{}, nil } + + var out, errOut bytes.Buffer + getPrinter = func(cmd *cobra.Command) *output.Printer { + return output.NewWithStderr(&out, &errOut, output.FormatJSON) + } + + cmd := newInfoCoinOverviewCmd() + err := cmd.RunE(cmd, nil) + require.Error(t, err) + assert.Empty(t, out.String()) + assert.Contains(t, errOut.String(), `"error"`) + assert.Contains(t, errOut.String(), `"label":"INVALID_ARGUMENTS"`) + assert.Contains(t, errOut.String(), "missing required flag: symbol") +} + +func TestInfoShortcutCoinOverviewOmitsScopeOnGetCoinInfo(t *testing.T) { + svc := &coinOverviewRecordingService{} + oldFactory, oldPrinter := newInfoService, getPrinter + t.Cleanup(func() { newInfoService = oldFactory; getPrinter = oldPrinter }) + newInfoService = func(cmd *cobra.Command) (infoService, error) { return svc, nil } + + var out, errOut bytes.Buffer + getPrinter = func(cmd *cobra.Command) *output.Printer { + return output.NewWithStderr(&out, &errOut, output.FormatJSON) + } + + cmd := newInfoCoinOverviewCmd() + require.NoError(t, cmd.Flags().Set("symbol", "BTC")) + require.NoError(t, cmd.RunE(cmd, nil)) + require.NotEmpty(t, svc.coinArgs) + assert.Equal(t, "BTC", svc.coinArgs["query"]) + assert.Equal(t, "symbol", svc.coinArgs["query_type"]) + if _, ok := svc.coinArgs["scope"]; ok { + t.Fatalf("expected get-coin-info without scope, got %#v", svc.coinArgs) + } + if _, ok := svc.coinArgs["symbol"]; ok { + t.Fatalf("expected query not symbol, got %#v", svc.coinArgs) + } +} + +type coinOverviewRecordingService struct { + coinArgs map[string]interface{} +} + +func (s *coinOverviewRecordingService) ListTools(ctx context.Context) ([]intelfacade.ToolSummary, *http.Response, error) { + return nil, nil, nil +} +func (s *coinOverviewRecordingService) DescribeTool(ctx context.Context, name string) (*intelfacade.ToolSummary, *http.Response, error) { + return &intelfacade.ToolSummary{Name: name}, nil, nil +} +func (s *coinOverviewRecordingService) CallTool(ctx context.Context, name string, arguments map[string]interface{}) (*mcpclient.CallResult, *http.Response, error) { + if name == "info_coin_get_coin_info" { + s.coinArgs = arguments + } + return &mcpclient.CallResult{StructuredContent: map[string]interface{}{"tool": name}}, nil, nil +} + +func TestInfoShortcutCoinCompareUsesQueryForCoinInfo(t *testing.T) { + svc := &coinCompareRecordingService{} + oldFactory, oldPrinter := newInfoService, getPrinter + t.Cleanup(func() { newInfoService = oldFactory; getPrinter = oldPrinter }) + newInfoService = func(cmd *cobra.Command) (infoService, error) { return svc, nil } + + var out, errOut bytes.Buffer + getPrinter = func(cmd *cobra.Command) *output.Printer { + return output.NewWithStderr(&out, &errOut, output.FormatJSON) + } + + cmd := newInfoCoinCompareCmd() + require.NoError(t, cmd.Flags().Set("symbols", "BTC,ETH")) + require.NoError(t, cmd.RunE(cmd, nil)) + require.NotEmpty(t, svc.coinArgs) + assert.Equal(t, "BTC", svc.coinArgs["query"]) + assert.Equal(t, "symbol", svc.coinArgs["query_type"]) +} + +type coinCompareRecordingService struct { + coinArgs map[string]interface{} +} + +func (s *coinCompareRecordingService) ListTools(ctx context.Context) ([]intelfacade.ToolSummary, *http.Response, error) { + return nil, nil, nil +} +func (s *coinCompareRecordingService) DescribeTool(ctx context.Context, name string) (*intelfacade.ToolSummary, *http.Response, error) { + return &intelfacade.ToolSummary{Name: name}, nil, nil +} +func (s *coinCompareRecordingService) CallTool(ctx context.Context, name string, arguments map[string]interface{}) (*mcpclient.CallResult, *http.Response, error) { + if name == "info_coin_get_coin_info" && s.coinArgs == nil { + s.coinArgs = arguments + } + return &mcpclient.CallResult{StructuredContent: map[string]interface{}{"tool": name}}, nil, nil +} + +func TestInfoShortcutCoinOverview(t *testing.T) { + oldFactory, oldPrinter := newInfoService, getPrinter + t.Cleanup(func() { newInfoService = oldFactory; getPrinter = oldPrinter }) + newInfoService = func(cmd *cobra.Command) (infoService, error) { return &fakeInfoShortcutService{}, nil } + + var out, errOut bytes.Buffer + getPrinter = func(cmd *cobra.Command) *output.Printer { + return output.NewWithStderr(&out, &errOut, output.FormatJSON) + } + + cmd := newInfoCoinOverviewCmd() + require.NoError(t, cmd.Flags().Set("symbol", "BTC")) + require.NoError(t, cmd.RunE(cmd, nil)) + assert.Contains(t, out.String(), `"summary"`) + assert.Contains(t, out.String(), `"basic_info"`) + assert.Empty(t, errOut.String()) +} + +func TestInfoShortcutTokenRiskNeedsResolvedCanonical(t *testing.T) { + stdout, stderr, err := runTokenRiskShortcut(t, &fakeInfoShortcutService{}, func(cmd *cobra.Command) { + require.NoError(t, cmd.Flags().Set("symbol", "BTC")) + }) + require.Error(t, err) + assert.Empty(t, stdout) + assert.Contains(t, stderr, `"error"`) + assert.Contains(t, stderr, "failed to resolve canonical contract or chain from symbol") +} + +func TestInfoShortcutTokenRiskNativeCoinMessage(t *testing.T) { + svc := &tokenRiskRecordingService{nativeCoin: true} + stdout, stderr, err := runTokenRiskShortcut(t, svc, func(cmd *cobra.Command) { + require.NoError(t, cmd.Flags().Set("symbol", "BTC")) + }) + require.Error(t, err) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "native coin has no contract address for token security") +} + +func TestInfoShortcutTokenRiskAddressBranchCallOrder(t *testing.T) { + svc := &tokenRiskRecordingService{} + stdout, stderr, err := runTokenRiskShortcut(t, svc, func(cmd *cobra.Command) { + require.NoError(t, cmd.Flags().Set("address", "0xabc")) + require.NoError(t, cmd.Flags().Set("chain", "eth")) + }) + require.NoError(t, err) + assert.Empty(t, stderr) + require.Equal(t, []string{ + "info_compliance_check_token_security", + "info_coin_get_coin_info", + }, svc.calls) + + var payload map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(stdout), &payload)) + assert.Equal(t, "low", payload["risk_level"].(map[string]interface{})["risk_level"]) + assert.NotNil(t, payload["token_identity"]) + assert.NotContains(t, payload, "partial") +} + +func TestInfoShortcutTokenRiskAddressBranchSecurityFailsBeforeCoinLookup(t *testing.T) { + svc := &tokenRiskRecordingService{failSecurity: true} + stdout, stderr, err := runTokenRiskShortcut(t, svc, func(cmd *cobra.Command) { + require.NoError(t, cmd.Flags().Set("address", "0xabc")) + require.NoError(t, cmd.Flags().Set("chain", "eth")) + }) + require.Error(t, err) + assert.Empty(t, stdout) + assert.Contains(t, stderr, `"error"`) + require.Equal(t, []string{"info_compliance_check_token_security"}, svc.calls) +} + +func TestInfoShortcutTokenRiskAddressBranchPartialWhenCoinMissing(t *testing.T) { + svc := &tokenRiskRecordingService{failCoin: true} + stdout, stderr, err := runTokenRiskShortcut(t, svc, func(cmd *cobra.Command) { + require.NoError(t, cmd.Flags().Set("address", "0xabc")) + require.NoError(t, cmd.Flags().Set("chain", "eth")) + }) + require.NoError(t, err) + assert.Empty(t, stderr) + require.Equal(t, []string{ + "info_compliance_check_token_security", + "info_coin_get_coin_info", + }, svc.calls) + + var payload map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(stdout), &payload)) + assert.Equal(t, true, payload["partial"]) + missing, ok := payload["missing_sections"].([]interface{}) + require.True(t, ok) + require.Len(t, missing, 1) + assert.Equal(t, "token_identity", missing[0]) +} + +func TestInfoShortcutTokenRiskSymbolAndAddressMutualExclusion(t *testing.T) { + stdout, stderr, err := runTokenRiskShortcut(t, &fakeInfoShortcutService{}, func(cmd *cobra.Command) { + require.NoError(t, cmd.Flags().Set("symbol", "USDT")) + require.NoError(t, cmd.Flags().Set("address", "0xabc")) + require.NoError(t, cmd.Flags().Set("chain", "eth")) + }) + require.Error(t, err) + assert.Empty(t, stdout) + assert.Contains(t, stderr, `"error"`) + assert.Contains(t, stderr, "provide either symbol or address, not both") +} + +func TestInfoShortcutTokenOnchainSymbolUsesQueryNotScopeFull(t *testing.T) { + svc := &tokenOnchainRecordingService{} + oldFactory, oldPrinter := newInfoService, getPrinter + t.Cleanup(func() { newInfoService = oldFactory; getPrinter = oldPrinter }) + newInfoService = func(cmd *cobra.Command) (infoService, error) { return svc, nil } + + var out, errOut bytes.Buffer + getPrinter = func(cmd *cobra.Command) *output.Printer { + return output.NewWithStderr(&out, &errOut, output.FormatJSON) + } + + cmd := newInfoTokenOnchainCmd() + require.NoError(t, cmd.Flags().Set("symbol", "USDT")) + require.NoError(t, cmd.RunE(cmd, nil)) + require.NotEmpty(t, svc.coinArgs) + assert.Equal(t, "USDT", svc.coinArgs["query"]) + assert.Equal(t, "symbol", svc.coinArgs["query_type"]) + if _, ok := svc.coinArgs["scope"]; ok { + t.Fatalf("expected get-coin-info without scope=full, got %#v", svc.coinArgs) + } + if _, ok := svc.coinArgs["symbol"]; ok { + t.Fatalf("expected query not symbol, got %#v", svc.coinArgs) + } +} + +type tokenOnchainRecordingService struct { + coinArgs map[string]interface{} +} + +func (s *tokenOnchainRecordingService) ListTools(ctx context.Context) ([]intelfacade.ToolSummary, *http.Response, error) { + return nil, nil, nil +} +func (s *tokenOnchainRecordingService) DescribeTool(ctx context.Context, name string) (*intelfacade.ToolSummary, *http.Response, error) { + return &intelfacade.ToolSummary{Name: name}, nil, nil +} +func (s *tokenOnchainRecordingService) CallTool(ctx context.Context, name string, arguments map[string]interface{}) (*mcpclient.CallResult, *http.Response, error) { + if name == "info_coin_get_coin_info" { + s.coinArgs = arguments + return &mcpclient.CallResult{StructuredContent: map[string]interface{}{ + "canonical_contract": "0xusdt", + "canonical_chain": "eth", + }}, nil, nil + } + return &mcpclient.CallResult{StructuredContent: map[string]interface{}{"tool": name}}, nil, nil +} + +func TestInfoShortcutTokenRiskSymbolBranchCallOrder(t *testing.T) { + svc := &tokenRiskRecordingService{} + stdout, stderr, err := runTokenRiskShortcut(t, svc, func(cmd *cobra.Command) { + require.NoError(t, cmd.Flags().Set("symbol", "BTC")) + }) + require.NoError(t, err) + assert.Empty(t, stderr) + require.Equal(t, []string{ + "info_coin_get_coin_info", + "info_compliance_check_token_security", + }, svc.calls) + + var payload map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(stdout), &payload)) + identity, ok := payload["token_identity"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "0xresolved", identity["canonical_contract"]) + assert.NotContains(t, payload, "partial") +} diff --git a/cmd/info/token_risk_resolve.go b/cmd/info/token_risk_resolve.go new file mode 100644 index 0000000..08a7ad1 --- /dev/null +++ b/cmd/info/token_risk_resolve.go @@ -0,0 +1,366 @@ +package info + +import ( + "encoding/json" + "strings" +) + +// resolveTokenAddressAndChain extracts contract address + chain slug from get_coin_info payload. +// querySymbol drives multi-chain preference (aligned with mcp-server rerank / coreSymbolPreferredChain). +func resolveTokenAddressAndChain(coin map[string]interface{}, querySymbol string) (string, string) { + if coin == nil { + return "", "" + } + symbol := strings.ToUpper(strings.TrimSpace(querySymbol)) + + if items := coinItems(coin); len(items) > 0 { + if addr, chain := contractFromCoinItem(items[0], symbol); addr != "" && chain != "" { + return addr, chain + } + for _, item := range items[1:] { + if addr, chain := contractFromCoinItem(item, symbol); addr != "" && chain != "" { + return addr, chain + } + } + } + + if address, chain := contractFromCoinItem(coin, symbol); address != "" && chain != "" { + return address, chain + } + for _, key := range []string{"data", "coin", "result", "project", "token", "asset"} { + if nested, ok := coin[key].(map[string]interface{}); ok { + if address, chain := contractFromCoinItem(nested, symbol); address != "" && chain != "" { + return address, chain + } + } + } + return "", "" +} + +// coinInfoIndicatesNativeAsset reports native / no-contract assets (e.g. BTC) where token security does not apply. +func coinInfoIndicatesNativeAsset(coin map[string]interface{}, querySymbol string) bool { + if coin == nil { + return false + } + items := coinItems(coin) + if len(items) == 0 { + items = []map[string]interface{}{coin} + } + symbol := strings.ToUpper(strings.TrimSpace(querySymbol)) + matched := 0 + nativeLike := 0 + for _, item := range items { + if !symbolMatchesItem(item, symbol) && symbol != "" { + continue + } + matched++ + if coinItemLooksNative(item) { + nativeLike++ + } + } + if matched == 0 { + return false + } + // Only treat as native when every symbol-matching hit lacks a resolvable contract. + return matched == nativeLike +} + +func coinItems(coin map[string]interface{}) []map[string]interface{} { + for _, key := range []string{"items", "coins", "results", "matches"} { + arr, ok := coin[key].([]interface{}) + if !ok { + continue + } + out := make([]map[string]interface{}, 0, len(arr)) + for _, raw := range arr { + if m, ok := raw.(map[string]interface{}); ok { + out = append(out, m) + } + } + if len(out) > 0 { + return out + } + } + return nil +} + +func symbolMatchesItem(item map[string]interface{}, symbol string) bool { + if symbol == "" { + return true + } + for _, key := range []string{"symbol", "gate_symbol", "source_id"} { + if strings.EqualFold(strings.TrimSpace(firstStringByKeys(item, key)), symbol) { + return true + } + } + return false +} + +func coinItemLooksNative(item map[string]interface{}) bool { + if item == nil { + return false + } + for _, key := range []string{"native_coin", "is_native", "is_main_asset", "is_main"} { + switch v := item[key].(type) { + case bool: + if v { + return true + } + case string: + s := strings.ToLower(strings.TrimSpace(v)) + if s == "1" || s == "true" || s == "yes" { + return true + } + } + } + if tt := strings.ToLower(strings.TrimSpace(firstStringByKeys(item, "token_type"))); tt == "native" || tt == "main" { + return true + } + addr := extractContractAddress(item) + if addr != "" { + return false + } + if addrs := extractTokenAddressList(item); len(addrs) > 0 { + return false + } + // Empty contract with chain present and no token_address → native / non-EVM token-security path. + return len(extractChainList(item)) > 0 || firstStringByKeys(item, "symbol") != "" +} + +func contractFromCoinItem(item map[string]interface{}, querySymbol string) (string, string) { + if item == nil { + return "", "" + } + address := extractContractAddress(item) + if address == "" { + if addrs := extractTokenAddressList(item); len(addrs) > 0 { + address = addrs[0] + } + } + if address == "" { + return "", "" + } + chain := pickPreferredChain(extractChainList(item), querySymbol) + return address, chain +} + +func extractContractAddress(m map[string]interface{}) string { + if addr := firstStringByKeys(m, "canonical_contract", "contract_address", "contract_addr", "address", "contract"); addr != "" { + return addr + } + return "" +} + +func extractTokenAddressList(m map[string]interface{}) []string { + raw, ok := m["token_address"] + if !ok || raw == nil { + return nil + } + switch v := raw.(type) { + case string: + s := strings.TrimSpace(v) + if s == "" { + return nil + } + if strings.HasPrefix(s, "[") { + var arr []interface{} + if err := json.Unmarshal([]byte(s), &arr); err == nil { + return addressesFromTokenAddressArray(arr) + } + } + return []string{s} + case []string: + out := make([]string, 0, len(v)) + for _, s := range v { + if t := strings.TrimSpace(s); t != "" { + out = append(out, t) + } + } + return out + case []interface{}: + return addressesFromTokenAddressArray(v) + } + return nil +} + +func addressesFromTokenAddressArray(arr []interface{}) []string { + out := make([]string, 0, len(arr)) + for _, it := range arr { + switch x := it.(type) { + case string: + if s := strings.TrimSpace(x); s != "" { + out = append(out, s) + } + case map[string]interface{}: + if s := firstStringByKeys(x, "contract_addr", "contract_address", "address"); s != "" { + out = append(out, s) + } + } + } + return out +} + +func extractChainList(m map[string]interface{}) []string { + if chains := stringSliceField(m, "chain"); len(chains) > 0 { + return chains + } + if c := firstStringByKeys(m, "canonical_chain", "network", "primary_chain"); c != "" { + return []string{c} + } + return nil +} + +func stringSliceField(m map[string]interface{}, key string) []string { + raw, ok := m[key] + if !ok || raw == nil { + return nil + } + switch v := raw.(type) { + case string: + s := strings.TrimSpace(v) + if s == "" { + return nil + } + return []string{s} + case []string: + out := make([]string, 0, len(v)) + for _, s := range v { + if t := strings.TrimSpace(s); t != "" { + out = append(out, t) + } + } + return out + case []interface{}: + out := make([]string, 0, len(v)) + for _, it := range v { + if s, ok := it.(string); ok { + if t := strings.TrimSpace(s); t != "" { + out = append(out, t) + } + } + } + return out + default: + return nil + } +} + +func pickPreferredChain(chains []string, querySymbol string) string { + if len(chains) == 0 { + return "" + } + if len(chains) == 1 { + return normalizeChainSlugForTokenSecurity(chains[0]) + } + symbol := strings.ToUpper(strings.TrimSpace(querySymbol)) + if hint := coreSymbolPreferredChainSlug(symbol); hint != "" { + for _, c := range chains { + if chainSlugMatches(c, hint) { + return normalizeChainSlugForTokenSecurity(c) + } + } + } + for _, pref := range tokenSecurityChainPriority { + for _, c := range chains { + if chainSlugMatches(c, pref) { + return normalizeChainSlugForTokenSecurity(c) + } + } + } + return normalizeChainSlugForTokenSecurity(chains[0]) +} + +// coreSymbolPreferredChainSlug mirrors mcp-server internal/coin/searcher.go coreSymbolPreferredChain. +func coreSymbolPreferredChainSlug(symbol string) string { + switch strings.ToUpper(strings.TrimSpace(symbol)) { + case "BTC": + return "btc" + case "ETH": + return "eth" + case "SOL": + return "sol" + case "BNB": + return "bsc" + default: + return "" + } +} + +var tokenSecurityChainPriority = []string{ + "eth", "ethereum", + "bsc", "bnb", + "arbitrum", "arb", + "base", + "polygon", "matic", + "optimism", "op", + "avalanche", "avax", + "solana", "sol", + "tron", "trx", +} + +func chainSlugMatches(chain, slug string) bool { + c := normalizeChainSlugForTokenSecurity(chain) + s := normalizeChainSlugForTokenSecurity(slug) + if c == s { + return true + } + aliases := chainSlugAliases[c] + for _, a := range aliases { + if a == s { + return true + } + } + aliases = chainSlugAliases[s] + for _, a := range aliases { + if a == c { + return true + } + } + return false +} + +var chainSlugAliases = map[string][]string{ + "eth": {"ethereum"}, + "ethereum": {"eth"}, + "bsc": {"bnb", "bnb chain", "binance-smart-chain"}, + "bnb": {"bsc"}, + "arb": {"arbitrum", "arbitrum one"}, + "arbitrum": {"arb"}, + "matic": {"polygon"}, + "polygon": {"matic"}, + "op": {"optimism"}, + "optimism": {"op"}, + "avax": {"avalanche", "avalanche-c"}, + "avalanche": {"avax"}, + "sol": {"solana"}, + "solana": {"sol"}, + "trx": {"tron"}, + "tron": {"trx"}, + "btc": {"bitcoin"}, + "bitcoin": {"btc"}, +} + +func normalizeChainSlugForTokenSecurity(chain string) string { + c := strings.ToLower(strings.TrimSpace(chain)) + c = strings.NewReplacer(" ", "-", "_", "-").Replace(c) + switch c { + case "ethereum", "eth-mainnet": + return "eth" + case "bnb-chain", "binance-smart-chain", "binance-smart-chain-mainnet": + return "bsc" + case "arbitrum-one", "arbitrum-one-mainnet": + return "arbitrum" + case "polygon-pos", "matic-mainnet": + return "polygon" + case "optimistic-ethereum", "op-mainnet": + return "optimism" + case "avalanche-c-chain", "avax-c": + return "avax" + case "solana-mainnet": + return "sol" + case "bitcoin", "btc-mainnet": + return "btc" + default: + return c + } +} diff --git a/cmd/info/token_risk_resolve_test.go b/cmd/info/token_risk_resolve_test.go new file mode 100644 index 0000000..2ee4810 --- /dev/null +++ b/cmd/info/token_risk_resolve_test.go @@ -0,0 +1,139 @@ +package info + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveTokenAddressAndChainNested(t *testing.T) { + coin := map[string]interface{}{ + "data": map[string]interface{}{ + "canonical_contract": "0xabc", + "canonical_chain": "eth", + }, + } + addr, chain := resolveTokenAddressAndChain(coin, "") + assert.Equal(t, "0xabc", addr) + assert.Equal(t, "eth", chain) +} + +func TestResolveTokenAddressAndChainItemsChainArray(t *testing.T) { + coin := map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "symbol": "USDT", + "contract_address": "0xdac17f958d2ee523a2206206994597c13d831ec7", + "chain": []interface{}{"Ethereum", "BNB Chain"}, + }, + }, + } + addr, chain := resolveTokenAddressAndChain(coin, "USDT") + assert.Equal(t, "0xdac17f958d2ee523a2206206994597c13d831ec7", addr) + assert.Equal(t, "eth", chain) +} + +func TestResolveTokenAddressAndChainTokenAddressNested(t *testing.T) { + coin := map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "symbol": "PEPE", + "chain": []interface{}{"Ethereum"}, + "token_address": []interface{}{ + map[string]interface{}{"contract_addr": "0x6982508145454ce325ddbe47a25d4ec3d2311933"}, + }, + }, + }, + } + addr, chain := resolveTokenAddressAndChain(coin, "PEPE") + assert.Equal(t, "0x6982508145454ce325ddbe47a25d4ec3d2311933", addr) + assert.Equal(t, "eth", chain) +} + +func TestResolveTokenAddressAndChainPrefersItemsZero(t *testing.T) { + coin := map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "symbol": "USDT", + "contract_address": "0xfirst", + "chain": []interface{}{"Ethereum"}, + }, + map[string]interface{}{ + "symbol": "USDT", + "contract_address": "0xsecond", + "chain": []interface{}{"BNB Chain"}, + }, + }, + } + addr, chain := resolveTokenAddressAndChain(coin, "USDT") + assert.Equal(t, "0xfirst", addr) + assert.Equal(t, "eth", chain) +} + +func TestCoinInfoIndicatesNativeAssetMixedItemsNotNative(t *testing.T) { + coin := map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "symbol": "USDT", + "contract_address": "", + "chain": []interface{}{"goat"}, + }, + map[string]interface{}{ + "symbol": "USDT", + "contract_address": "0xdac17f958d2ee523a2206206994597c13d831ec7", + "chain": []interface{}{"Ethereum"}, + }, + }, + } + assert.False(t, coinInfoIndicatesNativeAsset(coin, "USDT")) + addr, chain := resolveTokenAddressAndChain(coin, "USDT") + assert.Equal(t, "0xdac17f958d2ee523a2206206994597c13d831ec7", addr) + assert.Equal(t, "eth", chain) +} + +func TestCoinInfoIndicatesNativeAssetAllItemsNative(t *testing.T) { + coin := map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "symbol": "BTC", + "contract_address": "", + "chain": []interface{}{"botanix"}, + }, + map[string]interface{}{ + "symbol": "BTC", + "contract_address": "", + "chain": []interface{}{"goat"}, + }, + }, + } + assert.True(t, coinInfoIndicatesNativeAsset(coin, "BTC")) +} + +func TestCoinInfoIndicatesNativeAssetBTCShape(t *testing.T) { + coin := map[string]interface{}{ + "query": "BTC", + "items": []interface{}{ + map[string]interface{}{ + "symbol": "BTC", + "contract_address": "", + "chain": []interface{}{"botanix"}, + }, + }, + } + assert.True(t, coinInfoIndicatesNativeAsset(coin, "BTC")) + addr, chain := resolveTokenAddressAndChain(coin, "BTC") + assert.Empty(t, addr) + assert.Empty(t, chain) +} + +func TestNormalizeChainSlugForTokenSecurity(t *testing.T) { + assert.Equal(t, "eth", normalizeChainSlugForTokenSecurity("Ethereum")) + assert.Equal(t, "bsc", normalizeChainSlugForTokenSecurity("BNB Chain")) + assert.Equal(t, "arbitrum", normalizeChainSlugForTokenSecurity("Arbitrum One")) +} + +func TestPickPreferredChainBNBSymbol(t *testing.T) { + chain := pickPreferredChain([]string{"Ethereum", "BNB Chain"}, "BNB") + require.Equal(t, "bsc", chain) +} diff --git a/cmd/migrate/migrate.go b/cmd/migrate/migrate.go index 7a4be98..3134870 100644 --- a/cmd/migrate/migrate.go +++ b/cmd/migrate/migrate.go @@ -1,12 +1,14 @@ package migrate import ( - "errors" + "strings" "github.com/spf13/cobra" + "github.com/gate/gate-cli/internal/cmdhint" "github.com/gate/gate-cli/internal/cmdutil" "github.com/gate/gate-cli/internal/exitcode" + "github.com/gate/gate-cli/internal/intelcmd" "github.com/gate/gate-cli/internal/migration" "github.com/gate/gate-cli/internal/output" ) @@ -32,7 +34,7 @@ func runMigrate(cmd *cobra.Command, args []string) error { p := cmdutil.GetPrinter(cmd) if p.IsTable() { p.PrintError(output.UnsupportedTableFormatError()) - return exitcode.New(exitcode.RenderOrInternal, errors.New("unsupported format")) + return exitcode.New(exitcode.RenderOrInternal, intelcmd.ErrSilenced) } dryRun, _ := cmd.Flags().GetBool("dry-run") apply, _ := cmd.Flags().GetBool("apply") @@ -41,12 +43,12 @@ func runMigrate(cmd *cobra.Command, args []string) error { backupDir, _ := cmd.Flags().GetString("backup-dir") if err := migration.ValidateMode(apply, dryRun); err != nil { - p.PrintError(&output.GateError{Status: 400, Label: "INVALID_ARGUMENTS", Message: err.Error()}) - return exitcode.New(exitcode.RenderOrInternal, err) + p.PrintError(output.InvalidArgsError(err.Error())) + return exitcode.New(exitcode.RenderOrInternal, intelcmd.ErrSilenced) } if apply && !yes { p.PrintError(&output.GateError{Status: 400, Label: "CONFIRMATION_REQUIRED", Message: "use --yes with --apply to run non-interactive migration"}) - return exitcode.New(exitcode.RenderOrInternal, errors.New("confirmation required")) + return exitcode.New(exitcode.RenderOrInternal, intelcmd.ErrSilenced) } report, err := migration.RunMigrate(migration.MigrateOptions{ @@ -55,20 +57,56 @@ func runMigrate(cmd *cobra.Command, args []string) error { BackupDir: backupDir, }) if err != nil { - p.PrintError(&output.GateError{Status: 500, Label: "MIGRATE_FAILED", Message: err.Error()}) - return exitcode.New(exitcode.RenderOrInternal, errors.New("migrate failed")) + ge := &output.GateError{Status: 500, Label: "MIGRATE_FAILED", Message: err.Error()} + output.FillAgentErrorConvergence(ge) + p.PrintError(ge) + return exitcode.New(exitcode.RenderOrInternal, intelcmd.ErrSilenced) } - if err := p.Print(report); err != nil { - return exitcode.New(exitcode.RenderOrInternal, err) + payload := interface{}(report) + if p.IsJSON() && cmdhint.AgentModeEnabled() { + payload = map[string]interface{}{ + "mode": report.Mode, + "status": report.Status, + "providers": report.Providers, + "recommended_next_step": report.RecommendedNextStep, + "suggested_next_action": cmdhint.AgentMigrateNextAction(report.Status), + "agent_resolve_hint": cmdhint.AgentResolveHint("intel migrate"), + } } if report.Status == "fail" { - p.PrintError(&output.GateError{Status: 422, Label: "MIGRATE_FAILED", Message: "migrate completed with failures"}) - return exitcode.New(migration.MigrateExitCode(report), errors.New("migrate report failed")) + ge := &output.GateError{Status: 422, Label: "MIGRATE_FAILED", Message: migrateFailMessage(report)} + output.FillAgentErrorConvergence(ge) + if cmdhint.AgentModeEnabled() { + ge.SuggestedNextAction = cmdhint.AgentMigrateNextAction(report.Status) + } else if err := p.Print(payload); err != nil { + return exitcode.New(exitcode.RenderOrInternal, err) + } + p.PrintError(ge) + return exitcode.New(migration.MigrateExitCode(report), intelcmd.ErrSilenced) + } + + if err := p.Print(payload); err != nil { + return exitcode.New(exitcode.RenderOrInternal, err) } if report.Status == "warn" { return exitcode.New(migration.MigrateExitCode(report), nil) } return nil } + +func migrateFailMessage(report migration.MigrateReport) string { + for _, pr := range report.Providers { + if strings.TrimSpace(pr.Status) != "fail" { + continue + } + if msg := strings.TrimSpace(pr.ManualPatch); msg != "" { + return "migrate failed: " + msg + } + if id := strings.TrimSpace(pr.ProviderID); id != "" { + return "migrate failed: provider " + id + } + } + return "migrate completed with failures" +} diff --git a/cmd/migrate/migrate_agent_fail_test.go b/cmd/migrate/migrate_agent_fail_test.go new file mode 100644 index 0000000..8b3e476 --- /dev/null +++ b/cmd/migrate/migrate_agent_fail_test.go @@ -0,0 +1,110 @@ +//go:build agent + +package migrate + +import ( + "bytes" + "encoding/json" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/gate/gate-cli/internal/migration" +) + +func TestMigrateAgentFailStderrOnlyJSON(t *testing.T) { + // Workspace-local temp: sandbox may block mkdir under system $TMPDIR/.cursor. + home, err := os.MkdirTemp(".", "migrate-agent-fail-*") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(home) }) + target := filepath.Join(home, ".config", "codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, []byte(`{"gate-info":{"command":"x"}}`), 0o644); err != nil { + t.Fatal(err) + } + backupDir := filepath.Join(home, "backup") + if err := os.MkdirAll(backupDir, 0o500); err != nil { // no write: backup fails on apply + t.Fatal(err) + } + + t.Setenv("HOME", home) + t.Setenv("GATE_CLI_AGENT", "1") + t.Cleanup(func() { + _ = os.Unsetenv("HOME") + _ = os.Unsetenv("GATE_CLI_AGENT") + }) + + root := &cobra.Command{Use: "gate-cli"} + root.PersistentFlags().String("format", "json", "") + root.PersistentFlags().Int64("max-output-bytes", 0, "") + root.AddCommand(Cmd) + + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetArgs([]string{ + "migrate", + "--apply", + "--yes", + "--provider", "codex", + "--backup-dir", backupDir, + "--format", "json", + }) + + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + oldStderr := os.Stderr + os.Stderr = w + t.Cleanup(func() { + os.Stderr = oldStderr + _ = w.Close() + }) + + if err := root.Execute(); err == nil { + t.Fatal("expected error exit") + } + _ = w.Close() + stderrBytes, _ := io.ReadAll(r) + if stdout.Len() > 0 { + t.Fatalf("agent fail must not write stdout, got %q", stdout.String()) + } + if !strings.Contains(string(stderrBytes), `"error"`) { + t.Fatalf("expected stderr GateError JSON, got %q", stderrBytes) + } + var wrap map[string]interface{} + if err := json.Unmarshal(stderrBytes, &wrap); err != nil { + t.Fatalf("stderr JSON: %v body=%q", err, stderrBytes) + } + errObj, _ := wrap["error"].(map[string]interface{}) + if errObj["label"] != "MIGRATE_FAILED" { + t.Fatalf("expected MIGRATE_FAILED, got %#v", errObj) + } + if retry, _ := errObj["retryable"].(bool); retry { + t.Fatalf("MIGRATE_FAILED must not be retryable: %#v", errObj) + } + msg, _ := errObj["message"].(string) + if !strings.Contains(msg, "migrate failed") { + t.Fatalf("expected provider failure detail in message, got %q", msg) + } +} + +func TestMigrateFailMessageUsesProviderDetail(t *testing.T) { + t.Parallel() + msg := migrateFailMessage(migration.MigrateReport{ + Providers: []migration.MigrateProviderResult{ + {ProviderID: "cursor", Status: "fail", ManualPatch: "failed to backup file: permission denied"}, + }, + }) + if !strings.Contains(msg, "permission denied") { + t.Fatalf("got %q", msg) + } +} diff --git a/cmd/news/aliases.go b/cmd/news/aliases.go index 63ba827..e11fca9 100644 --- a/cmd/news/aliases.go +++ b/cmd/news/aliases.go @@ -5,6 +5,7 @@ import ( "github.com/gate/gate-cli/internal/intelcmd" "github.com/gate/gate-cli/internal/intelfacade" + "github.com/gate/gate-cli/internal/mcpspec" "github.com/gate/gate-cli/internal/toolschema" ) @@ -13,6 +14,7 @@ func makeNewsAliasCommand(use, toolName string) *cobra.Command { BackendCLI: "news", Use: use, ToolName: toolName, + LongAppend: mcpspec.NewsLeafLongAppend(toolName), RunE: func(cmd *cobra.Command, args []string) error { return runNewsCallByName(cmd, toolName, intelcmd.ReservedMCPJSONFallbackFlags()) }, @@ -53,9 +55,14 @@ func loadNewsToolSchemas() map[string]toolschema.ToolSummary { } var newsBusinessAliases = map[string][]string{ - "news_feed_search_news": {"search"}, + "news_feed_search_news": {"search", "search-news"}, "news_events_get_latest_events": {"latest-events"}, + "news_events_explain_market_move": {"explain-market-move"}, "news_feed_get_social_sentiment": {"sentiment"}, "news_feed_get_exchange_announcements": {"announcements"}, + "news_feed_get_mention_burst": {"mention-burst"}, + "news_feed_get_hot_topics": {"hot-topics"}, "news_events_get_event_detail": {"event-detail"}, + "news_events_get_market_move_report": {"market-move-report", "get-report"}, + "news_events_list_market_move_reports": {"market-move-reports", "report-list"}, } diff --git a/cmd/news/aliases_test.go b/cmd/news/aliases_test.go index ca7f4a8..9f44eb9 100644 --- a/cmd/news/aliases_test.go +++ b/cmd/news/aliases_test.go @@ -6,7 +6,9 @@ import ( "github.com/spf13/cobra" + "github.com/gate/gate-cli/internal/intelcmd" "github.com/gate/gate-cli/internal/intelfacade" + "github.com/gate/gate-cli/internal/mcpspec" "github.com/gate/gate-cli/internal/toolschema" ) @@ -79,3 +81,184 @@ func TestNewsSearchNewsHasStaticFlatFlagsWhenLoaderEmpty(t *testing.T) { } } } + +func TestNewsIntelLeafToolAnnotation(t *testing.T) { + for _, tool := range intelfacade.NewsToolBaseline { + parts := strings.Split(tool, "_") + if len(parts) < 3 { + t.Fatalf("invalid tool %q", tool) + } + group := parts[1] + leaf := strings.Join(parts[2:], "-") + leafCmd, _, err := Cmd.Find([]string{group, leaf}) + if err != nil || leafCmd == nil { + t.Fatalf("find %s/%s for %q: %v", group, leaf, tool, err) + } + if got := leafCmd.Annotations[intelcmd.AnnotationIntelToolName]; got != tool { + t.Fatalf("%s/%s: annotation %q want %q", group, leaf, got, tool) + } + } +} + +func TestNewsEachLeafRegistersAllBaselineFlags(t *testing.T) { + oldLoader := newsSchemaLoader + newsSchemaLoader = func() map[string]toolschema.ToolSummary { return map[string]toolschema.ToolSummary{} } + t.Cleanup(func() { newsSchemaLoader = oldLoader }) + + cmd := &cobra.Command{Use: "news"} + orig := Cmd + Cmd = cmd + t.Cleanup(func() { Cmd = orig }) + buildNewsAliases() + + for _, tool := range intelfacade.NewsToolBaseline { + parts := strings.Split(tool, "_") + group := parts[1] + leaf := strings.Join(parts[2:], "-") + leafCmd, _, err := cmd.Find([]string{group, leaf}) + if err != nil || leafCmd == nil { + t.Fatalf("find %s/%s for %q: %v", group, leaf, tool, err) + } + schema := intelfacade.NewsBaselineInputSchema(tool) + if schema == nil { + t.Fatalf("nil baseline schema for %q", tool) + } + props, ok := schema["properties"].(map[string]interface{}) + if !ok { + t.Fatalf("%q: missing properties", tool) + } + for k := range props { + flagName := strings.ReplaceAll(k, "_", "-") + if leafCmd.Flags().Lookup(flagName) == nil { + t.Errorf("tool %q missing flag --%s (baseline key %q)", tool, flagName, k) + } + } + } +} + +func TestNewsSearchEventsStatusFlagNoDefault(t *testing.T) { + oldLoader := newsSchemaLoader + newsSchemaLoader = func() map[string]toolschema.ToolSummary { return map[string]toolschema.ToolSummary{} } + t.Cleanup(func() { newsSchemaLoader = oldLoader }) + + cmd := &cobra.Command{Use: "news"} + orig := Cmd + Cmd = cmd + t.Cleanup(func() { Cmd = orig }) + buildNewsAliases() + + leafCmd, _, err := cmd.Find([]string{"prediction", "search-events"}) + if err != nil { + t.Fatalf("find prediction/search-events: %v", err) + } + fl := leafCmd.Flags().Lookup("status") + if fl == nil { + t.Fatal("missing --status flag") + } + if fl.DefValue != "" { + t.Fatalf("status DefValue: want empty got %q", fl.DefValue) + } +} + +func TestNewsSocialInsightLeavesExposeStaticFlags(t *testing.T) { + for _, tc := range []struct { + leaf string + flags []string + }{ + {leaf: "get-mention-burst", flags: []string{"coin", "window", "platforms"}}, + {leaf: "get-hot-topics", flags: []string{"coin", "window", "limit", "platforms"}}, + } { + leafCmd, _, err := Cmd.Find([]string{"feed", tc.leaf}) + if err != nil || leafCmd == nil { + t.Fatalf("find feed/%s: %v", tc.leaf, err) + } + for _, flag := range tc.flags { + if leafCmd.Flags().Lookup(flag) == nil { + t.Errorf("feed/%s missing --%s", tc.leaf, flag) + } + } + } +} + +func TestNewsMarketMoveReportLeavesExposeStaticFlags(t *testing.T) { + for _, tc := range []struct { + leaf string + flags []string + }{ + {leaf: "get-market-move-report", flags: []string{"symbol", "report-id", "event-id"}}, + {leaf: "list-market-move-reports", flags: []string{"symbol", "start-time", "end-time", "limit"}}, + } { + leafCmd, _, err := Cmd.Find([]string{"events", tc.leaf}) + if err != nil || leafCmd == nil { + t.Fatalf("find events/%s: %v", tc.leaf, err) + } + for _, flag := range tc.flags { + if leafCmd.Flags().Lookup(flag) == nil { + t.Errorf("events/%s missing --%s", tc.leaf, flag) + } + } + if leafCmd.Flags().Lookup("is-make-new") != nil { + t.Errorf("events/%s must not expose --is-make-new", tc.leaf) + } + } +} + +func TestNewsEachLeafRegistersAllSpecParams(t *testing.T) { + doc, err := mcpspec.NewsToolsArgs() + if err != nil { + t.Fatal(err) + } + root := doc.(map[string]interface{}) + raw := root["tools"].([]interface{}) + specParamsByTool := make(map[string][]string, len(raw)) + for _, item := range raw { + tm := item.(map[string]interface{}) + name, _ := tm["name"].(string) + if name == "" { + continue + } + ir, ok := tm["input_rules"].(map[string]interface{}) + if !ok { + continue + } + params, ok := ir["params"].([]interface{}) + if !ok { + continue + } + for _, p := range params { + pm := p.(map[string]interface{}) + if pn, _ := pm["name"].(string); pn != "" { + specParamsByTool[name] = append(specParamsByTool[name], pn) + } + } + } + + oldLoader := newsSchemaLoader + newsSchemaLoader = func() map[string]toolschema.ToolSummary { return map[string]toolschema.ToolSummary{} } + t.Cleanup(func() { newsSchemaLoader = oldLoader }) + cmd := &cobra.Command{Use: "news"} + orig := Cmd + Cmd = cmd + t.Cleanup(func() { Cmd = orig }) + buildNewsAliases() + + for _, tool := range intelfacade.NewsToolBaseline { + names := specParamsByTool[tool] + if len(names) == 0 { + continue + } + parts := strings.Split(tool, "_") + group := parts[1] + leaf := strings.Join(parts[2:], "-") + leafCmd, _, err := cmd.Find([]string{group, leaf}) + if err != nil || leafCmd == nil { + t.Fatalf("find %s/%s for %q: %v", group, leaf, tool, err) + } + for _, param := range names { + flagName := strings.ReplaceAll(param, "_", "-") + if leafCmd.Flags().Lookup(flagName) == nil { + t.Errorf("tool %q missing flag for spec param %q (--%s)", tool, param, flagName) + } + } + } +} diff --git a/cmd/news/call_describe_test.go b/cmd/news/call_describe_test.go index 2d7e1c7..46fb6d3 100644 --- a/cmd/news/call_describe_test.go +++ b/cmd/news/call_describe_test.go @@ -47,7 +47,8 @@ func TestRunNewsDescribeJSON(t *testing.T) { cmd.Flags().String("name", "", "") _ = cmd.Flags().Set("name", "news_feed_search_news") require.NoError(t, runNewsDescribe(cmd, nil)) - assert.Contains(t, out.String(), "news_feed_search_news") + assert.Contains(t, out.String(), `"command":"news feed search-news"`) + assert.NotContains(t, out.String(), "news_feed_search_news") } func TestRunNewsDescribePrettySections(t *testing.T) { @@ -78,6 +79,8 @@ func TestRunNewsDescribePrettySections(t *testing.T) { _ = cmd.Flags().Set("name", "news_feed_search_news") require.NoError(t, runNewsDescribe(cmd, nil)) assert.Contains(t, out.String(), "Overview") + assert.Contains(t, out.String(), "news feed search-news") + assert.NotContains(t, out.String(), "news_feed_search_news") assert.Contains(t, out.String(), "Parameters") assert.Contains(t, out.String(), "coin") assert.NotContains(t, out.String(), "input_schema") diff --git a/cmd/news/call_output_test.go b/cmd/news/call_output_test.go index 057ec85..2e8f33e 100644 --- a/cmd/news/call_output_test.go +++ b/cmd/news/call_output_test.go @@ -34,6 +34,17 @@ func (f *fakeNewsCallService) CallTool(ctx context.Context, name string, argumen return f.result, f.callHTTP, nil } +func newsCallTestCmd() *cobra.Command { + root := &cobra.Command{Use: "gate-cli"} + root.PersistentFlags().Int64("max-output-bytes", 0, "") + cmd := &cobra.Command{Use: "call"} + cmd.Flags().String("params", "", "") + cmd.Flags().String("args-json", `{"coin":"BTC"}`, "") + cmd.Flags().String("args-file", "", "") + root.AddCommand(cmd) + return cmd +} + func TestRunNewsCall_JSONEnvelope(t *testing.T) { oldFactory, oldPrinter := newNewsService, getPrinter t.Cleanup(func() { newNewsService = oldFactory; getPrinter = oldPrinter }) @@ -48,10 +59,7 @@ func TestRunNewsCall_JSONEnvelope(t *testing.T) { getPrinter = func(cmd *cobra.Command) *output.Printer { return output.NewWithStderr(&out, &errOut, output.FormatJSON) } - cmd := &cobra.Command{Use: "call"} - cmd.Flags().String("params", "", "") - cmd.Flags().String("args-json", "", "") - cmd.Flags().String("args-file", "", "") + cmd := newsCallTestCmd() require.NoError(t, runNewsCallByName(cmd, "news_feed_search_news", map[string]struct{}{})) assert.NotContains(t, out.String(), "tool_name") assert.NotContains(t, out.String(), "data_source") @@ -74,10 +82,7 @@ func TestRunNewsCall_IsErrorPrintsStderrOnly(t *testing.T) { getPrinter = func(cmd *cobra.Command) *output.Printer { return output.NewWithStderr(&out, &errOut, output.FormatJSON) } - cmd := &cobra.Command{Use: "call"} - cmd.Flags().String("params", "", "") - cmd.Flags().String("args-json", "", "") - cmd.Flags().String("args-file", "", "") + cmd := newsCallTestCmd() err := runNewsCallByName(cmd, "news_feed_search_news", map[string]struct{}{}) require.Error(t, err) @@ -88,7 +93,8 @@ func TestRunNewsCall_IsErrorPrintsStderrOnly(t *testing.T) { assert.Empty(t, out.String()) assert.Contains(t, errOut.String(), `"error":`) assert.Contains(t, errOut.String(), `"label":"INTEL_RESULT_ERROR"`) - assert.Contains(t, errOut.String(), `"tool_name":"news_feed_search_news"`) + assert.NotContains(t, errOut.String(), `"tool_name"`) + assert.Contains(t, errOut.String(), `"url":"news feed search-news"`) } func TestRunNewsCall_IsErrorUnaffectedByMaxOutputBytes(t *testing.T) { @@ -111,7 +117,7 @@ func TestRunNewsCall_IsErrorUnaffectedByMaxOutputBytes(t *testing.T) { root.PersistentFlags().Int64("max-output-bytes", 8, "") cmd := &cobra.Command{Use: "call"} cmd.Flags().String("params", "", "") - cmd.Flags().String("args-json", "", "") + cmd.Flags().String("args-json", `{"coin":"BTC"}`, "") cmd.Flags().String("args-file", "", "") root.AddCommand(cmd) @@ -140,10 +146,7 @@ func TestRunNewsCall_PrettyIsErrorPrintsReadableStderrOnly(t *testing.T) { getPrinter = func(cmd *cobra.Command) *output.Printer { return output.NewWithStderr(&out, &errOut, output.FormatPretty) } - cmd := &cobra.Command{Use: "call"} - cmd.Flags().String("params", "", "") - cmd.Flags().String("args-json", "", "") - cmd.Flags().String("args-file", "", "") + cmd := newsCallTestCmd() err := runNewsCallByName(cmd, "news_feed_search_news", map[string]struct{}{}) require.Error(t, err) @@ -151,8 +154,10 @@ func TestRunNewsCall_PrettyIsErrorPrintsReadableStderrOnly(t *testing.T) { require.True(t, errors.As(err, &coded)) assert.Equal(t, 1, coded.Code) assert.Empty(t, out.String()) - assert.Contains(t, errOut.String(), "Error [502 INTEL_RESULT_ERROR]: tool returned isError=true") - assert.Contains(t, errOut.String(), "Tool: news_feed_search_news") + assert.Contains(t, errOut.String(), "INTEL_RESULT_ERROR") + assert.Contains(t, errOut.String(), "tool returned isError=true") + assert.NotContains(t, errOut.String(), "Tool: news_feed_search_news") + assert.Contains(t, errOut.String(), "Request: POST news feed search-news") } func TestRunNewsCall_IsErrorIncludesTraceIDJSON(t *testing.T) { @@ -173,10 +178,7 @@ func TestRunNewsCall_IsErrorIncludesTraceIDJSON(t *testing.T) { getPrinter = func(cmd *cobra.Command) *output.Printer { return output.NewWithStderr(&out, &errOut, output.FormatJSON) } - cmd := &cobra.Command{Use: "call"} - cmd.Flags().String("params", "", "") - cmd.Flags().String("args-json", "", "") - cmd.Flags().String("args-file", "", "") + cmd := newsCallTestCmd() err := runNewsCallByName(cmd, "news_feed_search_news", map[string]struct{}{}) require.Error(t, err) diff --git a/cmd/news/describe.go b/cmd/news/describe.go index c94844e..7854f19 100644 --- a/cmd/news/describe.go +++ b/cmd/news/describe.go @@ -4,17 +4,17 @@ import ( "github.com/spf13/cobra" "github.com/gate/gate-cli/internal/intelcmd" - "github.com/gate/gate-cli/internal/intelfacade" ) var describeCmd = &cobra.Command{ - Use: "describe --name ", - Short: "Describe one News capability", - RunE: runNewsDescribe, + Use: "describe --name ", + Short: "Describe one News capability", + Example: " gate-cli news describe --name \"news feed search-news\" --format json", + RunE: runNewsDescribe, } func init() { - describeCmd.Flags().String("name", "", "News tool name") + describeCmd.Flags().String("name", "", "News MCP tool name or CLI command (e.g. news feed search-news)") _ = describeCmd.MarkFlagRequired("name") Cmd.AddCommand(describeCmd) } @@ -30,12 +30,10 @@ func runNewsDescribe(cmd *cobra.Command, args []string) error { } name, _ := cmd.Flags().GetString("name") + name = intelcmd.ResolveMCPToolName("news", name) tool, httpResp, err := svc.DescribeTool(cmd.Context(), name) if err != nil { return intelcmd.FailDescribeTransport(p, err, httpResp, "news", name) } - if p.IsJSON() { - return p.Print(tool) - } - return p.WritePretty(intelfacade.DescribePrettyText(tool)) + return intelcmd.RenderDescribeTool(p, "news", tool) } diff --git a/cmd/news/invoke.go b/cmd/news/invoke.go index 1b71acf..5beb9c8 100644 --- a/cmd/news/invoke.go +++ b/cmd/news/invoke.go @@ -10,15 +10,16 @@ import ( ) var invokeCmd = &cobra.Command{ - Use: "invoke --name [flags]", - Short: "Run one News capability by tool name (flat flags when --name is on the command line)", + Use: "invoke --name [flags]", + Short: "Run one News capability (MCP wire name or CLI command path)", + Example: " gate-cli news invoke --name \"news feed search-news\" --coin BTC --format json\n gate-cli news invoke --name news_feed_search_news --coin BTC --format json", Hidden: true, Aliases: []string{"call"}, RunE: runNewsInvoke, } func init() { - invokeCmd.Flags().String("name", "", "News tool name") + invokeCmd.Flags().String("name", "", "News MCP tool name or CLI command (e.g. news feed search-news)") invokeCmd.Flags().String("params", "", "JSON object arguments (fallback)") invokeCmd.Flags().String("args-json", "", "JSON object arguments (alias of --params)") invokeCmd.Flags().String("args-file", "", "Path to JSON file containing arguments object") @@ -41,6 +42,7 @@ func runNewsInvoke(cmd *cobra.Command, args []string) error { func runNewsCallByName(cmd *cobra.Command, name string, reserved map[string]struct{}) error { p := getPrinter(cmd) maxOutputBytes, _ := cmd.Root().PersistentFlags().GetInt64("max-output-bytes") + name = intelcmd.ResolveMCPToolName("news", name) svc, err := newNewsService(cmd) if err != nil { return intelcmd.FailIntelClientInit(p, err, "news", "invoke", name) diff --git a/cmd/news/list.go b/cmd/news/list.go index dcd49ca..e4342d8 100644 --- a/cmd/news/list.go +++ b/cmd/news/list.go @@ -33,7 +33,7 @@ func runNewsList(cmd *cobra.Command, args []string) error { } _ = saveNewsSchemaCache("news", toNewsSchemaSummaries(items)) - return intelcmd.RenderToolList(p, items) + return intelcmd.RenderToolList(p, "news", items) } func toNewsSchemaSummaries(items []intelfacade.ToolSummary) []toolschema.ToolSummary { diff --git a/cmd/news/list_test.go b/cmd/news/list_test.go index 2f0beaa..00821c0 100644 --- a/cmd/news/list_test.go +++ b/cmd/news/list_test.go @@ -75,7 +75,8 @@ func TestRunNewsListJSON(t *testing.T) { err := runNewsList(cmd, nil) require.NoError(t, err) - assert.Contains(t, out.String(), "news_feed_search_news") + assert.Contains(t, out.String(), "news feed search-news") + assert.NotContains(t, out.String(), "news_feed_search_news") assert.Empty(t, errOut.String()) } @@ -107,7 +108,8 @@ func TestRunNewsListSaveCacheFailureIgnored(t *testing.T) { err := runNewsList(cmd, nil) require.NoError(t, err) - assert.Contains(t, out.String(), "news_feed_search_news") + assert.Contains(t, out.String(), "news feed search-news") + assert.NotContains(t, out.String(), "news_feed_search_news") assert.Empty(t, errOut.String()) } @@ -173,7 +175,8 @@ func TestRunNewsListPrettySegmented(t *testing.T) { require.NoError(t, runNewsList(cmd, nil)) assert.Contains(t, out.String(), "Capabilities") - assert.Contains(t, out.String(), "news_feed_search_news") + assert.Contains(t, out.String(), "news feed search-news") + assert.NotContains(t, out.String(), "news_feed_search_news") assert.NotContains(t, out.String(), "HasInputSchema") assert.Empty(t, errOut.String()) } @@ -206,6 +209,7 @@ func TestRunNewsListTableColumns(t *testing.T) { require.NoError(t, runNewsList(cmd, nil)) assert.Contains(t, out.String(), "Accepts parameters") - assert.Contains(t, out.String(), "news_feed_search_news") + assert.Contains(t, out.String(), "news feed search-news") + assert.NotContains(t, out.String(), "news_feed_search_news") assert.Empty(t, errOut.String()) } diff --git a/cmd/news/mcp_spec.go b/cmd/news/mcp_spec.go new file mode 100644 index 0000000..4407e52 --- /dev/null +++ b/cmd/news/mcp_spec.go @@ -0,0 +1,34 @@ +package news + +import ( + "github.com/spf13/cobra" + + "github.com/gate/gate-cli/internal/intelcmd" + "github.com/gate/gate-cli/internal/mcpspec" +) + +var mcpSpecCmd = &cobra.Command{ + Use: "mcp-spec", + Short: "Print embedded News MCP tools args/logic JSON (offline, for agents and LLMs)", + Long: "Prints the embedded News MCP tools args/logic JSON shipped inside gate-cli (English description per tool, params, logic). " + + "No MCP network call; use with --format json or pretty. Leaf -h reads description from this same embedded document. " + + "Maintainers sync from gate/mcp-server into internal/mcpspec/bundled/; do not treat specs/mcp/ as a release description source.", + Args: cobra.NoArgs, + RunE: runNewsMCPSpec, +} + +func init() { + Cmd.AddCommand(mcpSpecCmd) +} + +func runNewsMCPSpec(cmd *cobra.Command, args []string) error { + p := getPrinter(cmd) + if p.IsTable() { + return intelcmd.FailLeafUnsupportedTable(p, "news") + } + doc, err := mcpspec.NewsToolsArgs() + if err != nil { + return err + } + return p.Print(doc) +} diff --git a/cmd/news/mcp_spec_test.go b/cmd/news/mcp_spec_test.go new file mode 100644 index 0000000..c9bcc6e --- /dev/null +++ b/cmd/news/mcp_spec_test.go @@ -0,0 +1,13 @@ +package news + +import "testing" + +func TestNewsMCPSpecCommandRegistered(t *testing.T) { + if Cmd == nil { + t.Fatal("Cmd is nil") + } + sub, _, err := Cmd.Find([]string{"mcp-spec"}) + if err != nil || sub == nil || sub.Name() != "mcp-spec" { + t.Fatalf("mcp-spec subcommand: err=%v cmd=%v", err, sub) + } +} diff --git a/cmd/news/news.go b/cmd/news/news.go index ab93adc..73eb7e1 100644 --- a/cmd/news/news.go +++ b/cmd/news/news.go @@ -6,6 +6,12 @@ import "github.com/spf13/cobra" var Cmd = &cobra.Command{ Use: "news", Short: "News and market intelligence commands", + Long: `News and market intelligence commands. + +Agent shortcuts (+ prefix): +brief, +event-explain, +community-scan. + +Discovery: gate-cli news list --format table (CLI command paths, not MCP wire names). +Describe: gate-cli news describe --name "news feed search-news" (CLI path or MCP wire name).`, } func init() { diff --git a/cmd/news/news_test.go b/cmd/news/news_test.go index f8a4073..b7b8431 100644 --- a/cmd/news/news_test.go +++ b/cmd/news/news_test.go @@ -12,6 +12,7 @@ func TestNewsCommandStructure(t *testing.T) { subCmds[c.Name()] = true } assert.True(t, subCmds["list"], "missing news list subcommand") + assert.True(t, subCmds["+brief"], "missing news +brief subcommand") assert.True(t, subCmds["verify-schema"], "missing news verify-schema subcommand") for _, c := range Cmd.Commands() { if c.Name() == "invoke" { diff --git a/cmd/news/shortcut.go b/cmd/news/shortcut.go new file mode 100644 index 0000000..c68c737 --- /dev/null +++ b/cmd/news/shortcut.go @@ -0,0 +1,336 @@ +package news + +import ( + "context" + "sort" + "strings" + "sync" + + "github.com/spf13/cobra" + + "github.com/gate/gate-cli/internal/cmdutil" + "github.com/gate/gate-cli/internal/intelcmd" + "github.com/gate/gate-cli/internal/output" + "github.com/gate/gate-cli/internal/toolrender" +) + +func init() { + buildNewsShortcuts() +} + +func buildNewsShortcuts() { + Cmd.AddCommand(newNewsBriefCmd(), newNewsEventExplainCmd(), newNewsCommunityScanCmd()) +} + +func newNewsBriefCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "+brief", + Aliases: []string{"brief"}, + RunE: func(cmd *cobra.Command, args []string) error { + coin, _ := cmd.Flags().GetString("coin") + query, _ := cmd.Flags().GetString("query") + timeRange, _ := cmd.Flags().GetString("time-range") + if err := validateBriefTimeRange(timeRange); err != nil { + ge := intelcmd.GateErrorFromShortcutErr(err, "news/+brief") + return intelcmd.FailAfterPrintError(getPrinter(cmd), ge) + } + return runNewsShortcut(cmd, "news/+brief", func(ctx context.Context, svc newsService) (map[string]interface{}, error) { + if strings.TrimSpace(coin) == "" && strings.TrimSpace(query) == "" { + return nil, intelcmd.ShortcutArgsError("coin or query is required") + } + var events, news, sentiment map[string]interface{} + var errEvents, errNews, errSentiment error + var mu sync.Mutex + _ = intelcmd.RunParallel(intelcmd.DefaultShortcutParallelism, []func() error{ + func() error { + v, err := callNewsShortcutTool(ctx, svc, "news_events_get_latest_events", map[string]interface{}{"coin": coin, "time_range": timeRange, "limit": 10}) + mu.Lock() + events, errEvents = v, err + mu.Unlock() + return nil + }, + func() error { + v, err := callNewsShortcutTool(ctx, svc, "news_feed_search_news", map[string]interface{}{"coin": coin, "query": query, "limit": 10, "sort_by": "importance"}) + mu.Lock() + news, errNews = v, err + mu.Unlock() + return nil + }, + func() error { + v, err := callNewsShortcutTool(ctx, svc, "news_feed_get_social_sentiment", map[string]interface{}{"coin": coin, "time_range": timeRange}) + mu.Lock() + sentiment, errSentiment = v, err + mu.Unlock() + return nil + }, + }) + if errEvents != nil && errNews != nil { + return nil, errEvents + } + missing := []string{} + out := map[string]interface{}{ + "headline_summary": map[string]interface{}{}, + "top_events": map[string]interface{}{}, + "news_digest": map[string]interface{}{}, + "sentiment_summary": map[string]interface{}{}, + "watch_items": []interface{}{}, + "missing_sections": []string{}, + } + if errEvents == nil { + out["top_events"] = events + } else { + missing = append(missing, "top_events") + } + if errNews == nil { + out["news_digest"] = news + } else { + missing = append(missing, "news_digest") + } + if errSentiment == nil { + out["sentiment_summary"] = sentiment + } else { + missing = append(missing, "sentiment_summary") + } + if len(missing) > 0 { + out["partial"] = true + out["missing_sections"] = missing + } + return out, nil + }) + }, + } + cmd.Flags().String("coin", "", "Coin symbol") + cmd.Flags().String("query", "", "Keyword query") + cmd.Flags().String("time-range", "24h", "Time range") + return cmd +} + +func newNewsEventExplainCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "+event-explain", + Aliases: []string{"event-explain"}, + RunE: func(cmd *cobra.Command, args []string) error { + eventID, _ := cmd.Flags().GetString("event-id") + coin, _ := cmd.Flags().GetString("coin") + query, _ := cmd.Flags().GetString("query") + timeRange, _ := cmd.Flags().GetString("time-range") + return runNewsShortcut(cmd, "news/+event-explain", func(ctx context.Context, svc newsService) (map[string]interface{}, error) { + if strings.TrimSpace(eventID) == "" && strings.TrimSpace(coin) == "" && strings.TrimSpace(query) == "" { + return nil, intelcmd.ShortcutArgsError("event-id or coin/query is required") + } + if strings.TrimSpace(eventID) == "" { + latest, err := callNewsShortcutTool(ctx, svc, "news_events_get_latest_events", map[string]interface{}{"coin": coin, "time_range": timeRange, "limit": 10}) + if err != nil { + return nil, err + } + if id := readFirstStringField(latest, "event_id"); id != "" { + eventID = id + } + } + if strings.TrimSpace(eventID) == "" { + return nil, intelcmd.ShortcutArgsError("failed to resolve event-id") + } + detail, err := callNewsShortcutTool(ctx, svc, "news_events_get_event_detail", map[string]interface{}{"event_id": eventID}) + if err != nil { + return nil, err + } + searchTerm := firstNonEmpty(query, coin, readFirstStringField(detail, "title")) + if strings.TrimSpace(searchTerm) == "" { + searchTerm = eventID + } + newsCov, err := callNewsShortcutTool(ctx, svc, "news_feed_search_news", map[string]interface{}{"query": searchTerm, "limit": 10}) + if err != nil { + return nil, err + } + out := map[string]interface{}{ + "event_summary": detail, + "timeline": detail, + "source_coverage": newsCov, + "market_interpretation": map[string]interface{}{}, + "open_questions": []interface{}{}, + } + if xCov, err := callNewsShortcutTool(ctx, svc, "news_feed_search_x", map[string]interface{}{"query": searchTerm, "limit": 10, "time_range": timeRange}); err == nil { + out["market_interpretation"] = xCov + } else { + out["partial"] = true + out["missing_sections"] = []string{"market_interpretation"} + } + return out, nil + }) + }, + } + cmd.Flags().String("event-id", "", "Event ID") + cmd.Flags().String("coin", "", "Coin symbol") + cmd.Flags().String("query", "", "Keyword query") + cmd.Flags().String("time-range", "24h", "Time range") + return cmd +} + +func newNewsCommunityScanCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "+community-scan", + Aliases: []string{"community-scan"}, + RunE: func(cmd *cobra.Command, args []string) error { + coin, _ := cmd.Flags().GetString("coin") + query, _ := cmd.Flags().GetString("query") + timeRange, _ := cmd.Flags().GetString("time-range") + return runNewsShortcut(cmd, "news/+community-scan", func(ctx context.Context, svc newsService) (map[string]interface{}, error) { + if strings.TrimSpace(coin) == "" && strings.TrimSpace(query) == "" { + return nil, intelcmd.ShortcutArgsError("coin or query is required") + } + var ugc, xres map[string]interface{} + var errUGC, errX error + var mu sync.Mutex + _ = intelcmd.RunParallel(intelcmd.DefaultShortcutParallelism, []func() error{ + func() error { + v, err := callNewsShortcutTool(ctx, svc, "news_feed_search_ugc", map[string]interface{}{"coin": coin, "query": query, "platform": "all", "time_range": timeRange, "sort_by": "relevance"}) + mu.Lock() + ugc, errUGC = v, err + mu.Unlock() + return nil + }, + func() error { + v, err := callNewsShortcutTool(ctx, svc, "news_feed_search_x", map[string]interface{}{"coin": coin, "query": query, "time_range": timeRange, "limit": 10}) + mu.Lock() + xres, errX = v, err + mu.Unlock() + return nil + }, + }) + if errUGC != nil && errX != nil { + return nil, errUGC + } + missing := []string{} + out := map[string]interface{}{ + "community_summary": map[string]interface{}{"coin": coin, "query": query}, + "top_ugc_posts": map[string]interface{}{}, + "top_x_threads": map[string]interface{}{}, + "sentiment_summary": map[string]interface{}{}, + "narratives": []interface{}{}, + "missing_sections": []string{}, + } + if errUGC == nil { + out["top_ugc_posts"] = ugc + } else { + missing = append(missing, "top_ugc_posts") + } + if errX == nil { + out["top_x_threads"] = xres + } else { + missing = append(missing, "top_x_threads") + } + if v, err := callNewsShortcutTool(ctx, svc, "news_feed_get_social_sentiment", map[string]interface{}{"coin": coin, "time_range": timeRange}); err == nil { + out["sentiment_summary"] = v + } else { + missing = append(missing, "sentiment_summary") + } + if len(missing) > 0 { + out["partial"] = true + out["missing_sections"] = missing + } + return out, nil + }) + }, + } + cmd.Flags().String("coin", "", "Coin symbol") + cmd.Flags().String("query", "", "Keyword query") + cmd.Flags().String("time-range", "24h", "Time range") + return cmd +} + +func runNewsShortcut(cmd *cobra.Command, path string, runner func(ctx context.Context, svc newsService) (map[string]interface{}, error)) error { + p := getPrinter(cmd) + if p.IsTable() { + return intelcmd.FailLeafUnsupportedTable(p, "news") + } + svc, err := newNewsService(cmd) + if err != nil { + return intelcmd.FailIntelClientInit(p, err, "news", "shortcut", "") + } + ctx, cancel := intelcmd.WithShortcutBudget(cmd.Context()) + defer cancel() + out, err := runner(ctx, svc) + if err != nil { + ge := intelcmd.GateErrorFromShortcutErr(err, path) + output.FillAgentErrorConvergence(ge) + return intelcmd.FailAfterPrintError(p, ge) + } + return toolrender.RenderIntelPayload(p, path, out, cmdutil.GetMaxOutputBytes(cmd)) +} + +func callNewsShortcutTool(ctx context.Context, svc newsService, name string, args map[string]interface{}) (map[string]interface{}, error) { + return intelcmd.CallShortcutTool(ctx, svc, name, args) +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if t := strings.TrimSpace(v); t != "" { + return t + } + } + return "" +} + +var eventArrayFieldPriority = []string{"items", "events", "list", "data"} + +func readFirstStringField(m map[string]interface{}, key string) string { + if m == nil { + return "" + } + if v, ok := m[key].(string); ok && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + seen := make(map[string]struct{}, len(m)) + for _, k := range eventArrayFieldPriority { + if s := firstStringInArrayField(m, k, key); s != "" { + return s + } + seen[k] = struct{}{} + } + keys := make([]string, 0, len(m)) + for k := range m { + if _, ok := seen[k]; ok { + continue + } + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + if s := firstStringInArrayField(m, k, key); s != "" { + return s + } + } + return "" +} + +func firstStringInArrayField(m map[string]interface{}, field, key string) string { + v, ok := m[field] + if !ok { + return "" + } + arr, ok := v.([]interface{}) + if !ok || len(arr) == 0 { + return "" + } + first, ok := arr[0].(map[string]interface{}) + if !ok { + return "" + } + if s, ok := first[key].(string); ok && strings.TrimSpace(s) != "" { + return strings.TrimSpace(s) + } + return "" +} + +func validateBriefTimeRange(tr string) error { + tr = strings.TrimSpace(strings.ToLower(tr)) + if tr == "" { + return nil + } + switch tr { + case "1h", "24h", "7d": + return nil + default: + return intelcmd.ShortcutArgsError("time-range must be 1h, 24h, or 7d for +brief") + } +} diff --git a/cmd/news/shortcut_test.go b/cmd/news/shortcut_test.go new file mode 100644 index 0000000..c6604c5 --- /dev/null +++ b/cmd/news/shortcut_test.go @@ -0,0 +1,70 @@ +package news + +import ( + "bytes" + "context" + "net/http" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gate/gate-cli/internal/intelfacade" + "github.com/gate/gate-cli/internal/mcpclient" + "github.com/gate/gate-cli/internal/output" +) + +type fakeNewsShortcutService struct{} + +func (f *fakeNewsShortcutService) ListTools(ctx context.Context) ([]intelfacade.ToolSummary, *http.Response, error) { + return nil, nil, nil +} +func (f *fakeNewsShortcutService) DescribeTool(ctx context.Context, name string) (*intelfacade.ToolSummary, *http.Response, error) { + return &intelfacade.ToolSummary{Name: name}, nil, nil +} +func (f *fakeNewsShortcutService) CallTool(ctx context.Context, name string, arguments map[string]interface{}) (*mcpclient.CallResult, *http.Response, error) { + if name == "news_events_get_latest_events" { + return &mcpclient.CallResult{ + StructuredContent: map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"event_id": "evt-1"}}, + }, + }, nil, nil + } + return &mcpclient.CallResult{StructuredContent: map[string]interface{}{"tool": name}}, nil, nil +} + +func TestNewsShortcutEventExplain(t *testing.T) { + oldFactory, oldPrinter := newNewsService, getPrinter + t.Cleanup(func() { newNewsService = oldFactory; getPrinter = oldPrinter }) + newNewsService = func(cmd *cobra.Command) (newsService, error) { return &fakeNewsShortcutService{}, nil } + + var out, errOut bytes.Buffer + getPrinter = func(cmd *cobra.Command) *output.Printer { + return output.NewWithStderr(&out, &errOut, output.FormatJSON) + } + + cmd := newNewsEventExplainCmd() + require.NoError(t, cmd.Flags().Set("coin", "BTC")) + require.NoError(t, cmd.RunE(cmd, nil)) + assert.Contains(t, out.String(), `"event_summary"`) + assert.Contains(t, out.String(), `"source_coverage"`) + assert.Empty(t, errOut.String()) +} + +func TestNewsShortcutBriefAllowsOneOfPrimarySources(t *testing.T) { + oldFactory, oldPrinter := newNewsService, getPrinter + t.Cleanup(func() { newNewsService = oldFactory; getPrinter = oldPrinter }) + newNewsService = func(cmd *cobra.Command) (newsService, error) { return &fakeNewsShortcutService{}, nil } + + var out, errOut bytes.Buffer + getPrinter = func(cmd *cobra.Command) *output.Printer { + return output.NewWithStderr(&out, &errOut, output.FormatJSON) + } + + cmd := newNewsBriefCmd() + require.NoError(t, cmd.Flags().Set("coin", "BTC")) + require.NoError(t, cmd.RunE(cmd, nil)) + assert.Contains(t, out.String(), `"missing_sections"`) + assert.Empty(t, errOut.String()) +} diff --git a/cmd/preflight/preflight.go b/cmd/preflight/preflight.go index d0cdbf1..0ebebe1 100644 --- a/cmd/preflight/preflight.go +++ b/cmd/preflight/preflight.go @@ -1,12 +1,12 @@ package preflight import ( - "errors" - "github.com/spf13/cobra" + "github.com/gate/gate-cli/internal/cmdhint" "github.com/gate/gate-cli/internal/cmdutil" "github.com/gate/gate-cli/internal/exitcode" + "github.com/gate/gate-cli/internal/intelcmd" "github.com/gate/gate-cli/internal/migration" "github.com/gate/gate-cli/internal/output" "github.com/gate/gate-cli/internal/version" @@ -29,18 +29,41 @@ func runPreflight(cmd *cobra.Command, args []string) error { p := cmdutil.GetPrinter(cmd) if p.IsTable() { p.PrintError(output.UnsupportedTableFormatError()) - return exitcode.New(exitcode.RenderOrInternal, errors.New("unsupported format")) + return exitcode.New(exitcode.RenderOrInternal, intelcmd.ErrSilenced) } fallbackEnabled, _ := cmd.Flags().GetBool("fallback-enabled") result := migration.BuildPreflight(migration.PreflightOptions{ FallbackEnabled: fallbackEnabled, Version: version.Version, }) - if err := p.Print(result); err != nil { - return exitcode.New(exitcode.RenderOrInternal, err) + payload := interface{}(result) + if p.IsJSON() && cmdhint.AgentModeEnabled() { + payload = map[string]interface{}{ + "route": result.Route, + "action_code": result.ActionCode, + "cli_installed": result.CLIInstalled, + "legacy_mcp_detected": result.LegacyMCPDetected, + "blocking_reason": result.BlockingReason, + "user_message": result.UserMessage, + "suggested_next_action": cmdhint.AgentPreflightNextAction(result.Route), + "agent_resolve_hint": cmdhint.AgentResolveHint("intel preflight"), + } } if result.Route == "BLOCK" { - return exitcode.New(exitcode.Failure, errors.New("preflight blocked")) + ge := &output.GateError{ + Status: 422, + Label: "PREFLIGHT_BLOCKED", + Message: result.UserMessage, + } + output.FillAgentErrorConvergence(ge) + if cmdhint.AgentModeEnabled() { + ge.SuggestedNextAction = cmdhint.AgentPreflightNextAction(result.Route) + } + p.PrintError(ge) + return exitcode.New(exitcode.Failure, intelcmd.ErrSilenced) + } + if err := p.Print(payload); err != nil { + return exitcode.New(exitcode.RenderOrInternal, err) } return nil } diff --git a/cmd/preflight/preflight_block_test.go b/cmd/preflight/preflight_block_test.go new file mode 100644 index 0000000..085e7ee --- /dev/null +++ b/cmd/preflight/preflight_block_test.go @@ -0,0 +1,72 @@ +package preflight + +import ( + "bytes" + "encoding/json" + "io" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/gate/gate-cli/internal/migration" + "github.com/gate/gate-cli/internal/version" +) + +func TestPreflightBlockStderrOnlyJSON(t *testing.T) { + prev := version.Version + version.Version = "0.1.0" + t.Cleanup(func() { version.Version = prev }) + + res := migration.BuildPreflight(migration.PreflightOptions{ + Installed: func(string) bool { return true }, + Version: version.Version, + }) + if res.Route != "BLOCK" { + t.Fatalf("expected BLOCK, got %s", res.Route) + } + + root := &cobra.Command{Use: "gate-cli"} + root.PersistentFlags().String("format", "pretty", "") + root.PersistentFlags().Int64("max-output-bytes", 0, "") + root.AddCommand(Cmd) + + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetArgs([]string{"preflight", "--format", "json"}) + + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + oldStderr := os.Stderr + os.Stderr = w + t.Cleanup(func() { + os.Stderr = oldStderr + _ = w.Close() + }) + + if err := root.Execute(); err == nil { + t.Fatal("expected error exit") + } + _ = w.Close() + stderrBytes, _ := io.ReadAll(r) + if stdout.Len() > 0 { + t.Fatalf("BLOCK must not write stdout, got %q", stdout.String()) + } + if !strings.Contains(string(stderrBytes), `"error"`) { + t.Fatalf("expected stderr GateError JSON, got %q", stderrBytes) + } + var wrap map[string]interface{} + if err := json.Unmarshal(stderrBytes, &wrap); err != nil { + t.Fatalf("stderr JSON: %v body=%q", err, stderrBytes) + } + errObj, _ := wrap["error"].(map[string]interface{}) + if errObj["error_type"] == nil || errObj["suggested_next_action"] == nil { + t.Fatalf("missing convergence fields: %#v", errObj) + } + if retry, _ := errObj["retryable"].(bool); retry { + t.Fatalf("PREFLIGHT_BLOCKED must not be retryable: %#v", errObj) + } +} diff --git a/cmd/root.go b/cmd/root.go index c49f01a..ff0323a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -12,12 +12,14 @@ import ( "github.com/spf13/cobra" cexcmd "github.com/gate/gate-cli/cmd/cex" + "github.com/gate/gate-cli/internal/agentfeature" configcmd "github.com/gate/gate-cli/cmd/config" "github.com/gate/gate-cli/cmd/doctor" "github.com/gate/gate-cli/cmd/info" "github.com/gate/gate-cli/cmd/migrate" "github.com/gate/gate-cli/cmd/news" "github.com/gate/gate-cli/cmd/preflight" + "github.com/gate/gate-cli/internal/cmdhint" "github.com/gate/gate-cli/internal/exitcode" "github.com/gate/gate-cli/internal/intelcmd" "github.com/gate/gate-cli/internal/version" @@ -30,6 +32,7 @@ var rootCmd = &cobra.Command{ Version: version.Version, PersistentPreRun: func(cmd *cobra.Command, args []string) { emitFormatCompatNotice(cmd) + applyDefaultMaxOutputBytesIfUnset(cmd) normalizeMaxOutputBytesFlag(cmd) }, } @@ -41,11 +44,36 @@ const ( func setupRootForExecute() { intelcmd.SilenceCommandTree(rootCmd) + // Print flag/parse errors to stderr for the full tree (cex/config included) so agents + // see diagnostics instead of a silent exit when SilenceErrors is enabled. + intelcmd.InstallFlagErrorHook(rootCmd) } func Execute() { setupRootForExecute() + if agentfeature.RuntimeActive() && cmdhint.ShouldBlockParentHelp(os.Args) { + d := &cmdhint.Diagnostic{ + Blocked: true, + Reason: "HELP_CRAWL_FORBIDDEN", + ErrorType: "COMMAND_NOT_FOUND", + SuggestedNextAction: agentfeature.DiscoveryResolveOrLeavesAction(), + Retryable: false, + Message: "help disabled on parent commands in agent mode", + } + cmdhint.PrintDiagnosticWithArgv(os.Stderr, d, os.Args) + os.Exit(1) + } + if newArgs, ok := intelcmd.RewriteFlexBoolSpaceArgs(rootCmd, os.Args[1:]); ok { + rootCmd.SetArgs(newArgs) + } if err := rootCmd.Execute(); err != nil { + d := cmdhint.SuggestFromError(os.Args, err, rootCmd) + msg := err.Error() + if d != nil && strings.TrimSpace(d.Message) != "" { + msg = d.Message + } + intelcmd.EmitExecuteErrorEnvelope(os.Stderr, rootCmd, os.Args, err, msg) + cmdhint.PrintDiagnosticWithArgv(os.Stderr, d, os.Args) var codedErr *exitcode.Error if errors.As(err, &codedErr) { os.Exit(codedErr.Code) @@ -67,7 +95,7 @@ func init() { rootCmd.PersistentFlags().String("profile", "default", "Config profile to use") rootCmd.PersistentFlags().Bool("debug", false, "Print HTTP debug summary (no auth headers/body)") rootCmd.PersistentFlags().Bool("verbose", false, "Print Intel MCP transport lines to stderr (info/news); does not change stdout JSON shape") - rootCmd.PersistentFlags().Int64("max-output-bytes", defaultMaxOutputBytes(), "Maximum bytes for info/news tool command output (0 means unlimited; env: GATE_MAX_OUTPUT_BYTES)") + rootCmd.PersistentFlags().Int64("max-output-bytes", defaultMaxOutputBytes(), "Maximum bytes for JSON/pretty stdout payload (0 means unlimited; env: GATE_MAX_OUTPUT_BYTES)") rootCmd.PersistentFlags().String("api-key", "", "Gate API key (overrides config file and GATE_API_KEY env)") rootCmd.PersistentFlags().String("api-secret", "", "Gate API secret (overrides config file and GATE_API_SECRET env)") @@ -80,17 +108,42 @@ func init() { rootCmd.AddCommand(migrate.Cmd) } +// Root returns the gate-cli root command tree (for agent discovery tests). +func Root() *cobra.Command { + return rootCmd +} + func defaultMaxOutputBytes() int64 { raw := strings.TrimSpace(os.Getenv("GATE_MAX_OUTPUT_BYTES")) - if raw == "" { - return 0 + if raw != "" { + v, err := strconv.ParseInt(raw, 10, 64) + if err != nil || v < 0 { + _, _ = fmt.Fprintf(os.Stderr, "Warning: invalid GATE_MAX_OUTPUT_BYTES=%q; fallback to unlimited output\n", raw) + return 0 + } + return v } - v, err := strconv.ParseInt(raw, 10, 64) - if err != nil || v < 0 { - _, _ = fmt.Fprintf(os.Stderr, "Warning: invalid GATE_MAX_OUTPUT_BYTES=%q; fallback to unlimited output\n", raw) - return 0 + return agentfeature.DefaultMaxOutputWhenUnset() +} + +// applyDefaultMaxOutputBytesIfUnset applies agent/runtime defaults at execute time (not only at init). +func applyDefaultMaxOutputBytesIfUnset(cmd *cobra.Command) { + if cmd == nil { + return + } + root := cmd.Root() + f := root.PersistentFlags().Lookup("max-output-bytes") + if f == nil || f.Changed { + return + } + if strings.TrimSpace(os.Getenv("GATE_MAX_OUTPUT_BYTES")) != "" { + return + } + def := agentfeature.DefaultMaxOutputWhenUnset() + if def <= 0 { + return } - return v + _ = root.PersistentFlags().Set("max-output-bytes", strconv.FormatInt(def, 10)) } // normalizeMaxOutputBytesFlag enforces a non-negative --max-output-bytes (CR-107). diff --git a/cmd/root_agent_wire.go b/cmd/root_agent_wire.go new file mode 100644 index 0000000..ecd84c7 --- /dev/null +++ b/cmd/root_agent_wire.go @@ -0,0 +1,9 @@ +//go:build agent + +package cmd + +import "github.com/gate/gate-cli/internal/agentcmd" + +func init() { + agentcmd.Register(rootCmd) +} diff --git a/cmd/root_test.go b/cmd/root_test.go index b062c0d..087a9e6 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -31,6 +31,14 @@ func TestDefaultMaxOutputBytes(t *testing.T) { t.Fatalf("expected 0, got %d", got) } }) + + t.Run("explicit env overrides agent env", func(t *testing.T) { + t.Setenv("GATE_MAX_OUTPUT_BYTES", "4096") + t.Setenv("GATE_CLI_AGENT", "1") + if got := defaultMaxOutputBytes(); got != 4096 { + t.Fatalf("expected 4096, got %d", got) + } + }) } func TestNormalizeMaxOutputBytesFlagNegativeClampsToZero(t *testing.T) { diff --git a/docs/quickstart.md b/docs/quickstart.md index f2e2006..f590086 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -339,6 +339,30 @@ gate-cli cex futures position list --format json | jq '.[].contract' --- +## Intel (`info` & `news`) + +Market intelligence and news are shipped as **45** MCP-style CLI tools (**31** `info`, **14** `news`). Invoke a capability as `gate-cli info ` or `gate-cli news ` with **flat flags** for arguments (add `--format json` for scripts and agents). + +**`info` groups:** `coin`, `marketsnapshot`, `markettrend`, `onchain`, `platformmetrics`, `marketdetail`, `macro`, `compliance`. + +**`news` groups:** `feed` (platform news, UGC, X, web research, sentiment, exchange announcements), `events` (latest events, detail by `event_id`, market-move explain), and `prediction` (UTC rankings on `predictionRankIndex`; `search-events` on `dws_prediction_event_signal_hf` with collapse per `pk_id`; `get-event-signal` on `dws_external_event_signal_hf`; live `get-market-orderbook` for polymarket CLOB + predict.fun). CLI pre-check: `search-ugc` and `search-events` need at least one filter flag. Unconfigured OpenSearch indices return `not_implemented`. Leaf `-h` text is bundled from `specs/mcp/news-tools-args-and-logic.json` (version **2026-05-20-rev2**). Prediction commands also run local enum/range checks before MCP. + +List tool ids: `gate-cli info list`, `gate-cli news list`. Flags and env vars: `gate-cli info -h`, `gate-cli news -h`. + +Optional Intel defaults go under `intel:` in `~/.gate-cli/config.yaml`. Gate trading `--api-key` / `GATE_API_KEY` are **not** used as the Intel bearer; follow your Intel/MCP backend if a bearer or base URL is required. Env vars, URLs, and timeouts: [`specs/intel-config-and-security.md`](../specs/intel-config-and-security.md). + +```bash +gate-cli info coin get-coin-info --query BTC --format json +gate-cli info marketsnapshot get-market-snapshot --symbol BTC_USDT --format json +gate-cli info marketsnapshot get-institutional-metrics --asset BTC --channel all --limit 30 --format json +gate-cli info platformmetrics get-stablecoin-info --scope full --sections '["issuance_flow","usage_structure"]' --start-date 2026-04-01 --end-date 2026-05-01 --format json +gate-cli news feed search-news --query bitcoin --format json # alias: search → search-news +``` + +One minimal example per tool (all 45) is in the repository **README.md**. + +--- + ## Multiple profiles Useful when managing multiple API keys (e.g., main account and sub-account). @@ -359,6 +383,8 @@ gate-cli cex spot market ticker --pair BTC_USDT --debug # Prints full HTTP request and response to stderr ``` +For **`info`** / **`news`**, root `--debug` and `--verbose` print Intel MCP transport lines on **stderr** (stdout JSON shape unchanged). Cap printed bytes with `--max-output-bytes` or `GATE_MAX_OUTPUT_BYTES`. See the **Global flags** table in the repository `README.md`. + --- ## Tips for scripting diff --git a/docs/quickstart_zh.md b/docs/quickstart_zh.md index a90d987..8800281 100644 --- a/docs/quickstart_zh.md +++ b/docs/quickstart_zh.md @@ -340,6 +340,30 @@ gate-cli cex futures position list --format json | jq '.[].contract' --- +## Intel(`info` / `news`) + +市场情报与资讯以 **45** 个 MCP 风格 CLI 工具提供(**31** 个 `info`,**14** 个 `news`)。调用方式为 `gate-cli info <分组> <子命令>` 或 `gate-cli news <分组> <子命令>`,参数以**平铺 flag** 为主(脚本/Agent 建议加 `--format json`)。 + +**`info` 分组:** `coin`、`marketsnapshot`、`markettrend`、`onchain`、`platformmetrics`、`marketdetail`、`macro`、`compliance`。 + +**`news` 分组:** `feed`(平台资讯、UGC、X、网页研究、情绪、交易所公告)、`events`(最新事件、按 `event_id` 详情、行情异动归因)、`prediction`(UTC 排名 `predictionRankIndex`;`search-events` 检索 `dws_prediction_event_signal_hf` 按 `pk_id` 折叠;`get-event-signal` 读取 `dws_external_event_signal_hf`;`get-market-orderbook` 拉 polymarket CLOB / predict.fun 实时盘口)。CLI 预检:`search-ugc` 与 `search-events` 至少提供一个筛选 flag。未配置 OpenSearch 索引时返回 `not_implemented`。叶子 `-h` 与 `specs/mcp/news-tools-args-and-logic.json`(**2026-05-20-rev2**)一致;prediction 子命令在 MCP 前会做枚举/范围预检。 + +列出工具名:`gate-cli info list`、`gate-cli news list`。参数与环境变量:`gate-cli info -h`、`gate-cli news -h`。 + +可选 Intel 默认值写在 `~/.gate-cli/config.yaml` 的 `intel:` 下。现货/合约用的 `--api-key` / `GATE_API_KEY`**不会**作为 Intel 的 Bearer;若后端需要鉴权,请按 Intel/MCP 环境单独配置。环境变量、URL、超时等见 [`specs/intel-config-and-security.md`](../specs/intel-config-and-security.md)。 + +```bash +gate-cli info coin get-coin-info --query BTC --format json +gate-cli info marketsnapshot get-market-snapshot --symbol BTC_USDT --format json +gate-cli info marketsnapshot get-institutional-metrics --asset BTC --channel all --limit 30 --format json +gate-cli info platformmetrics get-stablecoin-info --scope full --sections '["issuance_flow","usage_structure"]' --start-date 2026-04-01 --end-date 2026-05-01 --format json +gate-cli news feed search-news --query bitcoin --format json # 别名:search → search-news +``` + +全部 **45** 个工具各一条最小示例见仓库根目录 **README.md**。 + +--- + ## 多账号 Profile 适合同时管理主账号和子账号等多套 API Key 的场景。 @@ -360,6 +384,8 @@ gate-cli cex spot market ticker --pair BTC_USDT --debug # 将完整的 HTTP 请求和响应输出到 stderr ``` +对 **`info`** / **`news`**,根级 `--debug` 与 `--verbose` 会在 **stderr** 打印 Intel MCP 传输日志(stdout 上 JSON 结构不变)。可用 `--max-output-bytes` 或环境变量 `GATE_MAX_OUTPUT_BYTES` 限制输出字节。详见仓库根目录 **README.md** 的 **Global flags** 表。 + --- ## 脚本使用技巧 diff --git a/go.mod b/go.mod index 005c6f2..2275f75 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.24 require ( github.com/antihax/optional v1.0.0 - github.com/gate/gateapi-go/v7 v7.2.57 + github.com/gate/gateapi-go/v7 v7.2.78 github.com/mattn/go-isatty v0.0.20 github.com/olekukonko/tablewriter v1.1.3 github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index dae9271..e6e1510 100644 --- a/go.sum +++ b/go.sum @@ -11,8 +11,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= -github.com/gate/gateapi-go/v7 v7.2.57 h1:0kSTmKdnoOstnBfS2iLXS0nnZ5bXt73vSRWUhccTkfI= -github.com/gate/gateapi-go/v7 v7.2.57/go.mod h1:FMb2Gao96D+abqzMDpwTkpGSxpeBdfEDaRuDMIcud8g= +github.com/gate/gateapi-go/v7 v7.2.71 h1:2gycuOCua9y/IwWDZ9+JOUezy2oAaE3ttm6I2ZTZEYk= +github.com/gate/gateapi-go/v7 v7.2.71/go.mod h1:FMb2Gao96D+abqzMDpwTkpGSxpeBdfEDaRuDMIcud8g= +github.com/gate/gateapi-go/v7 v7.2.78 h1:LihvvDFURMYneG+Qq/Z8yPPG3Mb69+E6YFO3bzOOowg= +github.com/gate/gateapi-go/v7 v7.2.78/go.mod h1:FMb2Gao96D+abqzMDpwTkpGSxpeBdfEDaRuDMIcud8g= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= diff --git a/internal/agentcmd/adversarial_test.go b/internal/agentcmd/adversarial_test.go new file mode 100644 index 0000000..feda296 --- /dev/null +++ b/internal/agentcmd/adversarial_test.go @@ -0,0 +1,121 @@ +//go:build agent + +package agentcmd_test + +import ( + "os" + "strings" + "testing" + + "github.com/gate/gate-cli/cmd" + "github.com/gate/gate-cli/internal/cmdhint" + "github.com/gate/gate-cli/internal/toolargs" +) + +// PRD adversarial cases: wrong top-level, help crawl, wrong news path, oversized kline. + +func TestAdversarialWrongTopLevelEarn(t *testing.T) { + t.Parallel() + d := cmdhint.SuggestTopLevel([]string{"gate-cli", "earn", "simple-earn", "check"}) + if d == nil || d.Reason != "wrong_top_level" { + t.Fatalf("got %#v", d) + } + if !strings.Contains(d.Suggested, "cex earn") { + t.Fatalf("suggested=%q", d.Suggested) + } +} + +func TestAdversarialWrongTopLevelMarkettrend(t *testing.T) { + t.Parallel() + d := cmdhint.SuggestTopLevel([]string{"gate-cli", "markettrend"}) + if d == nil || !strings.Contains(d.Suggested, "info markettrend") { + t.Fatalf("got %#v", d) + } +} + +func TestAdversarialNewsExplainWrongGroup(t *testing.T) { + t.Parallel() + d := cmdhint.SuggestFromError([]string{"gate-cli", "news", "feed", "explain-market-move", "--coin", "EDX"}, errAdversarialUnknownCmd{}, cmd.Root()) + if d == nil || d.Suggested == "" { + t.Fatal("expected path correction") + } + if !strings.Contains(d.Suggested, "news events explain-market-move") { + t.Fatalf("suggested=%q", d.Suggested) + } +} + +func TestAdversarialParentHelpBlockedInAgentMode(t *testing.T) { + t.Setenv("GATE_CLI_AGENT", "1") + t.Cleanup(func() { _ = os.Unsetenv("GATE_CLI_AGENT") }) + if !cmdhint.ShouldBlockParentHelp([]string{"gate-cli", "news", "--help"}) { + t.Fatal("expected parent help block") + } + if cmdhint.ShouldBlockParentHelp([]string{"gate-cli", "news", "feed", "search-news", "--help"}) { + t.Fatal("leaf help should be allowed") + } +} + +func TestAdversarialKlineSize5000Rejected(t *testing.T) { + t.Parallel() + err := toolargs.ValidateForTool("info_markettrend_get_kline", map[string]interface{}{"size": 5000}) + if err == nil || !strings.Contains(err.Error(), "500") { + t.Fatalf("err=%v", err) + } +} + +type errAdversarialUnknownCmd struct{} + +func (errAdversarialUnknownCmd) Error() string { + return `unknown command "explain-market-move" for "gate-cli news feed"` +} + +type errAdversarialEventDetail struct{} + +func (errAdversarialEventDetail) Error() string { + return `unknown command "get-event-detail" for "gate-cli news"` +} + +func TestAdversarialRootHelpBlocked(t *testing.T) { + t.Setenv("GATE_CLI_AGENT", "1") + t.Cleanup(func() { _ = os.Unsetenv("GATE_CLI_AGENT") }) + if !cmdhint.ShouldBlockParentHelp([]string{"gate-cli", "--help"}) { + t.Fatal("expected root --help block") + } +} + +func TestAdversarialOnchainAddressRequired(t *testing.T) { + t.Parallel() + if err := toolargs.ValidateForTool("info_onchain_get_address_info", map[string]interface{}{}); err == nil { + t.Fatal("expected address required") + } +} + +func TestAdversarialMacroIndicatorRequired(t *testing.T) { + t.Parallel() + if err := toolargs.ValidateForTool("info_macro_get_macro_indicator", map[string]interface{}{}); err == nil { + t.Fatal("expected indicator required") + } +} + +func TestAdversarialPathNewsEventDetail(t *testing.T) { + t.Parallel() + d := cmdhint.SuggestFromError([]string{"gate-cli", "news", "get-event-detail", "--event-id", "x"}, errAdversarialEventDetail{}, cmd.Root()) + if d == nil || !strings.Contains(d.Suggested, "news events get-event-detail") { + t.Fatalf("got %#v", d) + } +} + +func TestAdversarialCoinInfoRequiresSymbol(t *testing.T) { + t.Parallel() + err := toolargs.ValidateForTool("info_coin_get_coin_info", map[string]interface{}{}) + if err == nil { + t.Fatal("expected validation error") + } +} + +func TestAdversarialAgentValidateDiscoveryOK(t *testing.T) { + report := cmdhint.ValidateAgentDiscovery(cmd.Root()) + if !report.OK { + t.Fatalf("discovery validation failed: %+v", report) + } +} diff --git a/internal/agentcmd/agent.go b/internal/agentcmd/agent.go new file mode 100644 index 0000000..f4f94fa --- /dev/null +++ b/internal/agentcmd/agent.go @@ -0,0 +1,19 @@ +//go:build agent + +package agentcmd + +import "github.com/spf13/cobra" + +// Register adds GateAI agent discovery commands to the gate-cli root (wired from cmd/root_agent_wire.go). +func Register(root *cobra.Command) { + if root == nil { + return + } + root.AddCommand( + NewLeavesCmd(), + NewResolveCmd(), + NewSearchCmd(), + NewIndexCmd(), + NewValidateCmd(), + ) +} diff --git a/internal/agentcmd/discovery.go b/internal/agentcmd/discovery.go new file mode 100644 index 0000000..efe9131 --- /dev/null +++ b/internal/agentcmd/discovery.go @@ -0,0 +1,25 @@ +//go:build agent + +package agentcmd + +import ( + "strings" + + "github.com/spf13/cobra" + + "github.com/gate/gate-cli/internal/cmdhint" + "github.com/gate/gate-cli/internal/cmdindex" +) + +// DiscoveryEntries returns leaf catalog entries for agent discovery commands. +// When domain is empty and GATE_CLI_AGENT=1, scope is limited to info + news. +func DiscoveryEntries(root *cobra.Command, domain string) []cmdindex.Entry { + entries := cmdindex.CollectLeaves(root) + if d := strings.TrimSpace(domain); d != "" { + return cmdindex.FilterByDomain(entries, d) + } + if cmdhint.AgentModeEnabled() { + return cmdindex.FilterInfoNewsOnly(entries) + } + return entries +} diff --git a/internal/agentcmd/discovery_test.go b/internal/agentcmd/discovery_test.go new file mode 100644 index 0000000..c0db395 --- /dev/null +++ b/internal/agentcmd/discovery_test.go @@ -0,0 +1,22 @@ +//go:build agent + +package agentcmd_test + +import ( + "testing" + + "github.com/gate/gate-cli/cmd" + "github.com/gate/gate-cli/internal/agentcmd" + "github.com/gate/gate-cli/internal/cmdindex" +) + +func TestDiscoveryEntriesAgentScope(t *testing.T) { + t.Setenv("GATE_CLI_AGENT", "1") + got := agentcmd.DiscoveryEntries(cmd.Root(), "") + for _, e := range got { + parts := cmdindex.FilterInfoNewsOnly([]cmdindex.Entry{e}) + if len(parts) != 1 { + t.Fatalf("entry outside info/news: %s", e.Path) + } + } +} diff --git a/internal/agentcmd/index.go b/internal/agentcmd/index.go new file mode 100644 index 0000000..2375d67 --- /dev/null +++ b/internal/agentcmd/index.go @@ -0,0 +1,47 @@ +//go:build agent + +package agentcmd + +import ( + "strings" + + "github.com/spf13/cobra" + + "github.com/gate/gate-cli/internal/cmdhint" + "github.com/gate/gate-cli/internal/cmdindex" + "github.com/gate/gate-cli/internal/cmdutil" +) + +// NewIndexCmd returns the agent-index discovery command. +func NewIndexCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "agent-index", + Short: "Export all runnable gate-cli leaf commands for offline agent indexing", + Long: "Full leaf catalog (path + short help). Pair with agent-search for keyword lookup.", + RunE: runIndex, + } + cmd.Flags().String("domain", "", "Limit catalog to domain: cex, info, news, config (aliases: trading, intel)") + return cmd +} + +func runIndex(cmd *cobra.Command, args []string) error { + domain, _ := cmd.Flags().GetString("domain") + leaves := DiscoveryEntries(cmd.Root(), domain) + commands := make([]string, 0, len(leaves)) + for _, e := range leaves { + commands = append(commands, cmdindex.CLICommandLine(e.Path)) + } + p := cmdutil.GetPrinter(cmd) + out := map[string]interface{}{ + "count": len(leaves), + "leaves": cmdhint.EnrichSearchMatches(leaves), + "commands": commands, + } + if cmdhint.AgentModeEnabled() { + out["agent_resolve_hint"] = cmdhint.AgentResolveHint("") + } + if d := strings.TrimSpace(domain); d != "" { + out["domain"] = d + } + return p.Print(out) +} diff --git a/internal/agentcmd/index_enrich_test.go b/internal/agentcmd/index_enrich_test.go new file mode 100644 index 0000000..fe593c0 --- /dev/null +++ b/internal/agentcmd/index_enrich_test.go @@ -0,0 +1,35 @@ +//go:build agent + +package agentcmd_test + +import ( + "testing" + + "github.com/gate/gate-cli/cmd" + "github.com/gate/gate-cli/internal/cmdhint" + "github.com/gate/gate-cli/internal/cmdindex" +) + +func TestAgentIndexLeavesHaveMatchSource(t *testing.T) { + t.Parallel() + entries := cmdindex.FilterInfoNewsOnly(cmdindex.CollectLeaves(cmd.Root())) + enriched := cmdhint.EnrichSearchMatches(entries) + if len(enriched) == 0 { + t.Fatal("expected entries") + } + for _, m := range enriched { + if m.MatchSource == "" { + t.Fatalf("missing match_source: %#v", m) + } + } + foundShortcut := false + for _, m := range enriched { + if m.IsShortcut { + foundShortcut = true + break + } + } + if !foundShortcut { + t.Fatal("expected at least one shortcut in info/news index") + } +} diff --git a/internal/agentcmd/index_test.go b/internal/agentcmd/index_test.go new file mode 100644 index 0000000..36377b5 --- /dev/null +++ b/internal/agentcmd/index_test.go @@ -0,0 +1,30 @@ +//go:build agent + +package agentcmd_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/gate/gate-cli/cmd" + "github.com/gate/gate-cli/internal/cmdindex" +) + +func TestNewAgentIndexCmdRegistered(t *testing.T) { + t.Parallel() + var found bool + for _, c := range cmd.Root().Commands() { + if c.Name() == "agent-index" { + found = true + break + } + } + require.True(t, found) +} + +func TestAgentIndexHasManyLeaves(t *testing.T) { + t.Parallel() + leaves := cmdindex.CollectLeaves(cmd.Root()) + require.Greater(t, len(leaves), 100, "expected substantial leaf catalog") +} diff --git a/internal/agentcmd/leaves.go b/internal/agentcmd/leaves.go new file mode 100644 index 0000000..bb0d67e --- /dev/null +++ b/internal/agentcmd/leaves.go @@ -0,0 +1,38 @@ +//go:build agent + +package agentcmd + +import ( + "github.com/spf13/cobra" + + "github.com/gate/gate-cli/internal/cmdhint" + "github.com/gate/gate-cli/internal/cmdutil" +) + +// NewLeavesCmd returns the agent-leaves discovery command. +func NewLeavesCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "agent-leaves", + Short: "List high-frequency info/news leaf commands for GateAI agents", + Long: "Prints info/news intent-to-command mappings (no CEX). Use agent-search --domain cex for trading leaves. Prefer over crawling --help from the root.", + RunE: func(cmd *cobra.Command, args []string) error { + p := cmdutil.GetPrinter(cmd) + catalog := cmdhint.BaselineMCPCatalog() + out := map[string]interface{}{ + "count_curated": len(cmdhint.AgentLeaves), + "leaves": cmdhint.AgentLeaves, + "count_mcp": len(catalog), + "mcp_catalog": catalog, + } + if cmdhint.AgentModeEnabled() { + out["scope"] = "info,news" + out["recommended_flow"] = []string{ + "gate-cli agent-resolve --query --format json", + "gate-cli agent-search --query --format json", + } + } + return p.Print(out) + }, + } + return cmd +} diff --git a/internal/agentcmd/leaves_test.go b/internal/agentcmd/leaves_test.go new file mode 100644 index 0000000..cd00a94 --- /dev/null +++ b/internal/agentcmd/leaves_test.go @@ -0,0 +1,41 @@ +//go:build agent + +package agentcmd_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/gate/gate-cli/cmd" + "github.com/gate/gate-cli/internal/cmdhint" +) + +func TestNewAgentLeavesCmdRegistered(t *testing.T) { + t.Parallel() + var found bool + for _, c := range cmd.Root().Commands() { + if c.Name() == "agent-leaves" { + found = true + break + } + } + require.True(t, found, "agent-leaves should be registered on root") +} + +func TestAgentLeavesPayloadShape(t *testing.T) { + t.Parallel() + require.Len(t, cmdhint.AgentLeaves, 31) + require.Len(t, cmdhint.BaselineMCPCatalog(), 50) + require.Equal(t, "market_kline", cmdhint.AgentLeaves[0].Intent) + require.Contains(t, cmdhint.AgentLeaves[0].Command, "info markettrend get-kline") + require.Equal(t, 200, cmdhint.AgentLeaves[0].DefaultLimit) + for _, leaf := range cmdhint.AgentLeaves { + require.Contains(t, []string{"info", "news"}, leaf.RequiredPrefix) + } + for _, leaf := range cmdhint.BaselineMCPCatalog() { + require.NotContains(t, leaf.Intent, "_", "mcp_catalog intent must not expose MCP wire names") + require.True(t, strings.Contains(leaf.Command, "gate-cli"), leaf.Command) + } +} diff --git a/internal/agentcmd/resolve.go b/internal/agentcmd/resolve.go new file mode 100644 index 0000000..3991b92 --- /dev/null +++ b/internal/agentcmd/resolve.go @@ -0,0 +1,57 @@ +//go:build agent + +package agentcmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/gate/gate-cli/internal/cmdhint" + "github.com/gate/gate-cli/internal/cmdindex" + "github.com/gate/gate-cli/internal/cmdutil" +) + +// NewResolveCmd returns the agent-resolve discovery command. +func NewResolveCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "agent-resolve --query ", + Short: "Resolve info/news intent to agent-leaves templates and leaf commands", + Long: "Layer-2 discovery: match curated agent-leaves first, then keyword search over info/news runnable leaves.", + RunE: runResolve, + } + cmd.Flags().String("query", "", "Intent phrase (e.g. brief BTC, latest events, token risk)") + cmd.Flags().String("domain", "", "Limit leaf search: info or news (default: both)") + cmd.Flags().Int("limit", 5, "Maximum leaf-search matches after curated leaves") + _ = cmd.MarkFlagRequired("query") + return cmd +} + +func runResolve(cmd *cobra.Command, args []string) error { + query, _ := cmd.Flags().GetString("query") + domain, _ := cmd.Flags().GetString("domain") + limit, _ := cmd.Flags().GetInt("limit") + query = strings.TrimSpace(query) + if query == "" { + return fmt.Errorf("missing required flag: query") + } + resolved := cmdhint.ResolveAgentIntent(query, 3, 3, domain) + entries := DiscoveryEntries(cmd.Root(), domain) + searchHits := cmdindex.Search(entries, query, limit) + commands := make([]string, 0, len(searchHits)) + for _, h := range searchHits { + commands = append(commands, cmdindex.CLICommandLine(h.Path)) + } + p := cmdutil.GetPrinter(cmd) + out := map[string]interface{}{ + "query": query, + "resolved_leaves": resolved, + "search_matches": searchHits, + "commands": commands, + } + if d := strings.TrimSpace(domain); d != "" { + out["domain"] = d + } + return p.Print(out) +} diff --git a/internal/agentcmd/resolve_domain_test.go b/internal/agentcmd/resolve_domain_test.go new file mode 100644 index 0000000..0b9f987 --- /dev/null +++ b/internal/agentcmd/resolve_domain_test.go @@ -0,0 +1,56 @@ +//go:build agent + +package agentcmd + +import ( + "encoding/json" + "io" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestAgentResolveDomainFiltersResolvedLeaves(t *testing.T) { + t.Parallel() + + oldStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + + root := &cobra.Command{Use: "gate-cli"} + root.PersistentFlags().String("format", "json", "") + root.AddCommand(NewResolveCmd()) + root.SetArgs([]string{"agent-resolve", "--query", "BTC brief", "--domain", "news", "--format", "json"}) + + execErr := root.Execute() + _ = w.Close() + os.Stdout = oldStdout + stdoutBytes, _ := io.ReadAll(r) + if execErr != nil { + t.Fatalf("execute: %v", execErr) + } + + var out map[string]interface{} + if err := json.Unmarshal(stdoutBytes, &out); err != nil { + t.Fatalf("parse stdout: %v body=%s", err, stdoutBytes) + } + if out["domain"] != "news" { + t.Fatalf("expected domain news, got %#v", out["domain"]) + } + leaves, _ := out["resolved_leaves"].([]interface{}) + if len(leaves) == 0 { + t.Fatalf("expected resolved_leaves, got %s", stdoutBytes) + } + for _, item := range leaves { + m, _ := item.(map[string]interface{}) + cmdStr, _ := m["command"].(string) + if cmdStr != "" && !strings.HasPrefix(cmdStr, "gate-cli news ") { + t.Fatalf("resolved_leaves must be news-only, got %q", cmdStr) + } + } +} diff --git a/internal/agentcmd/resolve_test.go b/internal/agentcmd/resolve_test.go new file mode 100644 index 0000000..02e6110 --- /dev/null +++ b/internal/agentcmd/resolve_test.go @@ -0,0 +1,31 @@ +//go:build agent + +package agentcmd_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/gate/gate-cli/cmd" + "github.com/gate/gate-cli/internal/cmdhint" +) + +func TestNewAgentResolveCmdRegistered(t *testing.T) { + t.Parallel() + var found bool + for _, c := range cmd.Root().Commands() { + if c.Name() == "agent-resolve" { + found = true + break + } + } + require.True(t, found) +} + +func TestMatchAgentLeavesKline(t *testing.T) { + t.Parallel() + got := cmdhint.MatchAgentLeaves("market kline BTC", 1) + require.NotEmpty(t, got) + require.Equal(t, "market_kline", got[0].Intent) +} diff --git a/internal/agentcmd/root_prerun_disabled_test.go b/internal/agentcmd/root_prerun_disabled_test.go new file mode 100644 index 0000000..6a58d52 --- /dev/null +++ b/internal/agentcmd/root_prerun_disabled_test.go @@ -0,0 +1,28 @@ +//go:build !agent + +package agentcmd_test + +import ( + "testing" + + "github.com/gate/gate-cli/cmd" +) + +func TestRootPreRunDoesNotApplyAgentMaxOutputWithoutBuildTag(t *testing.T) { + t.Setenv("GATE_CLI_AGENT", "1") + t.Setenv("GATE_MAX_OUTPUT_BYTES", "") + + root := cmd.Root() + if root.PersistentPreRun == nil { + t.Fatal("expected root PersistentPreRun") + } + root.PersistentPreRun(root, nil) + + v, err := root.PersistentFlags().GetInt64("max-output-bytes") + if err != nil { + t.Fatal(err) + } + if v != 0 { + t.Fatalf("expected 0 without -tags agent, got %d", v) + } +} diff --git a/internal/agentcmd/root_prerun_enabled_test.go b/internal/agentcmd/root_prerun_enabled_test.go new file mode 100644 index 0000000..2854493 --- /dev/null +++ b/internal/agentcmd/root_prerun_enabled_test.go @@ -0,0 +1,28 @@ +//go:build agent + +package agentcmd_test + +import ( + "testing" + + "github.com/gate/gate-cli/cmd" +) + +func TestRootPreRunAppliesAgentMaxOutputWithBuildTag(t *testing.T) { + t.Setenv("GATE_CLI_AGENT", "1") + t.Setenv("GATE_MAX_OUTPUT_BYTES", "") + + root := cmd.Root() + if root.PersistentPreRun == nil { + t.Fatal("expected root PersistentPreRun") + } + root.PersistentPreRun(root, nil) + + v, err := root.PersistentFlags().GetInt64("max-output-bytes") + if err != nil { + t.Fatal(err) + } + if v != 65536 { + t.Fatalf("expected 65536 at pre-run with agent env, got %d", v) + } +} diff --git a/internal/agentcmd/search.go b/internal/agentcmd/search.go new file mode 100644 index 0000000..3fea104 --- /dev/null +++ b/internal/agentcmd/search.go @@ -0,0 +1,59 @@ +//go:build agent + +package agentcmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/gate/gate-cli/internal/cmdhint" + "github.com/gate/gate-cli/internal/cmdindex" + "github.com/gate/gate-cli/internal/cmdutil" +) + +// NewSearchCmd returns the agent-search discovery command. +func NewSearchCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "agent-search --query ", + Short: "Search runnable gate-cli leaf commands (agent discovery)", + Long: "Keyword search over the command tree. Prefer over root or group --help when GATE_CLI_AGENT=1.", + RunE: runSearch, + } + cmd.Flags().String("query", "", "Keywords (e.g. coin overview, market kline, explain market move)") + cmd.Flags().String("domain", "", "Limit to domain: cex, info, news, config (aliases: trading, intel)") + cmd.Flags().Int("limit", 10, "Maximum matches") + _ = cmd.MarkFlagRequired("query") + return cmd +} + +func runSearch(cmd *cobra.Command, args []string) error { + query, _ := cmd.Flags().GetString("query") + domain, _ := cmd.Flags().GetString("domain") + limit, _ := cmd.Flags().GetInt("limit") + query = strings.TrimSpace(query) + if query == "" { + return fmt.Errorf("missing required flag: query") + } + entries := DiscoveryEntries(cmd.Root(), domain) + hits := cmdindex.Search(entries, query, limit) + matches := cmdhint.EnrichSearchMatches(hits) + commands := make([]string, 0, len(hits)) + for _, h := range hits { + commands = append(commands, cmdindex.CLICommandLine(h.Path)) + } + p := cmdutil.GetPrinter(cmd) + out := map[string]interface{}{ + "query": query, + "matches": matches, + "commands": commands, + } + if cmdhint.AgentModeEnabled() { + out["agent_resolve_hint"] = cmdhint.AgentResolveHint(query) + } + if d := strings.TrimSpace(domain); d != "" { + out["domain"] = d + } + return p.Print(out) +} diff --git a/internal/agentcmd/search_enrich_test.go b/internal/agentcmd/search_enrich_test.go new file mode 100644 index 0000000..fda40bb --- /dev/null +++ b/internal/agentcmd/search_enrich_test.go @@ -0,0 +1,30 @@ +//go:build agent + +package agentcmd_test + +import ( + "testing" + + "github.com/gate/gate-cli/cmd" + "github.com/gate/gate-cli/internal/cmdhint" + "github.com/gate/gate-cli/internal/cmdindex" +) + +func TestAgentSearchEnrichMatchesBriefShortcut(t *testing.T) { + t.Parallel() + entries := cmdindex.FilterByDomain(cmdindex.CollectLeaves(cmd.Root()), "news") + hits := cmdindex.Search(entries, "brief", 3) + matches := cmdhint.EnrichSearchMatches(hits) + if len(matches) == 0 { + t.Fatal("expected hits") + } + found := false + for _, m := range matches { + if m.IsShortcut && m.MatchSource == "shortcut" { + found = true + } + } + if !found { + t.Fatalf("expected shortcut match in %#v", matches) + } +} diff --git a/internal/agentcmd/search_test.go b/internal/agentcmd/search_test.go new file mode 100644 index 0000000..2ece8d2 --- /dev/null +++ b/internal/agentcmd/search_test.go @@ -0,0 +1,49 @@ +//go:build agent + +package agentcmd_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/gate/gate-cli/cmd" + "github.com/gate/gate-cli/internal/cmdindex" +) + +func TestAgentSearchCollectsCexLeaves(t *testing.T) { + t.Parallel() + var hasCex bool + for _, c := range cmd.Root().Commands() { + if c.Name() == "cex" { + hasCex = true + break + } + } + require.True(t, hasCex, "cex command should be registered on root") + hits := cmdindex.Search(cmdindex.CollectLeaves(cmd.Root()), "earn uni redeem records", 8) + require.NotEmpty(t, hits, "expected earn uni records in search index") +} + +func TestAgentSearchDomainInfo(t *testing.T) { + t.Parallel() + entries := cmdindex.FilterByDomain(cmdindex.CollectLeaves(cmd.Root()), "info") + hits := cmdindex.Search(entries, "kline", 5) + require.NotEmpty(t, hits) + for _, h := range hits { + require.True(t, strings.HasPrefix(h.Path, "info "), "path=%s", h.Path) + } +} + +func TestNewAgentSearchCmdRegistered(t *testing.T) { + t.Parallel() + var found bool + for _, c := range cmd.Root().Commands() { + if c.Name() == "agent-search" { + found = true + break + } + } + require.True(t, found) +} diff --git a/internal/agentcmd/shield_test.go b/internal/agentcmd/shield_test.go new file mode 100644 index 0000000..01e0c2b --- /dev/null +++ b/internal/agentcmd/shield_test.go @@ -0,0 +1,18 @@ +//go:build !agent + +package agentcmd_test + +import ( + "strings" + "testing" + + "github.com/gate/gate-cli/cmd" +) + +func TestNoAgentCommandsRegisteredByDefault(t *testing.T) { + for _, c := range cmd.Root().Commands() { + if strings.HasPrefix(c.Name(), "agent-") { + t.Fatalf("unexpected agent command on root in default build: %s", c.Name()) + } + } +} diff --git a/internal/agentcmd/stub.go b/internal/agentcmd/stub.go new file mode 100644 index 0000000..01df2b1 --- /dev/null +++ b/internal/agentcmd/stub.go @@ -0,0 +1,8 @@ +//go:build !agent + +package agentcmd + +import "github.com/spf13/cobra" + +// Register is a no-op when the binary is built without -tags agent. +func Register(*cobra.Command) {} diff --git a/internal/agentcmd/validate.go b/internal/agentcmd/validate.go new file mode 100644 index 0000000..c178a27 --- /dev/null +++ b/internal/agentcmd/validate.go @@ -0,0 +1,33 @@ +//go:build agent + +package agentcmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/gate/gate-cli/internal/cmdhint" + "github.com/gate/gate-cli/internal/cmdutil" +) + +// NewValidateCmd returns the agent-validate CI helper command. +func NewValidateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "agent-validate", + Short: "Validate agent mcp_catalog paths against the live info/news cobra tree", + Long: "Reports baseline MCP tools (50) and info/news shortcuts (10) whose CLI paths are missing from the runnable cobra tree. Use in CI after adding MCP tools or shortcuts.", + RunE: func(cmd *cobra.Command, args []string) error { + report := cmdhint.ValidateAgentDiscovery(cmd.Root()) + p := cmdutil.GetPrinter(cmd) + if err := p.Print(report); err != nil { + return err + } + if !report.OK { + return fmt.Errorf("%d agent discovery path(s) not found in cobra tree", report.TotalCount) + } + return nil + }, + } + return cmd +} diff --git a/internal/agentcmd/validate_test.go b/internal/agentcmd/validate_test.go new file mode 100644 index 0000000..47d76b1 --- /dev/null +++ b/internal/agentcmd/validate_test.go @@ -0,0 +1,39 @@ +//go:build agent + +package agentcmd_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/gate/gate-cli/cmd" + "github.com/gate/gate-cli/internal/cmdhint" +) + +func TestAgentValidateDiscovery(t *testing.T) { + report := cmdhint.ValidateAgentDiscovery(cmd.Root()) + if !report.OK { + for _, m := range report.MCP.Mismatches { + t.Errorf("mcp_catalog mismatch tool=%s expected=%q command=%q suggestion=%q", + m.ToolName, m.Expected, m.Command, m.Suggestion) + } + for _, m := range report.Shortcuts.Mismatches { + t.Errorf("shortcut mismatch expected=%q suggestion=%q", m.Expected, m.Suggestion) + } + } + require.Equal(t, 10, len(cmdhint.InfoNewsShortcutPaths)) +} + +func TestDeferredAddressRiskShortcutNotRegistered(t *testing.T) { + for _, c := range cmd.Root().Commands() { + if c.Name() != "info" { + continue + } + for _, sc := range c.Commands() { + if sc.Name() == "+address-risk" || sc.Name() == "address-risk" { + t.Fatal("+address-risk must stay deferred until info_compliance_check_address_risk ships") + } + } + } +} diff --git a/internal/agentfeature/disabled.go b/internal/agentfeature/disabled.go new file mode 100644 index 0000000..83684ef --- /dev/null +++ b/internal/agentfeature/disabled.go @@ -0,0 +1,56 @@ +//go:build !agent + +package agentfeature + +import ( + "fmt" + "os" + "strings" +) + +// Commands is false in default/release builds. Enable with: go build -tags agent . +const Commands = false + +// RuntimeEnvEnabled reports GATE_CLI_AGENT / GATE_AI_AGENT truthy values (User-Agent override uses any non-empty value separately in useragent). +func RuntimeEnvEnabled() bool { + switch strings.TrimSpace(os.Getenv("GATE_CLI_AGENT")) { + case "1", "true", "yes": + return true + } + switch strings.TrimSpace(os.Getenv("GATE_AI_AGENT")) { + case "1", "true", "yes": + return true + } + return false +} + +// RuntimeActive is always false without -tags agent. +func RuntimeActive() bool { return false } + +// DefaultMaxOutputWhenUnset returns 0 without -tags agent. +func DefaultMaxOutputWhenUnset() int64 { return 0 } + +// DiscoveryCatalogHint suggests how to browse runnable leaves (release build). +func DiscoveryCatalogHint() string { + return "gate-cli info list or gate-cli news list" +} + +// DiscoveryResolveOrLeavesAction is suggested_next_action when a command is missing. +func DiscoveryResolveOrLeavesAction() string { + return "use gate-cli info list or gate-cli news list to find leaf commands" +} + +// DiscoveryResolveOrSearchAction is suggested_next_action for unknown leaf commands. +func DiscoveryResolveOrSearchAction() string { + return "run gate-cli info -h or gate-cli news -h for command groups" +} + +// TopLevelWrongNextAction follows a wrong_top_level path correction. +func TopLevelWrongNextAction() string { + return "use the suggested command prefix; run gate-cli info list or gate-cli news list for available tools" +} + +// TopLevelInvalidGroupedMessage explains an invalid top-level token. +func TopLevelInvalidGroupedMessage(token string) string { + return fmt.Sprintf("top-level %q is not valid; use gate-cli info or gate-cli news subcommands", token) +} diff --git a/internal/agentfeature/enabled.go b/internal/agentfeature/enabled.go new file mode 100644 index 0000000..789bda1 --- /dev/null +++ b/internal/agentfeature/enabled.go @@ -0,0 +1,7 @@ +//go:build agent + +package agentfeature + +// Commands enables GateAI agent discovery and GATE_CLI_AGENT runtime behaviors. +// Wire agent commands: go build -tags agent . (see cmd/root_agent_wire.go). +const Commands = true diff --git a/internal/agentfeature/feature_test.go b/internal/agentfeature/feature_test.go new file mode 100644 index 0000000..ab948a0 --- /dev/null +++ b/internal/agentfeature/feature_test.go @@ -0,0 +1,11 @@ +//go:build !agent + +package agentfeature + +import "testing" + +func TestCommandsDisabledByDefault(t *testing.T) { + if Commands { + t.Fatal("expected Commands=false in default builds (no -tags agent)") + } +} diff --git a/internal/agentfeature/hints.go b/internal/agentfeature/hints.go new file mode 100644 index 0000000..fc551a8 --- /dev/null +++ b/internal/agentfeature/hints.go @@ -0,0 +1,45 @@ +//go:build agent + +package agentfeature + +import "fmt" + +// DiscoveryCatalogHint suggests how to browse runnable leaves. +func DiscoveryCatalogHint() string { + if Commands { + return "gate-cli agent-leaves --format json" + } + return "gate-cli info list or gate-cli news list" +} + +// DiscoveryResolveOrLeavesAction is suggested_next_action when help crawl is blocked or command not found. +func DiscoveryResolveOrLeavesAction() string { + if Commands { + return "use gate-cli agent-resolve --query or gate-cli agent-leaves --format json" + } + return "use gate-cli info list or gate-cli news list to find leaf commands" +} + +// DiscoveryResolveOrSearchAction is suggested_next_action for unknown leaf commands. +func DiscoveryResolveOrSearchAction() string { + if Commands { + return "run gate-cli agent-resolve --query or gate-cli agent-search --domain info|news" + } + return "run gate-cli info -h or gate-cli news -h for command groups" +} + +// TopLevelWrongNextAction follows a wrong_top_level path correction. +func TopLevelWrongNextAction() string { + if Commands { + return "use the suggested command prefix; run gate-cli agent-leaves --format json for high-frequency leaf commands" + } + return "use the suggested command prefix; run gate-cli info list or gate-cli news list for available tools" +} + +// TopLevelInvalidGroupedMessage explains an invalid top-level token. +func TopLevelInvalidGroupedMessage(token string) string { + if Commands { + return fmt.Sprintf("top-level %q is not valid; use a grouped leaf command (see gate-cli agent-leaves)", token) + } + return fmt.Sprintf("top-level %q is not valid; use gate-cli info or gate-cli news subcommands", token) +} diff --git a/internal/agentfeature/hints_test.go b/internal/agentfeature/hints_test.go new file mode 100644 index 0000000..df6d20c --- /dev/null +++ b/internal/agentfeature/hints_test.go @@ -0,0 +1,23 @@ +//go:build !agent + +package agentfeature + +import ( + "strings" + "testing" +) + +func TestDiscoveryHintsOmitAgentCommandsWhenDisabled(t *testing.T) { + t.Parallel() + for _, hint := range []string{ + DiscoveryCatalogHint(), + DiscoveryResolveOrLeavesAction(), + DiscoveryResolveOrSearchAction(), + TopLevelWrongNextAction(), + TopLevelInvalidGroupedMessage("foo"), + } { + if strings.Contains(hint, "agent-") { + t.Fatalf("hint must not reference agent-* when -tags agent is off: %q", hint) + } + } +} diff --git a/internal/agentfeature/maxoutput_test.go b/internal/agentfeature/maxoutput_test.go new file mode 100644 index 0000000..e37e98c --- /dev/null +++ b/internal/agentfeature/maxoutput_test.go @@ -0,0 +1,23 @@ +//go:build agent + +package agentfeature + +import "testing" + +func TestDefaultMaxOutputWhenUnsetAgentMode(t *testing.T) { + t.Run("agent env default when bytes unset", func(t *testing.T) { + t.Setenv("GATE_CLI_AGENT", "1") + t.Setenv("GATE_AI_AGENT", "") + if got := DefaultMaxOutputWhenUnset(); got != DefaultMaxOutputBytes { + t.Fatalf("expected %d, got %d", DefaultMaxOutputBytes, got) + } + }) + + t.Run("inactive when env unset", func(t *testing.T) { + t.Setenv("GATE_CLI_AGENT", "") + t.Setenv("GATE_AI_AGENT", "") + if got := DefaultMaxOutputWhenUnset(); got != 0 { + t.Fatalf("expected 0, got %d", got) + } + }) +} diff --git a/internal/agentfeature/runtime.go b/internal/agentfeature/runtime.go new file mode 100644 index 0000000..b5d4c9c --- /dev/null +++ b/internal/agentfeature/runtime.go @@ -0,0 +1,37 @@ +//go:build agent + +package agentfeature + +import ( + "os" + "strings" +) + +// DefaultMaxOutputBytes is the agent-mode stdout cap when GATE_MAX_OUTPUT_BYTES is unset. +const DefaultMaxOutputBytes int64 = 65536 + +// RuntimeEnvEnabled reports GATE_CLI_AGENT / GATE_AI_AGENT truthy values (ignores build tag). +func RuntimeEnvEnabled() bool { + switch strings.TrimSpace(os.Getenv("GATE_CLI_AGENT")) { + case "1", "true", "yes": + return true + } + switch strings.TrimSpace(os.Getenv("GATE_AI_AGENT")) { + case "1", "true", "yes": + return true + } + return false +} + +// RuntimeActive is true when agent commands are compiled in and agent env is set. +func RuntimeActive() bool { + return Commands && RuntimeEnvEnabled() +} + +// DefaultMaxOutputWhenUnset returns the --max-output-bytes default after env is absent. +func DefaultMaxOutputWhenUnset() int64 { + if !RuntimeActive() { + return 0 + } + return DefaultMaxOutputBytes +} diff --git a/internal/agentfeature/runtime_disabled_test.go b/internal/agentfeature/runtime_disabled_test.go new file mode 100644 index 0000000..5c52196 --- /dev/null +++ b/internal/agentfeature/runtime_disabled_test.go @@ -0,0 +1,19 @@ +//go:build !agent + +package agentfeature + +import "testing" + +func TestRuntimeActiveFalseWhenBuildTagOff(t *testing.T) { + t.Setenv("GATE_CLI_AGENT", "1") + t.Setenv("GATE_AI_AGENT", "") + if RuntimeActive() { + t.Fatal("RuntimeActive must be false without -tags agent even when GATE_CLI_AGENT=1") + } + if DefaultMaxOutputWhenUnset() != 0 { + t.Fatalf("expected 0 default max output, got %d", DefaultMaxOutputWhenUnset()) + } + if !RuntimeEnvEnabled() { + t.Fatal("RuntimeEnvEnabled should still read GATE_CLI_AGENT for UA etc.") + } +} diff --git a/internal/client/client.go b/internal/client/client.go index 8ae4759..cde17fd 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -41,6 +41,8 @@ type Client struct { LaunchAPI *gateapi.LaunchApiService SquareAPI *gateapi.SquareApiService WelfareAPI *gateapi.WelfareApiService + AssetswapAPI *gateapi.AssetswapApiService + BotAPI *gateapi.BotApiService ctx context.Context auth bool userAgent string @@ -102,6 +104,8 @@ func New(cfg *config.Config, cmdPath string) (*Client, error) { LaunchAPI: apiClient.LaunchApi, SquareAPI: apiClient.SquareApi, WelfareAPI: apiClient.WelfareApi, + AssetswapAPI: apiClient.AssetswapApi, + BotAPI: apiClient.BotApi, ctx: context.Background(), auth: cfg.APIKey != "" && cfg.APISecret != "", userAgent: gateCfg.UserAgent, diff --git a/internal/client/client_test.go b/internal/client/client_test.go index 66a9d50..a3d9163 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -86,3 +86,29 @@ func TestRequireAuthSucceedsWhenKeySet(t *testing.T) { c, _ := client.New(cfg, "test") assert.NoError(t, c.RequireAuth()) } + +// TestNewClientExposesAllSDKApis guards against API accessor fields being +// dropped during refactors. Each CEX module's command layer dereferences one +// of these, so a missing field surfaces only as a nil-pointer panic at +// runtime without this check. +func TestNewClientExposesAllSDKApis(t *testing.T) { + cfg := &config.Config{BaseURL: "https://api.gateio.ws"} + c, err := client.New(cfg, "test") + require.NoError(t, err) + + assert.NotNil(t, c.SpotAPI, "SpotAPI") + assert.NotNil(t, c.FuturesAPI, "FuturesAPI") + assert.NotNil(t, c.DeliveryAPI, "DeliveryAPI") + assert.NotNil(t, c.MarginAPI, "MarginAPI") + assert.NotNil(t, c.MarginUniAPI, "MarginUniAPI") + assert.NotNil(t, c.OptionsAPI, "OptionsAPI") + assert.NotNil(t, c.UnifiedAPI, "UnifiedAPI") + assert.NotNil(t, c.SubAccountAPI, "SubAccountAPI") + assert.NotNil(t, c.WalletAPI, "WalletAPI") + assert.NotNil(t, c.EarnAPI, "EarnAPI") + assert.NotNil(t, c.EarnUniAPI, "EarnUniAPI") + assert.NotNil(t, c.RebateAPI, "RebateAPI") + assert.NotNil(t, c.AccountAPI, "AccountAPI") + assert.NotNil(t, c.LaunchAPI, "LaunchAPI") + assert.NotNil(t, c.AssetswapAPI, "AssetswapAPI (added in v7.2.71 sync)") +} diff --git a/internal/cmdhint/agent_env.go b/internal/cmdhint/agent_env.go new file mode 100644 index 0000000..be8d6aa --- /dev/null +++ b/internal/cmdhint/agent_env.go @@ -0,0 +1,10 @@ +//go:build agent + +package cmdhint + +import "github.com/gate/gate-cli/internal/agentfeature" + +// AgentModeEnabled reports whether GateAI/agent-oriented CLI defaults are active. +func AgentModeEnabled() bool { + return agentfeature.RuntimeActive() +} diff --git a/internal/cmdhint/agent_env_disabled_test.go b/internal/cmdhint/agent_env_disabled_test.go new file mode 100644 index 0000000..a13166e --- /dev/null +++ b/internal/cmdhint/agent_env_disabled_test.go @@ -0,0 +1,13 @@ +//go:build !agent + +package cmdhint + +import "testing" + +func TestAgentModeEnabledFalseWithoutBuildTag(t *testing.T) { + t.Setenv("GATE_CLI_AGENT", "1") + t.Setenv("GATE_AI_AGENT", "") + if AgentModeEnabled() { + t.Fatal("AgentModeEnabled must be false without -tags agent") + } +} diff --git a/internal/cmdhint/agent_env_test.go b/internal/cmdhint/agent_env_test.go new file mode 100644 index 0000000..69b054a --- /dev/null +++ b/internal/cmdhint/agent_env_test.go @@ -0,0 +1,22 @@ +//go:build agent + +package cmdhint + +import "testing" + +func TestAgentModeEnabled(t *testing.T) { + t.Setenv("GATE_CLI_AGENT", "1") + t.Setenv("GATE_AI_AGENT", "") + if !AgentModeEnabled() { + t.Fatal("expected enabled with GATE_CLI_AGENT=1") + } + t.Setenv("GATE_CLI_AGENT", "") + t.Setenv("GATE_AI_AGENT", "true") + if !AgentModeEnabled() { + t.Fatal("expected enabled with GATE_AI_AGENT=true") + } + t.Setenv("GATE_AI_AGENT", "") + if AgentModeEnabled() { + t.Fatal("expected disabled when env unset") + } +} diff --git a/internal/cmdhint/agent_help.go b/internal/cmdhint/agent_help.go new file mode 100644 index 0000000..24c065b --- /dev/null +++ b/internal/cmdhint/agent_help.go @@ -0,0 +1,53 @@ +//go:build agent + +package cmdhint + +import "strings" + +// ShouldBlockParentHelp reports whether argv is a parent/group --help crawl in agent mode. +// Leaf help (deep paths) is allowed once after INVALID_ARGS per PRD. +func ShouldBlockParentHelp(argv []string) bool { + if len(argv) < 2 { + return false + } + args := argv[1:] + if !argsContainHelp(args) { + return false + } + pos := nonFlagPositionals(args) + if len(pos) >= 4 { + return false + } + // info/news leaves are typically gate-cli (3 positionals). + if len(pos) == 3 { + switch strings.ToLower(pos[0]) { + case "info", "news": + return false + } + } + // info/news shortcuts and discovery leaves: gate-cli info|news +|list|describe (2 positionals). + if len(pos) == 2 { + switch strings.ToLower(pos[0]) { + case "info", "news": + switch pos[1] { + case "list", "describe": + return false + default: + if strings.HasPrefix(pos[1], "+") { + return false + } + } + } + } + return true +} + +func argsContainHelp(args []string) bool { + for _, a := range args { + switch a { + case "-h", "--help", "-help": + return true + } + } + return false +} diff --git a/internal/cmdhint/agent_help_test.go b/internal/cmdhint/agent_help_test.go new file mode 100644 index 0000000..3c79eed --- /dev/null +++ b/internal/cmdhint/agent_help_test.go @@ -0,0 +1,33 @@ +//go:build agent + +package cmdhint + +import "testing" + +func TestShouldBlockParentHelp(t *testing.T) { + t.Parallel() + if !ShouldBlockParentHelp([]string{"gate-cli", "cex", "--help"}) { + t.Fatal("expected block on cex --help") + } + if ShouldBlockParentHelp([]string{"gate-cli", "cex", "earn", "uni", "records", "--help"}) { + t.Fatal("expected allow on leaf --help") + } + if !ShouldBlockParentHelp([]string{"gate-cli", "--help"}) { + t.Fatal("expected block on root --help") + } + if ShouldBlockParentHelp([]string{"gate-cli", "news", "feed", "search-news", "--help"}) { + t.Fatal("expected allow on info/news 3-segment leaf --help") + } + if !ShouldBlockParentHelp([]string{"gate-cli", "cex", "spot", "market", "--help"}) { + t.Fatal("expected block on cex 3-segment group --help") + } + if ShouldBlockParentHelp([]string{"gate-cli", "info", "+coin-overview", "--help"}) { + t.Fatal("expected allow on info/news shortcut --help") + } + if ShouldBlockParentHelp([]string{"gate-cli", "news", "list", "--help"}) { + t.Fatal("expected allow on news list --help") + } + if ShouldBlockParentHelp([]string{"gate-cli", "info", "describe", "-h"}) { + t.Fatal("expected allow on info describe --help") + } +} diff --git a/internal/cmdhint/agent_hints.go b/internal/cmdhint/agent_hints.go new file mode 100644 index 0000000..32b2c26 --- /dev/null +++ b/internal/cmdhint/agent_hints.go @@ -0,0 +1,65 @@ +//go:build agent + +package cmdhint + +import ( + "fmt" + "strings" + + "github.com/gate/gate-cli/internal/agentfeature" +) + +// AgentResolveHint returns a machine-readable follow-up discovery command for agents. +func AgentResolveHint(query string) string { + q := strings.TrimSpace(query) + if q == "" { + return agentfeature.DiscoveryCatalogHint() + } + if agentfeature.Commands { + return fmt.Sprintf("gate-cli agent-resolve --query %q --format json", q) + } + return agentfeature.DiscoveryCatalogHint() +} + +// AgentPreflightNextAction suggests discovery/doctor steps after preflight in agent mode. +func AgentPreflightNextAction(route string) string { + switch route { + case "BLOCK": + return "run gate-cli doctor --format json; configure GATE_INTEL_* URLs and bearer tokens before retrying info/news tools" + default: + if agentfeature.Commands { + return "use gate-cli agent-leaves --format json or agent-resolve --query for leaf commands" + } + return "use " + agentfeature.DiscoveryCatalogHint() + " to browse leaf commands" + } +} + +// AgentMigrateNextAction suggests next steps after migrate failures in agent mode. +func AgentMigrateNextAction(status string) string { + switch status { + case "fail": + return "fix migrate failures (backup dir permissions, provider config paths); rerun gate-cli doctor --format json before info/news tools" + case "warn": + return "review migrate warnings; remove legacy Gate MCP entries manually if auto-migrate did not apply" + default: + return "gate-cli doctor --format json" + } +} + +// AgentDoctorNextAction suggests discovery after doctor failures in agent mode. +func AgentDoctorNextAction(status string) string { + switch status { + case "fail": + if agentfeature.Commands { + return "fix failing doctor checks (config, GATE_INTEL_* connectivity); then gate-cli agent-resolve --query --format json" + } + return "fix failing doctor checks (config, GATE_INTEL_* connectivity); then " + agentfeature.DiscoveryCatalogHint() + case "warn": + if agentfeature.Commands { + return "review doctor warnings; prefer gate-cli agent-leaves --format json over --help crawl" + } + return "review doctor warnings; use " + agentfeature.DiscoveryCatalogHint() + default: + return agentfeature.DiscoveryCatalogHint() + } +} diff --git a/internal/cmdhint/agent_stub.go b/internal/cmdhint/agent_stub.go new file mode 100644 index 0000000..3a78dd3 --- /dev/null +++ b/internal/cmdhint/agent_stub.go @@ -0,0 +1,28 @@ +//go:build !agent + +package cmdhint + +import "io" + +// AgentModeEnabled is false when agent support is not compiled in (-tags agent). +func AgentModeEnabled() bool { return false } + +// AgentResolveHint is a no-op without -tags agent. +func AgentResolveHint(string) string { return "" } + +// AgentPreflightNextAction is unused when AgentModeEnabled is false. +func AgentPreflightNextAction(string) string { return "" } + +// AgentMigrateNextAction is unused when AgentModeEnabled is false. +func AgentMigrateNextAction(string) string { return "" } + +// AgentDoctorNextAction is unused when AgentModeEnabled is false. +func AgentDoctorNextAction(string) string { return "" } + +// EnrichDiagnosticWithAgentLeaf is a no-op without -tags agent. +func EnrichDiagnosticWithAgentLeaf(d *Diagnostic, argv []string) {} + +// ShouldBlockParentHelp is a no-op without -tags agent. +func ShouldBlockParentHelp(argv []string) bool { return false } + +func printAgentResolveHint(w io.Writer, argv []string) {} diff --git a/internal/cmdhint/agent_validate.go b/internal/cmdhint/agent_validate.go new file mode 100644 index 0000000..18bc00a --- /dev/null +++ b/internal/cmdhint/agent_validate.go @@ -0,0 +1,81 @@ +//go:build agent + +package cmdhint + +import ( + "github.com/spf13/cobra" + + "github.com/gate/gate-cli/internal/cmdindex" +) + +// ShortcutPathMismatch describes a shipped shortcut missing from the cobra tree. +type ShortcutPathMismatch struct { + Expected string `json:"expected_path"` + Suggestion string `json:"suggestion,omitempty"` +} + +// AgentValidateReport is the combined CI report for MCP catalog + shortcuts. +type AgentValidateReport struct { + OK bool `json:"ok"` + MCP MCPValidateSection `json:"mcp_catalog"` + Shortcuts ShortcutValidateSection `json:"shortcuts"` + TotalCount int `json:"count"` +} + +// MCPValidateSection holds baseline MCP path validation results. +type MCPValidateSection struct { + OK bool `json:"ok"` + Count int `json:"count"` + Mismatches []CatalogPathMismatch `json:"mismatches"` +} + +// ShortcutValidateSection holds shortcut path validation results. +type ShortcutValidateSection struct { + OK bool `json:"ok"` + Count int `json:"count"` + Mismatches []ShortcutPathMismatch `json:"mismatches"` +} + +// ValidateAgentDiscovery checks MCP catalog (50) and info/news shortcuts (10) against root. +func ValidateAgentDiscovery(root *cobra.Command) AgentValidateReport { + mcp := ValidateMCPCatalogAgainstTree(root) + shortcuts := ValidateShortcutsAgainstTree(root) + total := len(mcp) + len(shortcuts) + return AgentValidateReport{ + OK: total == 0, + MCP: MCPValidateSection{ + OK: len(mcp) == 0, + Count: len(mcp), + Mismatches: mcp, + }, + Shortcuts: ShortcutValidateSection{ + OK: len(shortcuts) == 0, + Count: len(shortcuts), + Mismatches: shortcuts, + }, + TotalCount: total, + } +} + +// ValidateShortcutsAgainstTree checks shipped + shortcuts exist as runnable leaves. +func ValidateShortcutsAgainstTree(root *cobra.Command) []ShortcutPathMismatch { + if root == nil { + return nil + } + leafSet := make(map[string]struct{}) + for _, e := range cmdindex.FilterInfoNewsOnly(cmdindex.CollectLeaves(root)) { + leafSet[e.Path] = struct{}{} + } + var out []ShortcutPathMismatch + for _, path := range InfoNewsShortcutPaths { + if _, ok := leafSet[path]; ok { + continue + } + mm := ShortcutPathMismatch{Expected: path} + if hint := cmdindex.ClosestPaths(root, path, 1); len(hint) > 0 { + mm.Suggestion = hint[0] + } + out = append(out, mm) + } + return out +} diff --git a/internal/cmdhint/baseline_catalog.go b/internal/cmdhint/baseline_catalog.go new file mode 100644 index 0000000..6f9f6b4 --- /dev/null +++ b/internal/cmdhint/baseline_catalog.go @@ -0,0 +1,53 @@ +//go:build agent + +package cmdhint + +import ( + "strings" + + "github.com/gate/gate-cli/internal/intelfacade" +) + +// BaselineMCPCatalog returns one Leaf per info/news MCP tool in the shipped baseline (50 tools). +// Intent uses CLI path tokens (e.g. coin-get-coin-info), not MCP wire names (info_coin_get_coin_info). +func BaselineMCPCatalog() []Leaf { + names := baselineToolNames() + out := make([]Leaf, 0, len(names)) + for _, name := range names { + out = append(out, BaselineToolLeaf(name)) + } + return out +} + +func baselineToolNames() []string { + names := make([]string, 0, intelfacade.BaselineToolCount()) + names = append(names, intelfacade.InfoToolBaseline...) + names = append(names, intelfacade.NewsToolBaseline...) + return names +} + +// BaselineToolLeaf builds a catalog Leaf entry for one MCP tool in the info/news baseline. +func BaselineToolLeaf(toolName string) Leaf { + toolName = strings.TrimSpace(toolName) + backend := "info" + if strings.HasPrefix(toolName, "news_") { + backend = "news" + } + cmd := MCPToolToCLICommand(toolName) + path := catalogCLIPath(cmd) + return Leaf{ + Intent: cliPathIntent(path, backend), + Command: cmd, + RequiredPrefix: backend, + OutputType: "json", + Risk: "public_read", + } +} + +func cliPathIntent(path, backend string) string { + path = strings.TrimSpace(path) + if backend != "" && strings.HasPrefix(path, backend+" ") { + path = strings.TrimSpace(path[len(backend)+1:]) + } + return strings.ReplaceAll(path, " ", "-") +} diff --git a/internal/cmdhint/baseline_paths.go b/internal/cmdhint/baseline_paths.go new file mode 100644 index 0000000..27d31f1 --- /dev/null +++ b/internal/cmdhint/baseline_paths.go @@ -0,0 +1,24 @@ +//go:build agent + +package cmdhint + +import ( + "fmt" + "strings" +) + +// MCPToolToCLICommand maps a baseline MCP tool name to a runnable gate-cli leaf command. +func MCPToolToCLICommand(toolName string) string { + toolName = strings.TrimSpace(toolName) + if toolName == "" { + return cliBinaryName + } + parts := strings.Split(toolName, "_") + if len(parts) < 3 { + return cliBinaryName + " " + strings.ReplaceAll(toolName, "_", " ") + } + backend := parts[0] + group := parts[1] + leaf := strings.Join(parts[2:], "-") + return fmt.Sprintf("%s %s %s %s --format json", cliBinaryName, backend, group, leaf) +} diff --git a/internal/cmdhint/baseline_paths_test.go b/internal/cmdhint/baseline_paths_test.go new file mode 100644 index 0000000..4cde1ba --- /dev/null +++ b/internal/cmdhint/baseline_paths_test.go @@ -0,0 +1,42 @@ +//go:build agent + +package cmdhint + +import ( + "strings" + "testing" +) + +func TestMCPToolToCLICommand(t *testing.T) { + t.Parallel() + if got := MCPToolToCLICommand("news_feed_search_news"); got != "gate-cli news feed search-news --format json" { + t.Fatalf("got %q", got) + } + if got := MCPToolToCLICommand("info_markettrend_get_kline"); got != "gate-cli info markettrend get-kline --format json" { + t.Fatalf("got %q", got) + } + if got := MCPToolToCLICommand("info_platformmetrics_get_chain_activity"); got != "gate-cli info platformmetrics get-chain-activity --format json" { + t.Fatalf("got %q", got) + } +} + +func TestBaselineToolLeafIntentUsesCLIPath(t *testing.T) { + t.Parallel() + leaf := BaselineToolLeaf("info_coin_get_coin_info") + if leaf.Intent != "coin-get-coin-info" { + t.Fatalf("intent=%q", leaf.Intent) + } + if strings.Contains(leaf.Intent, "_") { + t.Fatalf("intent must not contain MCP wire underscores: %q", leaf.Intent) + } + if !strings.Contains(leaf.Command, "info coin get-coin-info") { + t.Fatalf("command=%q", leaf.Command) + } +} + +func TestBaselineMCPCatalogCount(t *testing.T) { + t.Parallel() + if len(BaselineMCPCatalog()) != 50 { + t.Fatalf("want 50 baseline tools, got %d", len(BaselineMCPCatalog())) + } +} diff --git a/internal/cmdhint/catalog_validate.go b/internal/cmdhint/catalog_validate.go new file mode 100644 index 0000000..b9f5693 --- /dev/null +++ b/internal/cmdhint/catalog_validate.go @@ -0,0 +1,59 @@ +//go:build agent + +package cmdhint + +import ( + "strings" + + "github.com/spf13/cobra" + + "github.com/gate/gate-cli/internal/cmdindex" +) + +// CatalogPathMismatch describes a baseline MCP tool with no matching runnable cobra leaf. +type CatalogPathMismatch struct { + ToolName string `json:"tool_name"` + Expected string `json:"expected_path"` + Command string `json:"command"` + Suggestion string `json:"suggestion,omitempty"` +} + +// ValidateMCPCatalogAgainstTree checks each baseline tool maps to an existing leaf path in root. +func ValidateMCPCatalogAgainstTree(root *cobra.Command) []CatalogPathMismatch { + if root == nil { + return nil + } + leafSet := make(map[string]struct{}) + for _, e := range cmdindex.FilterInfoNewsOnly(cmdindex.CollectLeaves(root)) { + leafSet[e.Path] = struct{}{} + } + var out []CatalogPathMismatch + for _, name := range baselineToolNames() { + leaf := BaselineToolLeaf(name) + path := catalogCLIPath(leaf.Command) + if _, ok := leafSet[path]; ok { + continue + } + mm := CatalogPathMismatch{ + ToolName: name, + Expected: path, + Command: leaf.Command, + } + if hint := cmdindex.ClosestPaths(root, path, 1); len(hint) > 0 { + mm.Suggestion = hint[0] + } + out = append(out, mm) + } + return out +} + +func catalogCLIPath(command string) string { + command = strings.TrimSpace(command) + if strings.HasPrefix(command, cliBinaryName+" ") { + command = strings.TrimPrefix(command, cliBinaryName+" ") + } + if idx := strings.Index(command, " --"); idx >= 0 { + command = command[:idx] + } + return strings.TrimSpace(command) +} diff --git a/internal/cmdhint/diagnostic_agent_test.go b/internal/cmdhint/diagnostic_agent_test.go new file mode 100644 index 0000000..eb4864a --- /dev/null +++ b/internal/cmdhint/diagnostic_agent_test.go @@ -0,0 +1,25 @@ +//go:build agent + +package cmdhint + +import ( + "bytes" + "strings" + "testing" +) + +func TestPrintDiagnosticJSONLineWithBuildTag(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + PrintDiagnostic(&buf, &Diagnostic{ + ErrorType: "COMMAND_NOT_FOUND", + Suggested: "gate-cli cex earn uni records", + }) + out := buf.String() + if !strings.Contains(out, "gate_cli_diagnostic=") { + t.Fatalf("missing diagnostic line with -tags agent: %q", out) + } + if !strings.Contains(out, "Hint:") { + t.Fatalf("missing human hint: %q", out) + } +} diff --git a/internal/cmdhint/diagnostic_shield_test.go b/internal/cmdhint/diagnostic_shield_test.go new file mode 100644 index 0000000..c77d6ae --- /dev/null +++ b/internal/cmdhint/diagnostic_shield_test.go @@ -0,0 +1,28 @@ +//go:build !agent + +package cmdhint + +import ( + "bytes" + "strings" + "testing" +) + +func TestPrintDiagnosticOmitsMachineLinesWithoutBuildTag(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + PrintDiagnostic(&buf, &Diagnostic{ + ErrorType: "COMMAND_NOT_FOUND", + SuggestedNextAction: "run gate-cli info -h", + }) + out := buf.String() + if strings.Contains(out, "gate_cli_diagnostic=") { + t.Fatalf("machine diagnostic must not print without -tags agent: %q", out) + } + if strings.Contains(out, "gate_cli_agent_resolve_hint=") { + t.Fatalf("resolve hint must not print without -tags agent: %q", out) + } + if !strings.Contains(out, "Hint:") { + t.Fatalf("human hint should still print: %q", out) + } +} diff --git a/internal/cmdhint/flag_suggest.go b/internal/cmdhint/flag_suggest.go new file mode 100644 index 0000000..7f2fa9e --- /dev/null +++ b/internal/cmdhint/flag_suggest.go @@ -0,0 +1,168 @@ +package cmdhint + +import ( + "regexp" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +var unknownFlagRE = regexp.MustCompile(`unknown flag: (--[^\s]+)`) + +// suggestUnknownFlag tries static hints, then cobra flag names on the resolved command. +func suggestUnknownFlag(root *cobra.Command, argv []string, msg string) string { + if hint := suggestFlagCorrection(argv); hint != "" { + return hint + } + if root == nil { + return "" + } + m := unknownFlagRE.FindStringSubmatch(msg) + if len(m) < 2 { + return "" + } + badFlag := strings.TrimSpace(m[1]) + pathArgs, _ := splitCommandPathAndFlags(argv[1:]) + if len(pathArgs) == 0 { + return "" + } + target, _, err := root.Find(pathArgs) + if err != nil || target == nil { + return "" + } + names := collectFlagNames(target) + best := closestFlagName(strings.TrimPrefix(badFlag, "--"), names) + if best == "" { + return "" + } + return replaceFlagInArgv(argv, badFlag, "--"+best) +} + +func splitCommandPathAndFlags(args []string) ([]string, []string) { + var path []string + var flags []string + inFlags := false + for _, a := range args { + if a == "--" { + inFlags = true + flags = append(flags, a) + continue + } + if inFlags || strings.HasPrefix(a, "-") { + inFlags = true + flags = append(flags, a) + continue + } + path = append(path, a) + } + return path, flags +} + +func collectFlagNames(cmd *cobra.Command) []string { + if cmd == nil { + return nil + } + seen := map[string]struct{}{} + var out []string + cmd.Flags().VisitAll(func(f *pflag.Flag) { + if f == nil || f.Name == "" { + return + } + if _, ok := seen[f.Name]; ok { + return + } + seen[f.Name] = struct{}{} + out = append(out, f.Name) + }) + return out +} + +func closestFlagName(bad string, names []string) string { + bad = strings.ToLower(strings.ReplaceAll(bad, "_", "-")) + if bad == "" { + return "" + } + best := "" + bestScore := 0 + for _, n := range names { + norm := strings.ToLower(strings.ReplaceAll(n, "_", "-")) + score := 0 + if norm == bad { + return n + } + if strings.Contains(norm, bad) || strings.Contains(bad, norm) { + score = 5 + } + if editDistance(norm, bad) <= 2 { + score += 3 + } + if score > bestScore { + bestScore = score + best = n + } + } + if bestScore == 0 { + return "" + } + return best +} + +func editDistance(a, b string) int { + if a == b { + return 0 + } + la, lb := len(a), len(b) + if la == 0 { + return lb + } + if lb == 0 { + return la + } + dp := make([]int, lb+1) + for j := 0; j <= lb; j++ { + dp[j] = j + } + for i := 1; i <= la; i++ { + prev := dp[0] + dp[0] = i + for j := 1; j <= lb; j++ { + cur := dp[j] + cost := 1 + if a[i-1] == b[j-1] { + cost = 0 + } + dp[j] = min(dp[j]+1, dp[j-1]+1, prev+cost) + prev = cur + } + } + return dp[lb] +} + +func replaceFlagInArgv(argv []string, oldFlag, newFlag string) string { + if len(argv) < 2 { + return "" + } + out := make([]string, len(argv)) + copy(out, argv) + for i := 1; i < len(out); i++ { + if out[i] == oldFlag { + out[i] = newFlag + break + } + } + return strings.Join(out[1:], " ") +} + +func min(a, b, c int) int { + if a < b { + if a < c { + return a + } + return c + } + if b < c { + return b + } + return c +} diff --git a/internal/cmdhint/flag_suggest_test.go b/internal/cmdhint/flag_suggest_test.go new file mode 100644 index 0000000..bad4364 --- /dev/null +++ b/internal/cmdhint/flag_suggest_test.go @@ -0,0 +1,28 @@ +package cmdhint + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestSuggestUnknownFlagPairOnSpotTicker(t *testing.T) { + t.Parallel() + root := &cobra.Command{Use: "gate-cli"} + cex := &cobra.Command{Use: "cex"} + spot := &cobra.Command{Use: "spot"} + market := &cobra.Command{Use: "market"} + ticker := &cobra.Command{Use: "ticker", Run: func(*cobra.Command, []string) {}} + ticker.Flags().String("pair", "", "pair") + root.AddCommand(cex) + cex.AddCommand(spot) + spot.AddCommand(market) + market.AddCommand(ticker) + + argv := []string{"gate-cli", "cex", "spot", "market", "ticker", "--pairs", "BTC_USDT"} + got := suggestUnknownFlag(root, argv, `unknown flag: --pairs`) + if got == "" || !strings.Contains(got, "--pair") { + t.Fatalf("got %q", got) + } +} diff --git a/internal/cmdhint/hint.go b/internal/cmdhint/hint.go new file mode 100644 index 0000000..7af3498 --- /dev/null +++ b/internal/cmdhint/hint.go @@ -0,0 +1,393 @@ +package cmdhint + +import ( + "encoding/json" + "fmt" + "io" + "regexp" + "strings" + + "github.com/spf13/cobra" + + "github.com/gate/gate-cli/internal/agentfeature" + "github.com/gate/gate-cli/internal/cmdindex" +) + +var unknownCommandRE = regexp.MustCompile(`unknown command "([^"]+)"`) + +// TopLevelSuggestion maps a mistaken first argument to the required gate-cli prefix. +var TopLevelSuggestion = map[string]string{ + "earn": "cex earn", + "spot": "cex spot", + "futures": "cex futures", + "alpha": "cex alpha", + "wallet": "cex wallet", + "margin": "cex margin", + "delivery": "cex delivery", + "options": "cex options", + "intel": "info", + "intelligence": "info", + "markettrend": "info markettrend", + "marketsnapshot": "info marketsnapshot", + "marketdetail": "info marketdetail", + "coin": "info coin", + "coinanalysis": "info coin get-coin-info", + "kline": "info markettrend get-kline", + "explain-market-move": "news events explain-market-move", + "platformmetrics": "info platformmetrics", + "compliance": "info compliance", + "macro": "info macro", + "onchain": "info onchain", + "events": "news events", + "prediction": "news prediction", + "simple-earn": "cex earn uni", + "news": "news feed", + "feed": "news feed", +} + +// pathCorrections fixes common multi-segment mistakes (substring match on argv tail). +var pathCorrections = []struct { + wrong string + fix string +}{ + {"news explain-market-move", "news events explain-market-move"}, + {"news feed explain-market-move", "news events explain-market-move"}, + {"explain-market-move", "news events explain-market-move"}, + {"news explain market move", "news events explain-market-move"}, + {"earn simple-earn", "cex earn uni"}, + {"earn uni records", "cex earn uni records"}, + {"earn uni lends", "cex earn uni lends"}, + {"info coinanalysis", "info coin get-coin-info"}, + {"info kline", "info markettrend get-kline"}, + {"info get-coin-info", "info coin get-coin-info"}, + {"info get-market-snapshot", "info marketsnapshot get-market-snapshot"}, + {"news get-latest-events", "news events get-latest-events"}, + {"news search-news", "news feed search-news"}, + {"news search news", "news feed search-news"}, + {"news latest events", "news events get-latest-events"}, + {"news latest", "news events get-latest-events"}, + {"info markettrend kline", "info markettrend get-kline"}, + {"info kline", "info markettrend get-kline"}, + {"info token security", "info compliance check-token-security"}, + {"info check-token-security", "info compliance check-token-security"}, + {"info technical", "info markettrend get-technical-analysis"}, + {"info coin analysis", "info coin get-coin-info"}, + {"news search", "news feed search-news"}, + {"news explain", "news events explain-market-move"}, + {"info batch snapshot", "info marketsnapshot batch-market-snapshot"}, + {"info institutional", "info marketsnapshot get-institutional-metrics"}, + {"news get-event-detail", "news events get-event-detail"}, + {"news event-detail", "news events get-event-detail"}, + {"news get-market-move-report", "news events get-market-move-report"}, + {"news list-market-move-reports", "news events list-market-move-reports"}, + {"news report-list", "news events list-market-move-reports"}, + {"news prediction orderbook", "news prediction get-market-orderbook"}, + {"news orderbook", "news prediction get-market-orderbook"}, +} + +// flagHints suggests replacements when a known bad flag appears in argv for a domain. +var flagHints = []struct { + domainPrefix string + wrongFlag string + rightFlag string +}{ + {"news events get-market-move-report", "--coin", "--symbol"}, + {"news events list-market-move-reports", "--coin", "--symbol"}, + {"news", "--symbol", "--coin"}, + {"news feed", "--symbol", "--coin"}, + {"news events", "--symbol", "--coin"}, + {"news events explain-market-move", "--symbol", "--coin"}, + {"info coin", "--coin", "--symbol"}, + {"info marketsnapshot", "--coin", "--symbol"}, +} + +// Diagnostic is a machine-readable hint for agents when a CLI invocation fails early. +type Diagnostic struct { + Blocked bool `json:"blocked,omitempty"` + Reason string `json:"reason,omitempty"` + ErrorType string `json:"error_type,omitempty"` + Original string `json:"original,omitempty"` + Suggested string `json:"suggested,omitempty"` + SuggestedNextAction string `json:"suggested_next_action,omitempty"` + Retryable bool `json:"retryable,omitempty"` + Message string `json:"message,omitempty"` +} + +const cliBinaryName = "gate-cli" + +// SuggestTopLevel returns a diagnostic when argv[0] is a known wrong top-level token. +func SuggestTopLevel(argv []string) *Diagnostic { + if len(argv) < 2 { + return nil + } + token := strings.ToLower(strings.TrimSpace(argv[1])) + prefix, ok := TopLevelSuggestion[token] + if !ok { + return nil + } + original := strings.Join(argv[1:], " ") + suggested := append([]string{cliBinaryName}, strings.Fields(prefix)...) + if len(argv) > 2 { + rest := argv[2:] + // Drop duplicated segment when user already typed part of the fix (e.g. gate-cli cex earn). + if len(suggested) > 1 && len(rest) > 0 && rest[0] == suggested[len(suggested)-1] { + rest = rest[1:] + } + suggested = append(suggested, rest...) + } + return &Diagnostic{ + Blocked: true, + Reason: "wrong_top_level", + ErrorType: "COMMAND_NOT_FOUND", + Original: original, + Suggested: strings.Join(suggested, " "), + SuggestedNextAction: agentfeature.TopLevelWrongNextAction(), + Retryable: false, + Message: topLevelMessage(token, prefix), + } +} + +func topLevelMessage(token, prefix string) string { + switch { + case strings.HasPrefix(prefix, "cex "): + return fmt.Sprintf("top-level %q is not valid; trading and earn commands live under gate-cli cex", token) + case strings.HasPrefix(prefix, "info "): + return fmt.Sprintf("top-level %q is not valid; market intelligence commands live under gate-cli info", token) + case strings.HasPrefix(prefix, "news "): + return fmt.Sprintf("top-level %q is not valid; news commands live under gate-cli news", token) + default: + return agentfeature.TopLevelInvalidGroupedMessage(token) + } +} + +// SuggestFromError augments cobra execution errors with routing hints. Pass root for fuzzy leaf search. +func SuggestFromError(argv []string, err error, root *cobra.Command) *Diagnostic { + if err == nil { + return nil + } + msg := err.Error() + lower := strings.ToLower(msg) + if d := suggestAuthError(argv, msg); d != nil { + return augmentDiagnostic(d, argv) + } + if strings.Contains(lower, "help_crawl_forbidden") || strings.Contains(lower, "help disabled on parent") { + return augmentDiagnostic(&Diagnostic{ + Blocked: true, + Reason: "HELP_CRAWL_FORBIDDEN", + ErrorType: "COMMAND_NOT_FOUND", + SuggestedNextAction: agentfeature.DiscoveryResolveOrLeavesAction(), + Retryable: false, + Message: msg, + }, argv) + } + if strings.Contains(lower, "unknown command") { + if d := suggestPathCorrection(argv, msg); d != nil { + d.Message = msg + return augmentDiagnostic(d, argv) + } + if d := SuggestTopLevel(argv); d != nil { + return augmentDiagnostic(d, argv) + } + if d := suggestUnknownCommand(argv, msg, root); d != nil { + return augmentDiagnostic(d, argv) + } + return augmentDiagnostic(&Diagnostic{ + ErrorType: "COMMAND_NOT_FOUND", + Original: strings.Join(argv[1:], " "), + SuggestedNextAction: agentfeature.DiscoveryResolveOrSearchAction(), + Retryable: false, + Message: msg, + }, argv) + } + if strings.Contains(lower, "unknown flag") || strings.Contains(lower, "required flag") { + d := &Diagnostic{ + ErrorType: "INVALID_ARGS", + Original: strings.Join(argv[1:], " "), + SuggestedNextAction: "check the leaf command --help once, then fix flags", + Retryable: true, + Message: msg, + } + if hint := suggestUnknownFlag(root, argv, msg); hint != "" { + d.Suggested = cliBinaryName + " " + hint + d.SuggestedNextAction = "replace mistyped flag (see suggested command)" + } + return augmentDiagnostic(d, argv) + } + return nil +} + +func suggestAuthError(argv []string, msg string) *Diagnostic { + lower := strings.ToLower(msg) + if !strings.Contains(lower, "api key") || !strings.Contains(lower, "secret") { + return nil + } + return &Diagnostic{ + ErrorType: "AUTH_ERROR", + Original: strings.Join(argv[1:], " "), + SuggestedNextAction: "configure GATE_API_KEY/GATE_API_SECRET or run gate-cli config init; do not retry without credentials", + Retryable: false, + Message: msg, + } +} + +func suggestPathCorrection(argv []string, msg string) *Diagnostic { + if len(argv) < 2 || !strings.Contains(strings.ToLower(msg), "unknown command") { + return nil + } + positionals := nonFlagPositionals(argv) + if len(positionals) == 0 { + return nil + } + joined := strings.Join(positionals, " ") + joinedLower := strings.ToLower(joined) + for _, pc := range pathCorrections { + fixLower := strings.ToLower(pc.fix) + if strings.Contains(joinedLower, fixLower) { + continue + } + if !strings.Contains(joinedLower, pc.wrong) { + continue + } + if strings.HasPrefix(pc.wrong, "earn") && strings.Contains(joinedLower, "cex earn") { + continue + } + corrected := replacePathSegment(joined, pc.wrong, pc.fix) + if corrected == "" { + continue + } + suggested := cliBinaryName + " " + corrected + if flags := flagTailFromArgv(argv); flags != "" { + suggested += " " + flags + } + return &Diagnostic{ + Blocked: true, + Reason: "wrong_command_path", + ErrorType: "COMMAND_NOT_FOUND", + Original: strings.Join(argv[1:], " "), + Suggested: suggested, + SuggestedNextAction: "use the corrected grouped leaf command", + Retryable: false, + Message: "command path should use grouped leaves under cex/info/news", + } + } + return nil +} + +func replacePathSegment(joined, wrong, fix string) string { + joinedLower := strings.ToLower(joined) + i := strings.Index(joinedLower, wrong) + if i < 0 { + return "" + } + return joined[:i] + fix + joined[i+len(wrong):] +} + +func flagTailFromArgv(argv []string) string { + for i := 1; i < len(argv); i++ { + if strings.HasPrefix(argv[i], "-") { + return strings.Join(argv[i:], " ") + } + } + return "" +} + +func nonFlagPositionals(args []string) []string { + var out []string + for i := 0; i < len(args); i++ { + a := args[i] + if a == "--" { + break + } + if strings.HasPrefix(a, "-") { + if strings.Contains(a, "=") { + continue + } + if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { + i++ + } + continue + } + out = append(out, a) + } + return out +} + +func suggestUnknownCommand(argv []string, msg string, root *cobra.Command) *Diagnostic { + phrase := strings.Join(argv[1:], " ") + if m := unknownCommandRE.FindStringSubmatch(msg); len(m) == 2 { + phrase = m[1] + " " + phrase + } + if root == nil { + return nil + } + closest := cmdindex.ClosestPaths(root, phrase, 3) + if len(closest) == 0 { + return nil + } + return &Diagnostic{ + ErrorType: "COMMAND_NOT_FOUND", + Original: strings.Join(argv[1:], " "), + Suggested: closest[0], + SuggestedNextAction: "closest leaf commands: " + strings.Join(closest, "; "), + Retryable: false, + Message: msg, + } +} + +func suggestFlagCorrection(argv []string) string { + if len(argv) < 2 { + return "" + } + tail := strings.Join(argv[1:], " ") + lower := strings.ToLower(tail) + for _, h := range flagHints { + if !strings.Contains(lower, h.domainPrefix) { + continue + } + if strings.Contains(lower, h.wrongFlag) { + return strings.Replace(tail, h.wrongFlag, h.rightFlag, 1) + } + } + return "" +} + +// PrintDiagnostic writes a human hint and one JSON line for agent parsers. +func PrintDiagnostic(w io.Writer, d *Diagnostic) { + PrintDiagnosticWithArgv(w, d, nil) +} + +// PrintDiagnosticWithArgv enriches diagnostics with agent-leaves matches when argv is known. +func PrintDiagnosticWithArgv(w io.Writer, d *Diagnostic, argv []string) { + if d == nil || w == nil { + return + } + if AgentModeEnabled() && len(argv) > 0 { + EnrichDiagnosticWithAgentLeaf(d, argv) + } + if d.Suggested != "" { + _, _ = fmt.Fprintf(w, "Hint: try %s\n", d.Suggested) + } else if d.SuggestedNextAction != "" { + _, _ = fmt.Fprintf(w, "Hint: %s\n", d.SuggestedNextAction) + } + if !agentfeature.Commands { + return + } + b, err := json.Marshal(d) + if err != nil { + return + } + _, _ = fmt.Fprintf(w, "gate_cli_diagnostic=%s\n", string(b)) + if AgentModeEnabled() && len(argv) > 0 { + printAgentResolveHint(w, argv) + } +} + +func augmentDiagnostic(d *Diagnostic, argv []string) *Diagnostic { + if d == nil { + return nil + } + if AgentModeEnabled() && len(argv) > 0 { + EnrichDiagnosticWithAgentLeaf(d, argv) + } + return d +} diff --git a/internal/cmdhint/hint_agent.go b/internal/cmdhint/hint_agent.go new file mode 100644 index 0000000..3c1e12d --- /dev/null +++ b/internal/cmdhint/hint_agent.go @@ -0,0 +1,17 @@ +//go:build agent + +package cmdhint + +import ( + "fmt" + "io" + "strings" +) + +func printAgentResolveHint(w io.Writer, argv []string) { + q := strings.TrimSpace(QueryFromArgv(argv)) + if q == "" || w == nil { + return + } + _, _ = fmt.Fprintf(w, "gate_cli_agent_resolve_hint=%s\n", AgentResolveHint(q)) +} diff --git a/internal/cmdhint/hint_test.go b/internal/cmdhint/hint_test.go new file mode 100644 index 0000000..bafeccb --- /dev/null +++ b/internal/cmdhint/hint_test.go @@ -0,0 +1,138 @@ +//go:build agent + +package cmdhint + +import ( + "bytes" + "strings" + "testing" +) + +func TestSuggestTopLevelEarn(t *testing.T) { + t.Parallel() + d := SuggestTopLevel([]string{"gate-cli", "earn", "uni", "records"}) + if d == nil { + t.Fatal("expected suggestion") + } + if d.Reason != "wrong_top_level" { + t.Fatalf("reason=%q", d.Reason) + } + if !strings.Contains(d.Suggested, "gate-cli cex earn") { + t.Fatalf("suggested=%q", d.Suggested) + } +} + +func TestSuggestTopLevelMarkettrend(t *testing.T) { + t.Parallel() + d := SuggestTopLevel([]string{"gate-cli", "markettrend", "get-kline", "--symbol", "ETH"}) + if d == nil || !strings.Contains(d.Suggested, "info markettrend") { + t.Fatalf("got %#v", d) + } +} + +func TestPrintDiagnosticJSONLine(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + PrintDiagnostic(&buf, &Diagnostic{ + ErrorType: "COMMAND_NOT_FOUND", + Suggested: "gate-cli cex earn uni records", + }) + out := buf.String() + if !strings.Contains(out, "Hint:") { + t.Fatalf("missing human hint: %q", out) + } +} + +func TestSuggestTopLevelInfoMessage(t *testing.T) { + t.Parallel() + d := SuggestTopLevel([]string{"gate-cli", "markettrend"}) + if d == nil || !strings.Contains(d.Message, "gate-cli info") { + t.Fatalf("message=%q", d.Message) + } +} + +func TestSuggestFromErrorUnknownFlagDoesNotUseWrongTopLevel(t *testing.T) { + t.Parallel() + d := SuggestFromError([]string{"gate-cli", "cex", "earn", "uni", "records", "--bad"}, errUnknownFlag{}, nil) + if d == nil { + t.Fatal("expected diagnostic") + } + if d.Reason == "wrong_top_level" { + t.Fatalf("unexpected top-level redirect: %#v", d) + } + if d.ErrorType != "INVALID_ARGS" { + t.Fatalf("error_type=%q", d.ErrorType) + } +} + +type errUnknownFlag struct{} + +func (errUnknownFlag) Error() string { return `unknown flag: --bad` } + +func TestSuggestPathCorrectionNewsExplain(t *testing.T) { + t.Parallel() + d := suggestPathCorrection([]string{"gate-cli", "news", "explain-market-move", "--coin", "BTC"}, `unknown command "explain-market-move" for "gate-cli news"`) + if d == nil || !strings.Contains(d.Suggested, "news events explain-market-move") { + t.Fatalf("got %#v", d) + } +} + +func TestSuggestPathCorrectionNewsFeedExplain(t *testing.T) { + t.Parallel() + d := suggestPathCorrection([]string{"gate-cli", "news", "feed", "explain-market-move", "--coin", "BTC"}, `unknown command "explain-market-move" for "gate-cli news feed"`) + if d == nil || !strings.Contains(d.Suggested, "news events explain-market-move") { + t.Fatalf("got %#v", d) + } +} + +func TestSuggestAuthError(t *testing.T) { + t.Parallel() + d := suggestAuthError([]string{"gate-cli", "cex", "spot", "account", "list"}, "API key and secret required") + if d == nil || d.ErrorType != "AUTH_ERROR" || d.Retryable { + t.Fatalf("got %#v", d) + } +} + +func TestSuggestFlagCorrectionNewsSymbol(t *testing.T) { + t.Parallel() + got := suggestFlagCorrection([]string{"gate-cli", "news", "events", "explain-market-move", "--symbol", "BTC"}) + if !strings.Contains(got, "--coin") || strings.Contains(got, "--symbol") { + t.Fatalf("got %q", got) + } +} + +func TestSuggestFlagCorrectionMarketMoveReportUsesSymbol(t *testing.T) { + t.Parallel() + got := suggestFlagCorrection([]string{"gate-cli", "news", "events", "get-market-move-report", "--coin", "TAIKO"}) + if !strings.Contains(got, "--symbol TAIKO") { + t.Fatalf("got %q", got) + } +} + +func TestSuggestPathCorrectionNewsSearch(t *testing.T) { + t.Parallel() + d := suggestPathCorrection([]string{"gate-cli", "news", "search", "--coin", "BTC"}, `unknown command "search" for "gate-cli news"`) + if d == nil || !strings.Contains(d.Suggested, "news feed search-news") { + t.Fatalf("got %#v", d) + } +} + +func TestSuggestTopLevelCoinanalysis(t *testing.T) { + t.Parallel() + d := SuggestTopLevel([]string{"gate-cli", "coinanalysis", "--symbol", "BTC"}) + if d == nil || !strings.Contains(d.Suggested, "info coin get-coin-info") { + t.Fatalf("got %#v", d) + } +} + +func TestAgentLeavesCount(t *testing.T) { + t.Parallel() + if len(AgentLeaves) != 31 { + t.Fatalf("expected 31 info/news leaves, got %d", len(AgentLeaves)) + } + for _, leaf := range AgentLeaves { + if leaf.RequiredPrefix != "info" && leaf.RequiredPrefix != "news" { + t.Fatalf("agent-leaves must be info/news only, got %q", leaf.RequiredPrefix) + } + } +} diff --git a/internal/cmdhint/leaf_resolve.go b/internal/cmdhint/leaf_resolve.go new file mode 100644 index 0000000..d51ec8b --- /dev/null +++ b/internal/cmdhint/leaf_resolve.go @@ -0,0 +1,247 @@ +//go:build agent + +package cmdhint + +import ( + "sort" + "strings" +) + +// ResolvedLeaf is a leaf match with discovery provenance for agent-resolve JSON. +type ResolvedLeaf struct { + Leaf + MatchSource string `json:"match_source"` +} + +// ResolveAgentIntent matches curated agent-leaves first, then baseline MCP catalog leaves. +// domain limits results to info or news (aliases: intel→info); empty means both. +func ResolveAgentIntent(query string, curatedLimit, mcpLimit int, domain string) []ResolvedLeaf { + if curatedLimit <= 0 { + curatedLimit = 3 + } + if mcpLimit <= 0 { + mcpLimit = 3 + } + domain = normalizeResolveDomain(domain) + curated := filterLeavesByDomain(MatchAgentLeaves(query, curatedLimit), domain) + seen := make(map[string]struct{}, curatedLimit+mcpLimit) + var out []ResolvedLeaf + for _, leaf := range curated { + path := catalogCLIPath(leaf.Command) + if _, ok := seen[path]; ok { + continue + } + seen[path] = struct{}{} + out = append(out, ResolvedLeaf{Leaf: leaf, MatchSource: "curated"}) + } + for _, leaf := range filterLeavesByDomain(MatchLeaves(BaselineMCPCatalog(), query, mcpLimit), domain) { + path := catalogCLIPath(leaf.Command) + if _, ok := seen[path]; ok { + continue + } + seen[path] = struct{}{} + out = append(out, ResolvedLeaf{Leaf: leaf, MatchSource: "mcp_catalog"}) + if len(out) >= curatedLimit+mcpLimit { + break + } + } + return out +} + +func normalizeResolveDomain(domain string) string { + domain = strings.ToLower(strings.TrimSpace(domain)) + switch domain { + case "", "all", "*": + return "" + case "intel": + return "info" + default: + return domain + } +} + +func filterLeavesByDomain(leaves []Leaf, domain string) []Leaf { + if domain == "" || len(leaves) == 0 { + return leaves + } + out := make([]Leaf, 0, len(leaves)) + for _, leaf := range leaves { + if leafMatchesResolveDomain(leaf, domain) { + out = append(out, leaf) + } + } + return out +} + +func leafMatchesResolveDomain(leaf Leaf, domain string) bool { + if strings.EqualFold(strings.TrimSpace(leaf.RequiredPrefix), domain) { + return true + } + path := catalogCLIPath(leaf.Command) + return strings.HasPrefix(path, domain+" ") +} + +// MatchAgentLeaves ranks curated info/news agent-leaves against a free-text query. +func MatchAgentLeaves(query string, limit int) []Leaf { + return MatchLeaves(AgentLeaves, query, limit) +} + +// MatchLeaves ranks leaves against a free-text query (intent keywords or argv tail). +func MatchLeaves(leaves []Leaf, query string, limit int) []Leaf { + query = strings.ToLower(strings.TrimSpace(query)) + if query == "" || len(leaves) == 0 { + return nil + } + tokens := expandLeafQueryTokens(strings.Fields(query)) + type scored struct { + leaf Leaf + score int + } + var hits []scored + for _, leaf := range leaves { + intent := strings.ToLower(leaf.Intent) + cmd := strings.ToLower(leaf.Command) + score := 0 + if strings.Contains(cmd, query) { + score += 25 + } + for _, tok := range tokens { + if tok == "" { + continue + } + if strings.Contains(intent, tok) { + score += 6 + } + if strings.Contains(cmd, tok) { + score += 10 + } + } + score += leafIntentBoost(leaf.Intent, tokens) + if score > 0 { + hits = append(hits, scored{leaf: leaf, score: score}) + } + } + sort.Slice(hits, func(i, j int) bool { + if hits[i].score != hits[j].score { + return hits[i].score > hits[j].score + } + return hits[i].leaf.Intent < hits[j].leaf.Intent + }) + if limit <= 0 || limit > len(hits) { + limit = len(hits) + } + out := make([]Leaf, 0, limit) + for i := 0; i < limit; i++ { + out = append(out, hits[i].leaf) + } + return out +} + +// QueryFromArgv builds a search phrase from non-flag positionals (skips binary name). +func QueryFromArgv(argv []string) string { + if len(argv) < 2 { + return "" + } + return strings.Join(nonFlagPositionals(argv[1:]), " ") +} + +// EnrichDiagnosticWithAgentLeaf fills Suggested when empty and a leaf matches argv/query. +func EnrichDiagnosticWithAgentLeaf(d *Diagnostic, argv []string) { + if d == nil || shouldSkipAgentLeafEnrich(d) { + return + } + if d.Suggested != "" { + return + } + q := QueryFromArgv(argv) + if q == "" { + return + } + leaves := MatchAgentLeaves(q, 1) + if len(leaves) == 0 { + return + } + d.Suggested = leaves[0].Command + if d.SuggestedNextAction == "" { + d.SuggestedNextAction = "matched intent " + leaves[0].Intent + "; substitute placeholders and run" + } +} + +func shouldSkipAgentLeafEnrich(d *Diagnostic) bool { + if d == nil || !d.Blocked { + return false + } + switch d.Reason { + case "HELP_CRAWL_FORBIDDEN", "wrong_command_path", "wrong_top_level": + return true + default: + return false + } +} + +var leafQueryAliases = map[string][]string{ + "latest": {"events", "latest", "get-latest-events"}, + "sentiment": {"social", "sentiment"}, + "security": {"token-risk", "compliance", "check-token-security"}, + "announcement": {"exchange", "announcements"}, + "ugc": {"community", "search-ugc"}, + "投票": {"events", "explain"}, +} + +func expandLeafQueryTokens(tokens []string) []string { + seen := make(map[string]struct{}, len(tokens)*2) + var out []string + add := func(s string) { + s = strings.ToLower(strings.TrimSpace(s)) + if s == "" { + return + } + if _, ok := seen[s]; ok { + return + } + seen[s] = struct{}{} + out = append(out, s) + } + for _, t := range tokens { + add(t) + for _, a := range leafQueryAliases[t] { + add(a) + } + } + return out +} + +func leafIntentBoost(intent string, tokens []string) int { + boosts, ok := leafIntentBoosts[intent] + if !ok { + return 0 + } + score := 0 + for _, tok := range tokens { + for _, b := range boosts { + if tok == b { + score += 8 + } + } + } + return score +} + +var leafIntentBoosts = map[string][]string{ + "news_latest_events": {"latest", "events"}, + "news_search_news": {"news", "search"}, + "news_brief": {"brief", "summary"}, + "news_social_sentiment": {"sentiment"}, + "news_search_ugc": {"ugc", "community", "reddit"}, + "info_market_overview_tool": {"overview", "market"}, + "info_token_security": {"security", "risk"}, + "info_token_risk_by_address": {"security", "risk", "address", "contract"}, + "info_token_onchain_by_address": {"onchain", "holder", "address", "contract"}, + "info_institutional_metrics": {"institutional", "metrics"}, + "info_batch_market_snapshot": {"batch", "symbols"}, + "news_explain_market_move": {"explain", "move"}, + "news_exchange_announcements": {"announcement", "announcements"}, + "news_event_detail": {"event", "detail", "event_id"}, + "news_prediction_orderbook": {"orderbook", "polymarket"}, + "news_prediction_search_events": {"prediction", "search"}, +} diff --git a/internal/cmdhint/leaf_resolve_catalog_test.go b/internal/cmdhint/leaf_resolve_catalog_test.go new file mode 100644 index 0000000..d13ac60 --- /dev/null +++ b/internal/cmdhint/leaf_resolve_catalog_test.go @@ -0,0 +1,13 @@ +//go:build agent + +package cmdhint + +import "testing" + +func TestMatchLeavesCatalogKline(t *testing.T) { + t.Parallel() + got := MatchLeaves(BaselineMCPCatalog(), "indicator history", 2) + if len(got) == 0 { + t.Fatal("expected catalog match") + } +} diff --git a/internal/cmdhint/leaf_resolve_domain_test.go b/internal/cmdhint/leaf_resolve_domain_test.go new file mode 100644 index 0000000..2eb5954 --- /dev/null +++ b/internal/cmdhint/leaf_resolve_domain_test.go @@ -0,0 +1,43 @@ +//go:build agent + +package cmdhint + +import ( + "strings" + "testing" +) + +func TestResolveAgentIntentDedupesByCLIPath(t *testing.T) { + t.Parallel() + got := ResolveAgentIntent("market kline BTC", 3, 3, "") + paths := make(map[string]int) + for _, r := range got { + paths[catalogCLIPath(r.Command)]++ + } + for path, n := range paths { + if n > 1 { + t.Fatalf("duplicate path %q in %#v", path, got) + } + } +} + +func TestResolveAgentIntentDomainNewsOnly(t *testing.T) { + t.Parallel() + got := ResolveAgentIntent("BTC brief kline overview", 5, 5, "news") + for _, r := range got { + path := catalogCLIPath(r.Command) + if !strings.HasPrefix(path, "news ") { + t.Fatalf("expected news-only, got path=%q source=%s", path, r.MatchSource) + } + } +} + +func TestResolveAgentIntentDomainIntelAlias(t *testing.T) { + t.Parallel() + got := ResolveAgentIntent("market kline", 3, 3, "intel") + for _, r := range got { + if !strings.HasPrefix(catalogCLIPath(r.Command), "info ") { + t.Fatalf("intel alias should map to info paths, got %q", r.Command) + } + } +} diff --git a/internal/cmdhint/leaf_resolve_enrich_test.go b/internal/cmdhint/leaf_resolve_enrich_test.go new file mode 100644 index 0000000..4674e01 --- /dev/null +++ b/internal/cmdhint/leaf_resolve_enrich_test.go @@ -0,0 +1,48 @@ +//go:build agent + +package cmdhint + +import "testing" + +func TestEnrichDiagnosticSkipsHelpCrawlBlock(t *testing.T) { + t.Parallel() + d := &Diagnostic{ + Blocked: true, + Reason: "HELP_CRAWL_FORBIDDEN", + SuggestedNextAction: "use gate-cli agent-resolve --query ", + Retryable: false, + } + EnrichDiagnosticWithAgentLeaf(d, []string{"gate-cli", "info", "+coin-overview", "-h"}) + if d.Suggested != "" { + t.Fatalf("must not set suggested on help block, got %q", d.Suggested) + } + if d.SuggestedNextAction != "use gate-cli agent-resolve --query " { + t.Fatalf("must not overwrite next action, got %q", d.SuggestedNextAction) + } +} + +func TestEnrichDiagnosticSkipsWrongTopLevel(t *testing.T) { + t.Parallel() + d := &Diagnostic{ + Blocked: true, + Reason: "wrong_top_level", + Suggested: "gate-cli cex earn uni", + SuggestedNextAction: "use the suggested command prefix; run gate-cli agent-leaves --format json", + } + EnrichDiagnosticWithAgentLeaf(d, []string{"gate-cli", "earn", "uni"}) + if d.SuggestedNextAction != "use the suggested command prefix; run gate-cli agent-leaves --format json" { + t.Fatalf("must not overwrite blocked top-level next action, got %q", d.SuggestedNextAction) + } +} + +func TestEnrichDiagnosticFillsEmptyNextAction(t *testing.T) { + t.Parallel() + d := &Diagnostic{ + ErrorType: "COMMAND_NOT_FOUND", + Message: "unknown command", + } + EnrichDiagnosticWithAgentLeaf(d, []string{"gate-cli", "news", "feed", "search-news"}) + if d.Suggested == "" || d.SuggestedNextAction == "" { + t.Fatalf("expected enrich, got %#v", d) + } +} diff --git a/internal/cmdhint/leaf_resolve_test.go b/internal/cmdhint/leaf_resolve_test.go new file mode 100644 index 0000000..33ae8c2 --- /dev/null +++ b/internal/cmdhint/leaf_resolve_test.go @@ -0,0 +1,36 @@ +//go:build agent + +package cmdhint + +import "testing" + +func TestResolveAgentIntentCuratedBeforeMCP(t *testing.T) { + t.Parallel() + got := ResolveAgentIntent("BTC news brief", 3, 3, "") + if len(got) == 0 { + t.Fatal("expected matches") + } + if got[0].MatchSource != "curated" { + t.Fatalf("first source=%q intent=%q", got[0].MatchSource, got[0].Intent) + } + if got[0].Intent != "news_brief" { + t.Fatalf("expected news_brief, got intent=%q cmd=%q", got[0].Intent, got[0].Command) + } +} + +func TestResolveAgentIntentFallsBackToMCPCatalog(t *testing.T) { + t.Parallel() + got := ResolveAgentIntent("info platformmetrics get-stablecoin-info", 3, 3, "") + if len(got) == 0 { + t.Fatal("expected mcp_catalog match") + } + foundMCP := false + for _, r := range got { + if r.MatchSource == "mcp_catalog" { + foundMCP = true + } + } + if !foundMCP { + t.Fatalf("expected mcp_catalog source in %#v", got) + } +} diff --git a/internal/cmdhint/leaves.go b/internal/cmdhint/leaves.go new file mode 100644 index 0000000..2853082 --- /dev/null +++ b/internal/cmdhint/leaves.go @@ -0,0 +1,243 @@ +//go:build agent + +package cmdhint + +// Leaf describes a high-frequency intent-to-command mapping for GateAI agents. +type Leaf struct { + Intent string `json:"intent"` + Command string `json:"command"` + RequiredPrefix string `json:"required_prefix"` + OutputType string `json:"output_type"` + Risk string `json:"risk,omitempty"` + DefaultLimit int `json:"default_limit,omitempty"` +} + +// AgentLeaves is the info/news cli_leaves set for GateAI agents. +// CEX high-frequency intents are out of scope here; use agent-search --domain cex or a separate catalog. +var AgentLeaves = []Leaf{ + { + Intent: "market_kline", + Command: "gate-cli info markettrend get-kline --symbol {symbol} --timeframe {timeframe} --period {period} --size {size} --format json", + RequiredPrefix: "info", + OutputType: "large_json", + Risk: "public_read", + DefaultLimit: 200, + }, + { + Intent: "news_explain_market_move", + Command: "gate-cli news events explain-market-move --coin {coin} --query {query} --time-range {time_range} --format json", + RequiredPrefix: "news", + OutputType: "json", + Risk: "public_read", + }, + { + Intent: "info_coin_overview", + Command: "gate-cli info +coin-overview --symbol {symbol} --format json", + RequiredPrefix: "info", + OutputType: "json", + Risk: "public_read", + }, + { + Intent: "info_market_overview", + Command: "gate-cli info +market-overview --format json", + RequiredPrefix: "info", + OutputType: "json", + Risk: "public_read", + }, + { + Intent: "info_coin_compare", + Command: "gate-cli info +coin-compare --symbols {symbols} --format json", + RequiredPrefix: "info", + OutputType: "json", + Risk: "public_read", + }, + { + Intent: "info_trend_analysis", + Command: "gate-cli info +trend-analysis --symbol {symbol} --format json", + RequiredPrefix: "info", + OutputType: "json", + Risk: "public_read", + }, + { + Intent: "info_token_risk", + Command: "gate-cli info +token-risk --symbol {symbol} --format json", + RequiredPrefix: "info", + OutputType: "json", + Risk: "public_read", + }, + { + Intent: "info_token_risk_by_address", + Command: "gate-cli info +token-risk --address {address} --chain {chain} --format json", + RequiredPrefix: "info", + OutputType: "json", + Risk: "public_read", + }, + { + Intent: "info_address_tracker", + Command: "gate-cli info +address-tracker --address {address} --chain {chain} --format json", + RequiredPrefix: "info", + OutputType: "json", + Risk: "public_read", + }, + { + Intent: "info_token_onchain", + Command: "gate-cli info +token-onchain --symbol {symbol} --format json", + RequiredPrefix: "info", + OutputType: "json", + Risk: "public_read", + }, + { + Intent: "info_token_onchain_by_address", + Command: "gate-cli info +token-onchain --address {address} --chain {chain} --format json", + RequiredPrefix: "info", + OutputType: "json", + Risk: "public_read", + }, + { + Intent: "news_brief", + Command: "gate-cli news +brief --coin {coin} --format json", + RequiredPrefix: "news", + OutputType: "json", + Risk: "public_read", + }, + { + Intent: "news_event_explain", + Command: "gate-cli news +event-explain --coin {coin} --format json", + RequiredPrefix: "news", + OutputType: "json", + Risk: "public_read", + }, + { + Intent: "news_community_scan", + Command: "gate-cli news +community-scan --coin {coin} --format json", + RequiredPrefix: "news", + OutputType: "json", + Risk: "public_read", + }, + { + Intent: "info_coin_info", + Command: "gate-cli info coin get-coin-info --query {symbol} --format json", + RequiredPrefix: "info", + OutputType: "json", + Risk: "public_read", + }, + { + Intent: "info_market_snapshot", + Command: "gate-cli info marketsnapshot get-market-snapshot --symbol {symbol} --format json", + RequiredPrefix: "info", + OutputType: "json", + Risk: "public_read", + }, + { + Intent: "info_technical_analysis", + Command: "gate-cli info markettrend get-technical-analysis --symbol {symbol} --format json", + RequiredPrefix: "info", + OutputType: "json", + Risk: "public_read", + }, + { + Intent: "info_token_security", + Command: "gate-cli info compliance check-token-security --token {token} --format json", + RequiredPrefix: "info", + OutputType: "json", + Risk: "public_read", + }, + { + Intent: "news_search_news", + Command: "gate-cli news feed search-news --coin {coin} --time-range 24h --limit 20 --format json", + RequiredPrefix: "news", + OutputType: "json_list", + Risk: "public_read", + DefaultLimit: 20, + }, + { + Intent: "news_search_x", + Command: "gate-cli news feed search-x --query {query} --time-range 24h --format json", + RequiredPrefix: "news", + OutputType: "json", + Risk: "public_read", + }, + { + Intent: "news_latest_events", + Command: "gate-cli news events get-latest-events --coin {coin} --time-range 24h --limit 20 --format json", + RequiredPrefix: "news", + OutputType: "json_list", + Risk: "public_read", + DefaultLimit: 20, + }, + { + Intent: "news_web_search", + Command: "gate-cli news feed web-search --query {query} --time-range 24h --limit 5 --format json", + RequiredPrefix: "news", + OutputType: "json", + Risk: "public_read", + DefaultLimit: 5, + }, + { + Intent: "news_social_sentiment", + Command: "gate-cli news feed get-social-sentiment --coin {coin} --time-range 24h --format json", + RequiredPrefix: "news", + OutputType: "json", + Risk: "public_read", + }, + { + Intent: "news_search_ugc", + Command: "gate-cli news feed search-ugc --coin {coin} --time-range 7d --limit 10 --format json", + RequiredPrefix: "news", + OutputType: "json_list", + Risk: "public_read", + DefaultLimit: 10, + }, + { + Intent: "info_market_overview_tool", + Command: "gate-cli info marketsnapshot get-market-overview --format json", + RequiredPrefix: "info", + OutputType: "json", + Risk: "public_read", + }, + { + Intent: "news_event_detail", + Command: "gate-cli news events get-event-detail --event-id {event_id} --format json", + RequiredPrefix: "news", + OutputType: "json", + Risk: "public_read", + }, + { + Intent: "news_prediction_orderbook", + Command: "gate-cli news prediction get-market-orderbook --venue {venue} --market-id {market_id} --format json", + RequiredPrefix: "news", + OutputType: "json", + Risk: "public_read", + }, + { + Intent: "news_prediction_search_events", + Command: "gate-cli news prediction search-events --coin {coin} --limit 20 --format json", + RequiredPrefix: "news", + OutputType: "json_list", + Risk: "public_read", + DefaultLimit: 20, + }, + { + Intent: "info_institutional_metrics", + Command: "gate-cli info marketsnapshot get-institutional-metrics --asset {asset} --channel all --limit 30 --format json", + RequiredPrefix: "info", + OutputType: "json", + Risk: "public_read", + DefaultLimit: 30, + }, + { + Intent: "info_batch_market_snapshot", + Command: "gate-cli info marketsnapshot batch-market-snapshot --symbols {symbols} --format json", + RequiredPrefix: "info", + OutputType: "json", + Risk: "public_read", + }, + { + Intent: "news_exchange_announcements", + Command: "gate-cli news feed get-exchange-announcements --coin {coin} --time-range 7d --limit 20 --format json", + RequiredPrefix: "news", + OutputType: "json_list", + Risk: "public_read", + DefaultLimit: 20, + }, +} diff --git a/internal/cmdhint/leaves_address_test.go b/internal/cmdhint/leaves_address_test.go new file mode 100644 index 0000000..8bd4732 --- /dev/null +++ b/internal/cmdhint/leaves_address_test.go @@ -0,0 +1,41 @@ +//go:build agent + +package cmdhint + +import "testing" + +func TestMatchAgentLeavesTokenRiskByAddress(t *testing.T) { + t.Parallel() + got := MatchAgentLeaves("token security contract address eth", 3) + if len(got) == 0 { + t.Fatal("expected matches") + } + found := false + for _, leaf := range got { + if leaf.Intent == "info_token_risk_by_address" { + found = true + break + } + } + if !found { + t.Fatalf("expected info_token_risk_by_address in %#v", got) + } +} + +func TestMatchAgentLeavesTokenOnchainByAddress(t *testing.T) { + t.Parallel() + got := MatchAgentLeaves("token onchain holder address contract", 3) + if len(got) == 0 { + t.Fatal("expected matches") + } + found := false + for _, leaf := range got { + if leaf.Intent == "info_token_onchain_by_address" { + found = true + break + } + } + if !found { + t.Fatalf("expected info_token_onchain_by_address in %#v", got) + } +} diff --git a/internal/cmdhint/search_match.go b/internal/cmdhint/search_match.go new file mode 100644 index 0000000..887ba0e --- /dev/null +++ b/internal/cmdhint/search_match.go @@ -0,0 +1,54 @@ +//go:build agent + +package cmdhint + +import ( + "strings" + + "github.com/gate/gate-cli/internal/cmdindex" +) + +// SearchMatch is an agent-search hit with discovery provenance. +type SearchMatch struct { + Path string `json:"path"` + Short string `json:"short,omitempty"` + Score int `json:"score,omitempty"` + MatchSource string `json:"match_source"` + CuratedIntent string `json:"curated_intent,omitempty"` + IsShortcut bool `json:"is_shortcut,omitempty"` +} + +// EnrichSearchMatches annotates cobra leaf hits with curated/shortcut/leaf source. +func EnrichSearchMatches(hits []cmdindex.Entry) []SearchMatch { + if len(hits) == 0 { + return nil + } + curatedByPath := curatedPathIndex() + out := make([]SearchMatch, 0, len(hits)) + for _, h := range hits { + m := SearchMatch{ + Path: h.Path, + Short: h.Short, + Score: h.Score, + } + if strings.Contains(h.Path, " +") { + m.MatchSource = "shortcut" + m.IsShortcut = true + } else if intent, ok := curatedByPath[h.Path]; ok { + m.MatchSource = "curated" + m.CuratedIntent = intent + } else { + m.MatchSource = "leaf" + } + out = append(out, m) + } + return out +} + +func curatedPathIndex() map[string]string { + out := make(map[string]string, len(AgentLeaves)) + for _, leaf := range AgentLeaves { + out[catalogCLIPath(leaf.Command)] = leaf.Intent + } + return out +} diff --git a/internal/cmdhint/search_match_test.go b/internal/cmdhint/search_match_test.go new file mode 100644 index 0000000..06c2441 --- /dev/null +++ b/internal/cmdhint/search_match_test.go @@ -0,0 +1,25 @@ +//go:build agent + +package cmdhint + +import ( + "testing" + + "github.com/gate/gate-cli/internal/cmdindex" +) + +func TestEnrichSearchMatchesShortcut(t *testing.T) { + t.Parallel() + got := EnrichSearchMatches([]cmdindex.Entry{{Path: "news +brief", Short: "brief"}}) + if len(got) != 1 || got[0].MatchSource != "shortcut" || !got[0].IsShortcut { + t.Fatalf("got %#v", got) + } +} + +func TestEnrichSearchMatchesCurated(t *testing.T) { + t.Parallel() + got := EnrichSearchMatches([]cmdindex.Entry{{Path: "info markettrend get-kline"}}) + if len(got) != 1 || got[0].MatchSource != "curated" || got[0].CuratedIntent != "market_kline" { + t.Fatalf("got %#v", got) + } +} diff --git a/internal/cmdhint/shortcuts_catalog.go b/internal/cmdhint/shortcuts_catalog.go new file mode 100644 index 0000000..468c6a5 --- /dev/null +++ b/internal/cmdhint/shortcuts_catalog.go @@ -0,0 +1,20 @@ +package cmdhint + +// InfoNewsShortcutPaths are shipped deterministic shortcuts (see specs/Shortcut/xuqiu.md §3.9.0). +var InfoNewsShortcutPaths = []string{ + "info +coin-overview", + "info +market-overview", + "info +coin-compare", + "info +trend-analysis", + "info +token-risk", + "info +address-tracker", + "info +token-onchain", + "news +brief", + "news +event-explain", + "news +community-scan", +} + +// DeferredInfoShortcutPaths are spec-defined but not registered until MCP baseline ships. +var DeferredInfoShortcutPaths = []string{ + "info +address-risk", +} diff --git a/internal/cmdhint/shortcuts_catalog_test.go b/internal/cmdhint/shortcuts_catalog_test.go new file mode 100644 index 0000000..a2bcf9a --- /dev/null +++ b/internal/cmdhint/shortcuts_catalog_test.go @@ -0,0 +1,14 @@ +package cmdhint + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestInfoNewsShortcutPathsCount(t *testing.T) { + t.Parallel() + require.Len(t, InfoNewsShortcutPaths, 10) + require.Len(t, DeferredInfoShortcutPaths, 1) + require.Equal(t, "info +address-risk", DeferredInfoShortcutPaths[0]) +} diff --git a/internal/cmdindex/domain.go b/internal/cmdindex/domain.go new file mode 100644 index 0000000..b3035ba --- /dev/null +++ b/internal/cmdindex/domain.go @@ -0,0 +1,42 @@ +package cmdindex + +import "strings" + +// FilterByDomain returns entries whose path belongs to domain. +// domain: cex|info|news|config|doctor|migrate|preflight|completion|agent-leaves|agent-search|agent-index +// Aliases: trading→cex, intel→info. Empty or "all" returns entries unchanged. +func FilterByDomain(entries []Entry, domain string) []Entry { + domain = normalizeDomain(domain) + if domain == "" { + return entries + } + out := make([]Entry, 0, len(entries)) + for _, e := range entries { + if entryMatchesDomain(e.Path, domain) { + out = append(out, e) + } + } + return out +} + +func normalizeDomain(domain string) string { + domain = strings.ToLower(strings.TrimSpace(domain)) + switch domain { + case "", "all", "*": + return "" + case "trading": + return "cex" + case "intel": + return "info" + default: + return domain + } +} + +func entryMatchesDomain(path, domain string) bool { + parts := strings.Fields(strings.TrimSpace(path)) + if len(parts) == 0 { + return false + } + return parts[0] == domain +} diff --git a/internal/cmdindex/export.go b/internal/cmdindex/export.go new file mode 100644 index 0000000..5a5e401 --- /dev/null +++ b/internal/cmdindex/export.go @@ -0,0 +1,6 @@ +package cmdindex + +// CLICommandLine formats a slash-free gate-cli invocation prefix. +func CLICommandLine(path string) string { + return cliCommandLine(path) +} diff --git a/internal/cmdindex/index.go b/internal/cmdindex/index.go new file mode 100644 index 0000000..4405a53 --- /dev/null +++ b/internal/cmdindex/index.go @@ -0,0 +1,120 @@ +package cmdindex + +import ( + "sort" + "strings" + + "github.com/spf13/cobra" +) + +// Entry is one runnable leaf command in the gate-cli tree. +type Entry struct { + Path string `json:"path"` + Short string `json:"short,omitempty"` + Score int `json:"score,omitempty"` +} + +// CollectLeaves walks the cobra tree and returns runnable leaf commands (no subcommands). +// The root command name (e.g. gate-cli) is omitted from paths. +func CollectLeaves(root *cobra.Command) []Entry { + if root == nil { + return nil + } + var out []Entry + for _, sub := range root.Commands() { + collectLeaves(sub, nil, &out) + } + sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) + return out +} + +func collectLeaves(cmd *cobra.Command, prefix []string, out *[]Entry) { + if cmd == nil || cmd.Hidden { + return + } + name := strings.TrimSpace(cmd.Name()) + if name == "" { + return + } + path := append(append([]string{}, prefix...), name) + if len(cmd.Commands()) == 0 && isRunnable(cmd) { + *out = append(*out, Entry{ + Path: strings.Join(path, " "), + Short: strings.TrimSpace(cmd.Short), + }) + return + } + for _, sub := range cmd.Commands() { + collectLeaves(sub, path, out) + } +} + +func isRunnable(cmd *cobra.Command) bool { + return cmd.Run != nil || cmd.RunE != nil +} + +// Search ranks leaves by token overlap with query (case-insensitive). +func Search(entries []Entry, query string, limit int) []Entry { + query = strings.ToLower(strings.TrimSpace(query)) + if query == "" || len(entries) == 0 { + return nil + } + tokens := expandQueryTokens(strings.Fields(query)) + if len(tokens) == 0 { + return nil + } + scored := make([]Entry, 0, len(entries)) + for _, e := range entries { + path := strings.ToLower(e.Path) + short := strings.ToLower(e.Short) + score := 0 + for _, tok := range tokens { + if tok == "" { + continue + } + if strings.Contains(path, tok) { + score += 10 + } + if strings.Contains(short, tok) { + score += 3 + } + } + if strings.Contains(path, query) { + score += 20 + } + if score > 0 { + e.Score = score + scored = append(scored, e) + } + } + sort.Slice(scored, func(i, j int) bool { + if scored[i].Score != scored[j].Score { + return scored[i].Score > scored[j].Score + } + return scored[i].Path < scored[j].Path + }) + if limit <= 0 || limit > len(scored) { + limit = len(scored) + } + return scored[:limit] +} + +// ClosestPaths returns up to limit command paths best matching a mistaken token or phrase. +func ClosestPaths(root *cobra.Command, phrase string, limit int) []string { + entries := Search(CollectLeaves(root), phrase, limit) + out := make([]string, 0, len(entries)) + for _, e := range entries { + out = append(out, cliCommandLine(e.Path)) + } + return out +} + +func cliCommandLine(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return cliBinaryName + } + return cliBinaryName + " " + path +} + +const cliBinaryName = "gate-cli" diff --git a/internal/cmdindex/index_test.go b/internal/cmdindex/index_test.go new file mode 100644 index 0000000..a46084d --- /dev/null +++ b/internal/cmdindex/index_test.go @@ -0,0 +1,74 @@ +package cmdindex + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestSearchRedeemRecords(t *testing.T) { + t.Parallel() + root := &cobra.Command{Use: "gate-cli"} + earn := &cobra.Command{Use: "earn"} + uni := &cobra.Command{Use: "uni"} + rec := &cobra.Command{Use: "records", Run: func(*cobra.Command, []string) {}} + root.AddCommand(earn) + earn.AddCommand(uni) + uni.AddCommand(rec) + + hits := Search(CollectLeaves(root), "redeem records", 5) + if len(hits) == 0 || !strings.Contains(hits[0].Path, "records") { + t.Fatalf("hits=%v", hits) + } +} + +func TestFilterByDomainCex(t *testing.T) { + t.Parallel() + root := &cobra.Command{Use: "gate-cli"} + cex := &cobra.Command{Use: "cex"} + info := &cobra.Command{Use: "info"} + leafCex := &cobra.Command{Use: "ticker", Run: func(*cobra.Command, []string) {}} + leafInfo := &cobra.Command{Use: "get-kline", Run: func(*cobra.Command, []string) {}} + root.AddCommand(cex, info) + cex.AddCommand(leafCex) + info.AddCommand(leafInfo) + + all := CollectLeaves(root) + filtered := FilterByDomain(all, "cex") + if len(filtered) != 1 || filtered[0].Path != "cex ticker" { + t.Fatalf("filtered=%v", filtered) + } + if len(FilterByDomain(all, "trading")) != 1 { + t.Fatalf("trading alias should match cex") + } +} + +func TestSearchSynonymRedeem(t *testing.T) { + t.Parallel() + root := &cobra.Command{Use: "gate-cli"} + earn := &cobra.Command{Use: "earn"} + uni := &cobra.Command{Use: "uni"} + rec := &cobra.Command{Use: "+redeem-records", Short: "simple earn redeem records", Run: func(*cobra.Command, []string) {}} + root.AddCommand(earn) + earn.AddCommand(uni) + uni.AddCommand(rec) + + hits := Search(CollectLeaves(root), "redeem", 5) + if len(hits) == 0 || !strings.Contains(hits[0].Path, "redeem") { + t.Fatalf("hits=%v", hits) + } +} + +func TestCollectSkipsParents(t *testing.T) { + t.Parallel() + root := &cobra.Command{Use: "gate-cli"} + parent := &cobra.Command{Use: "cex"} + leaf := &cobra.Command{Use: "ticker", RunE: func(*cobra.Command, []string) error { return nil }} + root.AddCommand(parent) + parent.AddCommand(leaf) + leaves := CollectLeaves(root) + if len(leaves) != 1 || leaves[0].Path != "cex ticker" { + t.Fatalf("leaves=%v", leaves) + } +} diff --git a/internal/cmdindex/intel_news.go b/internal/cmdindex/intel_news.go new file mode 100644 index 0000000..e720445 --- /dev/null +++ b/internal/cmdindex/intel_news.go @@ -0,0 +1,19 @@ +package cmdindex + +import "strings" + +// FilterInfoNewsOnly keeps runnable leaves under info and news (excludes cex, config, agent-*, etc.). +func FilterInfoNewsOnly(entries []Entry) []Entry { + out := make([]Entry, 0, len(entries)) + for _, e := range entries { + parts := strings.Fields(strings.TrimSpace(e.Path)) + if len(parts) == 0 { + continue + } + switch parts[0] { + case "info", "news": + out = append(out, e) + } + } + return out +} diff --git a/internal/cmdindex/intel_news_test.go b/internal/cmdindex/intel_news_test.go new file mode 100644 index 0000000..056a191 --- /dev/null +++ b/internal/cmdindex/intel_news_test.go @@ -0,0 +1,17 @@ +package cmdindex + +import "testing" + +func TestFilterInfoNewsOnly(t *testing.T) { + t.Parallel() + entries := []Entry{ + {Path: "info coin get-coin-info"}, + {Path: "news feed search-news"}, + {Path: "cex spot ticker"}, + {Path: "config list"}, + } + got := FilterInfoNewsOnly(entries) + if len(got) != 2 { + t.Fatalf("got=%v", got) + } +} diff --git a/internal/cmdindex/synonyms.go b/internal/cmdindex/synonyms.go new file mode 100644 index 0000000..7321262 --- /dev/null +++ b/internal/cmdindex/synonyms.go @@ -0,0 +1,60 @@ +package cmdindex + +import "strings" + +// expandQueryTokens adds domain-specific synonyms so informal agent queries still rank leaves. +func expandQueryTokens(tokens []string) []string { + if len(tokens) == 0 { + return nil + } + seen := make(map[string]struct{}, len(tokens)*2) + var out []string + add := func(s string) { + s = strings.ToLower(strings.TrimSpace(s)) + if s == "" { + return + } + if _, ok := seen[s]; ok { + return + } + seen[s] = struct{}{} + out = append(out, s) + } + for _, t := range tokens { + add(t) + if extra, ok := querySynonyms[t]; ok { + for _, e := range extra { + add(e) + } + } + } + return out +} + +var querySynonyms = map[string][]string{ + "redeem": {"records", "uni", "earn"}, + "records": {"redeem", "uni"}, + "simple": {"uni", "earn"}, + "earn": {"uni", "cex"}, + "lend": {"lends", "uni"}, + "lends": {"lend", "uni"}, + "kline": {"markettrend", "marketdetail", "get-kline"}, + "candle": {"kline", "markettrend"}, + "ticker": {"market", "pair"}, + "tickers": {"market", "alpha"}, + "alpha": {"tickers", "market"}, + "overview": {"coin-overview", "market-overview"}, + "brief": {"news", "feed", "brief"}, + "latest": {"events", "get-latest-events"}, + "sentiment": {"social"}, + "security": {"compliance", "token-risk"}, + "explain": {"explain-market-move", "events"}, + "move": {"explain-market-move", "events"}, + "compare": {"coin-compare"}, + "risk": {"token-risk", "compliance"}, + "futures": {"cex", "market"}, + "spot": {"cex", "market"}, + "wallet": {"cex", "balance"}, + "balance": {"wallet", "account"}, + "orderbook": {"orderbook", "depth", "market"}, +} diff --git a/internal/cmdutil/cmdutil.go b/internal/cmdutil/cmdutil.go index 3bea3b0..ca1791e 100644 --- a/internal/cmdutil/cmdutil.go +++ b/internal/cmdutil/cmdutil.go @@ -16,8 +16,18 @@ import ( // GetPrinter returns an output.Printer configured from the --format flag. func GetPrinter(cmd *cobra.Command) *output.Printer { + return output.NewWithLimit(os.Stdout, getFormat(cmd), GetMaxOutputBytes(cmd)) +} + +// GetMaxOutputBytes reads the root --max-output-bytes flag (0 = unlimited). +func GetMaxOutputBytes(cmd *cobra.Command) int64 { + maxOut, _ := cmd.Root().PersistentFlags().GetInt64("max-output-bytes") + return maxOut +} + +func getFormat(cmd *cobra.Command) output.Format { format, _ := cmd.Root().PersistentFlags().GetString("format") - return output.New(os.Stdout, output.ParseFormat(format)) + return output.ParseFormat(format) } // IntelMCPTransportDiag reports whether info/news MCP clients should emit RPC transport diff --git a/internal/cmdutil/cmdutil_test.go b/internal/cmdutil/cmdutil_test.go index af12516..ad33c47 100644 --- a/internal/cmdutil/cmdutil_test.go +++ b/internal/cmdutil/cmdutil_test.go @@ -99,6 +99,7 @@ func TestGetClient_CredentialsFromEnvWhenNoFile(t *testing.T) { } func TestGetClient_NoCredentials(t *testing.T) { + t.Setenv("HOME", t.TempDir()) // isolate from any real ~/.gate-cli/config.yaml t.Setenv("GATE_API_KEY", "") t.Setenv("GATE_API_SECRET", "") diff --git a/internal/intelcmd/argv_rewrite.go b/internal/intelcmd/argv_rewrite.go new file mode 100644 index 0000000..d3982b1 --- /dev/null +++ b/internal/intelcmd/argv_rewrite.go @@ -0,0 +1,85 @@ +package intelcmd + +import ( + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "github.com/gate/gate-cli/internal/toolschema" +) + +// RewriteFlexBoolSpaceArgs collapses "--flag VALUE" into "--flag=VALUE" for flags +// whose pflag Value Type equals toolschema.FlexBoolTypeName. flexBool flags also set +// NoOptDefVal="true" so bare "--flag" already means true; the side effect is that +// pflag refuses to consume the following argv token (the bug fixed in this change). +// Pre-rewriting the argv layer keeps backward compatibility for legacy scripts that +// wrote "--flag true|false" with a space, without re-introducing the original silent +// failure. +// +// Rules: +// - Only flags registered on the leaf command resolved from args participate; flag +// names from other subcommands are ignored. +// - Only the long form "--name" (no embedded "=") is rewritten. +// - The following argv token must be a recognized boolean literal (true|false|1|0, +// case-insensitive). Anything else (including "--next-flag") is left untouched. +// - Tokens after a bare "--" are never rewritten (POSIX argv terminator). +// +// Returns the (possibly rewritten) slice and a bool indicating whether any rewrite +// occurred. Callers typically pass the result to cobra.Command.SetArgs only when the +// bool is true to avoid replacing cobra's default argv source unnecessarily. +func RewriteFlexBoolSpaceArgs(root *cobra.Command, args []string) ([]string, bool) { + if root == nil || len(args) == 0 { + return args, false + } + leaf, _, err := root.Find(args) + if err != nil || leaf == nil { + return args, false + } + + flexNames := map[string]struct{}{} + leaf.Flags().VisitAll(func(f *pflag.Flag) { + if f != nil && f.Value != nil && f.Value.Type() == toolschema.FlexBoolTypeName { + flexNames[f.Name] = struct{}{} + } + }) + if len(flexNames) == 0 { + return args, false + } + + out := make([]string, 0, len(args)) + rewritten := false + seenDoubleDash := false + for i := 0; i < len(args); i++ { + a := args[i] + if seenDoubleDash { + out = append(out, a) + continue + } + if a == "--" { + seenDoubleDash = true + out = append(out, a) + continue + } + if strings.HasPrefix(a, "--") && !strings.Contains(a, "=") && i+1 < len(args) { + name := strings.TrimPrefix(a, "--") + if _, ok := flexNames[name]; ok && isBoolLiteral(args[i+1]) { + out = append(out, "--"+name+"="+args[i+1]) + i++ + rewritten = true + continue + } + } + out = append(out, a) + } + return out, rewritten +} + +// isBoolLiteral reports whether s is a value accepted by strconv.ParseBool (case-insensitive). +func isBoolLiteral(s string) bool { + switch strings.ToLower(strings.TrimSpace(s)) { + case "1", "0", "t", "f", "true", "false": + return true + } + return false +} diff --git a/internal/intelcmd/argv_rewrite_test.go b/internal/intelcmd/argv_rewrite_test.go new file mode 100644 index 0000000..ac5ba9d --- /dev/null +++ b/internal/intelcmd/argv_rewrite_test.go @@ -0,0 +1,176 @@ +package intelcmd + +import ( + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gate/gate-cli/internal/toolschema" +) + +// flexBoolStub mirrors the toolschema.flexBool pflag.Value surface for argv rewriting +// tests. It reuses toolschema.FlexBoolTypeName so the stub stays compile-time bound to +// the production constant — if the real flexBool ever renames its Type(), this test +// breaks loudly instead of silently passing while the rewriter ignores production flags. +type flexBoolStub struct{ v bool } + +func (b *flexBoolStub) Set(s string) error { b.v = s == "true" || s == "1" || s == "t"; return nil } +func (b *flexBoolStub) String() string { + if b.v { + return "true" + } + return "false" +} +func (b *flexBoolStub) Type() string { return toolschema.FlexBoolTypeName } + +func newLeafWithFlexBool() *cobra.Command { + root := &cobra.Command{Use: "root"} + sub := &cobra.Command{Use: "sub", Run: func(*cobra.Command, []string) {}} + fb := &flexBoolStub{} + sub.Flags().Var(fb, "with-indicators", "boolean flag") + sub.Flags().Lookup("with-indicators").NoOptDefVal = "true" + sub.Flags().Int("limit", 0, "int flag") + sub.Flags().String("symbol", "", "string flag") + root.AddCommand(sub) + return root +} + +func TestRewriteFlexBoolSpaceArgs_CollapsesSpacedTrue(t *testing.T) { + root := newLeafWithFlexBool() + in := []string{"sub", "--with-indicators", "true", "--limit", "5"} + out, rewritten := RewriteFlexBoolSpaceArgs(root, in) + assert.True(t, rewritten) + assert.Equal(t, []string{"sub", "--with-indicators=true", "--limit", "5"}, out) +} + +func TestRewriteFlexBoolSpaceArgs_CollapsesSpacedFalse(t *testing.T) { + root := newLeafWithFlexBool() + in := []string{"sub", "--with-indicators", "False", "--symbol", "ETH"} + out, rewritten := RewriteFlexBoolSpaceArgs(root, in) + assert.True(t, rewritten) + assert.Equal(t, []string{"sub", "--with-indicators=False", "--symbol", "ETH"}, out) +} + +func TestRewriteFlexBoolSpaceArgs_BareFlagFollowedByAnotherFlagUntouched(t *testing.T) { + root := newLeafWithFlexBool() + in := []string{"sub", "--with-indicators", "--limit", "5"} + out, rewritten := RewriteFlexBoolSpaceArgs(root, in) + assert.False(t, rewritten) + assert.Equal(t, in, out) +} + +func TestRewriteFlexBoolSpaceArgs_EqualsFormUntouched(t *testing.T) { + root := newLeafWithFlexBool() + in := []string{"sub", "--with-indicators=false", "--limit", "5"} + out, rewritten := RewriteFlexBoolSpaceArgs(root, in) + assert.False(t, rewritten) + assert.Equal(t, in, out) +} + +func TestRewriteFlexBoolSpaceArgs_NonBoolNextTokenUntouched(t *testing.T) { + root := newLeafWithFlexBool() + in := []string{"sub", "--with-indicators", "maybe"} + out, rewritten := RewriteFlexBoolSpaceArgs(root, in) + assert.False(t, rewritten) + assert.Equal(t, in, out) +} + +func TestRewriteFlexBoolSpaceArgs_OnlyRewritesFlexBoolNames(t *testing.T) { + root := newLeafWithFlexBool() + // "--symbol" is a string flag; its argv must stay as separate tokens even when the + // next token happens to look like a boolean literal. + in := []string{"sub", "--symbol", "true"} + out, rewritten := RewriteFlexBoolSpaceArgs(root, in) + assert.False(t, rewritten) + assert.Equal(t, in, out) +} + +func TestRewriteFlexBoolSpaceArgs_DashDashTerminatorRespected(t *testing.T) { + root := newLeafWithFlexBool() + in := []string{"sub", "--", "--with-indicators", "true"} + out, rewritten := RewriteFlexBoolSpaceArgs(root, in) + assert.False(t, rewritten) + assert.Equal(t, in, out) +} + +func TestRewriteFlexBoolSpaceArgs_NoLeafReturnsArgsUnchanged(t *testing.T) { + root := newLeafWithFlexBool() + // Unknown subcommand: cobra.Find returns the root itself; root has no flexBool flags, + // so the rewriter must short-circuit without touching args. + in := []string{"missing-subcommand", "--with-indicators", "true"} + out, rewritten := RewriteFlexBoolSpaceArgs(root, in) + assert.False(t, rewritten) + assert.Equal(t, in, out) +} + +func TestRewriteFlexBoolSpaceArgs_NilRootSafe(t *testing.T) { + out, rewritten := RewriteFlexBoolSpaceArgs(nil, []string{"sub"}) + assert.False(t, rewritten) + assert.Equal(t, []string{"sub"}, out) +} + +func TestRewriteFlexBoolSpaceArgs_EmptyArgs(t *testing.T) { + root := newLeafWithFlexBool() + out, rewritten := RewriteFlexBoolSpaceArgs(root, nil) + assert.False(t, rewritten) + assert.Nil(t, out) +} + +func TestRewriteFlexBoolSpaceArgs_TrailingBoolFlagWithoutValue(t *testing.T) { + root := newLeafWithFlexBool() + in := []string{"sub", "--with-indicators"} + out, rewritten := RewriteFlexBoolSpaceArgs(root, in) + assert.False(t, rewritten) + assert.Equal(t, in, out) +} + +func TestRewriteFlexBoolSpaceArgs_TwoFlexBoolsInARow(t *testing.T) { + root := &cobra.Command{Use: "root"} + sub := &cobra.Command{Use: "sub", Run: func(*cobra.Command, []string) {}} + a := &flexBoolStub{} + b := &flexBoolStub{} + sub.Flags().Var(a, "with-a", "") + sub.Flags().Var(b, "with-b", "") + sub.Flags().Lookup("with-a").NoOptDefVal = "true" + sub.Flags().Lookup("with-b").NoOptDefVal = "true" + root.AddCommand(sub) + + in := []string{"sub", "--with-a", "true", "--with-b", "false"} + out, rewritten := RewriteFlexBoolSpaceArgs(root, in) + assert.True(t, rewritten) + assert.Equal(t, []string{"sub", "--with-a=true", "--with-b=false"}, out) +} + +func TestRewriteFlexBoolSpaceArgs_DoesNotTouchNativeBoolFlags(t *testing.T) { + root := &cobra.Command{Use: "root"} + sub := &cobra.Command{Use: "sub", Run: func(*cobra.Command, []string) {}} + // Native pflag bool also carries NoOptDefVal="true" but Type() == "bool", not "flexBool"; + // the rewriter must preserve the historical pflag behavior for non-flexBool boolean flags + // to avoid accidentally widening its scope. + sub.Flags().Bool("debug", false, "") + root.AddCommand(sub) + + in := []string{"sub", "--debug", "true"} + out, rewritten := RewriteFlexBoolSpaceArgs(root, in) + assert.False(t, rewritten) + assert.Equal(t, in, out) +} + +// TestRewriteFlexBoolSpaceArgs_EndToEndParse verifies that after rewriting, cobra parses +// the spaced form to true and still binds the next flag value to its real flag. +func TestRewriteFlexBoolSpaceArgs_EndToEndParse(t *testing.T) { + root := newLeafWithFlexBool() + in := []string{"sub", "--with-indicators", "true", "--limit", "5"} + out, rewritten := RewriteFlexBoolSpaceArgs(root, in) + require.True(t, rewritten) + + leaf, _, err := root.Find(out) + require.NoError(t, err) + require.NoError(t, leaf.ParseFlags(out[1:])) + assert.Equal(t, "true", leaf.Flags().Lookup("with-indicators").Value.String()) + lim, err := leaf.Flags().GetInt("limit") + require.NoError(t, err) + assert.Equal(t, 5, lim) +} diff --git a/internal/intelcmd/execute_error.go b/internal/intelcmd/execute_error.go new file mode 100644 index 0000000..dbd75ae --- /dev/null +++ b/internal/intelcmd/execute_error.go @@ -0,0 +1,59 @@ +package intelcmd + +import ( + "errors" + "io" + "strings" + + "github.com/spf13/cobra" + + "github.com/gate/gate-cli/internal/agentfeature" + "github.com/gate/gate-cli/internal/output" +) + +// EmitExecuteErrorEnvelope prints {"error":…} for cobra execution failures when the user +// requested json/pretty output. Skips when Intel already printed via FailAfterPrintError. +func EmitExecuteErrorEnvelope(w io.Writer, root *cobra.Command, argv []string, err error, message string) { + if w == nil || err == nil { + return + } + if errors.Is(err, ErrSilenced) { + return + } + format := resolveOutputFormatFromArgv(root, argv) + if format != output.FormatJSON && format != output.FormatPretty { + return + } + msg := strings.TrimSpace(message) + if msg == "" { + msg = strings.TrimSpace(err.Error()) + } + if msg == "" { + return + } + p := output.NewWithStderr(io.Discard, w, format) + ge := output.InvalidArgsError(msg) + output.FillAgentErrorConvergence(ge) + p.PrintError(ge) +} + +func resolveOutputFormatFromArgv(root *cobra.Command, argv []string) output.Format { + if root != nil { + if f := root.PersistentFlags().Lookup("format"); f != nil && f.Changed { + return output.ParseFormat(f.Value.String()) + } + } + for i := 0; i < len(argv); i++ { + a := argv[i] + if a == "--format" && i+1 < len(argv) { + return output.ParseFormat(argv[i+1]) + } + if strings.HasPrefix(a, "--format=") { + return output.ParseFormat(strings.TrimPrefix(a, "--format=")) + } + } + if agentfeature.RuntimeEnvEnabled() { + return output.FormatJSON + } + return output.FormatPretty +} diff --git a/internal/intelcmd/intel_result_error_classify.go b/internal/intelcmd/intel_result_error_classify.go new file mode 100644 index 0000000..6a05228 --- /dev/null +++ b/internal/intelcmd/intel_result_error_classify.go @@ -0,0 +1,180 @@ +package intelcmd + +import ( + "regexp" + "strconv" + "strings" + + "github.com/gate/gate-cli/internal/mcpclient" +) + +// paramSnakeNotSupportedRE matches messages like "time_range not supported" where the +// leading token looks like a snake_case tool parameter (at least one underscore). +var paramSnakeNotSupportedRE = regexp.MustCompile(`(?i)^[a-z][a-z0-9]*(_[a-z0-9]+)+ not supported$`) + +// gateErrorMetaForIntelToolIsError picks HTTP status and label for MCP tools/call when +// result.isError is true. Tool-side argument / validation failures map to 400 + +// INVALID_ARGUMENTS so scripts can distinguish them from transport/protocol issues; +// everything else stays 502 + INTEL_RESULT_ERROR. +func gateErrorMetaForIntelToolIsError(msg string, result *mcpclient.CallResult) (status int, label string) { + if status, ok := intelToolIsErrorClientHTTPStatus(result); ok { + return status, "INVALID_ARGUMENTS" + } + code := resolveIntelToolErrorCode(msg, result) + if code != "" { + if isOnchainClientArgumentCode(code) { + return 400, gateErrorLabelForCode(code) + } + if isIntelToolClientArgumentCode(code) { + return 400, "INVALID_ARGUMENTS" + } + if isOnchainUpstreamErrorCode(code) { + return 502, gateErrorLabelForCode(code) + } + } + if intelToolIsErrorLikelyClientArgs(msg, result) { + return 400, "INVALID_ARGUMENTS" + } + return 502, "INTEL_RESULT_ERROR" +} + +func isIntelToolClientArgumentCode(code string) bool { + switch strings.ToUpper(strings.TrimSpace(strings.ReplaceAll(code, "-", "_"))) { + case "INVALID_ARGUMENT", "INVALID_ARGUMENTS", "BAD_REQUEST", "VALIDATION_ERROR", + "ARGUMENT_ERROR", "ILLEGAL_ARGUMENT", "OUT_OF_RANGE": + return true + default: + return false + } +} + +func intelToolIsErrorClientHTTPStatus(result *mcpclient.CallResult) (int, bool) { + if result == nil { + return 0, false + } + for _, m := range []map[string]interface{}{result.StructuredContent, result.Raw, result.Meta} { + for _, key := range []string{"http_status", "httpStatus", "status_code", "statusCode"} { + if n, ok := intFromInterface(m[key]); ok && n >= 400 && n < 500 { + return n, true + } + } + } + return 0, false +} + +func intFromInterface(v interface{}) (int, bool) { + if v == nil { + return 0, false + } + switch t := v.(type) { + case int: + return t, true + case int32: + return int(t), true + case int64: + return int(t), true + case float64: + if t == float64(int64(t)) { + return int(t), true + } + case string: + if n, err := strconv.Atoi(strings.TrimSpace(t)); err == nil { + return n, true + } + } + return 0, false +} + +func extractIntelToolErrorCode(result *mcpclient.CallResult) string { + if result == nil { + return "" + } + for _, m := range []map[string]interface{}{result.StructuredContent, result.Raw, result.Meta} { + if c := errorCodeFromMap(m); c != "" { + return c + } + } + return "" +} + +func errorCodeFromMap(m map[string]interface{}) string { + if m == nil { + return "" + } + for _, k := range []string{"code", "error_code", "errorCode", "error_type", "grpc_code"} { + if c := normalizeCodeString(m[k]); c != "" { + return c + } + } + if ev, ok := m["error"]; ok { + if em, ok2 := ev.(map[string]interface{}); ok2 { + if c := errorCodeFromMap(em); c != "" { + return c + } + } + } + return "" +} + +func normalizeCodeString(v interface{}) string { + s, ok := pickTrimmedString(v) + if !ok { + return "" + } + return strings.ToUpper(strings.ReplaceAll(s, "-", "_")) +} + +// intelToolIsErrorLikelyClientArgs uses conservative substring checks on the extracted +// message plus optional structured hints. It intentionally avoids bare "not supported" +// (ambiguous with product limitations). +func intelToolIsErrorLikelyClientArgs(msg string, result *mcpclient.CallResult) bool { + msg = strings.TrimSpace(msg) + lower := strings.ToLower(msg) + if strings.Contains(lower, "upstream") { + return false + } + if result != nil { + for _, m := range []map[string]interface{}{result.StructuredContent, result.Raw, result.Meta} { + if m == nil { + continue + } + for _, k := range []string{"invalid_argument", "invalidArgument", "validation_failed", "validationFailed"} { + if b, ok := m[k].(bool); ok && b { + return true + } + } + } + } + if msg != "" { + for _, sub := range []string{ + "参数不合法", "非法参数", "无效参数", "缺少必填", "未知参数", "仅支持", + "无效或不受支持", "address 格式无效", + } { + if strings.Contains(msg, sub) { + return true + } + } + for _, sub := range []string{ + "invalid argument", + "invalid parameter", + "invalid parameters", + "invalid value", + "invalid values", + "missing required", + "unknown field", + "malformed", + "must be one of", + "illegal argument", + "unexpected argument", + "unexpected parameter", + } { + if strings.Contains(lower, sub) { + return true + } + } + } + if msg != "" && paramSnakeNotSupportedRE.MatchString(msg) { + return true + } + return false +} diff --git a/internal/intelcmd/intel_result_error_classify_test.go b/internal/intelcmd/intel_result_error_classify_test.go new file mode 100644 index 0000000..994d728 --- /dev/null +++ b/internal/intelcmd/intel_result_error_classify_test.go @@ -0,0 +1,50 @@ +package intelcmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/gate/gate-cli/internal/mcpclient" +) + +func TestGateErrorMetaForIntelToolIsError_ChineseValidation(t *testing.T) { + t.Parallel() + st, lb := gateErrorMetaForIntelToolIsError("参数不合法: time_range 仅支持 1h、24h、7d", nil) + assert.Equal(t, 400, st) + assert.Equal(t, "INVALID_ARGUMENTS", lb) +} + +func TestGateErrorMetaForIntelToolIsError_StructuredCode(t *testing.T) { + t.Parallel() + r := &mcpclient.CallResult{ + StructuredContent: map[string]interface{}{"code": "INVALID_ARGUMENT"}, + } + st, lb := gateErrorMetaForIntelToolIsError("something", r) + assert.Equal(t, 400, st) + assert.Equal(t, "INVALID_ARGUMENTS", lb) +} + +func TestGateErrorMetaForIntelToolIsError_HTTPStatusHint(t *testing.T) { + t.Parallel() + r := &mcpclient.CallResult{ + StructuredContent: map[string]interface{}{"http_status": float64(422)}, + } + st, lb := gateErrorMetaForIntelToolIsError("", r) + assert.Equal(t, 422, st) + assert.Equal(t, "INVALID_ARGUMENTS", lb) +} + +func TestGateErrorMetaForIntelToolIsError_UpstreamStays502(t *testing.T) { + t.Parallel() + st, lb := gateErrorMetaForIntelToolIsError("upstream validation failed", nil) + assert.Equal(t, 502, st) + assert.Equal(t, "INTEL_RESULT_ERROR", lb) +} + +func TestGateErrorMetaForIntelToolIsError_GenericStays502(t *testing.T) { + t.Parallel() + st, lb := gateErrorMetaForIntelToolIsError("internal tool failure", nil) + assert.Equal(t, 502, st) + assert.Equal(t, "INTEL_RESULT_ERROR", lb) +} diff --git a/internal/intelcmd/intelcmd.go b/internal/intelcmd/intelcmd.go index 005090c..ae515bf 100644 --- a/internal/intelcmd/intelcmd.go +++ b/internal/intelcmd/intelcmd.go @@ -4,7 +4,9 @@ package intelcmd import ( "errors" + "strings" + "github.com/gate/gate-cli/internal/cmdhint" "github.com/gate/gate-cli/internal/exitcode" "github.com/gate/gate-cli/internal/intelfacade" "github.com/gate/gate-cli/internal/output" @@ -40,9 +42,13 @@ func FailLeafUnsupportedTable(p *output.Printer, backend string) error { } // RenderToolList prints list output in json/table/pretty formats with shared behavior for info/news. -func RenderToolList(p *output.Printer, items []intelfacade.ToolSummary) error { +// User-facing output uses gate-cli command paths, not MCP wire tool names (info_*_*). +func RenderToolList(p *output.Printer, backend string, items []intelfacade.ToolSummary) error { + if p.IsJSON() && cmdhint.AgentModeEnabled() { + return p.Print(compactToolList(backend, items)) + } if p.IsJSON() { - return p.Print(items) + return p.Print(userFacingListJSON(backend, items)) } if p.IsTable() { rows := make([][]string, 0, len(items)) @@ -51,9 +57,139 @@ func RenderToolList(p *output.Printer, items []intelfacade.ToolSummary) error { if item.HasInputSchema { params = "yes" } - rows = append(rows, []string{item.Name, item.Description, params}) + rows = append(rows, []string{ + UserFacingCLICommand(backend, "", item.Name), + item.Description, + params, + }) + } + return p.Table([]string{"Command", "Description", "Accepts parameters"}, rows) + } + return p.WritePretty(listCapabilitiesPrettyText(backend, items)) +} + +// RenderDescribeTool prints describe output; agent+json uses a compact shape. +func RenderDescribeTool(p *output.Printer, backend string, tool *intelfacade.ToolSummary) error { + if tool == nil { + return nil + } + if p.IsJSON() && cmdhint.AgentModeEnabled() { + return p.Print(compactDescribeTool(backend, tool)) + } + if p.IsJSON() { + return p.Print(userFacingDescribeJSON(backend, tool)) + } + return p.WritePretty(describePrettyText(backend, tool)) +} + +func userFacingListJSON(backend string, items []intelfacade.ToolSummary) []map[string]interface{} { + out := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + entry := map[string]interface{}{ + "command": UserFacingCLICommand(backend, "", item.Name), + "has_input_schema": item.HasInputSchema, + } + if d := strings.TrimSpace(item.Description); d != "" { + entry["description"] = d + } + out = append(out, entry) + } + return out +} + +func userFacingDescribeJSON(backend string, tool *intelfacade.ToolSummary) map[string]interface{} { + out := map[string]interface{}{ + "command": UserFacingCLICommand(backend, "", tool.Name), + "has_input_schema": tool.HasInputSchema, + } + if d := strings.TrimSpace(tool.Description); d != "" { + out["description"] = d + } + if tool.InputSchema != nil { + out["input_schema"] = tool.InputSchema + } + return out +} + +func listCapabilitiesPrettyText(backend string, items []intelfacade.ToolSummary) string { + if len(items) == 0 { + return "Capabilities\n\n(no entries)\n" + } + var b strings.Builder + b.WriteString("Capabilities\n\n") + for i, item := range items { + if i > 0 { + b.WriteByte('\n') + } + b.WriteString(UserFacingCLICommand(backend, "", item.Name)) + b.WriteByte('\n') + if d := strings.TrimSpace(item.Description); d != "" { + b.WriteString(d) + b.WriteByte('\n') } - return p.Table([]string{"Name", "Description", "Accepts parameters"}, rows) + if item.HasInputSchema { + b.WriteString("Accepts parameters: yes\n") + } else { + b.WriteString("Accepts parameters: no\n") + } + } + return b.String() +} + +func describePrettyText(backend string, tool *intelfacade.ToolSummary) string { + if tool == nil { + return "" + } + var b strings.Builder + b.WriteString("Overview\n\n") + b.WriteString(UserFacingCLICommand(backend, "", tool.Name)) + b.WriteByte('\n') + if d := strings.TrimSpace(tool.Description); d != "" { + b.WriteString(d) + b.WriteByte('\n') + } + if paramBlock := intelfacade.FormatParameterSummary(tool); paramBlock != "" { + b.WriteString("\nParameters\n\n") + b.WriteString(paramBlock) + b.WriteByte('\n') + } + b.WriteString("\nNext steps\n\n") + b.WriteString("- Use --format json for the full definition suitable for automation.\n") + b.WriteString("- Use --help on the leaf command that maps to this capability for CLI flags.\n") + return b.String() +} + +func compactDescribeTool(backend string, tool *intelfacade.ToolSummary) map[string]interface{} { + out := map[string]interface{}{ + "path": UserFacingCLICommand(backend, "", tool.Name), + "description": tool.Description, + } + if tool.HasInputSchema { + out["has_input_schema"] = true + } + return out +} + +func compactToolList(backend string, items []intelfacade.ToolSummary) []map[string]string { + backend = strings.TrimSpace(backend) + out := make([]map[string]string, 0, len(items)) + for _, item := range items { + out = append(out, map[string]string{ + "path": strings.TrimSpace(UserFacingCLICommand(backend, "", item.Name)), + }) + } + return out +} + +func toolNameToCLIPath(backend, toolName string) string { + parts := strings.Split(strings.TrimPrefix(toolName, backend+"_"), "_") + if len(parts) < 2 { + return strings.ReplaceAll(toolName, "_", " ") + } + group := parts[0] + leaf := strings.Join(parts[1:], "-") + if backend != "" { + return group + " " + leaf } - return p.WritePretty(intelfacade.ListCapabilitiesPrettyText(items)) + return strings.Join(parts, " ") } diff --git a/internal/intelcmd/intelcmd_test.go b/internal/intelcmd/intelcmd_test.go index a119c41..98df84e 100644 --- a/internal/intelcmd/intelcmd_test.go +++ b/internal/intelcmd/intelcmd_test.go @@ -29,14 +29,14 @@ func TestFailAfterPrintErrorReturnsExitCode1(t *testing.T) { func TestGateErrorForIntelToolIsErrorCopiesTraceID(t *testing.T) { resp := &http.Response{Header: http.Header{}} resp.Header.Set("x-gate-trace-id", "abc-123") - ge := GateErrorForIntelToolIsError("info_coin_get_coin_info", resp) + ge := GateErrorForIntelToolIsError("info_coin_get_coin_info", resp, nil) require.Equal(t, "abc-123", ge.TraceID) } func TestGateErrorForIntelToolIsErrorNoTraceWhenMissing(t *testing.T) { - ge := GateErrorForIntelToolIsError("t", nil) + ge := GateErrorForIntelToolIsError("t", nil, nil) assert.Empty(t, ge.TraceID) - ge2 := GateErrorForIntelToolIsError("t", &http.Response{Header: http.Header{}}) + ge2 := GateErrorForIntelToolIsError("t", &http.Response{Header: http.Header{}}, nil) assert.Empty(t, ge2.TraceID) } diff --git a/internal/intelcmd/is_error_message.go b/internal/intelcmd/is_error_message.go new file mode 100644 index 0000000..f44ddc6 --- /dev/null +++ b/internal/intelcmd/is_error_message.go @@ -0,0 +1,142 @@ +package intelcmd + +import ( + "encoding/json" + "regexp" + "strings" + "unicode/utf8" + + "github.com/gate/gate-cli/internal/mcpclient" +) + +// maxIntelToolIsErrorMessageRunes caps stderr / JSON error text from tool payloads. +const maxIntelToolIsErrorMessageRunes = 2048 + +var bearerTokenRE = regexp.MustCompile(`(?i)bearer\s+\S+`) + +func redactIntelToolErrorMessage(s string) string { + return bearerTokenRE.ReplaceAllString(s, "Bearer [redacted]") +} + +// messageFromIntelToolIsError extracts a short human-readable explanation from an MCP +// tools/call result when isError is true. Returns empty if nothing usable is found. +func messageFromIntelToolIsError(result *mcpclient.CallResult) string { + if result == nil { + return "" + } + if s := stringFromStructuredContent(result.StructuredContent); s != "" { + return redactIntelToolErrorMessage(truncateIntelToolIsErrorMessage(s)) + } + if s := stringFromContentRaw(result.ContentRaw); s != "" { + return redactIntelToolErrorMessage(truncateIntelToolIsErrorMessage(s)) + } + if s := stringFromRawMap(result.Raw); s != "" { + return redactIntelToolErrorMessage(truncateIntelToolIsErrorMessage(s)) + } + return "" +} + +func truncateIntelToolIsErrorMessage(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + if utf8.RuneCountInString(s) <= maxIntelToolIsErrorMessageRunes { + return s + } + r := []rune(s) + return string(r[:maxIntelToolIsErrorMessageRunes]) + "…" +} + +func stringFromStructuredContent(sc map[string]interface{}) string { + if sc == nil { + return "" + } + for _, k := range []string{"message", "detail", "reason", "description", "error_message"} { + if s, ok := pickTrimmedString(sc[k]); ok { + return s + } + } + if v, ok := sc["error"]; ok { + if s := stringFromErrorValue(v); s != "" { + return s + } + } + return "" +} + +func stringFromErrorValue(v interface{}) string { + switch t := v.(type) { + case string: + return strings.TrimSpace(t) + case map[string]interface{}: + for _, k := range []string{"message", "detail", "reason", "description"} { + if s, ok := pickTrimmedString(t[k]); ok { + return s + } + } + } + return "" +} + +func pickTrimmedString(v interface{}) (string, bool) { + if v == nil { + return "", false + } + switch t := v.(type) { + case string: + s := strings.TrimSpace(t) + return s, s != "" + case json.Number: + s := strings.TrimSpace(t.String()) + return s, s != "" + default: + return "", false + } +} + +func stringFromContentRaw(items []interface{}) string { + if len(items) == 0 { + return "" + } + var b strings.Builder + for _, it := range items { + m, ok := it.(map[string]interface{}) + if !ok { + continue + } + txt, ok := m["text"].(string) + if !ok { + continue + } + txt = strings.TrimSpace(txt) + if txt == "" { + continue + } + var obj map[string]interface{} + if json.Unmarshal([]byte(txt), &obj) == nil { + if inner := stringFromStructuredContent(obj); inner != "" { + txt = inner + } + } + if b.Len() > 0 { + _ = b.WriteByte(' ') + } + _, _ = b.WriteString(txt) + } + return strings.TrimSpace(b.String()) +} + +func stringFromRawMap(raw map[string]interface{}) string { + if raw == nil { + return "" + } + for _, k := range []string{"message", "detail", "reason", "error"} { + if v, ok := raw[k]; ok { + if s := stringFromErrorValue(v); s != "" { + return s + } + } + } + return "" +} diff --git a/internal/intelcmd/is_error_message_test.go b/internal/intelcmd/is_error_message_test.go new file mode 100644 index 0000000..067dcd2 --- /dev/null +++ b/internal/intelcmd/is_error_message_test.go @@ -0,0 +1,94 @@ +package intelcmd + +import ( + "strings" + "testing" + "unicode/utf8" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gate/gate-cli/internal/mcpclient" +) + +func TestMessageFromIntelToolIsError_StructuredContentMessage(t *testing.T) { + t.Parallel() + r := &mcpclient.CallResult{ + StructuredContent: map[string]interface{}{ + "message": "invalid time_range for this tool", + }, + } + assert.Equal(t, "invalid time_range for this tool", messageFromIntelToolIsError(r)) +} + +func TestMessageFromIntelToolIsError_StructuredContentNestedError(t *testing.T) { + t.Parallel() + r := &mcpclient.CallResult{ + StructuredContent: map[string]interface{}{ + "error": map[string]interface{}{ + "message": "upstream validation failed", + }, + }, + } + assert.Equal(t, "upstream validation failed", messageFromIntelToolIsError(r)) +} + +func TestMessageFromIntelToolIsError_ContentTextJSON(t *testing.T) { + t.Parallel() + r := &mcpclient.CallResult{ + ContentRaw: []interface{}{ + map[string]interface{}{ + "type": "text", + "text": `{"message":"bad argument"}`, + }, + }, + } + assert.Equal(t, "bad argument", messageFromIntelToolIsError(r)) +} + +func TestMessageFromIntelToolIsError_RawMap(t *testing.T) { + t.Parallel() + r := &mcpclient.CallResult{ + Raw: map[string]interface{}{ + "isError": true, + "message": "from raw only", + }, + } + assert.Equal(t, "from raw only", messageFromIntelToolIsError(r)) +} + +func TestMessageFromIntelToolIsError_Truncates(t *testing.T) { + t.Parallel() + long := strings.Repeat("x", maxIntelToolIsErrorMessageRunes+50) + r := &mcpclient.CallResult{ + StructuredContent: map[string]interface{}{"message": long}, + } + got := messageFromIntelToolIsError(r) + require.Greater(t, len(got), 10) + assert.Contains(t, got, "…") + assert.LessOrEqual(t, utf8.RuneCountInString(got), maxIntelToolIsErrorMessageRunes+1) // + ellipsis +} + +func TestMessageFromIntelToolIsError_RedactsBearer(t *testing.T) { + t.Parallel() + r := &mcpclient.CallResult{ + StructuredContent: map[string]interface{}{ + "message": "auth failed bearer secret-token-value please retry", + }, + } + got := messageFromIntelToolIsError(r) + assert.Contains(t, got, "Bearer [redacted]") + assert.NotContains(t, got, "secret-token-value") +} + +func TestGateErrorForIntelToolIsErrorUsesExtractedMessage(t *testing.T) { + t.Parallel() + ge := GateErrorForIntelToolIsError("news_events_get_latest_events", nil, &mcpclient.CallResult{ + StructuredContent: map[string]interface{}{ + "message": "time_range not supported", + }, + }) + assert.Equal(t, "time_range not supported", ge.Message) + assert.Equal(t, 400, ge.Status) + assert.Equal(t, "INVALID_ARGUMENTS", ge.Label) +} diff --git a/internal/intelcmd/leaf_alias.go b/internal/intelcmd/leaf_alias.go index 9c37585..e70e8ec 100644 --- a/internal/intelcmd/leaf_alias.go +++ b/internal/intelcmd/leaf_alias.go @@ -4,8 +4,14 @@ import ( "strings" "github.com/spf13/cobra" + + "github.com/gate/gate-cli/internal/cmdhint" ) +// AnnotationIntelToolName is the cobra.Command.Annotations key holding the MCP tool name +// (e.g. info_markettrend_get_indicator_history) for intel leaf aliases. Used by tests and tooling. +const AnnotationIntelToolName = "gate-cli.intel.tool-name" + // LeafAliasConfig builds one info/news-style shortcut leaf command (CR-811). type LeafAliasConfig struct { // BackendCLI is the top-level Cobra command name for examples, e.g. "info" or "news". @@ -13,6 +19,8 @@ type LeafAliasConfig struct { Use string ToolName string RunE func(cmd *cobra.Command, args []string) error + // LongAppend is optional MCP-spec narrative (logic, field notes) appended to the standard leaf Long. + LongAppend string } // NewLeafAliasCommand returns a leaf alias command with shared help text and fallback flags. @@ -22,15 +30,31 @@ func NewLeafAliasCommand(cfg LeafAliasConfig) *cobra.Command { if len(parts) >= 2 { group = parts[1] } + var long string + if cmdhint.AgentModeEnabled() { + long = "Shortcut " + cfg.Use + ". Use flags below; discovery: gate-cli agent-search --domain " + cfg.BackendCLI + ". Full spec help: GATE_INTEL_LEAF_HELP=full." + } else { + long = "Intel command " + cfg.BackendCLI + " " + group + " " + cfg.Use + ". Prefer flat flags below; --params, --args-json, and --args-file are JSON fallbacks for uncommon fields.\n" + + "Per-field notes in this help: set GATE_INTEL_LEAF_HELP=full (default omits the Parameters block to avoid duplicating flag lines)." + if strings.TrimSpace(cfg.LongAppend) != "" { + long = long + "\n\n---\n\n" + strings.TrimSpace(cfg.LongAppend) + } + } cmd := &cobra.Command{ Use: cfg.Use, - Short: "Shortcut for " + cfg.ToolName, - Long: "Calls " + cfg.ToolName + ". Flat flags come from a static baseline plus any extra fields from the intel backend; use --params/--args-json/--args-file as JSON fallback.", + Short: cfg.BackendCLI + " " + group + " " + cfg.Use, + Long: long, Example: " gate-cli " + cfg.BackendCLI + " " + group + " " + cfg.Use + " --format json\n" + " gate-cli " + cfg.BackendCLI + " " + group + " " + cfg.Use + " --params '{\"key\":\"value\"}'", Args: cobra.NoArgs, RunE: cfg.RunE, } + if cfg.ToolName != "" { + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + cmd.Annotations[AnnotationIntelToolName] = cfg.ToolName + } AddFallbackArgFlags(cmd) return cmd } diff --git a/internal/intelcmd/list_compact_test.go b/internal/intelcmd/list_compact_test.go new file mode 100644 index 0000000..96d452d --- /dev/null +++ b/internal/intelcmd/list_compact_test.go @@ -0,0 +1,27 @@ +package intelcmd + +import ( + "testing" + + "github.com/gate/gate-cli/internal/intelfacade" +) + +func TestCompactToolListPaths(t *testing.T) { + t.Parallel() + got := compactToolList("news", []intelfacade.ToolSummary{ + {Name: "news_feed_search_news"}, + }) + if len(got) != 1 || got[0]["path"] != "news feed search-news" { + t.Fatalf("got=%v", got) + } + if _, ok := got[0]["name"]; ok { + t.Fatalf("unexpected MCP wire name in compact list: %v", got) + } +} + +func TestToolNameToCLIPath(t *testing.T) { + t.Parallel() + if p := toolNameToCLIPath("info", "info_coin_get_coin_info"); p != "coin get-coin-info" { + t.Fatalf("got %q", p) + } +} diff --git a/internal/intelcmd/merge_baseline.go b/internal/intelcmd/merge_baseline.go index 408260c..c55a670 100644 --- a/internal/intelcmd/merge_baseline.go +++ b/internal/intelcmd/merge_baseline.go @@ -1,6 +1,63 @@ package intelcmd -import "github.com/gate/gate-cli/internal/toolschema" +import ( + "github.com/gate/gate-cli/internal/intelfacade" + "github.com/gate/gate-cli/internal/toolschema" +) + +func baselineInputSchemaForMissingCheck(backend, toolName string) map[string]interface{} { + switch backend { + case "info": + return intelfacade.InfoBaselineInputSchema(toolName) + case "news": + return intelfacade.NewsBaselineInputSchema(toolName) + default: + return nil + } +} + +// InputSchemaForMissingRequiredCheck returns schema for toolschema.MissingRequiredArguments. +// When the committed baseline omits top-level JSON Schema "required" but MCP describe/list +// still carries a stale "required" array, this returns a shallow clone with "required" removed +// so XOR / conditional_required tools (e.g. platform history) stay callable with exchange_slug only. +func InputSchemaForMissingRequiredCheck(backend, toolName string, schema interface{}) interface{} { + baseline := baselineInputSchemaForMissingCheck(backend, toolName) + if baseline == nil { + return schema + } + m, ok := schema.(map[string]interface{}) + if !ok { + return schema + } + cloned := shallowCloneStringMap(m) + if patchInputSchemaRequiredFromBaseline(cloned, baseline) { + return cloned + } + return schema +} + +func shallowCloneStringMap(m map[string]interface{}) map[string]interface{} { + out := make(map[string]interface{}, len(m)) + for k, v := range m { + out[k] = v + } + return out +} + +// patchInputSchemaRequiredFromBaseline drops stale JSON Schema "required" from MCP cache +// when the committed baseline intentionally omits "required" (e.g. XOR / conditional fields +// documented only in specs). When baseline lists "required", the cached server array is kept +// so we do not strip server-only constraints. +func patchInputSchemaRequiredFromBaseline(dst map[string]interface{}, baseline map[string]interface{}) bool { + if _, baselineHas := baseline["required"]; baselineHas { + return false + } + if _, had := dst["required"]; had { + delete(dst, "required") + return true + } + return false +} // MergeToolBaselineInto fills or patches entries in out using static baseline schemas (CR-811). func MergeToolBaselineInto(out map[string]toolschema.ToolSummary, toolNames []string, baselineFor func(string) map[string]interface{}) { @@ -22,6 +79,14 @@ func MergeToolBaselineInto(out map[string]toolschema.ToolSummary, toolNames []st existing.HasInputSchema = true existing.InputSchema = baseline out[name] = existing + continue + } + if m, ok := existing.InputSchema.(map[string]interface{}); ok { + cloned := shallowCloneStringMap(m) + if patchInputSchemaRequiredFromBaseline(cloned, baseline) { + existing.InputSchema = cloned + out[name] = existing + } } } } diff --git a/internal/intelcmd/merge_baseline_test.go b/internal/intelcmd/merge_baseline_test.go index 2099c6e..c61c926 100644 --- a/internal/intelcmd/merge_baseline_test.go +++ b/internal/intelcmd/merge_baseline_test.go @@ -3,6 +3,7 @@ package intelcmd import ( "testing" + "github.com/gate/gate-cli/internal/intelfacade" "github.com/gate/gate-cli/internal/toolschema" ) @@ -25,3 +26,60 @@ func TestMergeToolBaselineIntoFillsMissing(t *testing.T) { t.Fatalf("expected schema, got %#v", s) } } + +func TestMergeToolBaselineIntoStripsStaleRequiredWhenBaselineOmitsRequired(t *testing.T) { + t.Parallel() + out := map[string]toolschema.ToolSummary{ + "info_platformmetrics_get_platform_history": { + Name: "info_platformmetrics_get_platform_history", + HasInputSchema: true, + InputSchema: map[string]interface{}{ + "type": "object", + "required": []interface{}{"platform_name"}, + "properties": map[string]interface{}{"platform_name": map[string]interface{}{"type": "string"}}, + }, + }, + } + names := []string{"info_platformmetrics_get_platform_history"} + baselineFor := func(name string) map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{"platform_name": map[string]interface{}{"type": "string"}}, + } + } + MergeToolBaselineInto(out, names, baselineFor) + s := out["info_platformmetrics_get_platform_history"] + m := s.InputSchema.(map[string]interface{}) + if _, ok := m["required"]; ok { + t.Fatalf("expected stale required removed, got %#v", m["required"]) + } +} + +func TestInputSchemaForMissingRequiredCheck_InfoPlatformHistoryStaleRequired(t *testing.T) { + t.Parallel() + server := map[string]interface{}{ + "type": "object", + "required": []interface{}{"platform_name"}, + "properties": map[string]interface{}{ + "platform_name": map[string]interface{}{"type": "string"}, + "exchange_slug": map[string]interface{}{"type": "string"}, + }, + } + patched := InputSchemaForMissingRequiredCheck("info", "info_platformmetrics_get_platform_history", server) + pm, ok := patched.(map[string]interface{}) + if !ok { + t.Fatalf("expected map schema, got %T", patched) + } + if _, ok := pm["required"]; ok { + t.Fatalf("expected required stripped for MissingRequiredArguments check") + } + args := map[string]interface{}{"exchange_slug": "binance"} + if miss := toolschema.MissingRequiredArguments(args, patched); len(miss) != 0 { + t.Fatalf("unexpected missing: %v", miss) + } + // Baseline still omits top-level required (conditional_required in spec only). + bl := intelfacade.InfoBaselineInputSchema("info_platformmetrics_get_platform_history") + if _, ok := bl["required"]; ok { + t.Fatalf("baseline must omit top-level required for this tool") + } +} diff --git a/internal/intelcmd/onchain_error_codes.go b/internal/intelcmd/onchain_error_codes.go new file mode 100644 index 0000000..2465276 --- /dev/null +++ b/internal/intelcmd/onchain_error_codes.go @@ -0,0 +1,79 @@ +package intelcmd + +import ( + "strings" + + "github.com/gate/gate-cli/internal/mcpclient" +) + +// MCP info_onchain error codes aligned with gate/mcp-server pkg/errs (NE enhancement). +const ( + mcpCodeInvalidChain = "INVALID_CHAIN" + mcpCodeInvalidAddress = "INVALID_ADDRESS" + mcpCodePartialUpstreamResponse = "PARTIAL_UPSTREAM_RESPONSE" + mcpCodeUpstreamNotReady = "UPSTREAM_NOT_READY" +) + +func resolveIntelToolErrorCode(msg string, result *mcpclient.CallResult) string { + if c := extractIntelToolErrorCode(result); c != "" { + return c + } + return inferMCPErrorCodeFromMessage(msg) +} + +func inferMCPErrorCodeFromMessage(msg string) string { + lower := strings.ToLower(strings.TrimSpace(msg)) + if lower == "" { + return "" + } + // Explicit code tokens in MCP SafeMessage / logs. + for _, c := range []string{ + "partial_upstream_response", + "upstream_not_ready", + "invalid_address", + "invalid_chain", + } { + if strings.Contains(lower, c) { + return strings.ToUpper(strings.ReplaceAll(c, "-", "_")) + } + } + // Chinese / paraphrased MCP messages. + switch { + case strings.Contains(msg, "无效或不受支持"): + return mcpCodeInvalidChain + case strings.Contains(msg, "address 格式无效"), strings.Contains(lower, "invalid_address"): + return mcpCodeInvalidAddress + case strings.Contains(lower, "partial_upstream"): + return mcpCodePartialUpstreamResponse + case strings.Contains(msg, "该链接口未就绪"), strings.Contains(lower, "not in get /chains"): + return mcpCodeUpstreamNotReady + default: + return "" + } +} + +func isOnchainUpstreamErrorCode(code string) bool { + switch strings.ToUpper(strings.TrimSpace(code)) { + case mcpCodePartialUpstreamResponse, mcpCodeUpstreamNotReady: + return true + default: + return false + } +} + +func isOnchainClientArgumentCode(code string) bool { + switch strings.ToUpper(strings.TrimSpace(code)) { + case mcpCodeInvalidChain, mcpCodeInvalidAddress: + return true + default: + return false + } +} + +func gateErrorLabelForCode(code string) string { + c := strings.ToUpper(strings.TrimSpace(strings.ReplaceAll(code, "-", "_"))) + if c == "" { + return "INTEL_RESULT_ERROR" + } + return c +} diff --git a/internal/intelcmd/onchain_error_codes_test.go b/internal/intelcmd/onchain_error_codes_test.go new file mode 100644 index 0000000..1f83667 --- /dev/null +++ b/internal/intelcmd/onchain_error_codes_test.go @@ -0,0 +1,47 @@ +package intelcmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/gate/gate-cli/internal/mcpclient" +) + +func TestGateErrorMetaForIntelToolIsError_OnchainInvalidChain(t *testing.T) { + t.Parallel() + st, lb := gateErrorMetaForIntelToolIsError("无效或不受支持的 chain: random", nil) + assert.Equal(t, 400, st) + assert.Equal(t, "INVALID_CHAIN", lb) +} + +func TestGateErrorMetaForIntelToolIsError_OnchainInvalidAddress(t *testing.T) { + t.Parallel() + st, lb := gateErrorMetaForIntelToolIsError("address 格式无效", nil) + assert.Equal(t, 400, st) + assert.Equal(t, "INVALID_ADDRESS", lb) +} + +func TestGateErrorMetaForIntelToolIsError_PartialUpstream502(t *testing.T) { + t.Parallel() + r := &mcpclient.CallResult{ + StructuredContent: map[string]interface{}{"code": "partial_upstream_response"}, + } + st, lb := gateErrorMetaForIntelToolIsError("partial_upstream_response", r) + assert.Equal(t, 502, st) + assert.Equal(t, "PARTIAL_UPSTREAM_RESPONSE", lb) +} + +func TestGateErrorForIntelToolIsError_PartialUpstreamFriendlyMessage(t *testing.T) { + t.Parallel() + ge := GateErrorForIntelToolIsError("info_onchain_get_address_transactions", nil, &mcpclient.CallResult{ + StructuredContent: map[string]interface{}{ + "code": "partial_upstream_response", + "message": "partial_upstream_response", + }, + }) + assert.Equal(t, 502, ge.Status) + assert.Equal(t, "PARTIAL_UPSTREAM_RESPONSE", ge.Label) + assert.Contains(t, ge.Message, "upstream returned total") + assert.NotContains(t, ge.Message, "暂无交易") +} diff --git a/internal/intelcmd/resolve_tool.go b/internal/intelcmd/resolve_tool.go new file mode 100644 index 0000000..361d924 --- /dev/null +++ b/internal/intelcmd/resolve_tool.go @@ -0,0 +1,45 @@ +package intelcmd + +import "strings" + +// ResolveMCPToolName maps a user-facing CLI command path to the MCP wire tool name. +// MCP names (info_* / news_*) pass through unchanged. Paths like "info coin get-coin-info" +// or "coin get-coin-info" (with backend "info") resolve to info_coin_get_coin_info. +func ResolveMCPToolName(backend, nameOrPath string) string { + nameOrPath = strings.TrimSpace(nameOrPath) + if nameOrPath == "" { + return "" + } + backend = strings.TrimSpace(backend) + if looksLikeMCPToolName(backend, nameOrPath) { + return nameOrPath + } + path := nameOrPath + if backend != "" { + prefix := backend + " " + if len(path) > len(prefix) && strings.EqualFold(path[:len(prefix)], prefix) { + path = strings.TrimSpace(path[len(prefix):]) + } + } + parts := strings.Fields(path) + if len(parts) < 2 { + return nameOrPath + } + group := strings.ToLower(parts[0]) + leaf := strings.ToLower(strings.Join(parts[1:], " ")) + leaf = strings.ReplaceAll(leaf, "-", "_") + if backend == "" { + return group + "_" + leaf + } + return backend + "_" + group + "_" + leaf +} + +func looksLikeMCPToolName(backend, name string) bool { + if !strings.Contains(name, "_") { + return false + } + if backend != "" && strings.HasPrefix(name, backend+"_") { + return true + } + return strings.HasPrefix(name, "info_") || strings.HasPrefix(name, "news_") +} diff --git a/internal/intelcmd/resolve_tool_test.go b/internal/intelcmd/resolve_tool_test.go new file mode 100644 index 0000000..6f6eb72 --- /dev/null +++ b/internal/intelcmd/resolve_tool_test.go @@ -0,0 +1,26 @@ +package intelcmd + +import "testing" + +func TestResolveMCPToolName(t *testing.T) { + t.Parallel() + cases := []struct { + backend string + in string + want string + }{ + {"info", "info_coin_get_coin_info", "info_coin_get_coin_info"}, + {"info", "info coin get-coin-info", "info_coin_get_coin_info"}, + {"info", "coin get-coin-info", "info_coin_get_coin_info"}, + {"info", "Info Coin Get-Coin-Info", "info_coin_get_coin_info"}, + {"news", "news feed search-news", "news_feed_search_news"}, + {"news", "feed search-news", "news_feed_search_news"}, + {"news", "news_feed_search_news", "news_feed_search_news"}, + {"info", "unknown", "unknown"}, + } + for _, tc := range cases { + if got := ResolveMCPToolName(tc.backend, tc.in); got != tc.want { + t.Fatalf("ResolveMCPToolName(%q, %q) = %q, want %q", tc.backend, tc.in, got, tc.want) + } + } +} diff --git a/internal/intelcmd/run_tool_call.go b/internal/intelcmd/run_tool_call.go index 8e6bf3a..8d1a568 100644 --- a/internal/intelcmd/run_tool_call.go +++ b/internal/intelcmd/run_tool_call.go @@ -26,14 +26,28 @@ func invokePath(backend string) string { } // GateErrorForIntelToolIsError is the stderr shape for tools/call when result.isError is true. +// When the payload looks like an argument/validation problem (structured error codes, 4xx +// hints in content, or typical validation wording), status is 400 with label INVALID_ARGUMENTS; +// otherwise status 502 with label INTEL_RESULT_ERROR. // When httpResp carries x-gate-trace-id, it is copied for support without exposing MCP content. -func GateErrorForIntelToolIsError(toolName string, httpResp *http.Response) *output.GateError { +// When result carries structuredContent or text content, a trimmed summary is used as Message. +func GateErrorForIntelToolIsError(toolName string, httpResp *http.Response, result *mcpclient.CallResult) *output.GateError { + msg := messageFromIntelToolIsError(result) + if msg == "" { + msg = "tool returned isError=true" + } + code := resolveIntelToolErrorCode(msg, result) + if toolName == "info_onchain_get_address_transactions" && code == mcpCodePartialUpstreamResponse { + msg = "upstream returned total but no parseable transaction list; use --format json for details or --debug / trace_id for support" + } + status, label := gateErrorMetaForIntelToolIsError(msg, result) ge := &output.GateError{ - Status: 502, - Label: "INTEL_RESULT_ERROR", - Message: "tool returned isError=true", - ToolName: toolName, + Status: status, + Label: label, + Message: msg, } + ge.ErrorType = output.ClassifyCLIError(status, label, msg) + output.FillAgentErrorConvergence(ge) if httpResp != nil && httpResp.Header != nil { if tid := strings.TrimSpace(httpResp.Header.Get("x-gate-trace-id")); tid != "" { ge.TraceID = tid @@ -42,48 +56,55 @@ func GateErrorForIntelToolIsError(toolName string, httpResp *http.Response) *out return ge } +// GateErrorForIntelToolIsErrorLeaf is GateErrorForIntelToolIsError with user-facing command path (no MCP tool_name). +func GateErrorForIntelToolIsErrorLeaf(backend, toolName string, httpResp *http.Response, result *mcpclient.CallResult) *output.GateError { + ge := GateErrorForIntelToolIsError(toolName, httpResp, result) + SanitizeUserFacingGateError(ge, backend, "", toolName) + return ge +} + // RunToolCall merges argv, validates required fields when schema is available, calls MCP tools/call, // and renders success output. backend is "info" or "news" (used in ParseError paths only). func RunToolCall(cmd *cobra.Command, p *output.Printer, svc ToolCaller, name string, reserved map[string]struct{}, backend string, maxOutputBytes int64) error { if p.IsTable() { return FailLeafUnsupportedTable(p, backend) } + name = ResolveMCPToolName(backend, name) arguments, err := toolargs.MergeFromCommand(cmd, toolargs.MergeOptions{ReservedFlags: reserved}) if err != nil { - return FailAfterPrintError(p, &output.GateError{ - Status: 400, - Label: "INVALID_ARGUMENTS", - Message: err.Error(), - }) + return FailAfterPrintError(p, output.InvalidArgsError(err.Error())) } arguments = toolargs.NormalizeForTool(name, arguments) + if err := toolargs.ValidateForTool(name, arguments); err != nil { + return FailAfterPrintError(p, output.InvalidArgsError(err.Error())) + } if tool, _, derr := svc.DescribeTool(cmd.Context(), name); derr == nil && tool != nil { - if missing := toolschema.MissingRequiredArguments(arguments, tool.InputSchema); len(missing) > 0 { - return FailAfterPrintError(p, &output.GateError{ - Status: 400, - Label: "INVALID_ARGUMENTS", - Message: "missing required fields: " + strings.Join(missing, ", "), - }) + schema := InputSchemaForMissingRequiredCheck(backend, name, tool.InputSchema) + if missing := toolschema.MissingRequiredArguments(arguments, schema); len(missing) > 0 { + return FailAfterPrintError(p, output.InvalidArgsError("missing required fields: "+strings.Join(missing, ", "))) } } result, httpResp, err := svc.CallTool(cmd.Context(), name, arguments) if err != nil { - return FailAfterPrintError(p, mcpclient.ParseError(err, httpResp, "POST", invokePath(backend), name)) + ge := mcpclient.ParseError(err, httpResp, "POST", invokePath(backend), "") + SanitizeUserFacingGateError(ge, backend, "", name) + return FailAfterPrintError(p, ge) } if result == nil { - return FailAfterPrintError(p, &output.GateError{ - Status: 502, - Label: "INTEL_PROTOCOL_ERROR", - Message: "tool returned empty response", - ToolName: name, - }) + ge := &output.GateError{ + Status: 502, + Label: "INTEL_PROTOCOL_ERROR", + Message: "tool returned empty response", + } + SanitizeUserFacingGateError(ge, backend, "", name) + return FailAfterPrintError(p, ge) } if result.IsError { - return FailAfterPrintError(p, GateErrorForIntelToolIsError(name, httpResp)) + return FailAfterPrintError(p, GateErrorForIntelToolIsErrorLeaf(backend, name, httpResp, result)) } - return toolrender.RenderCallResult(p, name, result, maxOutputBytes) + return toolrender.RenderCallResult(p, backend, name, result, maxOutputBytes) } // FailListTransport maps list endpoint failures to stderr + exit 1. @@ -93,10 +114,14 @@ func FailListTransport(p *output.Printer, err error, httpResp *http.Response, ba // FailDescribeTransport maps describe endpoint failures to stderr + exit 1. func FailDescribeTransport(p *output.Printer, err error, httpResp *http.Response, backend, toolName string) error { - return FailAfterPrintError(p, mcpclient.ParseError(err, httpResp, "POST", backend+"/describe", toolName)) + ge := mcpclient.ParseError(err, httpResp, "POST", backend+"/describe", "") + SanitizeUserFacingGateError(ge, backend, "", toolName) + return FailAfterPrintError(p, ge) } // FailIntelClientInit maps MCP client construction failures (before list/describe/invoke RPC). func FailIntelClientInit(p *output.Printer, err error, backend, segment, toolName string) error { - return FailAfterPrintError(p, mcpclient.ParseError(err, nil, "POST", backend+"/"+segment, toolName)) + ge := mcpclient.ParseError(err, nil, "POST", backend+"/"+segment, "") + SanitizeUserFacingGateError(ge, backend, "", toolName) + return FailAfterPrintError(p, ge) } diff --git a/internal/intelcmd/shortcut_args.go b/internal/intelcmd/shortcut_args.go new file mode 100644 index 0000000..8ff9e79 --- /dev/null +++ b/internal/intelcmd/shortcut_args.go @@ -0,0 +1,14 @@ +package intelcmd + +import ( + "github.com/gate/gate-cli/internal/toolargs" +) + +// PrepareToolArguments normalizes aliases and applies agent defaults, then validates before MCP call. +func PrepareToolArguments(toolName string, args map[string]interface{}) (map[string]interface{}, error) { + args = toolargs.NormalizeForTool(toolName, args) + if err := toolargs.ValidateForTool(toolName, args); err != nil { + return nil, err + } + return args, nil +} diff --git a/internal/intelcmd/shortcut_call.go b/internal/intelcmd/shortcut_call.go new file mode 100644 index 0000000..d470b2c --- /dev/null +++ b/internal/intelcmd/shortcut_call.go @@ -0,0 +1,108 @@ +package intelcmd + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/gate/gate-cli/internal/mcpclient" + "github.com/gate/gate-cli/internal/output" +) + +// ShortcutToolCaller is the MCP tools/call surface used by info/news shortcuts. +type ShortcutToolCaller interface { + CallTool(ctx context.Context, name string, arguments map[string]interface{}) (*mcpclient.CallResult, *http.Response, error) +} + +// ShortcutHTTPError wraps a transport error from a shortcut internal tools/call. +type ShortcutHTTPError struct { + Err error + HTTPResp *http.Response + ToolName string +} + +func (e *ShortcutHTTPError) Error() string { + if e == nil || e.Err == nil { + return "" + } + return e.Err.Error() +} + +// ShortcutToolIsError wraps MCP tools/call result.isError=true. +type ShortcutToolIsError struct { + ToolName string + HTTPResp *http.Response + Result *mcpclient.CallResult +} + +func (e *ShortcutToolIsError) Error() string { return "tool returned isError=true" } + +// ShortcutArgsError is a shortcut-local argument validation failure. +type ShortcutArgsError string + +func (e ShortcutArgsError) Error() string { return string(e) } + +// CallShortcutTool prepares args, calls MCP, and parses structured shortcut payload. +func CallShortcutTool(ctx context.Context, caller ShortcutToolCaller, name string, args map[string]interface{}) (map[string]interface{}, error) { + args, err := PrepareToolArguments(name, args) + if err != nil { + return nil, err + } + result, resp, err := caller.CallTool(ctx, name, args) + if err != nil { + return nil, &ShortcutHTTPError{Err: err, HTTPResp: resp, ToolName: name} + } + if result == nil { + return nil, errors.New("intel tool returned empty response") + } + if result.IsError { + return nil, &ShortcutToolIsError{ToolName: name, HTTPResp: resp, Result: result} + } + if len(result.StructuredContent) > 0 { + return result.StructuredContent, nil + } + for _, raw := range result.ContentRaw { + item, ok := raw.(map[string]interface{}) + if !ok { + continue + } + text, _ := item["text"].(string) + if strings.TrimSpace(text) == "" { + continue + } + var parsed map[string]interface{} + if err := json.Unmarshal([]byte(text), &parsed); err == nil { + return parsed, nil + } + } + if len(result.Raw) > 0 { + return result.Raw, nil + } + return map[string]interface{}{}, nil +} + +// GateErrorFromShortcutErr maps shortcut runner errors to stderr GateError shape. +func GateErrorFromShortcutErr(err error, path string) *output.GateError { + if err == nil { + return output.InvalidArgsError("unknown shortcut error") + } + backend := "info" + if strings.HasPrefix(path, "news/") { + backend = "news" + } + var ge *output.GateError + var isErr *ShortcutToolIsError + if errors.As(err, &isErr) { + ge = GateErrorForIntelToolIsError(isErr.ToolName, isErr.HTTPResp, isErr.Result) + } else if httpErr, ok := err.(*ShortcutHTTPError); ok { + ge = mcpclient.ParseError(httpErr.Err, httpErr.HTTPResp, "POST", path, "") + } else if argErr, ok := err.(ShortcutArgsError); ok { + return output.InvalidArgsError(argErr.Error()) + } else { + return output.InvalidArgsError(err.Error()) + } + SanitizeUserFacingGateError(ge, backend, path, "") + return ge +} diff --git a/internal/intelcmd/shortcut_call_test.go b/internal/intelcmd/shortcut_call_test.go new file mode 100644 index 0000000..c7d1d8a --- /dev/null +++ b/internal/intelcmd/shortcut_call_test.go @@ -0,0 +1,47 @@ +package intelcmd + +import ( + "errors" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gate/gate-cli/internal/mcpclient" +) + +func TestGateErrorFromShortcutErrToolIsError(t *testing.T) { + err := &ShortcutToolIsError{ + ToolName: "news_events_get_latest_events", + Result: &mcpclient.CallResult{ + IsError: true, + StructuredContent: map[string]interface{}{ + "message": "invalid coin", + }, + }, + } + ge := GateErrorFromShortcutErr(err, "news/+brief") + require.NotNil(t, ge) + assert.Empty(t, ge.ToolName) + assert.Equal(t, "news/+brief", ge.Request.URL) + assert.Contains(t, ge.Message, "invalid coin") +} + +func TestGateErrorFromShortcutErrArgs(t *testing.T) { + ge := GateErrorFromShortcutErr(ShortcutArgsError("coin or query is required"), "news/+brief") + require.NotNil(t, ge) + assert.Equal(t, "INVALID_ARGUMENTS", ge.Label) + assert.Contains(t, ge.Message, "coin or query is required") +} + +func TestGateErrorFromShortcutErrHTTP(t *testing.T) { + ge := GateErrorFromShortcutErr(&ShortcutHTTPError{ + Err: errors.New("connection reset"), + ToolName: "info_coin_get_coin_info", + HTTPResp: &http.Response{StatusCode: 503}, + }, "info/+coin-overview") + require.NotNil(t, ge) + assert.Empty(t, ge.ToolName) + assert.Equal(t, "info/+coin-overview", ge.Request.URL) +} diff --git a/internal/intelcmd/shortcut_parallel.go b/internal/intelcmd/shortcut_parallel.go new file mode 100644 index 0000000..65857a7 --- /dev/null +++ b/internal/intelcmd/shortcut_parallel.go @@ -0,0 +1,52 @@ +package intelcmd + +import ( + "context" + "sync" + "time" +) + +const ( + DefaultShortcutParallelism = 3 + DefaultShortcutTimeout = 120 * time.Second +) + +// WithShortcutBudget wraps parent with an overall deadline for shortcut orchestration. +func WithShortcutBudget(parent context.Context) (context.Context, context.CancelFunc) { + if parent == nil { + parent = context.Background() + } + return context.WithTimeout(parent, DefaultShortcutTimeout) +} + +// RunParallel runs tasks with at most limit concurrent goroutines. The first error is returned. +func RunParallel(limit int, tasks []func() error) error { + if len(tasks) == 0 { + return nil + } + if limit <= 0 || limit > len(tasks) { + limit = len(tasks) + } + sem := make(chan struct{}, limit) + var wg sync.WaitGroup + var mu sync.Mutex + var firstErr error + for _, task := range tasks { + task := task + wg.Add(1) + go func() { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + if err := task(); err != nil { + mu.Lock() + if firstErr == nil { + firstErr = err + } + mu.Unlock() + } + }() + } + wg.Wait() + return firstErr +} diff --git a/internal/intelcmd/shortcut_tool.go b/internal/intelcmd/shortcut_tool.go new file mode 100644 index 0000000..b32259b --- /dev/null +++ b/internal/intelcmd/shortcut_tool.go @@ -0,0 +1,8 @@ +package intelcmd + +import "github.com/gate/gate-cli/internal/toolrender" + +// ShortcutLogicalToolName maps a shortcut CLI path to a pseudo tool name for envelope meta (e.g. news_shortcut_brief). +func ShortcutLogicalToolName(_ string, path string) string { + return toolrender.MetaToolName(path) +} diff --git a/internal/intelcmd/silence.go b/internal/intelcmd/silence.go index 5fa7496..b7bad9c 100644 --- a/internal/intelcmd/silence.go +++ b/internal/intelcmd/silence.go @@ -1,10 +1,20 @@ package intelcmd -import "github.com/spf13/cobra" +import ( + "fmt" + "io" + "strings" + + "github.com/spf13/cobra" + + "github.com/gate/gate-cli/internal/output" +) // SilenceCommandTree sets SilenceErrors and SilenceUsage on root and all descendants -// so cobra does not print a second line after PrintError + exitcode returns. -// Call after all subcommands (including dynamically registered leaves) are on root. +// so cobra does not print a second line after PrintError + exitcode returns. This is +// the historical behavior (since 17985cf) covering the entire command tree including +// non-Intel subtrees (cex, config). Call after all subcommands (including dynamically +// registered leaves) are on root. func SilenceCommandTree(root *cobra.Command) { if root == nil { return @@ -21,3 +31,73 @@ func silenceRecursive(c *cobra.Command) { silenceRecursive(sub) } } + +// InstallFlagErrorHook recursively installs a FlagErrorFunc that prints pflag parse +// errors to stderr before propagating the error. When installed on the root command, +// this covers the full tree (cex/config included) so SilenceErrors does not hide flag +// diagnostics from agents. +// +// SilenceErrors=true on these subtrees already prevents cobra from echoing the same +// message twice; this hook only makes sure the user actually sees the diagnostic. +func InstallFlagErrorHook(c *cobra.Command) { + if c == nil { + return + } + c.SetFlagErrorFunc(printFlagErrorToStderr) + for _, sub := range c.Commands() { + InstallFlagErrorHook(sub) + } +} + +// printFlagErrorToStderr writes the pflag parse error to the command's stderr so the +// user sees the diagnostic. When --format json (or pretty), emits the same {"error":…} +// envelope as PrintError for agent parsers. Returning the error preserves the non-zero +// exit code while SilenceErrors=true prevents cobra from echoing the same message. +func printFlagErrorToStderr(cmd *cobra.Command, err error) error { + if cmd == nil || err == nil { + return err + } + w := cmd.ErrOrStderr() + format := resolveOutputFormat(cmd) + if format == output.FormatJSON || format == output.FormatPretty { + p := output.NewWithStderr(io.Discard, w, format) + ge := output.InvalidArgsError(normalizeFlagErrorMessage(err.Error())) + p.PrintError(ge) + return err + } + _, _ = fmt.Fprintf(w, "Error: %s\n", err.Error()) + return err +} + +func resolveOutputFormat(cmd *cobra.Command) output.Format { + if cmd == nil { + return output.FormatPretty + } + root := cmd.Root() + if root == nil { + return output.FormatPretty + } + f := root.PersistentFlags().Lookup("format") + if f == nil { + return output.FormatPretty + } + raw := strings.TrimSpace(f.Value.String()) + if raw == "" { + raw = strings.TrimSpace(f.DefValue) + } + if raw == "" { + return output.FormatPretty + } + return output.ParseFormat(raw) +} + +func normalizeFlagErrorMessage(msg string) string { + msg = strings.TrimSpace(msg) + if msg == "" { + return "invalid flag arguments" + } + if strings.HasPrefix(msg, "Error: ") { + return strings.TrimPrefix(msg, "Error: ") + } + return msg +} diff --git a/internal/intelcmd/silence_test.go b/internal/intelcmd/silence_test.go new file mode 100644 index 0000000..8cdbbc4 --- /dev/null +++ b/internal/intelcmd/silence_test.go @@ -0,0 +1,77 @@ +package intelcmd + +import ( + "bytes" + "errors" + "os" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gate/gate-cli/internal/exitcode" + "github.com/gate/gate-cli/internal/output" +) + +func TestPrintFlagErrorToStderrJSON(t *testing.T) { + root := &cobra.Command{Use: "gate-cli"} + root.PersistentFlags().String("format", "json", "") + leaf := &cobra.Command{Use: "describe"} + root.AddCommand(leaf) + + var errOut bytes.Buffer + leaf.SetErr(&errOut) + err := printFlagErrorToStderr(leaf, errors.New(`required flag(s) "name" not set`)) + require.Error(t, err) + out := errOut.String() + assert.Contains(t, out, `"error"`) + assert.Contains(t, out, `"label":"INVALID_ARGUMENTS"`) + assert.Contains(t, out, "required flag") +} + +func TestPrintFlagErrorToStderrPlain(t *testing.T) { + root := &cobra.Command{Use: "gate-cli"} + root.PersistentFlags().String("format", "pretty", "") + leaf := &cobra.Command{Use: "describe"} + root.AddCommand(leaf) + + var errOut bytes.Buffer + leaf.SetErr(&errOut) + err := printFlagErrorToStderr(leaf, errors.New(`required flag(s) "name" not set`)) + require.Error(t, err) + out := errOut.String() + assert.Contains(t, out, "Error [400") + assert.NotContains(t, out, `"error"`) +} + +func TestEmitExecuteErrorEnvelopeRequiredFlag(t *testing.T) { + var errOut bytes.Buffer + err := errors.New(`required flag(s) "name" not set`) + EmitExecuteErrorEnvelope(&errOut, nil, []string{"gate-cli", "info", "describe", "--format", "json"}, err, err.Error()) + out := errOut.String() + assert.Contains(t, out, `"error"`) + assert.Contains(t, out, `"label":"INVALID_ARGUMENTS"`) +} + +func TestEmitExecuteErrorEnvelopeSkipsSilenced(t *testing.T) { + var errOut bytes.Buffer + EmitExecuteErrorEnvelope(&errOut, nil, []string{"gate-cli", "info", "+coin-overview", "--format", "json"}, exitcode.New(1, ErrSilenced), "") + assert.Empty(t, errOut.String()) +} + +func TestResolveOutputFormatFromArgv(t *testing.T) { + assert.Equal(t, output.FormatJSON, resolveOutputFormatFromArgv(nil, []string{"gate-cli", "info", "describe", "--format", "json"})) + assert.Equal(t, output.FormatJSON, resolveOutputFormatFromArgv(nil, []string{"gate-cli", "info", "describe", "--format=json"})) + root := &cobra.Command{Use: "gate-cli"} + root.PersistentFlags().String("format", "pretty", "") + assert.Equal(t, output.FormatPretty, resolveOutputFormatFromArgv(root, []string{"gate-cli", "info", "list"})) +} + +func TestResolveOutputFormatAgentEnv(t *testing.T) { + t.Setenv("GATE_CLI_AGENT", "true") + t.Cleanup(func() { _ = os.Unsetenv("GATE_CLI_AGENT") }) + root := &cobra.Command{Use: "gate-cli"} + root.PersistentFlags().String("format", "pretty", "") + assert.Equal(t, output.FormatJSON, resolveOutputFormatFromArgv(root, []string{"gate-cli", "info", "list"})) +} diff --git a/internal/intelcmd/user_facing.go b/internal/intelcmd/user_facing.go new file mode 100644 index 0000000..5271be2 --- /dev/null +++ b/internal/intelcmd/user_facing.go @@ -0,0 +1,40 @@ +package intelcmd + +import ( + "strings" + + "github.com/gate/gate-cli/internal/output" +) + +// UserFacingCLICommand returns a user-facing gate-cli command reference without MCP wire names. +// shortcutPath is like "info/+token-risk"; mcpToolName is like "info_coin_get_coin_info" (leaf only). +func UserFacingCLICommand(backend, shortcutPath, mcpToolName string) string { + if p := strings.TrimSpace(shortcutPath); p != "" { + return p + } + backend = strings.TrimSpace(backend) + leaf := strings.TrimSpace(toolNameToCLIPath(backend, mcpToolName)) + if backend == "" { + return leaf + } + if leaf == "" { + return backend + } + return backend + " " + leaf +} + +// SanitizeUserFacingGateError clears MCP tool_name from stderr JSON and uses CLI command paths instead. +func SanitizeUserFacingGateError(ge *output.GateError, backend, shortcutPath, mcpToolName string) { + if ge == nil { + return + } + ge.ToolName = "" + cmd := UserFacingCLICommand(backend, shortcutPath, mcpToolName) + if cmd == "" { + return + } + if ge.Request == nil { + ge.Request = &output.RequestInfo{Method: "POST"} + } + ge.Request.URL = cmd +} diff --git a/internal/intelcmd/user_facing_test.go b/internal/intelcmd/user_facing_test.go new file mode 100644 index 0000000..adbfbc8 --- /dev/null +++ b/internal/intelcmd/user_facing_test.go @@ -0,0 +1,60 @@ +package intelcmd + +import ( + "errors" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gate/gate-cli/internal/mcpclient" + "github.com/gate/gate-cli/internal/output" +) + +func TestUserFacingCLICommandShortcutPath(t *testing.T) { + assert.Equal(t, "info/+token-risk", UserFacingCLICommand("info", "info/+token-risk", "")) +} + +func TestUserFacingCLICommandLeafPath(t *testing.T) { + got := UserFacingCLICommand("info", "", "info_coin_get_coin_info") + assert.Equal(t, "info coin get-coin-info", got) +} + +func TestSanitizeUserFacingGateErrorClearsMCPToolName(t *testing.T) { + ge := &output.GateError{ + ToolName: "info_coin_get_coin_info", + Request: &output.RequestInfo{Method: "POST", URL: "info/invoke"}, + } + SanitizeUserFacingGateError(ge, "info", "", "info_coin_get_coin_info") + assert.Empty(t, ge.ToolName) + assert.Equal(t, "info coin get-coin-info", ge.Request.URL) +} + +func TestGateErrorFromShortcutErrOmitsMCPToolName(t *testing.T) { + err := &ShortcutToolIsError{ + ToolName: "info_compliance_check_token_security", + Result: &mcpclient.CallResult{ + IsError: true, + StructuredContent: map[string]interface{}{ + "message": "upstream failed", + }, + }, + } + ge := GateErrorFromShortcutErr(err, "info/+token-risk") + require.NotNil(t, ge) + assert.Empty(t, ge.ToolName) + assert.Equal(t, "info/+token-risk", ge.Request.URL) + assert.Contains(t, ge.Message, "upstream failed") +} + +func TestGateErrorFromShortcutErrHTTPOmitsMCPToolName(t *testing.T) { + ge := GateErrorFromShortcutErr(&ShortcutHTTPError{ + Err: errors.New("connection reset"), + ToolName: "info_coin_get_coin_info", + HTTPResp: &http.Response{StatusCode: 503}, + }, "info/+coin-overview") + require.NotNil(t, ge) + assert.Empty(t, ge.ToolName) + assert.Equal(t, "info/+coin-overview", ge.Request.URL) +} diff --git a/internal/intelfacade/describe_pretty.go b/internal/intelfacade/describe_pretty.go index 292fca6..29238a5 100644 --- a/internal/intelfacade/describe_pretty.go +++ b/internal/intelfacade/describe_pretty.go @@ -19,7 +19,7 @@ func DescribePrettyText(tool *ToolSummary) string { b.WriteString(d) b.WriteByte('\n') } - if paramBlock := formatParameterSummary(tool); paramBlock != "" { + if paramBlock := FormatParameterSummary(tool); paramBlock != "" { b.WriteString("\nParameters\n\n") b.WriteString(paramBlock) b.WriteByte('\n') @@ -30,7 +30,8 @@ func DescribePrettyText(tool *ToolSummary) string { return b.String() } -func formatParameterSummary(tool *ToolSummary) string { +// FormatParameterSummary renders required/optional parameter names for pretty describe output. +func FormatParameterSummary(tool *ToolSummary) string { if !tool.HasInputSchema || tool.InputSchema == nil { return "" } diff --git a/internal/intelfacade/info_schema_baseline.go b/internal/intelfacade/info_schema_baseline.go index a61516b..9117dc5 100644 --- a/internal/intelfacade/info_schema_baseline.go +++ b/internal/intelfacade/info_schema_baseline.go @@ -4,14 +4,14 @@ import "sync" // InfoBaselineInputSchemas are static JSON-Schema-shaped objects: the stable source for CLI flat flags. // MCP tools/list may add additional flags (non-colliding) on top; --params / --args-json remain JSON fallback. -// Align with specs/mcp/info-mcp-tools-inputs-logic.json. +// Enum/default/bounds mirror the embedded Intel MCP spec (internal/mcpspec/bundled) for flat-flag help. var InfoBaselineInputSchemas = map[string]map[string]interface{}{ "info_coin_get_coin_info": infoObj(map[string]interface{}{ "query": infoStr("query"), - "query_type": infoStr("query_type"), + "query_type": infoStrEnum("query_type", "auto", "auto", "address", "symbol", "name", "project", "gate_symbol", "source_id"), "chain": infoStr("chain"), - "scope": infoStr("scope"), - "size": infoInt("size"), + "scope": infoStrEnum("scope", "basic", "basic", "detailed", "full", "with_project", "with_tokenomics"), + "size": infoIntDefaultMax("size", 10, 100), "fields": infoArrStr("fields"), "symbol": infoStr("Coin symbol alias to query"), }, "query"), @@ -20,153 +20,197 @@ var InfoBaselineInputSchemas = map[string]map[string]interface{}{ "chain": infoStr("chain"), "market_cap_min": infoNum("market_cap_min"), "market_cap_max": infoNum("market_cap_max"), - "asset_type": infoStr("asset_type"), - "sort_by": infoStr("sort_by"), - "limit": infoInt("limit"), - "offset": infoInt("offset"), + "asset_type": infoStrEnum("asset_type", "crypto", "crypto", "tradefi", "all"), + "sort_by": infoStrEnum("sort_by", "market_cap", "market_cap", "fdv", "circulating_supply"), + "limit": infoIntDefaultMax("limit", 20, 100), + "offset": infoIntDefaultMax("offset", 0, 100000), }), "info_coin_get_coin_rankings": infoObj(map[string]interface{}{ - "ranking_type": infoStr("ranking_type"), - "time_range": infoStr("time_range"), - "limit": infoInt("limit"), + "ranking_type": infoStrEnum("ranking_type", "", "popular", "top_gainers", "top_losers", "twitter_hot", "airdrop", "new_listing", "market_pulse_hot"), + "time_range": infoStrEnum("time_range", "", "1h", "24h", "7d"), + "limit": infoIntDefaultMax("limit", 50, 100), "listing_query": infoStr("listing_query"), "listing_from": infoInt("listing_from"), "listing_tickers": infoStr("listing_tickers"), }, "ranking_type"), "info_markettrend_get_kline": infoObj(map[string]interface{}{ "symbol": infoStr("symbol"), - "timeframe": infoStr("timeframe"), - "period": infoStr("period"), - "size": infoInt("size"), - "limit": infoInt("limit"), + "timeframe": infoStrEnum("timeframe", "", "1m", "5m", "15m", "1h", "4h", "1d"), + "period": infoStrEnum("period", "24h", "1h", "4h", "24h", "7d", "3d", "5d", "10d", "all"), + "size": infoIntDefaultMax("size; agent default 200, max 500 to limit stdout", 200, 500), + "limit": infoIntDefaultMax("limit; agent default 200, max 500 to limit stdout", 200, 500), "start_time": infoStr("start_time"), "end_time": infoStr("end_time"), "with_indicators": infoBool("with_indicators"), }, "symbol", "timeframe"), "info_markettrend_get_indicator_history": infoObj(map[string]interface{}{ "symbol": infoStr("symbol"), - "indicators": infoArrStr("indicators"), - "timeframe": infoStr("timeframe"), + "indicators": infoArrStrIndicatorHints("indicators"), + "timeframe": infoStrEnum("timeframe", "", "15m", "1h", "4h", "1d"), "start_time": infoStr("start_time"), "end_time": infoStr("end_time"), - "limit": infoInt("limit"), + "limit": infoIntDefaultMax("limit", 100, 500), }, "symbol", "indicators", "timeframe"), "info_markettrend_get_technical_analysis": infoObj(map[string]interface{}{ "symbol": infoStr("symbol"), - "period": infoStr("period"), + "period": infoStrEnum("period", "3d", "1h", "4h", "24h", "7d", "3d", "5d", "10d", "all"), "start_time": infoStr("start_time"), "end_time": infoStr("end_time"), }, "symbol"), "info_marketsnapshot_get_market_snapshot": infoObj(map[string]interface{}{ "symbol": infoStr("symbol"), - "timeframe": infoStr("timeframe"), - "indicator_timeframe": infoStr("indicator_timeframe"), - "source": infoStr("source"), + "timeframe": infoStrEnum("timeframe", "1h", "15m", "1h", "4h", "1d"), + "indicator_timeframe": infoStrEnum("indicator_timeframe", "", "15m", "1h", "4h", "1d"), + "source": infoStrEnum("source", "spot", "alpha", "spot", "future", "fx", "futures"), "quote": infoStr("quote"), - "scope": infoStr("scope"), + "scope": infoStrEnum("scope", "basic", "basic", "detailed", "full"), }, "symbol"), "info_marketsnapshot_batch_market_snapshot": infoObj(map[string]interface{}{ - "symbols": infoArrStr("symbols"), - "timeframe": infoStr("timeframe"), - "source": infoStr("source"), + "symbols": infoArrStrMaxItems("symbols (max 20 pairs)", 20), + "timeframe": infoStrEnum("timeframe", "1h", "15m", "1h", "4h", "1d"), + "source": infoStrEnum("source", "spot", "alpha", "spot", "future", "fx", "futures"), "quote": infoStr("quote"), - "scope": infoStr("scope"), + "scope": infoStrEnum("scope", "basic", "basic", "detailed", "full"), }, "symbols"), "info_marketsnapshot_get_market_overview": infoObj(map[string]interface{}{}), + "info_marketsnapshot_get_institutional_metrics": infoObj(map[string]interface{}{ + "asset": infoStrEnum("asset; omit -> BTC; all returns BTC and ETH groups", "BTC", "BTC", "ETH", "all"), + "start_date": infoStr("start_date; YYYY-MM-DD; omit -> end_date - 30d when available"), + "end_date": infoStr("end_date; YYYY-MM-DD; omit -> latest institutionalChannelIndex part_date"), + "channel": infoStrEnum("channel; channel-specific response nulls unrelated ETF/CME/CFTC fields; cme returns null CME fields with cme_source_not_procured/cme_unavailable status", "all", "all", "etf", "cme", "cftc"), + "limit": infoIntDefaultMinMax("limit; range 1..366", 30, 1, 366), + }), "info_onchain_get_address_info": infoObj(map[string]interface{}{ "address": infoStr("address"), - "chain": infoStr("chain"), - "scope": infoStr("scope"), + "chain": infoStr("chain; aliases: eth, op→optimism, avax|avalanche-c→avalanche, bera|berachain→bera, polygon|matic, zksync, blast, gatelayer, linea, unichain, btc|bitcoin, sol|solana, trx|tron; invalid → invalid_chain"), + "scope": infoStrEnum("scope", "basic", "basic", "with_defi", "with_counterparties", "with_pnl", "full", "detailed"), "min_value_usd": infoNum("min_value_usd"), "include_upstream_raw": infoBool("include_upstream_raw"), - "upstream_raw_mode": infoStr("upstream_raw_mode"), + "upstream_raw_mode": infoStrEnum("upstream_raw_mode", "off", "off", "lite", "full"), }, "address"), "info_onchain_get_address_transactions": infoObj(map[string]interface{}{ "address": infoStr("address"), - "chain": infoStr("chain"), + "chain": infoStr("chain; same aliases as get_address_info; BTC uses bitcoin/btc slug"), "min_value_usd": infoNum("min_value_usd"), - "tx_type": infoStr("tx_type"), - "time_range": infoStr("time_range"), + "tx_type": infoStrEnum("tx_type", "all", "transfer", "contract_call", "token_transfer", "all"), + "time_range": infoStrEnum("time_range", "", "1h", "24h", "1d", "7d", "30d", "90d"), "start_time": infoInt("start_time"), "end_time": infoInt("end_time"), - "limit": infoInt("limit"), + "limit": infoIntDefaultMax("limit", 50, 200), "from_address": infoStr("from_address"), "to_address": infoStr("to_address"), "nonzero_value": infoBool("nonzero_value"), "include_upstream_raw": infoBool("include_upstream_raw"), - "upstream_raw_mode": infoStr("upstream_raw_mode"), + "upstream_raw_mode": infoStrEnum("upstream_raw_mode", "off", "off", "lite", "full"), }, "address"), "info_onchain_get_transaction": infoObj(map[string]interface{}{ "tx_hash": infoStr("tx_hash"), "chain": infoStr("chain"), "include_upstream_raw": infoBool("include_upstream_raw"), - "upstream_raw_mode": infoStr("upstream_raw_mode"), + "upstream_raw_mode": infoStrEnum("upstream_raw_mode", "off", "off", "lite", "full"), }, "tx_hash"), "info_onchain_get_token_onchain": infoObj(map[string]interface{}{ "token": infoStr("token"), "chain": infoStr("chain"), - "scope": infoStr("scope"), + "scope": infoStrEnum("scope", "full", "holders", "activity", "transfers", "smart_money", "full"), "include_upstream_raw": infoBool("include_upstream_raw"), - "upstream_raw_mode": infoStr("upstream_raw_mode"), + "upstream_raw_mode": infoStrEnum("upstream_raw_mode", "off", "off", "lite", "full"), }, "token"), "info_compliance_check_token_security": infoObj(map[string]interface{}{ - "token": infoStr("token"), - "address": infoStr("address"), - "chain": infoStr("chain"), - "scope": infoStr("scope"), - "lang": infoStr("lang"), - }, "token", "chain"), + "token": infoStr("token symbol; exactly one of token or address (not both)"), + "address": infoStr("contract address; exactly one of token or address (not both)"), + "chain": infoStr("chain (required)"), + "scope": infoStrEnum("scope", "basic", "basic", "full"), + "lang": infoStrEnum("lang", "en", "en", "cn", "tw", "ja", "kr"), + }, "chain"), "info_platformmetrics_get_platform_info": infoObj(map[string]interface{}{ - "platform_name": infoStr("platform_name"), - "scope": infoStr("scope"), + "platform_name": infoStr("platform_name"), + "scope": infoStrEnum("scope", "basic", "basic", "with_chain_breakdown", "full", "detailed"), + "include_oi_symbol_detail": infoBool("include_oi_symbol_detail; scope=full only; adds competition_metrics.oi_symbol_detail when CEX index configured"), + "oi_symbol_limit": infoIntDefaultMax("oi_symbol_limit; with include_oi_symbol_detail; omit or <=0 -> 20 server-side", 20, 100), }, "platform_name"), "info_platformmetrics_search_platforms": infoObj(map[string]interface{}{ "platform_type": infoStr("platform_type"), "chain": infoStr("chain"), - "sort_by": infoStr("sort_by"), - "limit": infoInt("limit"), + "sort_by": infoStrEnum("sort_by", "tvl", "tvl", "volume_24h", "volume_spot_24h", "volume_perps_24h", "volume_perps_7d", "volume_perps_30d", "volume_perps_qtd", "fees_24h"), + "sort_order": infoStrEnum("sort_order", "desc", "asc", "desc"), + "limit": infoIntDefaultMax("limit", 20, 100), }), "info_platformmetrics_get_defi_overview": infoObj(map[string]interface{}{ - "category": infoStr("category"), + "category": infoStrEnum("category", "all", "all", "defi", "de-fi", "cex", "perp", "spot", "stablecoin", "dex", "dexs", "dexes", "lending", "cdp", "yield", "bridge", "derivatives", "yield aggregator"), }), "info_platformmetrics_get_stablecoin_info": infoObj(map[string]interface{}{ - "symbol": infoStr("symbol"), - "chain": infoStr("chain"), - "limit": infoInt("limit"), + "symbol": infoStr("symbol; issuance_flow: USDT|USDC; usage_structure: USDT|USDC|DAI|FDUSD|PYUSD; depeg_events: filter by depeg_asset; omit for ranked list"), + "chain": infoStr("chain; basic: filter chain_circulating; extension sections: ethereum|tron|... or aliases eth/sol/arb"), + "limit": infoIntDefaultMax("limit", 10, 400), + "scope": infoStrEnum("scope", "basic", "basic", "full"), + "sections": infoArrStrEnum("sections; issuance_flow and/or usage_structure and/or depeg_events; requires scope=full", "issuance_flow", "usage_structure", "depeg_events"), + "start_date": infoStr("start_date; UTC YYYY-MM-DD; scope=full and sections set; depeg_events: default 2020-01-01"), + "end_date": infoStr("end_date; UTC YYYY-MM-DD; extension window end date"), + "min_deviation": infoNum("min_deviation; depeg_events only: filter max_deviation >= this value; range 0.001-0.2, default 0.005"), + "review_status": infoStrEnum("review_status; depeg_events only: candidate|approved|rejected, default approved", "approved", "candidate", "approved", "rejected"), }), "info_platformmetrics_get_bridge_metrics": infoObj(map[string]interface{}{ "bridge_name": infoStr("bridge_name"), "chain": infoStr("chain"), - "sort_by": infoStr("sort_by"), - "limit": infoInt("limit"), + "sort_by": infoStrEnum("sort_by", "volume_24h", "volume_24h", "volume_7d", "volume_30d", "deposit_txs_24h"), + "limit": infoIntDefaultMax("limit", 20, 100), }), + "info_platformmetrics_get_cex_orderbook_depth": infoObj(map[string]interface{}{ + "symbol": infoStr("symbol (required); aggregated CEX depth, not Gate marketdetail orderbook"), + "market_type": infoStrEnum("market_type", "perp", "spot", "perp", "perps", "futures", "future"), + "exchange": infoStr("exchange filter"), + "data_scope": infoStrEnum("data_scope; omit picks exchange index when exchange set else market", "", "exchange", "market"), + "limit": infoIntDefaultMax("limit; depth rows, default 20 max 100", 20, 100), + }, "symbol"), "info_platformmetrics_get_yield_pools": infoObj(map[string]interface{}{ "project": infoStr("project"), "chain": infoStr("chain"), "symbol": infoStr("symbol"), "pool_type": infoStr("pool_type"), - "sort_by": infoStr("sort_by"), - "limit": infoInt("limit"), + "sort_by": infoStrEnum("sort_by", "apy", "apy", "tvl_usd"), + "limit": infoIntDefaultMax("limit", 20, 100), "min_tvl_usd": infoNum("min_tvl_usd"), + "scope": infoStrEnum("scope", "basic", "basic", "full"), }), "info_platformmetrics_get_platform_history": infoObj(map[string]interface{}{ "platform_name": infoStr("platform_name"), - "metrics": infoArrStr("metrics"), + "exchange_slug": infoStr("exchange_slug"), + "metrics": infoArrStrMetricsHints("metrics"), + "granularity": infoStrEnum("granularity", "day", "day", "week", "month", "quarter"), "start_date": infoStr("start_date"), "end_date": infoStr("end_date"), - }, "platform_name"), + }), "info_platformmetrics_get_exchange_reserves": infoObj(map[string]interface{}{ - "exchange": infoStr("exchange"), - "asset": infoStr("asset"), - "period": infoStr("period"), + "exchange": infoStr("exchange; empty -> all-exchange rollup"), + "asset": infoStrEnum("asset", "", "", "BTC", "ETH", "USDT", "USDC"), + "scope": infoStrEnum("scope", "basic", "basic", "full"), + "include_history": infoBool("include_history; true only with scope=full"), + "history_window": infoStrEnum("history_window", "", "", "quarter"), + "include_flows": infoBool("include_flows; scope=full only; returns flows.series[] (daily inflow/outflow/netflow in native+USD)"), + "include_events": infoBool("include_events; scope=full only; returns events[] (large_flow events with threshold/rolling_30d_std)"), + "start_date": infoStr("start_date; YYYY-MM-DD; only when include_flows=true or include_events=true"), + "end_date": infoStr("end_date; YYYY-MM-DD; only when include_flows=true or include_events=true"), + "event_type": infoStrEnum("event_type; only when include_events=true; all or large_flow filter", "all", "all", "large_flow"), + "limit": infoIntDefaultMax("limit; only when include_flows=true or include_events=true; max flow rows", 100, 400), }), "info_platformmetrics_get_liquidation_heatmap": infoObj(map[string]interface{}{ "symbol": infoStr("symbol"), "exchange": infoStr("exchange"), "range": infoStr("range"), }, "symbol"), + "info_platformmetrics_get_chain_activity": infoObj(map[string]interface{}{ + "metric_group": infoStrEnum("metric_group (required); staking=ETH beacon, l2=L2 ops daily, btc_l2=BTC L2 protocol ecosystem", "", "staking", "l2", "btc_l2"), + "chain": infoStr("chain; staking: eth|ethereum; l2: base/arbitrum/optimism/linea/zksync_era/blast or empty; btc_l2: empty=btc"), + "project": infoStr("project; btc_l2 only: filter by project_key (stacks|rootstock|merlin|bob|bitlayer)"), + "start_date": infoStr("start_date; UTC YYYY-MM-DD"), + "end_date": infoStr("end_date; UTC YYYY-MM-DD"), + "lookback": infoStrEnum("lookback when start_date empty; ignored when both dates set", "30d", "30d", "90d", "1y"), + "granularity": infoStrEnum("granularity; l2 only: day (default); ignored for staking/btc_l2", "day", "day"), + "limit": infoInt("limit; l2 only: max series rows returned; 0 means no extra cap"), + }, "metric_group"), "info_macro_get_macro_indicator": infoObj(map[string]interface{}{ - "mode": infoStr("mode"), + "mode": infoStrEnum("mode", "latest", "latest", "timeseries"), "indicator": infoStr("indicator"), "country": infoStr("country"), "country_code": infoStr("country_code"), @@ -174,37 +218,37 @@ var InfoBaselineInputSchemas = map[string]map[string]interface{}{ "end_time": infoStr("end_time"), "start_date": infoStr("start_date"), "end_date": infoStr("end_date"), - "size": infoInt("size"), + "size": infoIntDefaultMax("size", 50, 200), }, "indicator"), "info_macro_get_economic_calendar": infoObj(map[string]interface{}{ "start_date": infoStr("start_date"), "end_date": infoStr("end_date"), "event_type": infoStr("event_type"), "importance": infoStr("importance"), - "size": infoInt("size"), + "size": infoIntDefaultMax("size", 50, 200), }), "info_macro_get_macro_summary": infoObj(map[string]interface{}{}), "info_marketdetail_get_orderbook": infoObj(map[string]interface{}{ "symbol": infoStr("symbol"), - "market_type": infoStr("market_type"), - "depth": infoInt("depth"), + "market_type": infoStrEnum("market_type", "spot", "spot", "futures", "delivery", "options"), + "depth": infoIntDefaultMax("depth", 20, 100), "settle": infoStr("settle"), "extra": infoObjAny("extra"), }, "symbol"), "info_marketdetail_get_recent_trades": infoObj(map[string]interface{}{ "symbol": infoStr("symbol"), - "market_type": infoStr("market_type"), - "limit": infoInt("limit"), + "market_type": infoStrEnum("market_type", "spot", "spot", "futures", "delivery", "options"), + "limit": infoIntDefaultMax("limit", 100, 1000), "settle": infoStr("settle"), "extra": infoObjAny("extra"), }, "symbol"), "info_marketdetail_get_kline": infoObj(map[string]interface{}{ "symbol": infoStr("symbol"), - "market_type": infoStr("market_type"), + "market_type": infoStrEnum("market_type", "spot", "spot", "futures", "delivery", "options"), "timeframe": infoStr("timeframe"), "start_time": infoInt("start_time"), "end_time": infoInt("end_time"), - "limit": infoInt("limit"), + "limit": infoIntDefaultMax("limit; agent default 200, max 500", 200, 500), "settle": infoStr("settle"), "extra": infoObjAny("extra"), }, "symbol", "timeframe"), @@ -214,6 +258,67 @@ func infoStr(desc string) map[string]interface{} { return map[string]interface{}{"type": "string", "description": desc} } +// infoStrEnum builds a string field with enum (and optional default) for CLI flag usage; values follow the embedded bundled Intel spec. +func infoStrEnum(desc, defaultVal string, enum ...string) map[string]interface{} { + ev := make([]interface{}, len(enum)) + for i, s := range enum { + ev[i] = s + } + m := map[string]interface{}{ + "type": "string", + "description": desc, + "enum": ev, + } + if defaultVal != "" { + m["default"] = defaultVal + } + return m +} + +func infoArrStrMaxItems(desc string, maxItems int) map[string]interface{} { + return map[string]interface{}{ + "type": "array", + "description": desc, + "items": map[string]interface{}{"type": "string"}, + "maxItems": float64(maxItems), + } +} + +func infoArrStrIndicatorHints(desc string) map[string]interface{} { + return map[string]interface{}{ + "type": "array", + "description": desc + " (required); ES _source names e.g. rsi, macd, close_price; not a closed server enum — see gate-cli info mcp-spec", + "items": map[string]interface{}{"type": "string"}, + } +} + +func infoArrStrMetricsHints(desc string) map[string]interface{} { + return map[string]interface{}{ + "type": "array", + "description": desc + "; typical tvl, volume, fees, revenue; empty -> [tvl] server-side; per-element not strictly validated", + "items": map[string]interface{}{"type": "string"}, + } +} + +func infoIntDefaultMax(desc string, def, max int) map[string]interface{} { + return map[string]interface{}{ + "type": "integer", + "description": desc, + "default": float64(def), + "maximum": float64(max), + } +} + +func infoIntDefaultMinMax(desc string, def, min, max int) map[string]interface{} { + return map[string]interface{}{ + "type": "integer", + "description": desc, + "default": float64(def), + "minimum": float64(min), + "maximum": float64(max), + } +} + func infoInt(desc string) map[string]interface{} { return map[string]interface{}{"type": "integer", "description": desc} } @@ -234,6 +339,19 @@ func infoArrStr(desc string) map[string]interface{} { } } +func infoArrStrEnum(desc string, enum ...string) map[string]interface{} { + ev := make([]interface{}, len(enum)) + for i, s := range enum { + ev[i] = s + } + return map[string]interface{}{ + "type": "array", + "description": desc, + "items": map[string]interface{}{"type": "string"}, + "enum": ev, + } +} + func infoObjAny(desc string) map[string]interface{} { return map[string]interface{}{"type": "object", "description": desc} } diff --git a/internal/intelfacade/info_schema_baseline_test.go b/internal/intelfacade/info_schema_baseline_test.go index 6101bfd..e582e5e 100644 --- a/internal/intelfacade/info_schema_baseline_test.go +++ b/internal/intelfacade/info_schema_baseline_test.go @@ -25,13 +25,15 @@ func TestInfoBaselineInputSchemaCoverage(t *testing.T) { func TestInfoBaselineInputSchemaCriticalFields(t *testing.T) { t.Parallel() cases := map[string][]string{ - "info_coin_get_coin_info": {"query", "symbol"}, - "info_markettrend_get_kline": {"symbol", "timeframe", "with_indicators"}, - "info_markettrend_get_indicator_history": {"symbol", "indicators", "timeframe"}, - "info_marketsnapshot_batch_market_snapshot": {"symbols", "timeframe"}, - "info_onchain_get_address_transactions": {"from_address", "to_address", "nonzero_value"}, - "info_compliance_check_token_security": {"token", "address", "chain"}, - "info_marketdetail_get_kline": {"symbol", "timeframe", "extra"}, + "info_coin_get_coin_info": {"query", "symbol"}, + "info_markettrend_get_kline": {"symbol", "timeframe", "with_indicators"}, + "info_markettrend_get_indicator_history": {"symbol", "indicators", "timeframe"}, + "info_marketsnapshot_batch_market_snapshot": {"symbols", "timeframe"}, + "info_marketsnapshot_get_institutional_metrics": {"asset", "channel", "start_date", "end_date", "limit"}, + "info_platformmetrics_get_chain_activity": {"metric_group", "chain", "start_date", "end_date", "lookback"}, + "info_onchain_get_address_transactions": {"from_address", "to_address", "nonzero_value"}, + "info_compliance_check_token_security": {"token", "address", "chain"}, + "info_marketdetail_get_kline": {"symbol", "timeframe", "extra"}, } for tool, fields := range cases { schema := InfoBaselineInputSchema(tool) @@ -50,6 +52,81 @@ func TestInfoBaselineInputSchemaCriticalFields(t *testing.T) { if typ, _ := symbols["type"].(string); typ != "array" { t.Fatalf("symbols type mismatch: want array got %q", typ) } + if symbols["maxItems"].(float64) != 20 { + t.Fatalf("symbols maxItems want 20 got %#v", symbols["maxItems"]) + } +} + +func TestInfoBaselineComplianceRequiredMatchesSpec(t *testing.T) { + t.Parallel() + sch := InfoBaselineInputSchema("info_compliance_check_token_security") + req := sch["required"].([]interface{}) + if len(req) != 1 { + t.Fatalf("expected single required field, got %#v", req) + } + key, _ := req[0].(string) + if key != "chain" { + t.Fatalf("expected required chain only (token xor address conditional), got %q", key) + } +} + +func TestInfoBaselineIntegerBoundsMatchSpecDoc(t *testing.T) { + t.Parallel() + search := InfoBaselineInputSchema("info_coin_search_coins") + props := search["properties"].(map[string]interface{}) + limit := props["limit"].(map[string]interface{}) + if limit["maximum"].(float64) != 100 || limit["default"].(float64) != 20 { + t.Fatalf("search_coins limit bounds: %#v", limit) + } + off := props["offset"].(map[string]interface{}) + if off["maximum"].(float64) != 100000 || off["default"].(float64) != 0 { + t.Fatalf("search_coins offset bounds: %#v", off) + } + kline := InfoBaselineInputSchema("info_marketdetail_get_kline") + kp := kline["properties"].(map[string]interface{})["limit"].(map[string]interface{}) + if kp["maximum"].(float64) != 500 || kp["default"].(float64) != 200 { + t.Fatalf("marketdetail kline limit bounds: %#v", kp) + } + trendKline := InfoBaselineInputSchema("info_markettrend_get_kline") + tp := trendKline["properties"].(map[string]interface{}) + size := tp["size"].(map[string]interface{}) + if size["maximum"].(float64) != 500 || size["default"].(float64) != 200 { + t.Fatalf("markettrend get_kline size bounds: %#v", size) + } + + pi := InfoBaselineInputSchema("info_platformmetrics_get_platform_info") + oiLim := pi["properties"].(map[string]interface{})["oi_symbol_limit"].(map[string]interface{}) + if oiLim["maximum"].(float64) != 100 || oiLim["default"].(float64) != 20 { + t.Fatalf("platform_info oi_symbol_limit bounds: %#v", oiLim) + } + + stable := InfoBaselineInputSchema("info_platformmetrics_get_stablecoin_info") + sp := stable["properties"].(map[string]interface{}) + for _, key := range []string{"scope", "sections", "start_date", "end_date"} { + if _, ok := sp[key]; !ok { + t.Fatalf("stablecoin_info missing field %q", key) + } + } + sections := sp["sections"].(map[string]interface{}) + enum := sections["enum"].([]interface{}) + if !reflect.DeepEqual(enum, []interface{}{"issuance_flow", "usage_structure", "depeg_events"}) { + t.Fatalf("stablecoin_info sections enum mismatch: %#v", enum) + } + lim := sp["limit"].(map[string]interface{}) + if lim["maximum"].(float64) != 400 || lim["default"].(float64) != 10 { + t.Fatalf("stablecoin_info limit bounds: %#v", lim) + } + + icm := InfoBaselineInputSchema("info_marketsnapshot_get_institutional_metrics") + icp := icm["properties"].(map[string]interface{}) + icLimit := icp["limit"].(map[string]interface{}) + if icLimit["minimum"].(float64) != 1 || icLimit["maximum"].(float64) != 366 || icLimit["default"].(float64) != 30 { + t.Fatalf("institutional_metrics limit bounds: %#v", icLimit) + } + channel := icp["channel"].(map[string]interface{}) + if !reflect.DeepEqual(channel["enum"].([]interface{}), []interface{}{"all", "etf", "cme", "cftc"}) { + t.Fatalf("institutional_metrics channel enum mismatch: %#v", channel["enum"]) + } } func TestInfoBaselineInputSchemaDeepCopyIsolation(t *testing.T) { diff --git a/internal/intelfacade/info_spec_description_test.go b/internal/intelfacade/info_spec_description_test.go new file mode 100644 index 0000000..1647c9e --- /dev/null +++ b/internal/intelfacade/info_spec_description_test.go @@ -0,0 +1,40 @@ +package intelfacade + +import ( + "strings" + "testing" + + "github.com/gate/gate-cli/internal/mcpspec" +) + +func TestInfoBaselineToolsHaveEnglishDescription(t *testing.T) { + t.Parallel() + doc, err := mcpspec.InfoInputsLogic() + if err != nil { + t.Fatal(err) + } + root := doc.(map[string]interface{}) + byName := map[string]map[string]interface{}{} + for _, item := range root["tools"].([]interface{}) { + tm := item.(map[string]interface{}) + if n, _ := tm["tool_name"].(string); n != "" { + byName[n] = tm + } + } + for _, tool := range InfoToolBaseline { + tm, ok := byName[tool] + if !ok { + t.Fatalf("missing spec entry for %s", tool) + } + desc, _ := tm["description"].(string) + if strings.TrimSpace(desc) == "" { + t.Errorf("%s: empty description", tool) + } + if !strings.HasPrefix(strings.TrimSpace(desc), "[Read]") { + t.Errorf("%s: description should start with [Read]: %q", tool, desc) + } + if zh, _ := tm["description_zh"].(string); strings.TrimSpace(zh) != "" { + t.Errorf("%s: embedded bundled spec must not ship description_zh", tool) + } + } +} diff --git a/internal/intelfacade/inventory.go b/internal/intelfacade/inventory.go index e86d56c..37596cc 100644 --- a/internal/intelfacade/inventory.go +++ b/internal/intelfacade/inventory.go @@ -1,6 +1,6 @@ package intelfacade -// Backend tool baselines aligned with the live Info/News MCP tool lists (Info: 29 tools on public gateway as of 2026-04). +// Backend tool baselines aligned with the live Info/News MCP tool lists (Info: 32; News: 18; total 50). Keep in sync with BaselineToolCount tests. var NewsToolBaseline = []string{ "news_feed_search_news", "news_feed_search_ugc", @@ -8,8 +8,18 @@ var NewsToolBaseline = []string{ "news_feed_web_search", "news_feed_get_social_sentiment", "news_feed_get_exchange_announcements", + "news_feed_get_mention_burst", + "news_feed_get_hot_topics", "news_events_get_latest_events", "news_events_get_event_detail", + "news_events_explain_market_move", + "news_events_get_market_move_report", + "news_events_list_market_move_reports", + "news_prediction_get_volume_delta_ranking", + "news_prediction_get_fastest_rising_ranking", + "news_prediction_get_market_orderbook", + "news_prediction_search_events", + "news_prediction_get_event_signal", } var InfoToolBaseline = []string{ @@ -27,10 +37,12 @@ var InfoToolBaseline = []string{ "info_platformmetrics_get_defi_overview", "info_platformmetrics_get_stablecoin_info", "info_platformmetrics_get_bridge_metrics", + "info_platformmetrics_get_cex_orderbook_depth", "info_platformmetrics_get_yield_pools", "info_platformmetrics_get_platform_history", "info_platformmetrics_get_exchange_reserves", "info_platformmetrics_get_liquidation_heatmap", + "info_platformmetrics_get_chain_activity", "info_marketdetail_get_orderbook", "info_marketdetail_get_recent_trades", "info_marketdetail_get_kline", @@ -41,6 +53,7 @@ var InfoToolBaseline = []string{ "info_coin_get_coin_rankings", "info_marketsnapshot_batch_market_snapshot", "info_marketsnapshot_get_market_overview", + "info_marketsnapshot_get_institutional_metrics", "info_compliance_check_token_security", } diff --git a/internal/intelfacade/inventory_test.go b/internal/intelfacade/inventory_test.go index 433da12..65f6d0f 100644 --- a/internal/intelfacade/inventory_test.go +++ b/internal/intelfacade/inventory_test.go @@ -3,7 +3,7 @@ package intelfacade import "testing" func TestBaselineToolCount(t *testing.T) { - if BaselineToolCount() != 37 { - t.Fatalf("expected baseline 37, got %d", BaselineToolCount()) + if BaselineToolCount() != 50 { + t.Fatalf("expected baseline 50, got %d", BaselineToolCount()) } } diff --git a/internal/intelfacade/news_schema_baseline.go b/internal/intelfacade/news_schema_baseline.go index 3f83ea3..a6e6c37 100644 --- a/internal/intelfacade/news_schema_baseline.go +++ b/internal/intelfacade/news_schema_baseline.go @@ -6,95 +6,268 @@ import "sync" // MCP tools/list may register additional non-colliding flags afterward; --params / --args-json are JSON fallback. // Server-side tools/call validation remains authoritative at runtime. // -// Keys and types follow specs/mcp/news-tools-args-and-logic.json; extend when upstream adds fields. +// Keys, types, enum/default, and JSON Schema bounds (minimum/maximum/maxLength/maxItems, etc.) +// mirror specs/mcp/news-tools-args-and-logic.json for flat-flag help text (incl. LLM-facing ranges); extend when upstream adds fields. var NewsBaselineInputSchemas = map[string]map[string]interface{}{ "news_feed_search_news": newsObj(map[string]interface{}{ - "query": newsStr("query"), - "coin": newsStr("coin"), - "platform": newsStr("platform"), - "platform_type": newsStr("platform_type"), - "lang": newsStr("lang"), - "time_range": newsStr("time_range"), - "start_time": newsStr("start_time"), - "end_time": newsStr("end_time"), - "sort_by": newsStr("sort_by"), - "top_total_score": newsNum("top_total_score"), - "limit": newsInt("limit"), - "page": newsInt("page"), - "similarity_score": newsStr("similarity_score"), + "query": newsStr("query; non-empty -> similarity mode (tickers not sent downstream)"), + "coin": newsStr("coin; comma-separated tickers when query empty"), + "platform": newsStr("platform; preferred source filter (over platform_type)"), + "platform_type": newsStr("platform_type; legacy mapping; ignored when platform set"), + "lang": newsStr("lang; MCP local filter only"), + "time_range": newsTimeRange13730Default24h("time_range; overrides start_time/end_time when set"), + "start_time": newsStr("start_time; ISO8601 or Unix sec/ms"), + "end_time": newsStr("end_time; date-only end treated as end-of-day"), + "sort_by": newsStrDefault("sort_by", "time"), + "top_total_score": newsNum("top_total_score; query non-empty -> 0; query empty default 1 unless explicitly 0"), + "limit": newsIntDefaultMax("limit", 10, 100), + "page": newsIntDefault("page", 1), + "similarity_score": newsStr("similarity_score; default ~0.6 when query non-empty"), }), "news_feed_search_ugc": newsObj(map[string]interface{}{ - "query": newsStr("query"), - "coin": newsStr("coin"), - "platform": newsStr("platform"), - "domain": newsStr("domain"), + "query": newsStr("query; non-empty -> vector API; query+coin may combine"), + "coin": newsStr("coin; required when query empty (OpenSearch branch); CLI: query or coin"), + "platform": newsStrEnum("platform", "all", "reddit", "discord", "telegram", "youtube", "all"), + "domain": newsStrEnum("domain", "all", "crypto", "defi", "finance", "macro", "ai_agent", "web3_dev", "all"), "channel": newsStr("channel"), - "quality_tier": newsStr("quality_tier"), - "time_range": newsStr("time_range"), - "sort_by": newsStr("sort_by"), - "limit": newsInt("limit"), + "quality_tier": newsStrEnum("quality_tier", "A", "A", "B", "all"), + "time_range": newsStrEnum("time_range", "7d", "1h", "24h", "7d", "30d", "all"), + "sort_by": newsStrEnum("sort_by", "relevance", "relevance", "upvotes", "recent"), + "limit": newsIntDefaultMax("limit", 10, 50), }), "news_feed_search_x": newsObj(map[string]interface{}{ - "query": newsStr("query"), - "days": newsInt("days"), - "allowed_handles": newsArrStr("allowed_handles"), - "excluded_handles": newsArrStr("excluded_handles"), + "query": newsStr("query; empty on xAI path returns empty result"), + "days": newsIntDefaultMin("days; xAI lookback when time_range omitted (runtime default 1)", 1, 1), + "allowed_handles": newsArrStrMaxItems("allowed_handles", 10), + "excluded_handles": newsArrStrMaxItems("excluded_handles", 10), "model": newsStr("model"), "enable_image_understanding": newsBool("enable_image_understanding"), "enable_video_understanding": newsBool("enable_video_understanding"), - "coin": newsStr("coin"), + "coin": newsStr("coin; platform fallback path only (maps to search_news-style); use --query for xAI"), "platform": newsStr("platform"), "platform_type": newsStr("platform_type"), "lang": newsLangDefaultZh("lang"), - "time_range": newsTimeRange24h("time_range"), + "time_range": newsTimeRange24h("time_range; 1h|24h|7d only — use --days for longer xAI lookback when time_range omitted"), "start_time": newsStr("start_time"), "end_time": newsStr("end_time"), "sort_by": newsStr("sort_by"), "top_total_score": newsNum("top_total_score"), - "limit": newsInt("limit"), - "page": newsInt("page"), + "limit": newsIntDefault("limit; platform fallback only (default 10)", 10), + "page": newsIntDefault("page", 1), "similarity_score": newsStr("similarity_score"), }), "news_feed_web_search": newsObj(map[string]interface{}{ - "query": newsStr("query"), + "query": newsStr("query; required"), "coin": newsStr("coin"), - "mode": newsStr("mode"), - "time_range": newsStr("time_range"), - "lang": newsLangDefaultZh("lang"), - "limit": newsInt("limit"), + "mode": newsStrEnum("mode", "analysis", "analysis", "brief"), + "time_range": newsTimeRange13730Default24h("time_range"), + "lang": newsWebSearchLang("lang"), + "limit": newsIntDefaultMax("limit", 5, 10), }, "query"), "news_feed_get_exchange_announcements": newsObj(map[string]interface{}{ "exchange": newsStr("exchange"), "platform": newsStr("platform"), "query": newsStr("query"), "coin": newsStr("coin"), - "announcement_type": newsStr("announcement_type"), - "limit": newsInt("limit"), + "announcement_type": newsStrEnum("announcement_type", "", "listing", "delisting", "maintenance", "all"), + "limit": newsIntMax("limit", 100), "from": newsInt("from"), "to": newsInt("to"), }), "news_feed_get_social_sentiment": newsObj(map[string]interface{}{ - "coin": newsStr("coin"), - "time_range": newsStr("time_range"), + "coin": newsStrDefault("coin", "BTC"), + "time_range": newsStrEnum("time_range", "24h", "1h", "24h", "7d"), }), + "news_feed_get_mention_burst": newsObj(map[string]interface{}{ + "coin": newsStr("coin; required; trimmed and normalized to uppercase; no dictionary validation; unknown/no-data tickers may return hide_reason=no_data"), + "window": newsStrEnum("window; only 24h is currently supported", "24h", "24h"), + "platforms": newsStrDefault("platforms; comma-separated: all, gate_square, binance_square, twitter, telegram, youtube, reddit, discord; all cannot be combined", "all"), + }, "coin"), + "news_feed_get_hot_topics": newsObj(map[string]interface{}{ + "coin": newsStr("coin; required; trimmed and normalized to uppercase; no dictionary validation; unknown/no-data tickers may return hide_reason=no_data"), + "window": newsStrEnum("window; only 4h is currently supported", "4h", "4h"), + "limit": newsIntDefaultMinMax("limit; number of qualified topics", 4, 2, 4), + "platforms": newsStrDefault("platforms; comma-separated: all, gate_square, binance_square, twitter, telegram, youtube, reddit, discord; all cannot be combined", "all"), + }, "coin"), "news_events_get_latest_events": newsObj(map[string]interface{}{ - "event_type": newsStr("event_type"), - "coin": newsStr("coin"), - "time_range": newsStr("time_range"), - "start_time": newsStr("start_time"), - "end_time": newsStr("end_time"), - "cursor": newsStr("cursor"), - "limit": newsInt("limit"), + "event_type": newsStr("event_type; all or empty -> no type filter"), + "coin": newsStr("coin; comma-separated; expanded for related_coins/symbols"), + "time_range": newsStrEnum("time_range; mutually exclusive with start_time/end_time", "", "1h", "24h", "7d"), + "start_time": newsStr("start_time; mutually exclusive with time_range"), + "end_time": newsStr("end_time; mutually exclusive with time_range"), + "cursor": newsStr("cursor; reserved; not used for OpenSearch pagination today"), + "limit": newsIntDefaultMax("limit; omitted or <=0 -> 20 upstream; >100 invalid_size", 20, 100), }), "news_events_get_event_detail": newsObj(map[string]interface{}{ - "event_id": newsStr("event_id"), + "event_id": newsEventID("event_id; max 512; pattern A-Za-z0-9:_-"), }, "event_id"), + "news_events_explain_market_move": newsObj(map[string]interface{}{ + "query": newsStr("query; required; e.g. why an asset moved"), + "coin": newsStr("coin; required; normalized upstream"), + "time_range": newsStrEnum("time_range; invalid/empty/7d -> 2h", "2h", "30m", "1h", "2h", "4h", "24h"), + "mode": newsStrEnum("mode", "auto", "auto", "price_move", "event_impact"), + "lang": newsStrEnum("lang", "zh", "zh", "en"), + }, "query", "coin"), + "news_events_get_market_move_report": newsObj(map[string]interface{}{ + "symbol": newsStringMaxLength("symbol; required; 1-20 characters; normalized to uppercase upstream", 20), + "report_id": newsStr("report_id; optional exact report lookup; takes priority over event_id"), + "event_id": newsStr("event_id; optional event lookup; omit both IDs to get latest report for symbol"), + }, "symbol"), + "news_events_list_market_move_reports": newsObj(map[string]interface{}{ + "symbol": newsStringMaxLength("symbol; required; 1-20 characters; normalized to uppercase upstream", 20), + "start_time": newsStr("start_time; required inclusive lower bound for report updated_at in UTC0; ISO8601 or YYYY-MM-DD HH:MM:SS; no timezone means UTC0; explicit offsets are converted to UTC0 by MCP"), + "end_time": newsStr("end_time; required inclusive upper bound for report updated_at in UTC0; no timezone means UTC0; explicit offsets are converted to UTC0 by MCP; must not precede start_time"), + "limit": newsIntDefaultMinMax("limit; omitted or 0 -> 20; otherwise maximum reports in range 1-100", 20, 0, 100), + }, "symbol", "start_time", "end_time"), + "news_prediction_get_volume_delta_ranking": newsPredictionRankingProps(), + "news_prediction_get_fastest_rising_ranking": newsPredictionRankingProps(), + "news_prediction_get_market_orderbook": newsObj(map[string]interface{}{ + "venue": newsStrEnum("venue; polymarket or predict_fun (trimmed)", "", "polymarket", "predict_fun"), + "market_id": newsStr("market_id; polymarket: venue_market_id (needs predictionMarketIndex); predict_fun: official numeric id"), + "depth": newsIntDefaultMinMax("depth; top-N per side in yes_bids/yes_asks (live book only)", 20, 1, 20), + "mode": newsStrEnum("mode; history rejected; empty -> current", "current", "current", ""), + "granularity": newsStr("granularity; unsupported; non-empty -> invalid_param"), + "start_time": newsStr("start_time; unsupported; non-empty -> invalid_param"), + "end_time": newsStr("end_time; unsupported; non-empty -> invalid_param"), + "page_token": newsStr("page_token; unsupported; non-empty -> invalid_param"), + }, "venue", "market_id"), + "news_prediction_search_events": newsObj(map[string]interface{}{ + "query": newsStr("query; wildcard venue_event_title; pure-digit also term venue_event_id; CLI: query or coin or category"), + "coin": newsStr("coin; NormalizeCoin; terms related_coins/symbols; coin-only (no query/category) -> status all"), + "category": newsPredictionSearchEventsCategory(), + "status": newsStrEnum("status; omit --status for MCP defaults (coin-only no query/category -> all); pass --status active|closed|resolved|all to override", "", "active", "closed", "resolved", "all"), + "venue": newsArrVenuePolymarketOpinionPredictFun("venue; venue.keyword filter"), + "sort_by": newsStrEnum("sort_by; signal index; default recently_listed; ES 400 fallback chain", "recently_listed", "attention", "volume", "liquidity", "recently_listed", "probability_change", "volume_delta_today"), + "limit": newsIntDefaultMinMax("limit; size=limit+1 for next_page_token", 20, 1, 100), + "page_token": newsStr("page_token; base64 {sort_by, search_after}; sort_by mismatch -> invalid_param"), + "with_markets": newsBoolDefault("with_markets; attach dws_prediction_market_hf summaries", false), + }), + "news_prediction_get_event_signal": newsObj(map[string]interface{}{ + "event_ref": newsEventRef("event_ref; venue:venue_event_id (first ':' splits; id may contain more colons)"), + "window": newsStrEnum("window; part_hour >= now-window; 1h may use signal_window_fallback", "24h", "1h", "24h", "7d"), + "venue": newsArrVenuePolymarketOpinionPredictFun("venue; optional; must match event_ref venue"), + "include_markets": newsBoolDefault("include_markets; external markets or legacy JSON; predictionMarketIndex fallback (default true)", true), + "include_orderbook_summary": newsBoolDefault("include_orderbook_summary; deprecated/unread; live depth -> get_market_orderbook", false), + }, "event_ref"), } func newsStr(desc string) map[string]interface{} { return map[string]interface{}{"type": "string", "description": desc} } +func newsStringMaxLength(desc string, max int) map[string]interface{} { + return map[string]interface{}{ + "type": "string", + "description": desc, + "maxLength": float64(max), + } +} + +// newsStrEnum builds a string field with enum (and optional default) for CLI flag usage; values follow specs/mcp/news-tools-args-and-logic.json. +func newsStrEnum(desc, defaultVal string, enum ...string) map[string]interface{} { + ev := make([]interface{}, len(enum)) + for i, s := range enum { + ev[i] = s + } + m := map[string]interface{}{ + "type": "string", + "description": desc, + "enum": ev, + } + if defaultVal != "" { + m["default"] = defaultVal + } + return m +} + +func newsTimeRange13730Default24h(desc string) map[string]interface{} { + return map[string]interface{}{ + "type": "string", + "description": desc, + "enum": []interface{}{"1h", "24h", "7d", "30d"}, + "default": "24h", + } +} + +func newsWebSearchLang(desc string) map[string]interface{} { + return map[string]interface{}{ + "type": "string", + "description": desc, + "enum": []interface{}{"zh", "en", "auto"}, + "default": "zh", + } +} + +func newsStrDefault(desc, defaultVal string) map[string]interface{} { + return map[string]interface{}{ + "type": "string", + "description": desc, + "default": defaultVal, + } +} + +func newsIntDefault(desc string, def int) map[string]interface{} { + return map[string]interface{}{ + "type": "integer", + "description": desc, + "default": float64(def), + } +} + +func newsIntDefaultMax(desc string, def, max int) map[string]interface{} { + return map[string]interface{}{ + "type": "integer", + "description": desc, + "default": float64(def), + "maximum": float64(max), + } +} + +func newsIntDefaultMin(desc string, def, min int) map[string]interface{} { + return map[string]interface{}{ + "type": "integer", + "description": desc, + "default": float64(def), + "minimum": float64(min), + } +} + +func newsIntDefaultMinMax(desc string, def, min, max int) map[string]interface{} { + return map[string]interface{}{ + "type": "integer", + "description": desc, + "default": float64(def), + "minimum": float64(min), + "maximum": float64(max), + } +} + +func newsIntMax(desc string, max int) map[string]interface{} { + return map[string]interface{}{ + "type": "integer", + "description": desc, + "maximum": float64(max), + } +} + +func newsArrStrMaxItems(desc string, maxItems int) map[string]interface{} { + return map[string]interface{}{ + "type": "array", + "description": desc, + "items": map[string]interface{}{"type": "string"}, + "maxItems": float64(maxItems), + } +} + +func newsEventID(desc string) map[string]interface{} { + return map[string]interface{}{ + "type": "string", + "description": desc, + "maxLength": float64(512), + "pattern": "^[A-Za-z0-9:_-]+$", + } +} + func newsInt(desc string) map[string]interface{} { return map[string]interface{}{"type": "integer", "description": desc} } @@ -107,6 +280,22 @@ func newsBool(desc string) map[string]interface{} { return map[string]interface{}{"type": "boolean", "description": desc} } +func newsBoolDefault(desc string, def bool) map[string]interface{} { + return map[string]interface{}{ + "type": "boolean", + "description": desc, + "default": def, + } +} + +func newsEventRef(desc string) map[string]interface{} { + return map[string]interface{}{ + "type": "string", + "description": desc, + "pattern": "^[^:]+:[^:]+$", + } +} + func newsTimeRange24h(desc string) map[string]interface{} { return map[string]interface{}{ "type": "string", @@ -133,6 +322,43 @@ func newsArrStr(desc string) map[string]interface{} { } } +func newsDateUTCOptional(desc string) map[string]interface{} { + return map[string]interface{}{ + "type": "string", + "description": desc, + "pattern": `^\d{4}-\d{2}-\d{2}$`, + } +} + +func newsArrVenuePolymarketOpinionPredictFun(desc string) map[string]interface{} { + return map[string]interface{}{ + "type": "array", + "description": desc, + "items": map[string]interface{}{ + "type": "string", + "enum": []interface{}{"polymarket", "predict_fun"}, + }, + } +} + +func newsPredictionRankingProps() map[string]interface{} { + return newsObj(map[string]interface{}{ + "date_utc": newsDateUTCOptional("date_utc; UTC YYYY-MM-DD rank_date; omit -> today UTC"), + "limit": newsIntDefaultMinMax("limit; OpenSearch size (<=0 normalized to 20)", 20, 1, 100), + "venue": newsArrVenuePolymarketOpinionPredictFun("venue; empty -> all venues; terms filter"), + "category": newsStr("category; empty omit; non-all exact term on rank index (no server enum)"), + "status": newsStrEnum("status; empty -> active; all -> no filter", "active", "active", "closed", "resolved", "all"), + }) +} + +func newsPredictionSearchEventsCategory() map[string]interface{} { + return newsStrEnum("category", "", + "crypto_event", "crypto_price", "culture", "earnings", "elections", + "finance", "geopolitics", "macro_economy", "mentions", "other", + "politics", "sports", "tech_ai", "weather_climate", "world", + ) +} + func newsObj(props map[string]interface{}, required ...string) map[string]interface{} { out := map[string]interface{}{ "type": "object", diff --git a/internal/intelfacade/news_schema_baseline_test.go b/internal/intelfacade/news_schema_baseline_test.go index 570b030..9bbf3c7 100644 --- a/internal/intelfacade/news_schema_baseline_test.go +++ b/internal/intelfacade/news_schema_baseline_test.go @@ -2,6 +2,7 @@ package intelfacade import ( "reflect" + "strings" "testing" ) @@ -26,7 +27,15 @@ func TestNewsBaselineInputSchemaCriticalFields(t *testing.T) { cases := map[string][]string{ "news_feed_search_news": {"query", "coin", "platform", "platform_type", "start_time", "end_time", "similarity_score", "top_total_score"}, "news_feed_get_exchange_announcements": {"announcement_type", "coin", "platform", "from", "to"}, + "news_feed_get_mention_burst": {"coin", "window", "platforms"}, + "news_feed_get_hot_topics": {"coin", "window", "limit", "platforms"}, "news_events_get_latest_events": {"event_type", "cursor", "start_time", "end_time"}, + "news_events_explain_market_move": {"query", "coin", "time_range", "mode", "lang"}, + "news_events_get_market_move_report": {"symbol", "report_id", "event_id"}, + "news_events_list_market_move_reports": {"symbol", "start_time", "end_time", "limit"}, + "news_prediction_get_market_orderbook": {"venue", "market_id", "depth", "mode", "granularity", "page_token"}, + "news_prediction_search_events": {"query", "coin", "category", "status", "venue", "sort_by", "limit", "page_token", "with_markets"}, + "news_prediction_get_event_signal": {"event_ref", "window", "venue", "include_markets", "include_orderbook_summary"}, "news_feed_search_x": {"allowed_handles", "excluded_handles", "enable_image_understanding", "enable_video_understanding", "platform_type", "time_range"}, } for tool, fields := range cases { @@ -40,8 +49,8 @@ func TestNewsBaselineInputSchemaCriticalFields(t *testing.T) { } searchX := NewsBaselineInputSchema("news_feed_search_x") - props := searchX["properties"].(map[string]interface{}) - timeRange := props["time_range"].(map[string]interface{}) + sxProps := searchX["properties"].(map[string]interface{}) + timeRange := sxProps["time_range"].(map[string]interface{}) if def, _ := timeRange["default"].(string); def != "24h" { t.Fatalf("time_range default mismatch: want 24h got %q", def) } @@ -50,16 +59,114 @@ func TestNewsBaselineInputSchemaCriticalFields(t *testing.T) { t.Fatalf("time_range enum mismatch: %#v", timeRange["enum"]) } - for _, tool := range []string{"news_feed_search_x", "news_feed_web_search"} { + sxLang := sxProps["lang"].(map[string]interface{}) + if def, _ := sxLang["default"].(string); def != "zh" { + t.Fatalf("search_x lang default mismatch: want zh got %q", def) + } + if enums, ok := sxLang["enum"].([]interface{}); !ok || len(enums) != 3 { + t.Fatalf("search_x lang enum mismatch: %#v", sxLang["enum"]) + } + + web := NewsBaselineInputSchema("news_feed_web_search") + wProps := web["properties"].(map[string]interface{}) + wLang := wProps["lang"].(map[string]interface{}) + if def, _ := wLang["default"].(string); def != "zh" { + t.Fatalf("web_search lang default mismatch: want zh got %q", def) + } + if enums, ok := wLang["enum"].([]interface{}); !ok || len(enums) != 3 { + t.Fatalf("web_search lang enum mismatch: %#v", wLang["enum"]) + } + + getReport := NewsBaselineInputSchema("news_events_get_market_move_report") + getReportProps := getReport["properties"].(map[string]interface{}) + if _, exists := getReportProps["is_make_new"]; exists { + t.Fatal("get_market_move_report must not expose is_make_new") + } +} + +func TestNewsBaselineBoundKeywordsForCLIHelp(t *testing.T) { + t.Parallel() + ugc := NewsBaselineInputSchema("news_feed_search_ugc") + lim := ugc["properties"].(map[string]interface{})["limit"].(map[string]interface{}) + if lim["maximum"].(float64) != 50 { + t.Fatalf("ugc limit maximum: got %#v", lim["maximum"]) + } + if lim["default"].(float64) != 10 { + t.Fatalf("ugc limit default: got %#v", lim["default"]) + } + + detail := NewsBaselineInputSchema("news_events_get_event_detail") + ev := detail["properties"].(map[string]interface{})["event_id"].(map[string]interface{}) + if ev["maxLength"].(float64) != 512 { + t.Fatalf("event_id maxLength: got %#v", ev["maxLength"]) + } + if pat, _ := ev["pattern"].(string); pat == "" { + t.Fatal("expected event_id pattern") + } + + x := NewsBaselineInputSchema("news_feed_search_x") + ah := x["properties"].(map[string]interface{})["allowed_handles"].(map[string]interface{}) + if ah["maxItems"].(float64) != 10 { + t.Fatalf("allowed_handles maxItems: got %#v", ah["maxItems"]) + } + + searchEv := NewsBaselineInputSchema("news_prediction_search_events") + props := searchEv["properties"].(map[string]interface{}) + st := props["status"].(map[string]interface{}) + if _, has := st["default"]; has { + t.Fatalf("search_events status must not set CLI/json default (MCP applies omit policy): %#v", st["default"]) + } + sb := props["sort_by"].(map[string]interface{}) + if def, _ := sb["default"].(string); def != "recently_listed" { + t.Fatalf("search_events sort_by default: want recently_listed got %q", def) + } + + reports := NewsBaselineInputSchema("news_events_list_market_move_reports") + reportProps := reports["properties"].(map[string]interface{}) + for _, field := range []string{"start_time", "end_time"} { + desc, _ := reportProps[field].(map[string]interface{})["description"].(string) + if !strings.Contains(desc, "UTC0") || !strings.Contains(desc, "updated_at") { + t.Fatalf("market move report %s semantics: %#v", field, reportProps[field]) + } + } + reportLimit := reportProps["limit"].(map[string]interface{}) + if reportLimit["default"].(float64) != 20 || reportLimit["minimum"].(float64) != 0 || reportLimit["maximum"].(float64) != 100 { + t.Fatalf("market move report limit bounds: %#v", reportLimit) + } + + pred := NewsBaselineInputSchema("news_prediction_get_volume_delta_ranking") + plim := pred["properties"].(map[string]interface{})["limit"].(map[string]interface{}) + if plim["default"].(float64) != 20 || plim["maximum"].(float64) != 100 { + t.Fatalf("prediction limit default/max: got %#v", plim) + } + venueItems := pred["properties"].(map[string]interface{})["venue"].(map[string]interface{})["items"].(map[string]interface{}) + if len(venueItems["enum"].([]interface{})) != 2 { + t.Fatalf("venue enum: got %#v", venueItems["enum"]) + } + cat := pred["properties"].(map[string]interface{})["category"].(map[string]interface{}) + if _, hasEnum := cat["enum"]; hasEnum { + t.Fatalf("prediction category must be free-form string (no enum): got %#v", cat) + } + if cat["type"] != "string" { + t.Fatalf("prediction category type: got %#v", cat["type"]) + } + + hotTopics := NewsBaselineInputSchema("news_feed_get_hot_topics") + hotProps := hotTopics["properties"].(map[string]interface{}) + hotLimit := hotProps["limit"].(map[string]interface{}) + if hotLimit["default"].(float64) != 4 || hotLimit["minimum"].(float64) != 2 || hotLimit["maximum"].(float64) != 4 { + t.Fatalf("hot topics limit bounds: got %#v", hotLimit) + } + for _, tool := range []string{"news_feed_get_mention_burst", "news_feed_get_hot_topics"} { schema := NewsBaselineInputSchema(tool) - props := schema["properties"].(map[string]interface{}) - lang := props["lang"].(map[string]interface{}) - if def, _ := lang["default"].(string); def != "zh" { - t.Fatalf("%s lang default mismatch: want zh got %q", tool, def) + coin := schema["properties"].(map[string]interface{})["coin"].(map[string]interface{}) + description, _ := coin["description"].(string) + lower := strings.ToLower(description) + if strings.Contains(lower, "validated by") || strings.Contains(lower, "recognized by") { + t.Errorf("%s coin description overstates validation: %q", tool, description) } - enums, ok := lang["enum"].([]interface{}) - if !ok || len(enums) != 3 { - t.Fatalf("%s lang enum mismatch: %#v", tool, lang["enum"]) + if !strings.Contains(description, "hide_reason=no_data") { + t.Errorf("%s coin description misses unknown/no-data behavior: %q", tool, description) } } } diff --git a/internal/intelfacade/news_spec_baseline_parity_test.go b/internal/intelfacade/news_spec_baseline_parity_test.go new file mode 100644 index 0000000..e18b650 --- /dev/null +++ b/internal/intelfacade/news_spec_baseline_parity_test.go @@ -0,0 +1,74 @@ +package intelfacade + +import ( + "testing" + + "github.com/gate/gate-cli/internal/mcpspec" +) + +// TestNewsBaselineCoversSpecParams ensures each news tool's spec input_rules.params +// names have a matching baseline property (so cobra -h lists the flag). +func TestNewsBaselineCoversSpecParams(t *testing.T) { + t.Parallel() + doc, err := mcpspec.NewsToolsArgs() + if err != nil { + t.Fatal(err) + } + root, ok := doc.(map[string]interface{}) + if !ok { + t.Fatalf("news spec root type %T", doc) + } + raw, ok := root["tools"].([]interface{}) + if !ok { + t.Fatal("news spec missing tools") + } + for _, item := range raw { + tm, ok := item.(map[string]interface{}) + if !ok { + continue + } + name, _ := tm["name"].(string) + if name == "" { + continue + } + inBaseline := false + for _, inv := range NewsToolBaseline { + if inv == name { + inBaseline = true + break + } + } + if !inBaseline { + continue + } + ir, ok := tm["input_rules"].(map[string]interface{}) + if !ok { + t.Fatalf("%s: missing input_rules", name) + } + params, ok := ir["params"].([]interface{}) + if !ok { + t.Fatalf("%s: missing params", name) + } + bl := NewsBaselineInputSchema(name) + if bl == nil { + t.Fatalf("missing news baseline for %s", name) + } + props, ok := bl["properties"].(map[string]interface{}) + if !ok { + t.Fatalf("%s: baseline missing properties", name) + } + for _, p := range params { + pm, ok := p.(map[string]interface{}) + if !ok { + continue + } + pn, _ := pm["name"].(string) + if pn == "" { + continue + } + if _, ok := props[pn]; !ok { + t.Errorf("%s: spec param %q missing from NewsBaselineInputSchemas", name, pn) + } + } + } +} diff --git a/internal/intelfacade/spec_baseline_parity_test.go b/internal/intelfacade/spec_baseline_parity_test.go new file mode 100644 index 0000000..6a0e25f --- /dev/null +++ b/internal/intelfacade/spec_baseline_parity_test.go @@ -0,0 +1,164 @@ +package intelfacade + +import ( + "testing" + + "github.com/gate/gate-cli/internal/mcpspec" +) + +// Baseline-only CLI ergonomics keys not present in MCP spec fields. +var infoBaselineExtraSpecKeys = map[string]map[string]struct{}{ + "info_coin_get_coin_info": {"symbol": {}}, +} + +var infoSpecOnlyTools = map[string]struct{}{ + "info_onchain_get_smart_money": {}, + "info_onchain_get_entity_profile": {}, + "info_onchain_trace_fund_flow": {}, + "info_compliance_check_address_risk": {}, + "info_compliance_search_regulatory_updates": {}, +} + +// TestInfoBaselineCoversSpecInputFields ensures every spec input field for inventory +// tools has a matching baseline property so cobra -h lists the flag (CR-815). +func TestInfoBaselineCoversSpecInputFields(t *testing.T) { + t.Parallel() + doc, err := mcpspec.InfoInputsLogic() + if err != nil { + t.Fatal(err) + } + root, ok := doc.(map[string]interface{}) + if !ok { + t.Fatalf("spec root type %T", doc) + } + raw, ok := root["tools"].([]interface{}) + if !ok { + t.Fatal("spec missing tools array") + } + specFieldsByTool := make(map[string][]string, len(raw)) + for _, item := range raw { + tm, ok := item.(map[string]interface{}) + if !ok { + continue + } + name, _ := tm["tool_name"].(string) + if name == "" { + continue + } + fields, _ := tm["fields"].([]interface{}) + var names []string + for _, f := range fields { + fm, ok := f.(map[string]interface{}) + if !ok { + continue + } + n, _ := fm["name"].(string) + if n != "" { + names = append(names, n) + } + } + specFieldsByTool[name] = names + } + + for _, tool := range InfoToolBaseline { + specNames, ok := specFieldsByTool[tool] + if !ok { + t.Fatalf("tool %q in InfoToolBaseline but absent from MCP spec JSON", tool) + } + if len(specNames) == 0 && tool != "info_marketsnapshot_get_market_overview" && tool != "info_macro_get_macro_summary" { + t.Fatalf("spec has empty fields for tool %q (unexpected)", tool) + } + bl := InfoBaselineInputSchema(tool) + if bl == nil { + t.Fatalf("missing baseline schema for %s", tool) + } + props, ok := bl["properties"].(map[string]interface{}) + if !ok { + t.Fatalf("%s: baseline missing properties", tool) + } + for _, fn := range specNames { + if _, ok := props[fn]; !ok { + t.Errorf("%s: spec field %q missing from InfoBaselineInputSchemas (will not appear in -h)", tool, fn) + } + } + extraOK := infoBaselineExtraSpecKeys[tool] + for k := range props { + if extraOK != nil { + if _, ok := extraOK[k]; ok { + continue + } + } + found := false + for _, fn := range specNames { + if fn == k { + found = true + break + } + } + if !found { + t.Logf("%s: baseline-only property %q (not in spec fields)", tool, k) + } + } + } +} + +func TestInfoSpecExtraToolsDocumented(t *testing.T) { + t.Parallel() + doc, err := mcpspec.InfoInputsLogic() + if err != nil { + t.Fatal(err) + } + root := doc.(map[string]interface{}) + raw := root["tools"].([]interface{}) + meta, ok := root["meta"].(map[string]interface{}) + if !ok { + t.Fatal("spec missing meta object") + } + if got, _ := meta["total_tools"].(float64); int(got) != len(raw) { + t.Fatalf("meta.total_tools=%v want %d", meta["total_tools"], len(raw)) + } + if got, _ := meta["cli_baseline_tools"].(float64); int(got) != len(InfoToolBaseline) { + t.Fatalf("meta.cli_baseline_tools=%v want %d", meta["cli_baseline_tools"], len(InfoToolBaseline)) + } + specOnlyRaw, ok := meta["spec_only_tools"].([]interface{}) + if !ok { + t.Fatal("spec meta missing spec_only_tools array") + } + if len(specOnlyRaw) != len(infoSpecOnlyTools) { + t.Fatalf("spec_only_tools count=%d want %d", len(specOnlyRaw), len(infoSpecOnlyTools)) + } + for _, item := range specOnlyRaw { + name, _ := item.(string) + if _, ok := infoSpecOnlyTools[name]; !ok { + t.Fatalf("unexpected spec_only_tools entry %q", name) + } + } + specTools := make(map[string]struct{}, len(raw)) + for _, item := range raw { + tm := item.(map[string]interface{}) + if n, _ := tm["tool_name"].(string); n != "" { + specTools[n] = struct{}{} + } + } + seenSpecOnly := map[string]struct{}{} + for name := range specTools { + found := false + for _, inv := range InfoToolBaseline { + if inv == name { + found = true + break + } + } + if !found { + if _, ok := infoSpecOnlyTools[name]; !ok { + t.Fatalf("spec documents tool %q not in InfoToolBaseline and not in spec-only allowlist", name) + } + seenSpecOnly[name] = struct{}{} + } + } + for name := range infoSpecOnlyTools { + if _, ok := seenSpecOnly[name]; !ok { + t.Fatalf("spec-only allowlist tool %q not present in spec tools", name) + } + } +} diff --git a/internal/mcpclient/client.go b/internal/mcpclient/client.go index 5b03f96..2e83670 100644 --- a/internal/mcpclient/client.go +++ b/internal/mcpclient/client.go @@ -154,6 +154,7 @@ type Client struct { mu sync.Mutex listCacheValid bool listCache []Tool + toolByName map[string]Tool listCacheUntil time.Time initializing bool initErr error @@ -249,6 +250,7 @@ func (c *Client) ListTools(ctx context.Context) ([]Tool, *http.Response, error) c.mu.Lock() c.listCacheValid = true c.listCache = append([]Tool(nil), fallbackTools...) + c.rebuildToolIndexLocked(c.listCache) ttl := c.listCacheTTLFull if len(fallbackTools) == 0 { ttl = c.listCacheTTLEmpty @@ -297,6 +299,7 @@ func (c *Client) ListTools(ctx context.Context) ([]Tool, *http.Response, error) c.mu.Lock() c.listCacheValid = true c.listCache = append([]Tool(nil), tools...) + c.rebuildToolIndexLocked(c.listCache) ttl := c.listCacheTTLFull if len(tools) == 0 { ttl = c.listCacheTTLEmpty @@ -349,6 +352,13 @@ func (c *Client) DescribeTool(ctx context.Context, name string) (*Tool, *http.Re if err != nil { return nil, resp, err } + c.mu.Lock() + if t, ok := c.toolByName[name]; ok { + c.mu.Unlock() + cp := t + return &cp, resp, nil + } + c.mu.Unlock() for _, t := range tools { if t.Name == name { cp := t @@ -481,8 +491,26 @@ func (c *Client) ensureInitialized(ctx context.Context) (err error) { }, "capabilities": map[string]interface{}{}, } - _, _, _, err = c.call(ctx, "initialize", params) - return err + respPayload, _, reqID, err := c.call(ctx, "initialize", params) + if err != nil { + return err + } + if len(respPayload.Result) == 0 { + return &Error{ + Kind: ErrorKindProtocol, + Err: errors.New("invalid initialize result: missing result"), + RequestID: reqID, + } + } + var initResult map[string]interface{} + if err := json.Unmarshal(respPayload.Result, &initResult); err != nil { + return &Error{ + Kind: ErrorKindProtocol, + Err: fmt.Errorf("invalid initialize result: %w", err), + RequestID: reqID, + } + } + return nil } // callWithRetry performs at most one follow-up attempt after HTTP 401 when retryOnUnauthorized @@ -582,6 +610,11 @@ func (c *Client) call(ctx context.Context, method string, params interface{}) (* JSONRPCCode: &parsed.Error.Code, } } + if !matchRPCResponseID(parsed.ID, reqID) { + idErr := fmt.Errorf("json-rpc id mismatch: got=%v want=%s", parsed.ID, reqID) + c.logTransportFailure(method, reqID, time.Since(start), idErr) + return nil, resp, reqID, &Error{Kind: ErrorKindProtocol, Err: idErr, RequestID: reqID} + } c.logDebug(method, reqID, time.Since(start), resp) return &parsed, resp, reqID, nil @@ -670,9 +703,37 @@ func (c *Client) invalidateListCache() { defer c.mu.Unlock() c.listCacheValid = false c.listCache = nil + c.toolByName = nil c.listCacheUntil = time.Time{} } +func (c *Client) rebuildToolIndexLocked(tools []Tool) { + if len(tools) == 0 { + c.toolByName = map[string]Tool{} + return + } + idx := make(map[string]Tool, len(tools)) + for _, t := range tools { + idx[t.Name] = t + } + c.toolByName = idx +} + +func matchRPCResponseID(got interface{}, want string) bool { + switch v := got.(type) { + case string: + return v == want + case float64: + n, err := strconv.ParseFloat(want, 64) + if err != nil { + return false + } + return v == n + default: + return false + } +} + func redactSensitive(input map[string]interface{}) map[string]interface{} { if len(input) == 0 { return map[string]interface{}{} diff --git a/internal/mcpclient/client_test.go b/internal/mcpclient/client_test.go index 2273b56..1c71c47 100644 --- a/internal/mcpclient/client_test.go +++ b/internal/mcpclient/client_test.go @@ -89,6 +89,7 @@ func TestListToolsJSONRPCError(t *testing.T) { } func TestDescribeToolFromList(t *testing.T) { + var listCalls atomic.Uint32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req map[string]interface{} require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) @@ -98,6 +99,7 @@ func TestDescribeToolFromList(t *testing.T) { _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"1","result":{"ok":true}}`)) return } + listCalls.Add(1) _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"2","result":{"tools":[{"name":"info_coin_get_coin_info","description":"desc","inputSchema":{"type":"object"}}]}}`)) })) defer srv.Close() @@ -111,6 +113,10 @@ func TestDescribeToolFromList(t *testing.T) { require.NoError(t, err) require.NotNil(t, tool) assert.Equal(t, "info_coin_get_coin_info", tool.Name) + tool, _, err = c.DescribeTool(context.Background(), "info_coin_get_coin_info") + require.NoError(t, err) + require.NotNil(t, tool) + assert.Equal(t, uint32(1), listCalls.Load(), "describe should use cached index on subsequent lookups") } func TestCallTool(t *testing.T) { @@ -507,18 +513,19 @@ func TestListToolsUnmarshalFailureAfterGoodListInvalidatesCache(t *testing.T) { var req map[string]interface{} require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) method := req["method"].(string) + id := req["id"].(string) if method == "initialize" { - _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"1","result":{"ok":true}}`)) + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"` + id + `","result":{"ok":true}}`)) return } n := toolListCalls.Add(1) switch n { case 1: - _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"2","result":{"tools":[{"name":"t0"}]}}`)) + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"` + id + `","result":{"tools":[{"name":"t0"}]}}`)) case 2: - _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"2","result":"not-an-object"}`)) + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"` + id + `","result":"not-an-object"}`)) default: - _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"2","result":{"tools":[{"name":"t1"}]}}`)) + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"` + id + `","result":{"tools":[{"name":"t1"}]}}`)) } })) defer srv.Close() @@ -554,12 +561,13 @@ func TestListToolsMissingToolsFieldReturnsErrorAndNoCache(t *testing.T) { var req map[string]interface{} require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) method := req["method"].(string) + id := req["id"].(string) if method == "initialize" { - _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"1","result":{"ok":true}}`)) + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"` + id + `","result":{"ok":true}}`)) return } callCount++ - _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"2","result":{}}`)) + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"` + id + `","result":{}}`)) })) defer srv.Close() @@ -583,12 +591,13 @@ func TestListToolsEmptyToolsUsesShortTTL(t *testing.T) { var req map[string]interface{} require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) method := req["method"].(string) + id := req["id"].(string) if method == "initialize" { - _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"1","result":{"ok":true}}`)) + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"` + id + `","result":{"ok":true}}`)) return } listCalls.Add(1) - _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"2","result":{"tools":[]}}`)) + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"` + id + `","result":{"tools":[]}}`)) })) defer srv.Close() @@ -617,17 +626,18 @@ func TestListToolsTransientJSONRPCErrorDoesNotInvalidateGoodCache(t *testing.T) var req map[string]interface{} require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) method := req["method"].(string) + id := req["id"].(string) if method == "initialize" { - _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"1","result":{"ok":true}}`)) + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"` + id + `","result":{"ok":true}}`)) return } n := listCalls.Add(1) if n == 2 { w.WriteHeader(http.StatusBadGateway) - _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"2","error":{"code":-32000,"message":"temporary"}}`)) + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"` + id + `","error":{"code":-32000,"message":"temporary"}}`)) return } - _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"2","result":{"tools":[{"name":"t1","description":"d"}]}}`)) + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"` + id + `","result":{"tools":[{"name":"t1","description":"d"}]}}`)) })) defer srv.Close() @@ -652,6 +662,45 @@ func TestListToolsTransientJSONRPCErrorDoesNotInvalidateGoodCache(t *testing.T) assert.Equal(t, uint32(2), listCalls.Load(), "good list cache should survive transient tools/list failure") } +func TestFallbackListToolsRebuildsDescribeIndex(t *testing.T) { + var listCalls atomic.Uint32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req map[string]interface{} + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + method := req["method"].(string) + id := req["id"].(string) + if method == "initialize" { + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"` + id + `","result":{"ok":true}}`)) + return + } + n := listCalls.Add(1) + if n == 1 { + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"` + id + `","result":{"tools":[{"name":"t1","description":"d"}]}}`)) + return + } + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"` + id + `","error":{"code":-32000,"message":"temporary"}}`)) + })) + defer srv.Close() + + c := New(&toolconfig.ResolvedEndpoint{ + Backend: "news", + BaseURL: srv.URL, + Timeout: 3 * time.Second, + }, CacheTTLForTest(200*time.Millisecond, 200*time.Millisecond)) + + _, _, err := c.ListTools(context.Background()) + require.NoError(t, err) + time.Sleep(250 * time.Millisecond) + _, _, err = c.ListTools(context.Background()) + require.NoError(t, err) + + c.mu.Lock() + _, ok := c.toolByName["t1"] + c.mu.Unlock() + assert.True(t, ok, "fallback cache restore should rebuild toolByName index") +} + func TestGATE_INTEL_MAX_RESPONSE_BYTESOverridesReadLimit(t *testing.T) { t.Setenv("GATE_INTEL_MAX_RESPONSE_BYTES", "10") srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -676,3 +725,55 @@ func TestGATE_INTEL_MAX_RESPONSE_BYTESOverridesReadLimit(t *testing.T) { assert.Contains(t, err.Error(), "response body exceeded 10 bytes") require.ErrorIs(t, err, errIntelHTTPBodyTooLarge) } + +func TestListToolsRejectsMismatchedResponseID(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req map[string]interface{} + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + method := req["method"].(string) + if method == "initialize" { + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"1","result":{"ok":true}}`)) + return + } + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"999","result":{"tools":[]}}`)) + })) + defer srv.Close() + + c := New(&toolconfig.ResolvedEndpoint{ + Backend: "news", + BaseURL: srv.URL, + Timeout: 3 * time.Second, + }) + _, _, err := c.ListTools(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "json-rpc id mismatch") + var mcpErr *Error + require.ErrorAs(t, err, &mcpErr) + assert.Equal(t, ErrorKindProtocol, mcpErr.Kind) +} + +func TestInitializeRejectsNonObjectResult(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req map[string]interface{} + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + method := req["method"].(string) + if method == "initialize" { + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"1","result":"ok"}`)) + return + } + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"2","result":{"tools":[]}}`)) + })) + defer srv.Close() + + c := New(&toolconfig.ResolvedEndpoint{ + Backend: "news", + BaseURL: srv.URL, + Timeout: 3 * time.Second, + }) + _, _, err := c.ListTools(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid initialize result") + var mcpErr *Error + require.ErrorAs(t, err, &mcpErr) + assert.Equal(t, ErrorKindProtocol, mcpErr.Kind) +} diff --git a/internal/mcpclient/errors.go b/internal/mcpclient/errors.go index 41c2d80..8022096 100644 --- a/internal/mcpclient/errors.go +++ b/internal/mcpclient/errors.go @@ -75,6 +75,8 @@ func ParseError(err error, httpResp *http.Response, method, url, toolName string } } + out.ErrorType = output.ClassifyCLIError(out.Status, out.Label, out.Message) + output.FillAgentErrorConvergence(out) return out } diff --git a/internal/mcpclient/errors_test.go b/internal/mcpclient/errors_test.go index 8c2eeda..107ee5d 100644 --- a/internal/mcpclient/errors_test.go +++ b/internal/mcpclient/errors_test.go @@ -32,6 +32,17 @@ func TestSanitizeUserErrorMessage_ResponseTooLargeHint(t *testing.T) { } } +func TestParseError_AgentConvergenceFields(t *testing.T) { + err := &Error{Kind: ErrorKindTransport, Err: errors.New("timeout")} + ge := ParseError(err, &http.Response{StatusCode: 504}, "POST", "info/invoke", "info_coin_get_coin_info") + if ge.ErrorType == "" || ge.SuggestedNextAction == "" { + t.Fatalf("expected convergence fields, got %#v", ge) + } + if ge.Retryable { + t.Fatal("NETWORK_ERROR class transport should not be retryable for blind retry") + } +} + func TestParseError_ResponseTooLargeLabel(t *testing.T) { err := &Error{Kind: ErrorKindTransport, Err: errors.New("response body exceeded 16777216 bytes")} ge := ParseError(err, &http.Response{StatusCode: 502}, "POST", "news/invoke", "x") diff --git a/internal/mcpspec/bundled/info-mcp-tools-inputs-logic.json b/internal/mcpspec/bundled/info-mcp-tools-inputs-logic.json new file mode 100644 index 0000000..acfa255 --- /dev/null +++ b/internal/mcpspec/bundled/info-mcp-tools-inputs-logic.json @@ -0,0 +1,1900 @@ +{ + "meta": { + "schema_version": "1.1", + "source_repo": "gate/mcp-server", + "purpose": "MCP Tool 入参契约清单,供 coin-mcp-cli / 外部 QC CLI 在启动或交互时生成参数模板、校验必填与枚举、展示 logic 摘要;不替代 MCP ListTools 的 JSON Schema(运行时以 mcphost 注册为准)。", + "consumer": "CLI 读取本文件后按 tool_name 定位条目,结合 fields/required/conditional_required 构造请求体;调用仍走 MCP HTTP(如 scripts/curl-*.sh)或 coin-mcp-cli 子命令。", + "scope": "与 internal/mcphost/toolnames.go 的 RegisteredToolNames 一一对应(当前 37 个);news-mcp-server、docs-mcp-server 工具不在此文件。", + "maintenance": "本目录 specs/QC/ 默认 .gitignore,本地维护;新增/改 Tool 须同步:toolnames.go 注册 → 本 JSON 增改 tools[] → total_tools 与条目数一致 → 跑 go test 相关领域包。logic 字段写服务端真实路由(mcphost → 领域 Fetcher),便于 CLI 排错。", + "total_tools": 37, + "tool_entry_keys": { + "tool_name": "MCP 工具名,与 ToolName* 常量一致", + "domain": "实现包/占位:coin|cointrend|snapshot|address|transaction|onchain|platformmetrics|macro|marketdetail|tokensecurity|placeholder", + "request_type": "Go 请求 struct,便于 CLI 对照源码", + "required": "JSON 字段名;无 jsonschema required 时以 Go 校验为准", + "conditional_required": "人类可读条件(如 platform_name or exchange_slug);CLI 宜提示而非硬编码", + "fields": "入参清单;type 为 JSON 类型;enum 为服务端会拒绝的闭集;enum_note/common_values 补充默认与别名", + "logic": "handler 路径与关键分支(索引、降级、错误码语义),不含完整出参结构", + "description": "English one-liner for agents and leaf -h ([Read] routing); embedded in gate-cli release binary only." + }, + "field_type_notes": { + "string|string[]": "sections 等字段 MCP 可传逗号分隔字符串或 JSON 数组,服务端 FlexibleSections 解析", + "int|int64": "与 Go struct 一致;CLI 传 JSON number", + "object": "透传 map(如 marketdetail.extra)" + }, + "enum_note": "多数 Request struct 无 jsonschema enum;闭集来自各领域 validate*。空字符串通常表示服务端默认。", + "items_enum_note": "string[] 的 items_enum 表示元素级闭集(若服务端校验)。", + "common_values_note": "common_values 为常见示例,未知值可能仍被接受(如 ES 指标名、platform_type)。", + "placeholder_note": "domain=placeholder 的工具调用固定返回 errs.CodeNotImplemented;CLI 可灰显或标注未实现。", + "response_note": "本文件主要描述入参与校验;出参字段见各 Tool 的 Go Response struct 或 docs/openapi/mcp-tool-curl.md。", + "cli_baseline_tools": 32, + "spec_only_tools": [ + "info_onchain_get_smart_money", + "info_onchain_get_entity_profile", + "info_onchain_trace_fund_flow", + "info_compliance_check_address_risk", + "info_compliance_search_regulatory_updates" + ] + }, + "tools": [ + { + "tool_name": "info_coin_get_coin_info", + "domain": "coin", + "request_type": "coin.CoinQueryRequest", + "required": [ + "query" + ], + "fields": [ + { + "name": "query", + "type": "string", + "required": true + }, + { + "name": "query_type", + "type": "string", + "required": false, + "enum": [ + "auto", + "address", + "symbol", + "name", + "project", + "gate_symbol", + "source_id" + ], + "enum_note": "omit defaults to auto" + }, + { + "name": "chain", + "type": "string", + "required": false + }, + { + "name": "scope", + "type": "string", + "required": false, + "enum": [ + "basic", + "detailed", + "full", + "with_project", + "with_tokenomics" + ], + "enum_note": "omit defaults to basic" + }, + { + "name": "size", + "type": "int", + "required": false + }, + { + "name": "fields", + "type": "string[]", + "required": false + } + ], + "logic": "mcphost tools_coin -> coinSearcher.SearchCoins; validates query non-empty (max 512), query_type auto|address|symbol|name|project|gate_symbol|source_id (default auto), scope basic|detailed|full|with_project|with_tokenomics (default basic). size default 3 max 20; auto query_type may expand OS fetch size for rerank. buildOpenSearchQuery by query_type with symbol.keyword boost; chain hint applied for address/auto (0x or 40-hex). effectiveFields from scope+fields; ES index dim_pub_coin_info. Errors: query_empty, invalid_param, invalid query_type/scope.", + "description": "[Read] Look up one coin or project by query (symbol, name, address, etc.). Filtered asset lists -> search_coins; leaderboards -> get_coin_rankings." + }, + { + "tool_name": "info_coin_search_coins", + "domain": "coin", + "request_type": "coin.SearchCoinsRequest", + "required": [], + "fields": [ + { + "name": "category", + "type": "string", + "required": false + }, + { + "name": "chain", + "type": "string", + "required": false + }, + { + "name": "market_cap_min", + "type": "float64", + "required": false + }, + { + "name": "market_cap_max", + "type": "float64", + "required": false + }, + { + "name": "asset_type", + "type": "string", + "required": false, + "enum": [ + "crypto", + "tradefi", + "all" + ], + "enum_note": "omit defaults to crypto" + }, + { + "name": "sort_by", + "type": "string", + "required": false, + "enum": [ + "market_cap", + "fdv", + "circulating_supply" + ], + "enum_note": "omit defaults to market_cap ordering" + }, + { + "name": "limit", + "type": "int", + "required": false + }, + { + "name": "offset", + "type": "int", + "required": false + } + ], + "logic": "mcphost tools_coin -> coinSearcher.SearchCoinsByFilter; validates sort_by market_cap|fdv|circulating_supply (default market_cap), asset_type crypto|tradefi|all (default crypto), offset>=0. category expands aliases; unknown category returns empty items (not error). limit default 20 max ToolMaxListSize; ES collapse symbol.keyword + cardinality total. Filters: category, chain (normalized aliases), market_cap min/max on market_value|market_cap, asset_type tradefi/crypto exclusion. offset pagination via from.", + "description": "[Read] Search coins by filters (category, chain, market cap, asset type). Single-asset detail -> get_coin_info." + }, + { + "tool_name": "info_coin_get_coin_rankings", + "domain": "coin", + "request_type": "coin.GetCoinRankingsRequest", + "required": [ + "ranking_type" + ], + "fields": [ + { + "name": "ranking_type", + "type": "string", + "required": true, + "enum": [ + "popular", + "top_gainers", + "top_losers", + "twitter_hot", + "airdrop", + "new_listing", + "market_pulse_hot" + ] + }, + { + "name": "limit", + "type": "int", + "required": false, + "enum_note": "default 20; max ToolMaxListSize (400)" + }, + { + "name": "time_range", + "type": "string", + "required": false, + "enum": [ + "1h", + "24h", + "7d" + ], + "enum_note": "only for top_gainers|top_losers (default 24h); must omit for other ranking_type or invalid_param" + }, + { + "name": "listing_query", + "type": "string", + "required": false + }, + { + "name": "listing_from", + "type": "int64", + "required": false + }, + { + "name": "listing_tickers", + "type": "string", + "required": false + } + ], + "logic": "mcphost tools_coin -> coinSearcher.GetCoinRankings; routes: popular->dim_pub ES, airdrop->cryptorank index, top_gainers|top_losers->movers index with time_range 1h|24h|7d, twitter_hot->dedicated index, new_listing->Gaten KQ HTTP (listing_* params), market_pulse_hot->memeMarketPulseIndex latest snapshot_hour sorted by heat_score desc (requires opensearch.memeMarketPulseIndex else not_implemented). time_range forbidden except gainers/losers; listing_* only for new_listing.", + "description": "[Read] Coin leaderboards (popular, gainers/losers, Twitter hot, airdrop, new listing, market pulse hot). Single coin -> get_coin_info." + }, + { + "tool_name": "info_markettrend_get_kline", + "domain": "cointrend", + "request_type": "cointrend.CoinTrendRequest", + "required": [ + "symbol", + "timeframe" + ], + "fields": [ + { + "name": "symbol", + "type": "string", + "required": true + }, + { + "name": "timeframe", + "type": "string", + "required": true, + "enum": [ + "1m", + "5m", + "15m", + "1h", + "4h", + "1d" + ] + }, + { + "name": "period", + "type": "string", + "required": false, + "enum": [ + "1h", + "4h", + "24h", + "7d", + "3d", + "5d", + "10d", + "all" + ], + "enum_note": "omit defaults to 24h; all disables time window filter" + }, + { + "name": "size", + "type": "int", + "required": false + }, + { + "name": "limit", + "type": "int", + "required": false + }, + { + "name": "start_time", + "type": "string", + "required": false + }, + { + "name": "end_time", + "type": "string", + "required": false + }, + { + "name": "with_indicators", + "type": "bool", + "required": false + } + ], + "logic": "mcphost tools_trend -> trendFetcherSet.GetCoinTrend (OSWithKlineAPIFetcher when configured). ValidateAndNormalize: symbol upper required, timeframe 1m|5m|15m|1h|4h|1d required, period default 24h or all clears time filter, limit overrides size (default 100 max ToolMaxListSize). OpenSearch trend index with USDT market filter; with_indicators explicit bool adds indicator _source. OS empty + Kline API configured: token-search resolves chain/pair then single /trade/kline fetch mapped to ADS fields (OS error blocks fallback). time field normalized to timestamp in items.", + "description": "[Read] OHLCV kline for a symbol with optional bundled indicators. Historical indicator series -> get_indicator_history; TA summary -> get_technical_analysis." + }, + { + "tool_name": "info_markettrend_get_indicator_history", + "domain": "cointrend", + "request_type": "cointrend.IndicatorHistoryRequest", + "required": [ + "symbol", + "indicators", + "timeframe" + ], + "fields": [ + { + "name": "symbol", + "type": "string", + "required": true + }, + { + "name": "indicators", + "type": "string[]", + "required": true, + "common_values": [ + "rsi", + "macd", + "macd_dea", + "macd_difference", + "ma7", + "ma30", + "ma120", + "ma200", + "ema7", + "ema30", + "ema120", + "ema200", + "boll_middle_band", + "boll_upper_band", + "boll_lower_band", + "signal_value_k", + "signal_value_d", + "signal_value_j", + "profit_rate_stddev_7d", + "profit_rate_stddev_30d", + "profit_rate_stddev_90d", + "adx", + "di_plus", + "di_minus", + "cci", + "wr", + "sar", + "open_price", + "high_price", + "low_price", + "close_price", + "deal_cnt", + "amt" + ], + "enum_note": "non-empty list required; names are ES _source fields (see internal/cointrend/ads_es_fields.go), not validated to a fixed enum" + }, + { + "name": "timeframe", + "type": "string", + "required": true, + "enum": [ + "15m", + "1h", + "4h", + "1d" + ] + }, + { + "name": "start_time", + "type": "string", + "required": false + }, + { + "name": "end_time", + "type": "string", + "required": false + }, + { + "name": "limit", + "type": "int", + "required": false + } + ], + "logic": "mcphost tools_trend -> trendFetcherSet.GetIndicatorHistory (OpenSearch only, no Kline API fallback). ValidateIndicatorHistory: non-empty deduped indicators list (ES _source names, see ads_es_fields.go), timeframe 15m|1h|4h|1d required, limit default 50 max ToolMaxListSize. Query returns timestamp+requested columns sorted desc; time mapped to timestamp. Errors: symbol_empty, indicators_empty, invalid_timeframe, opensearch_trend_query_failed.", + "description": "[Read] Historical technical indicator values (ES field names) for a symbol/timeframe. Kline candles -> get_kline; narrative TA -> get_technical_analysis." + }, + { + "tool_name": "info_markettrend_get_technical_analysis", + "domain": "cointrend", + "request_type": "cointrend.TechnicalAnalysisRequest", + "required": [ + "symbol" + ], + "fields": [ + { + "name": "symbol", + "type": "string", + "required": true + }, + { + "name": "period", + "type": "string", + "required": false, + "enum": [ + "1h", + "4h", + "24h", + "7d", + "3d", + "5d", + "10d", + "all" + ], + "enum_note": "omit defaults to 3d when no start/end; all disables time filter" + }, + { + "name": "start_time", + "type": "string", + "required": false + }, + { + "name": "end_time", + "type": "string", + "required": false + } + ], + "logic": "mcphost tools_trend -> cointrend.GetTechnicalAnalysis; NormalizeTechnicalAnalysisTimeWindow: start/end absolute times win over period (default 3d when neither; all disables filter). Parallel GetCoinTrend per 15m/1h/4h/1d with with_indicators=true; per-TF derives rsi_zone, macd_signal, ma_alignment, support/resistance, adx/cci/wr/sar. Root signal bullish|bearish|neutral aggregated from timeframe votes; first TF error fails whole tool.", + "description": "[Read] Aggregated technical-analysis signal across 15m/1h/4h/1d. Raw klines -> get_kline; per-indicator history -> get_indicator_history." + }, + { + "tool_name": "info_marketsnapshot_get_market_snapshot", + "domain": "snapshot", + "request_type": "snapshot.SnapshotRequest", + "required": [ + "symbol" + ], + "fields": [ + { + "name": "symbol", + "type": "string", + "required": true + }, + { + "name": "timeframe", + "type": "string", + "required": false, + "enum": [ + "15m", + "1h", + "4h", + "1d" + ], + "enum_note": "merged with indicator_timeframe; both omit -> 1h" + }, + { + "name": "indicator_timeframe", + "type": "string", + "required": false, + "enum": [ + "15m", + "1h", + "4h", + "1d" + ], + "enum_note": "alias when timeframe empty" + }, + { + "name": "source", + "type": "string", + "required": false, + "enum": [ + "alpha", + "spot", + "future", + "fx", + "futures" + ], + "enum_note": "futures accepted as alias of future; omit -> spot; normalized to alpha|spot|future|fx" + }, + { + "name": "quote", + "type": "string", + "required": false + }, + { + "name": "scope", + "type": "string", + "required": false, + "enum": [ + "basic", + "detailed", + "full" + ], + "enum_note": "omit -> basic; scope=full merges Coinglass derivatives + institutional_channel_summary + market_pulse; scope=detailed 不再返回 derivatives(与 basic 行为一致)" + } + ], + "logic": "mcphost tools_snapshot -> snapshot.Service.GetSnapshot; scope=full fetches Coinglass derivatives from platformmetrics.indices.snapshotCoinglassDerivatives (dwd_external_coinglass_coin_derivatives_hf) via fetchCoinglassDerivativesDoc, field remap: funding_rate_oi_weighted→funding_rate, open_interest_usd→open_interest, long_short_ratio_global→long_short_ratio, time fields (snapshot_time, etl_time) stripped, sort by part_hour desc. scope=full also fetches institutional_channel_summary for BTC/ETH and market_pulse. scope=detailed|basic no longer fetches derivatives. if snapshot service nil returns snapshot_not_configured.", + "description": "[Read] Single-pair market snapshot (price, kline, optional Coinglass derivatives). Batch symbols -> batch_market_snapshot; macro venue flow -> get_institutional_metrics." + }, + { + "tool_name": "info_marketsnapshot_batch_market_snapshot", + "domain": "snapshot", + "request_type": "snapshot.BatchMarketSnapshotRequest", + "required": [ + "symbols" + ], + "fields": [ + { + "name": "symbols", + "type": "string[]", + "required": true + }, + { + "name": "timeframe", + "type": "string", + "required": false, + "enum": [ + "15m", + "1h", + "4h", + "1d" + ], + "enum_note": "omit -> 1h" + }, + { + "name": "source", + "type": "string", + "required": false, + "enum": [ + "alpha", + "spot", + "future", + "fx", + "futures" + ], + "enum_note": "futures alias of future; omit -> spot" + }, + { + "name": "quote", + "type": "string", + "required": false + }, + { + "name": "scope", + "type": "string", + "required": false, + "enum": [ + "basic", + "detailed", + "full" + ], + "enum_note": "omit -> basic; scope=full adds derivatives per symbol (Coinglass via snapshotCoinglassDerivatives index); scope=detailed no longer returns derivatives" + } + ], + "logic": "mcphost tools_snapshot -> snapshot.Service.BatchMarketSnapshot; validates symbols(max 20), timeframe/scope/source then msearch kline and enrich realtime/project_info. scope=full fetches Coinglass derivatives per symbol via fetchCoinglassDerivativesDoc (index: platformmetrics.indices.snapshotCoinglassDerivatives, field remap same as get_market_snapshot). scope=detailed|basic skip derivatives fetch. empty derivatives returns {} per symbol.", + "description": "[Read] Batch market snapshots for up to 20 symbols. One symbol -> get_market_snapshot." + }, + { + "tool_name": "info_marketsnapshot_get_market_overview", + "domain": "snapshot", + "request_type": "snapshot.GetMarketOverviewRequest", + "required": [], + "fields": [], + "logic": "mcphost tools_snapshot -> snapshot.Service.GetMarketOverview; if market_stats index configured use single-index path, else parallel aggregate CoinGecko + project info + BTC price.", + "description": "[Read] Global/crypto market overview (breadth, BTC context, aggregate stats). Per-symbol snapshot -> get_market_snapshot." + }, + { + "tool_name": "info_marketsnapshot_get_institutional_metrics", + "domain": "snapshot", + "request_type": "snapshot.InstitutionalChannelMetricsRequest", + "required": [], + "fields": [ + { + "name": "asset", + "type": "string", + "required": false, + "enum": [ + "BTC", + "ETH", + "all" + ], + "enum_note": "case-insensitive after trim; omit -> BTC; all returns BTC and ETH groups; invalid -> invalid_param" + }, + { + "name": "start_date", + "type": "string", + "required": false, + "enum_note": "YYYY-MM-DD; omit -> end_date - 30d when end_date/latest exists; invalid date -> invalid_param" + }, + { + "name": "end_date", + "type": "string", + "required": false, + "enum_note": "YYYY-MM-DD; omit -> latest part_date from institutionalChannelIndex; invalid date -> invalid_param" + }, + { + "name": "channel", + "type": "string", + "required": false, + "enum": [ + "all", + "etf", + "cme", + "cftc" + ], + "enum_note": "case-insensitive after trim; omit -> all; channel-specific response nulls unrelated fields and recomputes point data_status; CME Daily Bulletin PDF is not a production source, so channel=cme returns cme_* fields null with cme_source_not_procured/cme_unavailable status" + }, + { + "name": "limit", + "type": "int", + "required": false, + "enum_note": "default 30; valid range 1..366; invalid -> invalid_param" + } + ], + "logic": "mcphost tools_snapshot -> snapshot.Service.GetInstitutionalChannelMetrics; requires snapshot service plus OpenSearch endpoint/client and snapshot.institutionalChannelIndex else snapshot_not_configured. Normalizes asset BTC|ETH|all, channel all|etf|cme|cftc, dates as YYYY-MM-DD, rejects start_date>end_date. Fetches latest part_date when end_date omitted, then queries dwd_external_asset_etf_cme_cftc_df by asset/date sorted part_date desc with size=limit; source_fields intentionally exclude CME fields because CME Daily Bulletin free PDF is not a production data source. asset=all runs BTC and ETH separately; response echoes asset/channel/start_date/end_date, total=sum ES totals, count=sum returned series length, assets[].latest=first series point, data_status aggregates ok|partial|missing. ETF and CFTC weekly fields continue; cme_volume/cme_oi/cme_oi_change and CME underlying fields are forced null even if stale index values exist, and all/cme point status uses cme_source_not_procured or cme_unavailable rather than cme_parse_failed/cme_missing. Channel filters null unrelated ETF/CME/CFTC fields; empty latest index returns missing with assets=[].", + "description": "[Read] ETF/CFTC institutional channel time series (BTC/ETH); CME fields are null (no procured CME bulletin). Retail pair snapshot -> get_market_snapshot; not CEX orderbook depth." + }, + { + "tool_name": "info_onchain_get_address_info", + "domain": "address", + "request_type": "address.AddressInfoRequest", + "required": [ + "address" + ], + "fields": [ + { + "name": "address", + "type": "string", + "required": true + }, + { + "name": "chain", + "type": "string", + "required": false, + "enum_note": "aliases: eth, op→optimism, avax|avalanche-c→avalanche, bera|berachain→bera, polygon|matic, zksync, blast, gatelayer, linea, unichain, btc|bitcoin, sol|solana, trx|tron; unknown slug → invalid_chain" + }, + { + "name": "scope", + "type": "string", + "required": false, + "enum": [ + "basic", + "with_defi", + "with_counterparties", + "with_pnl", + "full", + "detailed" + ], + "enum_note": "detailed maps to full; unknown values normalize to basic" + }, + { + "name": "min_value_usd", + "type": "float64", + "required": false + }, + { + "name": "include_upstream_raw", + "type": "bool", + "required": false + }, + { + "name": "upstream_raw_mode", + "type": "string", + "required": false, + "enum": [ + "off", + "lite", + "full" + ], + "enum_note": "case-insensitive; unknown -> off; include_upstream_raw=true forces full" + } + ], + "logic": "mcphost tools_address -> addrService.GetAddressInfo; New Explorer first, BlockInfo fallback. Response includes asset_summary (total_usd_value, token_usd_value, token_num, native_balance, native_usd_value, have_multi_chain_asset, exist_address), token_balances[] sorted by value_usd desc (incl. multi-page assets merge), multi_chain_token_balances[] sorted by usd_value desc when token_list HTTP 200 (business code!=0 degrades to [] with warn, not tool error), Solana token_account/mint_account/value_percent/total_value_usd on token_balances. get_holder_info upstream_not_ready fails whole tool only when no other presentable fields; else quality_reasons includes asset_summary_upstream_not_ready. exist_address=false clears token_balances and multi_chain_token_balances. Nine new EVM chains share EVM address validation.", + "errors": [ + { + "code": "invalid_chain", + "when": "chain slug cannot be normalized (e.g. random chain name)" + }, + { + "code": "invalid_address", + "when": "address format invalid for resolved chain" + }, + { + "code": "upstream_not_ready", + "when": "chain configured but New Explorer endpoint returns 404/501 or chain not in GET /chains" + } + ], + "response_fields": [ + "asset_summary", + "token_balances", + "multi_chain_token_balances", + "detected_chains", + "duration_ms" + ], + "description": "[Read] On-chain address profile (balances, tokens, optional DeFi/PnL scope). Transfers list -> get_address_transactions; tx by hash -> get_transaction." + }, + { + "tool_name": "info_onchain_get_address_transactions", + "domain": "transaction", + "request_type": "transaction.TxQueryRequest", + "required": [ + "address" + ], + "fields": [ + { + "name": "address", + "type": "string", + "required": true + }, + { + "name": "chain", + "type": "string", + "required": false, + "enum_note": "same aliases as get_address_info; BTC uses bitcoin/btc slug" + }, + { + "name": "min_value_usd", + "type": "float64", + "required": false + }, + { + "name": "tx_type", + "type": "string", + "required": false, + "enum": [ + "transfer", + "contract_call", + "token_transfer", + "all" + ], + "enum_note": "omit -> all" + }, + { + "name": "time_range", + "type": "string", + "required": false, + "enum": [ + "1h", + "24h", + "1d", + "7d", + "30d", + "90d" + ], + "enum_note": "ignored when start_time or end_time set; 1d same window as 24h" + }, + { + "name": "start_time", + "type": "int64", + "required": false + }, + { + "name": "end_time", + "type": "int64", + "required": false + }, + { + "name": "limit", + "type": "int", + "required": false + }, + { + "name": "from_address", + "type": "string", + "required": false + }, + { + "name": "to_address", + "type": "string", + "required": false + }, + { + "name": "nonzero_value", + "type": "bool", + "required": false + }, + { + "name": "include_upstream_raw", + "type": "bool", + "required": false + }, + { + "name": "upstream_raw_mode", + "type": "string", + "required": false, + "enum": [ + "off", + "lite", + "full" + ], + "enum_note": "case-insensitive; unknown -> off; include_upstream_raw=true forces full" + } + ], + "logic": "mcphost tools_address -> txService.GetTransactions; New Explorer BTC list maps inputs/outputs/tx_status. NE native list applies tx_type filter at merge. partial_upstream_response only when upstream total>0 and NE list empty or zero rows mapped from list (checked before time_range/min_value_usd/tx_type client filters). When upstream total>0 but list cannot be parsed, MCP returns isError with partial_upstream_response (CLI failure: stdout empty).", + "errors": [ + { + "code": "invalid_chain", + "when": "chain slug cannot be normalized" + }, + { + "code": "invalid_address", + "when": "address format invalid for resolved chain" + }, + { + "code": "partial_upstream_response", + "when": "upstream reports total>0 but transaction list cannot be parsed (e.g. BTC partial body)" + }, + { + "code": "upstream_not_ready", + "when": "chain endpoint not ready or unsupported on New Explorer" + } + ], + "response_fields": [ + "total", + "count", + "items", + "transactions", + "duration_ms" + ], + "description": "[Read] Paginated transfers for an address with filters. Address summary -> get_address_info; single tx -> get_transaction." + }, + { + "tool_name": "info_onchain_get_transaction", + "domain": "onchain", + "request_type": "onchain.GetTransactionRequest", + "required": [ + "tx_hash" + ], + "fields": [ + { + "name": "tx_hash", + "type": "string", + "required": true + }, + { + "name": "chain", + "type": "string", + "required": false + }, + { + "name": "include_upstream_raw", + "type": "bool", + "required": false + }, + { + "name": "upstream_raw_mode", + "type": "string", + "required": false, + "enum": [ + "off", + "lite", + "full" + ], + "enum_note": "case-insensitive; unknown -> off; include_upstream_raw=true forces full" + } + ], + "logic": "mcphost tools_onchain -> onchain.GetTransaction then getTransactionMCPOutputFrom(upstream_raw_mode). tx_hash required max 128; optional chain validated via explorer known slugs. Route: New Explorer GET .../transactions/{txid} first (UTXO/BTC, TRON account, SOL account branches return early); else parallel BlockInfo v1 detail + ?module=input decode (12s timeout). NE subs enrich (8s); BlockInfo nil returns sparse placeholder not error. include_upstream_raw=true forces full raw; mode off|lite|full strips explorer/blockinfo raw from MCP output. data_quality + sources on response.", + "description": "[Read] Single transaction detail by tx_hash. Address activity -> get_address_transactions." + }, + { + "tool_name": "info_onchain_get_token_onchain", + "domain": "onchain", + "request_type": "onchain.GetTokenOnchainRequest", + "required": [ + "token" + ], + "fields": [ + { + "name": "token", + "type": "string", + "required": true + }, + { + "name": "chain", + "type": "string", + "required": false + }, + { + "name": "scope", + "type": "string", + "required": false, + "enum": [ + "holders", + "activity", + "transfers", + "smart_money", + "full" + ], + "enum_note": "unknown -> full" + }, + { + "name": "include_upstream_raw", + "type": "bool", + "required": false + }, + { + "name": "upstream_raw_mode", + "type": "string", + "required": false, + "enum": [ + "off", + "lite", + "full" + ], + "enum_note": "case-insensitive; unknown -> off; include_upstream_raw=true forces full" + } + ], + "logic": "mcphost tools_onchain -> onchain.GetTokenOnchain (20s timeout). scope holders|activity|transfers|smart_money|full (unknown->full). Parallel fetches by scope: holders+token_info via New Explorer GET /tokens/{addr} (+holders page); activity via BlockInfo chain overview; transfers via NE GetTokenTransferEvents when EVM contract or tron/solana else BlockInfo 4.6. smart_money placeholder when scope includes it. BlockInfo disabled returns empty section placeholders not error. upstream_raw_mode controls explorer_*_raw and row source_raw in MCP output.", + "description": "[Read] Token on-chain metrics (holders, activity, transfers; scope=smart_money is token analytics, not get_smart_money placeholder). Address/token security -> compliance tools." + }, + { + "tool_name": "info_compliance_check_token_security", + "domain": "tokensecurity", + "request_type": "tokensecurity.CheckTokenSecurityRequest", + "required": [ + "chain" + ], + "conditional_required": [ + "token xor address (one required, not both)" + ], + "fields": [ + { + "name": "token", + "type": "string", + "required": false + }, + { + "name": "address", + "type": "string", + "required": false + }, + { + "name": "chain", + "type": "string", + "required": true + }, + { + "name": "scope", + "type": "string", + "required": false, + "enum": [ + "basic", + "full" + ], + "enum_note": "non-full treated like basic for extended sections" + }, + { + "name": "lang", + "type": "string", + "required": false, + "enum": [ + "en", + "cn", + "tw", + "ja", + "kr" + ], + "enum_note": "omit -> en" + } + ], + "logic": "mcphost tools_compliance -> tokenSecuritySvc.CheckTokenSecurity; registered only when Data API client enabled (non-mock). chain required; token xor address (not both). token-only resolves chain+contract via cointrend token-search (needs tokenSearchBaseURL). Calls risk_infos then optional name/validate; scope basic|full (default basic) gates high/middle/low risk lists, tax_analysis, holders top10. lang en|cn|tw|ja|kr default en. mock mode returns empty risk_summary. Errors: data_api_not_configured, token_security_param, data_api_risk_infos_failed.", + "description": "[Read] Token contract security check; require chain and exactly one of token or address. Not address-risk placeholder check_address_risk." + }, + { + "tool_name": "info_platformmetrics_get_platform_info", + "domain": "platformmetrics", + "request_type": "platformmetrics.GetPlatformInfoRequest", + "required": [ + "platform_name" + ], + "fields": [ + { + "name": "platform_name", + "type": "string", + "required": true + }, + { + "name": "scope", + "type": "string", + "required": false, + "enum": [ + "basic", + "with_chain_breakdown", + "full", + "detailed" + ], + "enum_note": "omit or unknown -> basic; detailed maps to with_chain_breakdown" + }, + { + "name": "include_oi_symbol_detail", + "type": "bool", + "required": false, + "enum_note": "scope=full only; true adds competition_metrics.oi_symbol_detail[] (OI USD desc) when CEX competition index configured; default false" + }, + { + "name": "oi_symbol_limit", + "type": "int", + "required": false, + "enum_note": "only when include_oi_symbol_detail=true; omit or <=0 uses default 20; values >100 return invalid_param" + } + ], + "logic": "mcphost tools_platformmetrics -> fetcher.GetPlatformInfo; resolve platform by platform_id|platform_name (recent snapshot_time); scope=full enriches with platform_history (tvl|volume|fees), top_pools (yield limit 5), cexExchangeDerivativesVolume merge (derivatives + period_metrics), then mergeCexCompetitionIntoPlatformInfo when CEX index + CEX row; include_oi_symbol_detail gates oi_symbol_detail slice capped by oi_symbol_limit; applyPlatformInfoScope strips fields for basic|with_chain_breakdown; nil fetcher returns requires_open_search.", + "description": "[Read] DeFi/CEX platform profile (TVL, volume, fees; scope=full enriches history/pools). OI symbol detail only when scope=full. Platform list -> search_platforms." + }, + { + "tool_name": "info_platformmetrics_search_platforms", + "domain": "platformmetrics", + "request_type": "platformmetrics.SearchPlatformsRequest", + "required": [], + "fields": [ + { + "name": "platform_type", + "type": "string", + "required": false, + "common_values": [ + "all", + "cex", + "derivatives", + "defi", + "dex", + "dexs", + "dexes", + "lending" + ], + "enum_note": "no closed enum validation; empty/all means no filter; unknown values pass through as platform_type term(s)" + }, + { + "name": "chain", + "type": "string", + "required": false + }, + { + "name": "sort_by", + "type": "string", + "required": false, + "enum": [ + "tvl", + "volume_24h", + "volume_spot_24h", + "volume_perps_24h", + "volume_perps_7d", + "volume_perps_30d", + "volume_perps_qtd", + "fees_24h" + ], + "enum_note": "omit or other -> tvl sort field" + }, + { + "name": "sort_order", + "type": "string", + "required": false, + "enum": [ + "asc", + "desc" + ], + "enum_note": "omit or non-asc -> desc" + }, + { + "name": "limit", + "type": "int64", + "required": false + } + ], + "logic": "mcphost tools_platformmetrics -> fetcher.SearchPlatforms; platform_type normalization: empty/all=no filter, dex|dexs|dexes -> DEX group, cex keeps CEX/cex variants, defi expands to grouped terms, unknown strings pass through to ES platform_type; sort_order normalized to asc|desc (default desc); nil fetcher returns requires_open_search.", + "description": "[Read] Rank/filter DeFi or CEX platforms. One platform detail -> get_platform_info." + }, + { + "tool_name": "info_platformmetrics_get_defi_overview", + "domain": "platformmetrics", + "request_type": "platformmetrics.GetDefiOverviewRequest", + "required": [], + "fields": [ + { + "name": "category", + "type": "string", + "required": false, + "enum": [ + "all", + "defi", + "spot", + "perp", + "stablecoin", + "bridge" + ], + "common_values": [ + "de-fi", + "cex", + "dex", + "dexs", + "dexes", + "lending", + "cdp", + "yield", + "derivatives", + "yield aggregator" + ], + "enum_note": "omit -> all; primary values match jsonschema_description; de-fi/cex/dex/dexs/dexes/lending/cdp/yield/derivatives are alias-expanded by normalizeDefiOverviewCategory; unknown strings are passed through as platform_type filter (no hard reject)" + } + ], + "logic": "mcphost tools_platformmetrics -> fetcher.GetDefiOverview; optional cex_perps_volume_* from cexExchangeDerivativesVolume index, exchange_usdt_reserve_total from exchange_reserves USDT sum; nil fetcher returns requires_open_search.", + "description": "[Read] DeFi ecosystem overview aggregates by category. Stablecoin supply/usage detail -> get_stablecoin_info." + }, + { + "tool_name": "info_platformmetrics_get_stablecoin_info", + "domain": "platformmetrics", + "request_type": "platformmetrics.GetStablecoinInfoRequest", + "required": [], + "fields": [ + { + "name": "symbol", + "type": "string", + "required": false, + "enum_note": "Basic DefiLlama items: any symbol or omit for ranked list; sections=issuance_flow whitelist USDT|USDC (omit defaults USDT+USDC); sections=usage_structure whitelist USDT|USDC|DAI|FDUSD|PYUSD (omit defaults all five); sections=depeg_events filters by depeg_asset.keyword (case-insensitive uppercased, omit = all assets); unsupported extension symbol -> unsupported_stablecoin_symbol" + }, + { + "name": "chain", + "type": "string", + "required": false, + "enum_note": "Basic: empty/all means no chain filter; single-chain filters by chain_circulating keys. Extension sections: all (default) or ethereum|omni|tron|solana|bsc|arbitrum|optimism|polygon|avalanche; aliases include eth/sol/bnb/arb/op/matic/avax; invalid extension chain -> invalid_chain" + }, + { + "name": "limit", + "type": "int64", + "required": false, + "enum_note": "default 10 max 400; single row when symbol set" + }, + { + "name": "scope", + "type": "string", + "required": false, + "enum": [ + "basic", + "full" + ], + "enum_note": "default basic; invalid scope -> invalid_param" + }, + { + "name": "sections", + "type": "string|string[]", + "required": false, + "enum": [ + "issuance_flow", + "usage_structure", + "depeg_events" + ], + "enum_note": "Comma string or array; requires scope=full (else sections_requires_full_scope); can request one or more sections; unknown section -> invalid_sections" + }, + { + "name": "start_date", + "type": "string", + "required": false, + "enum_note": "UTC YYYY-MM-DD; only with scope=full and sections set else invalid_param; issuance_flow/usage_structure: default last 30 calendar days inclusive; depeg_events: default 2020-01-01 to today" + }, + { + "name": "end_date", + "type": "string", + "required": false, + "enum_note": "UTC YYYY-MM-DD; issuance_flow caps future dates to today; usage_structure defaults to latest data date when omitted; depeg_events defaults to today; window >366d truncates start with range_truncated=true" + }, + { + "name": "min_deviation", + "type": "float64", + "required": false, + "enum_note": "depeg_events only: filter rows where max_deviation >= this value; range 0.001-0.2, default 0.005" + }, + { + "name": "review_status", + "type": "string", + "required": false, + "enum": [ + "candidate", + "approved", + "rejected" + ], + "enum_note": "depeg_events only: filter by review_status; default approved; invalid value -> invalid_review_status" + } + ], + "logic": "mcphost tools_platformmetrics -> fetcher.GetStablecoinInfo; validates scope basic|full and sections issuance_flow|usage_structure|depeg_events, rejects sections without scope=full and date params without extension sections. Base items query stablecoinInfo recent snapshots sorted by total_circulating desc; chain filter is applied in Go by parsing chain_circulating (list without chain returns chain_circulating=null, list with chain returns requested chain peggedUSD, detail without chain returns top5+_other_chains_*, detail with chain returns requested chain). scope=full+sections=issuance_flow queries stablecoinIssuanceFlow via ES by_asset terms + by_date date_histogram agg (empty agg -> hit scan fallback) and adds issuance_flows[] with summary/series/chain_breakdown when chain=all; issuance_flow index/query errors degrade to placeholder flow blocks with quality_note. scope=full+sections=usage_structure queries stablecoinRealUsage, resolves latest/default date window, and adds usage_structure{window_start,window_end,items[]} with adjusted_transfer_volume (entity_adjusted first, adjusted fallback), bot_share (derived from entity_adjusted/transfer_volume first, bot_share fallback), retail_transfer_change, stablecoin_trade_volume, reference fields, data_status, missing_fields, row_count; missing usage index or query errors degrade to placeholder usage items. scope=full+sections=depeg_events queries stablecoinDepeg index, filters by depeg_asset (symbol uppercased) and optional review_status (default approved) and min_deviation (max_deviation >= min_deviation, default 0.005); returns depeg_events[] with asset/date/price/deviation values, sorted by max_deviation desc or date desc. range_truncated=true when any extension window is clipped; nil fetcher returns requires_open_search; missing stablecoinInfo index returns not_implemented.", + "description": "[Read] Stablecoin circulation and chain breakdown; scope=full + sections issuance_flow|usage_structure|depeg_events for supply, usage, and depeg analytics. DeFi category overview -> get_defi_overview." + }, + { + "tool_name": "info_platformmetrics_get_bridge_metrics", + "domain": "platformmetrics", + "request_type": "platformmetrics.GetBridgeMetricsRequest", + "required": [], + "fields": [ + { + "name": "bridge_name", + "type": "string", + "required": false + }, + { + "name": "chain", + "type": "string", + "required": false + }, + { + "name": "sort_by", + "type": "string", + "required": false, + "enum": [ + "volume_24h", + "volume_7d", + "volume_30d", + "deposit_txs_24h" + ], + "enum_note": "omit -> volume_24h" + }, + { + "name": "limit", + "type": "int64", + "required": false + } + ], + "logic": "mcphost tools_platformmetrics -> fetcher.GetBridgeMetrics; sort_by maps to ES field volume_24h|volume_7d|volume_30d|deposit_txs_24h (unknown -> volume_24h); limit default 10 max 400; bridge_name normalized lowercase; nil fetcher returns requires_open_search.", + "description": "[Read] Cross-chain bridge volume/TVL metrics. Platform-level metrics -> get_platform_info." + }, + { + "tool_name": "info_platformmetrics_get_cex_orderbook_depth", + "domain": "platformmetrics", + "request_type": "platformmetrics.GetCexOrderbookDepthRequest", + "required": [ + "symbol" + ], + "fields": [ + { + "name": "symbol", + "type": "string", + "required": true + }, + { + "name": "market_type", + "type": "string", + "required": false, + "enum": [ + "spot", + "perp", + "perps", + "futures", + "future" + ], + "enum_note": "case-insensitive; omit -> PERP; spot -> SPOT; unrecognized values are uppercased and passed through" + }, + { + "name": "exchange", + "type": "string", + "required": false + }, + { + "name": "data_scope", + "type": "string", + "required": false, + "enum": [ + "exchange", + "market" + ], + "enum_note": "omit: exchange when exchange is set and exchange index exists, else market if market index exists, else exchange; invalid value -> invalid_param; missing target index -> not_implemented" + }, + { + "name": "limit", + "type": "int64", + "required": false + } + ], + "logic": "mcphost tools_platformmetrics -> fetcher.GetCexOrderbookDepth; symbol required; limit default 20 max 100; requires orderbookDepthExchange or orderbookDepthMarket index; nil fetcher returns requires_open_search.", + "description": "[Read] Aggregated CEX order-book depth from indices (multi-exchange); not Gate live book -> marketdetail get_orderbook." + }, + { + "tool_name": "info_platformmetrics_get_yield_pools", + "domain": "platformmetrics", + "request_type": "platformmetrics.GetYieldPoolsRequest", + "required": [], + "fields": [ + { + "name": "project", + "type": "string", + "required": false + }, + { + "name": "chain", + "type": "string", + "required": false + }, + { + "name": "symbol", + "type": "string", + "required": false + }, + { + "name": "pool_type", + "type": "string", + "required": false + }, + { + "name": "sort_by", + "type": "string", + "required": false, + "enum": [ + "apy", + "tvl_usd" + ], + "enum_note": "only tvl_usd switches sort key; omit or any other value -> apy sort" + }, + { + "name": "limit", + "type": "int64", + "required": false + }, + { + "name": "min_tvl_usd", + "type": "float64", + "required": false, + "enum_note": "omit -> default floor 100000 USD; 0 = no TVL floor" + }, + { + "name": "scope", + "type": "string", + "required": false, + "enum": [ + "basic", + "full" + ], + "enum_note": "omit -> basic; full adds apy_base_30d, apy_reward_30d, reward_tokens, market_share per pool" + } + ], + "logic": "mcphost tools_platformmetrics -> fetcher.GetYieldPools; requires yieldPools index else not_implemented. Filters project/chain (alias expand)/symbol/pool_type; ES fetch oversamples (limit*30 clamp 100..500) then dedupeYieldPoolsLatest, sort apy desc (default) or tvl_usd, trim to limit (default 20 max 400), applyYieldPoolsScope strips fields for basic. nil fetcher -> requires_open_search.", + "description": "[Read] DeFi yield pool rankings (APY/TVL filters; scope=full adds 30d APY and reward fields). Platform context -> get_platform_info." + }, + { + "tool_name": "info_platformmetrics_get_platform_history", + "domain": "platformmetrics", + "request_type": "platformmetrics.GetPlatformHistoryRequest", + "required": [], + "conditional_required": [ + "platform_name or exchange_slug (at least one required)" + ], + "fields": [ + { + "name": "platform_name", + "type": "string", + "required": false + }, + { + "name": "exchange_slug", + "type": "string", + "required": false + }, + { + "name": "metrics", + "type": "string[]", + "required": false, + "common_values": [ + "tvl", + "volume", + "fees", + "revenue", + "volume_perps" + ], + "enum_note": "omit or empty -> [tvl]; no strict per-element validation in fetcher" + }, + { + "name": "granularity", + "type": "string", + "required": false, + "enum": [ + "day", + "week", + "month", + "quarter" + ], + "enum_note": "omit or unknown -> day; affects volume_perps field mapping in derivatives history index" + }, + { + "name": "start_date", + "type": "string", + "required": false + }, + { + "name": "end_date", + "type": "string", + "required": false + } + ], + "logic": "mcphost tools_platformmetrics -> fetcher.GetPlatformHistory; requires platform_name or exchange_slug; defaults metrics=['tvl'] and granularity='day'; when metrics contains volume_perps switches to cexExchangeDerivativesVolume index and maps output field by granularity; nil fetcher returns requires_open_search.", + "description": "[Read] Historical TVL/volume/fees (or perps volume) for a platform_name or exchange_slug (at least one required). Snapshot -> get_platform_info." + }, + { + "tool_name": "info_platformmetrics_get_exchange_reserves", + "domain": "platformmetrics", + "request_type": "platformmetrics.GetExchangeReservesRequest", + "required": [], + "fields": [ + { "name": "exchange", "type": "string", "required": false, "enum_note": "empty -> all-exchange rollup (mustBoolAllExchangeExchangeName); non-empty expands exchange name terms for ES match" }, + { "name": "asset", "type": "string", "required": false, "enum": ["BTC", "ETH", "USDT", "USDC"], "enum_note": "omit defaults to BTC; other values -> invalid_param" }, + { "name": "scope", "type": "string", "required": false, "enum": ["basic", "full"], "enum_note": "omit -> basic; full merges por from exchangeReservesPOR index when configured + optionally flows/events; invalid scope -> invalid_param" }, + { "name": "include_history", "type": "bool", "required": false, "enum_note": "default false; true only with scope=full else invalid_param; attaches precomputed PoR comparison fields, not raw snapshots" }, + { "name": "history_window", "type": "string", "required": false, "enum": ["quarter"], "enum_note": "only when include_history=true; omit defaults to quarter; other values -> invalid_param" }, + { "name": "include_flows", "type": "bool", "required": false, "enum_note": "scope=full only; true returns flows.series[] (daily inflow/outflow/netflow in native+USD); requires exchangeFlow index configured else not_implemented; omit defaults to false" }, + { "name": "include_events", "type": "bool", "required": false, "enum_note": "scope=full only; true returns events[] (large_flow events with threshold/rolling_30d_std); requires exchangeFlow index; omit defaults to false" }, + { "name": "start_date", "type": "string", "required": false, "enum_note": "YYYY-MM-DD; only when include_flows=true or include_events=true; omit defaults to end_date - 30d; invalid date -> invalid_param" }, + { "name": "end_date", "type": "string", "required": false, "enum_note": "YYYY-MM-DD; only when include_flows=true or include_events=true; omit defaults to today; invalid date -> invalid_param" }, + { "name": "event_type", "type": "string", "required": false, "enum": ["all", "large_flow"], "enum_note": "only when include_events=true; all returns all events (large_flow_event=true+false); large_flow filters large_flow_event=true only; omit defaults to all" }, + { "name": "limit", "type": "int", "required": false, "enum_note": "only when include_flows=true or include_events=true; max flow rows; default 100, max 400" } + ], + "logic": "mcphost tools_platformmetrics -> fetcher.GetExchangeReserves; searches exchangeReserves (no default snapshot_time range; latest via sort); scope=full and POR index set runs second query on POR index and enrichExchangeReservesItemsWithPOR. scope=full + include_flows=true queries exchangeFlow index (dwd_external_exchange_net_flow_di) via fetchExchangeFlow and returns flows.series[] (ExchangeFlowPoint per day). scope=full + include_events=true returns events[] (large_flow_event=true or all, with threshold_usd/rolling_30d_std_usd). asset defaults to BTC. requires exchangeReserves index else not_implemented; nil fetcher returns requires_open_search.", + "description": "[Read] Exchange reserve balances (optional asset filter); scope=full for PoR, flows (daily netflow), and large-flow events. Not stablecoin chain breakdown -> get_stablecoin_info." + }, + { + "tool_name": "info_platformmetrics_get_liquidation_heatmap", + "domain": "platformmetrics", + "request_type": "platformmetrics.GetLiquidationHeatmapRequest", + "required": [ + "symbol" + ], + "fields": [ + { + "name": "symbol", + "type": "string", + "required": true + }, + { + "name": "exchange", + "type": "string", + "required": false, + "enum_note": "omit = all exchanges; non-empty expands alias terms on exchange.keyword" + }, + { + "name": "range", + "type": "string", + "required": false, + "enum_note": "optional price bucket filter, format min-max e.g. 40000-50000; parse fail = no range filter (not invalid_param)" + } + ], + "logic": "mcphost tools_platformmetrics -> fetcher.GetLiquidationHeatmap; symbol required; queries liquidationHeatmap index with recent snapshot_time; nil fetcher returns requires_open_search.", + "description": "[Read] Futures liquidation heatmap by symbol (and optional exchange/range). Order book depth -> get_cex_orderbook_depth or marketdetail get_orderbook." + }, + { + "tool_name": "info_platformmetrics_get_chain_activity", + "domain": "platformmetrics", + "request_type": "platformmetrics.GetChainActivityRequest", + "required": [ + "metric_group" + ], + "fields": [ + { "name": "metric_group", "type": "string", "required": true, "enum": ["staking", "l2", "btc_l2"], "enum_note": "phase-1 supported groups; staking=Ethereum beacon daily, l2=L2 ops daily (growthepie+L2Beat), btc_l2=BTC L2 protocol ecosystem; other values -> not_implemented" }, + { "name": "chain", "type": "string", "required": false, "common_values": ["ethereum", "eth", "base", "arbitrum", "optimism", "linea", "zksync_era", "blast", "btc"], "enum_note": "staking: only eth|ethereum allowed (others -> unsupported_chain); l2: supports base/arbitrum/optimism/linea/zksync_era/blast or empty for all chains; btc_l2: empty defaults to btc (only btc allowed)" }, + { "name": "project", "type": "string", "required": false, "enum_note": "btc_l2 only: filter by project_key; empty returns whitelist (stacks|rootstock|merlin|bob|bitlayer). Non-whitelist project returns data_status=not_in_scope" }, + { "name": "start_date", "type": "string", "required": false, "enum_note": "UTC YYYY-MM-DD; with end_date defines inclusive window (ES gte + lt end+1d); start only -> single ES gte query, end from newest hit; span >400d or start-only beyond cap -> range_truncated=true and start_date adjusted to effective oldest returned date; future dates capped to today with start_date_capped" }, + { "name": "end_date", "type": "string", "required": false, "enum_note": "UTC YYYY-MM-DD; with start_date defines window; omit with start empty and end set -> end anchors lookback window; omit with start only -> derived from newest hit; both dates empty -> ignored (uses lookback-only path); future capped to today with end_date_capped" }, + { "name": "lookback", "type": "string", "required": false, "enum": ["30d", "90d", "1y"], "enum_note": "when start_date empty: default 30d for lookback-only or end-anchored window; invalid -> invalid_param; ignored when both start_date and end_date set" }, + { "name": "granularity", "type": "string", "required": false, "enum": ["day"], "enum_note": "l2 only: day (default); hour or minute returns unsupported_granularity; ignored for staking/btc_l2" }, + { "name": "limit", "type": "int", "required": false, "enum_note": "l2 only: max series rows returned; 0 means no extra cap beyond date window; ignored for staking/btc_l2" } + ], + "logic": "mcphost tools_platformmetrics -> fetcher.GetChainActivity (GAP-012/GAP-009/GAP-0xx). Routes by metric_group: staking->getChainActivityStaking queries platformmetrics.indices.ethStakingNetworkDaily (dwd_external_beaconcha_in_staking_data_di); l2->getChainActivityL2 queries l2Metrics index (dwd_external_l2_metrics_daily_di); btc_l2->getChainActivityBtcL2 queries btcProtocol index (dwd_external_btc_protocol_di). staking: series[].validator_active/entry_queue_eth/exit_queue_eth/eth_supply/staking_rate/staking_apr_7d/entry_wait_days/exit_wait_days with validator_estimate=eth/32; per-row missing_fields+data_status; 2025-05-07..2025-05-21 rows quality_warning; explicit window >400d truncates. l2: series[] per chain with tps_avg_1d/active_addresses_1d/blob_cost_usd_1d/sequencer_revenue_usd_1d/stage_label/tvl_usd/data_source; tx_count/l1_fee_usd 本期不提供; granularity=day required. btc_l2: items[] with project_key/project_name/category/main_chain/tvl_usd/protocol_count/tx_count_1d/active_addresses_1d/bridge_volume_24h_usd/source_refs; project whitelist stacks|rootstock|merlin|bob|bitlayer; missing_required_fields when tvl_usd/protocol_count/tx_count_1d nil; data_status=complete when 3 required fields non-nil. nil fetcher -> requires_open_search.", + "errors": [ + { "code": "invalid_param", "when": "metric_group empty, bad YYYY-MM-DD, invalid lookback, or start_date after end_date" }, + { "code": "not_implemented", "when": "metric_group not in staking|l2|btc_l2 or target index unset" }, + { "code": "unsupported_chain_for_metric_group", "when": "staking and chain not eth|ethereum; l2 and chain not in supported list; btc_l2 and chain not btc" }, + { "code": "unsupported_granularity", "when": "l2 granularity not day" }, + { "code": "requires_open_search", "when": "platformmetrics fetcher or OpenSearch client nil" } + ], + "response_fields": [ + "chain", "metric_group", "normalized_chain", "start_date", "end_date", "lookback", "granularity", "total", "count", + "staking_metrics.series", "l2_metrics.series", "btc_l2_metrics.items", + "data_status", "range_truncated", "end_date_capped", "start_date_capped", "duration_ms" + ], + "series_fields": [ + "date", + "validator_active", + "total_value_staked_eth", + "staking_rate", + "eth_supply", + "entry_queue_eth", + "exit_queue_eth", + "entry_wait_days", + "exit_wait_days", + "staking_apr_7d", + "entry_queue_validator_estimate", + "exit_queue_validator_estimate", + "missing_fields", + "data_status", + "quality_note" + ], + "series_fields_note": "eth_supply / staking_apr_7d / entry_wait_days / exit_wait_days mapped from ES _source; missing index writes missing_fields. l2: tx_count/l1_fee_usd 本期不提供. btc_l2: protocol_count is int64, other values float64/int64.", + "cli": { + "binary": "coin-mcp-cli", + "subcommand": "platformmetrics", + "request_type": "chain-activity", + "example": "coin-mcp-cli platformmetrics --request_type chain-activity --metric_group staking --chain ethereum --lookback 30d" + }, + "description": "[Read] Chain-level activity metrics (staking=Ethereum beacon, l2=daily ops, btc_l2=BTC L2 protocol ecosystem). DeFi platform context -> get_platform_info." + }, + { + "tool_name": "info_macro_get_macro_indicator", + "domain": "macro", + "request_type": "macro.GetMacroIndicatorRequest", + "required": [ + "indicator" + ], + "fields": [ + { + "name": "mode", + "type": "string", + "required": false, + "enum": [ + "latest", + "timeseries" + ], + "enum_note": "anything other than timeseries normalizes to latest" + }, + { + "name": "indicator", + "type": "string", + "required": true + }, + { + "name": "country", + "type": "string", + "required": false + }, + { + "name": "country_code", + "type": "string", + "required": false + }, + { + "name": "start_time", + "type": "string", + "required": false + }, + { + "name": "end_time", + "type": "string", + "required": false + }, + { + "name": "start_date", + "type": "string", + "required": false + }, + { + "name": "end_date", + "type": "string", + "required": false + }, + { + "name": "size", + "type": "int", + "required": false + } + ], + "logic": "mcphost tools_macro -> fetcher.GetMacroIndicator; mode latest|timeseries (non-timeseries->latest). indicator required; queries macroIndicatorLatest or macroIndicatorTimeseries index (missing -> not_implemented). ES terms on indicator|indicator_id|slugname keyword variants; optional country/country_code post-filter in Go. timeseries: date range from start_time/end_time aliases start_date/end_date (default gte now-1y); latest: date/part_date range when times given. size default 20 max 400. Output: latest.snapshots or timeseries.observations; empty timeseries sets note hinting try latest. nil fetcher -> requires_open_search.", + "description": "[Read] Macro indicator latest or timeseries (CPI, rates, etc.). Calendar events -> get_economic_calendar; dashboard -> get_macro_summary." + }, + { + "tool_name": "info_macro_get_economic_calendar", + "domain": "macro", + "request_type": "macro.GetEconomicCalendarRequest", + "required": [], + "fields": [ + { + "name": "start_date", + "type": "string", + "required": false + }, + { + "name": "end_date", + "type": "string", + "required": false + }, + { + "name": "event_type", + "type": "string", + "required": false + }, + { + "name": "importance", + "type": "string", + "required": false + }, + { + "name": "size", + "type": "int", + "required": false + } + ], + "logic": "mcphost tools_macro -> fetcher.GetEconomicCalendar; requires economicCalendar index else not_implemented. Date window UTC YYYY-MM-DD: both omit -> today..today+30d; start only -> start..start+30d; end only -> today..end; validates end>=start. event_type filters unless empty/all; importance filter best-effort if index has field. size default 20 max 400; sorted event_date desc. nil fetcher -> requires_open_search.", + "description": "[Read] Economic calendar events. Single indicator series -> get_macro_indicator." + }, + { + "tool_name": "info_macro_get_macro_summary", + "domain": "macro", + "request_type": "macro.GetMacroSummaryRequest", + "required": [], + "fields": [], + "logic": "mcphost tools_macro -> fetcher.GetMacroSummary; zero-arg dashboard aggregating macroIndicatorLatest (6 US indicators: federal_funds_rate, cpi_yoy, gdp_growth, unemployment_rate, nonfarm_payroll, pce_yoy via keyword/slug aliases) plus upcoming economicCalendar events (next 5, requires calendar index optional). Requires macroIndicatorLatest index else not_implemented; no standalone macroSummary index. nil fetcher -> requires_open_search.", + "description": "[Read] Macro dashboard summary (no inputs). Indicator detail -> get_macro_indicator." + }, + { + "tool_name": "info_marketdetail_get_orderbook", + "domain": "marketdetail", + "request_type": "marketdetail.GetOrderbookRequest", + "required": [ + "symbol" + ], + "fields": [ + { + "name": "symbol", + "type": "string", + "required": true + }, + { + "name": "market_type", + "type": "string", + "required": false, + "enum": [ + "spot", + "futures", + "delivery", + "options" + ], + "enum_note": "omit -> spot" + }, + { + "name": "depth", + "type": "int", + "required": false + }, + { + "name": "settle", + "type": "string", + "required": false + }, + { + "name": "extra", + "type": "object", + "required": false + } + ], + "logic": "mcphost tools_marketdetail -> marketdetail.Service.GetOrderbook; registered when Gate SDK service configured. symbol required; market_type spot|futures|delivery|options (default spot); depth default 20 max 100; settle default usdt for derivatives. Routes to gate_sdk_spot|futures|delivery|options List*OrderBook; extra map merged into SDK opts. Gate API errors degrade to empty items (not tool error); cex_tool labels upstream. not configured -> public_mcp_not_configured.", + "description": "[Read] Live Gate order book for a symbol (spot/futures/etc.). Aggregated CEX depth -> platformmetrics get_cex_orderbook_depth." + }, + { + "tool_name": "info_marketdetail_get_recent_trades", + "domain": "marketdetail", + "request_type": "marketdetail.GetRecentTradesRequest", + "required": [ + "symbol" + ], + "fields": [ + { + "name": "symbol", + "type": "string", + "required": true + }, + { + "name": "market_type", + "type": "string", + "required": false, + "enum": [ + "spot", + "futures", + "delivery", + "options" + ], + "enum_note": "omit -> spot" + }, + { + "name": "limit", + "type": "int64", + "required": false + }, + { + "name": "settle", + "type": "string", + "required": false + }, + { + "name": "extra", + "type": "object", + "required": false + } + ], + "logic": "mcphost tools_marketdetail -> marketdetail.Service.GetRecentTrades; registered when Gate SDK configured. symbol required; market_type default spot; limit default 50 max ToolMaxListSize; settle for futures/delivery. gate_sdk List*Trades per market_type; UTC time fields added to items. API errors -> empty items not error; invalid market_type -> invalid_market_type; unconfigured -> public_mcp_not_configured.", + "description": "[Read] Recent Gate trades for a symbol. Kline -> get_kline." + }, + { + "tool_name": "info_marketdetail_get_kline", + "domain": "marketdetail", + "request_type": "marketdetail.GetKlineRequest", + "required": [ + "symbol", + "timeframe" + ], + "fields": [ + { + "name": "symbol", + "type": "string", + "required": true + }, + { + "name": "market_type", + "type": "string", + "required": false, + "enum": [ + "spot", + "futures", + "delivery", + "options" + ], + "enum_note": "omit -> spot" + }, + { + "name": "timeframe", + "type": "string", + "required": true, + "common_values": [ + "1s", + "1m", + "5m", + "15m", + "1h", + "4h", + "1d" + ], + "enum_note": "required non-empty; passed to Gate API interval; unparseable duration falls back to 1m internally" + }, + { + "name": "start_time", + "type": "int64", + "required": false + }, + { + "name": "end_time", + "type": "int64", + "required": false + }, + { + "name": "limit", + "type": "int64", + "required": false, + "enum_note": "default 100 when no time range; capped by ToolMaxListSize" + }, + { + "name": "settle", + "type": "string", + "required": false + }, + { + "name": "extra", + "type": "object", + "required": false + } + ], + "logic": "mcphost tools_marketdetail -> marketdetail.Service.GetKline; registered when Gate SDK configured. symbol+timeframe required; market_type default spot; no start/end -> latest limit candles (default 100 max ToolMaxListSize) computed from timeframe seconds then reversed desc. With range passes Unix seconds to ListCandlesticks/ListFuturesCandlesticks etc.; extra may override interval. timeframe unparseable -> 60s fallback internally. API errors -> empty items; unconfigured -> public_mcp_not_configured.", + "description": "[Read] Gate API candlesticks (interval/limit/time range). Index/market snapshot klines -> markettrend get_kline or marketsnapshot tools." + }, + { + "tool_name": "info_onchain_get_smart_money", + "domain": "placeholder", + "request_type": "mcphost.placeholderArgs", + "required": [], + "fields": [ + { + "name": "query", + "type": "string", + "required": false + }, + { + "name": "symbol", + "type": "string", + "required": false + }, + { + "name": "limit", + "type": "int64", + "required": false + }, + { + "name": "address", + "type": "string", + "required": false + } + ], + "logic": "mcphost tools_placeholder -> notImplemented returns errs.CodeNotImplemented.", + "description": "[N/A] Not in shipped gate-cli info baseline; MCP returns notImplemented. Token on-chain scope=smart_money -> get_token_onchain." + }, + { + "tool_name": "info_onchain_get_entity_profile", + "domain": "placeholder", + "request_type": "mcphost.placeholderArgs", + "required": [], + "fields": [ + { + "name": "query", + "type": "string", + "required": false + }, + { + "name": "symbol", + "type": "string", + "required": false + }, + { + "name": "limit", + "type": "int64", + "required": false + }, + { + "name": "address", + "type": "string", + "required": false + } + ], + "logic": "mcphost tools_placeholder -> notImplemented returns errs.CodeNotImplemented.", + "description": "[N/A] Not in shipped gate-cli info baseline; MCP returns notImplemented." + }, + { + "tool_name": "info_onchain_trace_fund_flow", + "domain": "placeholder", + "request_type": "mcphost.placeholderArgs", + "required": [], + "fields": [ + { + "name": "query", + "type": "string", + "required": false + }, + { + "name": "symbol", + "type": "string", + "required": false + }, + { + "name": "limit", + "type": "int64", + "required": false + }, + { + "name": "address", + "type": "string", + "required": false + } + ], + "logic": "mcphost tools_placeholder -> notImplemented returns errs.CodeNotImplemented.", + "description": "[N/A] Not in shipped gate-cli info baseline; MCP returns notImplemented. Address/tx tools -> get_address_* / get_transaction." + }, + { + "tool_name": "info_compliance_check_address_risk", + "domain": "placeholder", + "request_type": "mcphost.placeholderArgs", + "required": [], + "fields": [ + { + "name": "query", + "type": "string", + "required": false + }, + { + "name": "symbol", + "type": "string", + "required": false + }, + { + "name": "limit", + "type": "int64", + "required": false + }, + { + "name": "address", + "type": "string", + "required": false + } + ], + "logic": "mcphost tools_placeholder -> notImplemented returns errs.CodeNotImplemented.", + "description": "[N/A] Not in shipped gate-cli info baseline; MCP returns notImplemented. Token security -> check_token_security." + }, + { + "tool_name": "info_compliance_search_regulatory_updates", + "domain": "placeholder", + "request_type": "mcphost.placeholderArgs", + "required": [], + "fields": [ + { + "name": "query", + "type": "string", + "required": false + }, + { + "name": "symbol", + "type": "string", + "required": false + }, + { + "name": "limit", + "type": "int64", + "required": false + }, + { + "name": "address", + "type": "string", + "required": false + } + ], + "logic": "mcphost tools_placeholder -> notImplemented returns errs.CodeNotImplemented.", + "description": "[N/A] Not in shipped gate-cli info baseline; MCP returns notImplemented." + } + ] +} diff --git a/internal/mcpspec/bundled/news-tools-args-and-logic.json b/internal/mcpspec/bundled/news-tools-args-and-logic.json new file mode 100644 index 0000000..7afd3f6 --- /dev/null +++ b/internal/mcpspec/bundled/news-tools-args-and-logic.json @@ -0,0 +1,561 @@ +{ + "version": "2026-07-13", + "source_of_truth": [ + "internal/mcphost/toolnames.go", + "internal/mcphost/tools_news_feed.go", + "internal/mcphost/tools_social_insights.go", + "internal/mcphost/tools_event.go", + "internal/mcphost/tools_explain_market_move.go", + "internal/mcphost/tools_market_move_reports.go", + "internal/mcphost/tools_prediction.go", + "internal/mcphost/tools_placeholder.go", + "internal/event/types.go", + "internal/event/service.go", + "internal/event/opensearch.go", + "internal/explain/types.go", + "internal/explain/service.go", + "internal/tavily/client.go", + "internal/prediction/types.go", + "internal/prediction/search_events_types.go", + "internal/prediction/event_signal_types.go", + "internal/prediction/service.go", + "internal/prediction/opensearch.go", + "internal/prediction/orderbook.go", + "internal/prediction/orderbook_validate.go", + "internal/prediction/search_events.go", + "internal/prediction/search_events_validate.go", + "internal/prediction/search_events_query.go", + "internal/prediction/event_signal.go", + "internal/prediction/event_signal_validate.go", + "internal/prediction/event_signal_map.go", + "internal/prediction/event_signal_query.go", + "internal/prediction/search_events_map.go", + "internal/prediction/opensearch_fields.go", + "internal/prediction/market_lookup.go", + "pkg/errs/client.go", + "internal/mcphost/sdk.go" + ], + "tools": [ + { + "name": "news_feed_search_news", + "category": "news_feed", + "description": "[Read] Search the platform news index for headlines, news items, and briefing-style result lists. Open-web research with synthesized answers and cited external pages -> web_search. Event catalog with event_id -> get_latest_events.", + "input_rules": { + "required_policy": "no strict required params", + "params": [ + { "name": "query", "type": "string", "required": false, "notes": "If non-empty, enters similarity mode; tickers are not sent downstream." }, + { "name": "coin", "type": "string", "required": false, "notes": "Comma-separated tickers; only sent as downstream tickers when query is empty." }, + { "name": "platform", "type": "string", "required": false, "notes": "Preferred source platform filter, higher priority than platform_type." }, + { "name": "platform_type", "type": "string", "required": false, "notes": "Legacy platform mapping; ignored when platform exists; 'all' means omit." }, + { "name": "lang", "type": "string", "required": false, "notes": "MCP local filter only, not sent downstream." }, + { "name": "time_range", "type": "string", "required": false, "enum": ["1h", "24h", "7d", "30d"], "notes": "If present, overrides start_time/end_time. Unknown preset normalizes to 24h. If all time inputs empty, implicit last 7d." }, + { "name": "start_time", "type": "string", "required": false, "notes": "ISO8601 or Unix sec/ms supported." }, + { "name": "end_time", "type": "string", "required": false, "notes": "ISO8601 or Unix sec/ms supported; date-only end is treated as end-of-day." }, + { "name": "sort_by", "type": "string", "required": false, "default": "time" }, + { "name": "top_total_score", "type": "integer|null", "required": false, "notes": "Effective value forced by mode: query non-empty -> 0; query empty -> default 1 unless explicitly 0." }, + { "name": "limit", "type": "integer", "required": false, "default": 10, "max": 100 }, + { "name": "page", "type": "integer", "required": false, "default": 1 }, + { "name": "similarity_score", "type": "string", "required": false, "default": "0.6 when query non-empty" } + ] + }, + "logic": [ + "Resolve effective time window: time_range > (start_time,end_time); if both empty then default to last 7d.", + "Build GET request to real_time_news_feed-compatible endpoint with from/to Unix seconds, paging, and mode fields.", + "If query is empty and initial default-window result is empty, auto fallback once to 30d window.", + "Apply local post-filters: time window safety filter, optional lang filter, optional entity-based rerank for query mode.", + "Sort locally by time desc only when top_total_score=0 and sort_by=time." + ], + "errors": [ + { "code": "invalid_param", "when": "start_time/end_time cannot be parsed or start_time > end_time" }, + { "code": "config_load_failed", "when": "API config missing" } + ] + }, + { + "name": "news_feed_search_ugc", + "category": "news_feed", + "description": "[Read] Reddit/Discord/Telegram/YouTube-style UGC: non-empty query uses vector API; coin without query uses OpenSearch. Both empty invalid. X/Twitter narrative -> search_x; headlines -> search_news. Not macro economic statistics; not structured event list -> get_latest_events.", + "input_rules": { + "required_policy": "query and coin cannot both be empty; may combine query with coin", + "params": [ + { "name": "query", "type": "string", "required": false, "notes": "If non-empty, uses vector API branch." }, + { "name": "coin", "type": "string", "required": false, "notes": "If query empty, coin is required to enter OpenSearch branch." }, + { "name": "platform", "type": "string", "required": false, "default": "all", "enum": ["reddit", "discord", "telegram", "youtube", "all"] }, + { "name": "domain", "type": "string", "required": false, "default": "all", "enum": ["crypto", "defi", "finance", "macro", "ai_agent", "web3_dev", "all"] }, + { "name": "channel", "type": "string", "required": false }, + { "name": "quality_tier", "type": "string", "required": false, "default": "A", "enum": ["A", "B", "all"] }, + { "name": "time_range", "type": "string", "required": false, "default": "7d", "enum": ["1h", "24h", "7d", "30d", "all"] }, + { "name": "sort_by", "type": "string", "required": false, "default": "relevance", "enum": ["relevance", "upvotes", "recent"] }, + { "name": "limit", "type": "integer", "required": false, "default": 10, "max": 50 } + ] + }, + "logic": [ + "Validation first: if both query and coin are empty, return invalid_param.", + "Branch A (query non-empty): call ugc_vector_data with normalized filters and time_start/time_end Unix seconds; local sort and trim by limit.", + "Branch B (query empty): use OpenSearch index query with filters/sort/time range mapping; requires opensearch client/index config.", + "Both branches map downstream items into unified spec field set." + ], + "errors": [ + { "code": "invalid_param", "when": "query and coin are both empty" }, + { "code": "config_load_failed", "when": "API config missing for vector branch or OpenSearch unavailable for empty-query branch" } + ] + }, + { + "name": "news_feed_search_x", + "category": "news_feed", + "description": "[Read] Search and analyze X/Twitter discussions for a topic, with tweet-level evidence and cited posts. Aggregate social mood, sentiment score, or positive/negative split -> get_social_sentiment. Open-web pages -> web_search. Multi-platform social search -> search_ugc.", + "input_rules": { + "required_policy": "no hard required param, but empty query on xAI path returns empty result", + "params": [ + { "name": "query", "type": "string", "required": false, "notes": "Topic text for X discussion search." }, + { "name": "time_range", "type": "string", "required": false, "default": "24h", "enum": ["1h", "24h", "7d"], "notes": "Preferred recency window; when provided it overrides days." }, + { "name": "days", "type": "integer", "required": false, "default": 1, "min": 1, "notes": "xAI only: lookback days when time_range omitted; omitted or <=0 treated as 1 (24h). jsonschema text says default 7 but runtime default is 1 when unset." }, + { "name": "allowed_handles", "type": "string[]", "required": false, "max_items": 10 }, + { "name": "excluded_handles", "type": "string[]", "required": false, "max_items": 10 }, + { "name": "model", "type": "string", "required": false }, + { "name": "enable_image_understanding", "type": "boolean", "required": false, "default": false }, + { "name": "enable_video_understanding", "type": "boolean", "required": false, "default": false }, + { "name": "coin", "type": "string", "required": false, "notes": "Only used in platform fallback path (mapped to search_news style)." }, + { "name": "platform", "type": "string", "required": false, "notes": "Platform fallback only." }, + { "name": "platform_type", "type": "string", "required": false, "notes": "Platform fallback only." }, + { "name": "lang", "type": "string", "required": false, "default": "zh", "enum": ["zh", "en", "auto"], "notes": "Controls xAI answer language; also used by platform fallback MCP lang filter." }, + { "name": "start_time", "type": "string", "required": false, "notes": "Platform fallback only." }, + { "name": "end_time", "type": "string", "required": false, "notes": "Platform fallback only." }, + { "name": "sort_by", "type": "string", "required": false, "notes": "Platform fallback only." }, + { "name": "top_total_score", "type": "integer|null", "required": false, "notes": "Platform fallback only." }, + { "name": "limit", "type": "integer", "required": false, "default": 10, "notes": "Platform fallback only." }, + { "name": "page", "type": "integer", "required": false, "default": 1, "notes": "Platform fallback only." }, + { "name": "similarity_score", "type": "string", "required": false, "notes": "Platform fallback only." } + ] + }, + "logic": [ + "Validation: allowed_handles and excluded_handles cannot both be non-empty.", + "If xAI key exists, run xAI Responses search_x flow with date window resolved from time_range (1h/24h->1d, 7d->7d); if time_range missing then use days.", + "If xAI key missing and platform fallback enabled, convert args to search_news-compatible args and call platform endpoint.", + "If xAI key missing and fallback disabled, return config_load_failed.", + "Always coerce sentiment fields in response to stable defaults." + ], + "errors": [ + { "code": "invalid_param", "when": "allowed_handles and excluded_handles are both provided" }, + { "code": "config_load_failed", "when": "xAI key missing and fallback disabled, or core config unavailable" } + ] + }, + { + "name": "news_feed_web_search", + "category": "news_feed", + "description": "[Read] Search the open web and return a synthesized answer with cited external pages. Built-in headline lookup, news-item search, or briefing-style news list -> search_news. X/Twitter-only discussion or tweet evidence -> search_x.", + "input_rules": { + "required_policy": "query is required", + "params": [ + { "name": "query", "type": "string", "required": true }, + { "name": "coin", "type": "string", "required": false }, + { "name": "mode", "type": "string", "required": false, "default": "analysis", "enum": ["analysis", "brief"] }, + { "name": "time_range", "type": "string", "required": false, "default": "24h", "enum": ["1h", "24h", "7d", "30d"] }, + { "name": "lang", "type": "string", "required": false, "default": "zh", "enum": ["zh", "en", "auto"] }, + { "name": "limit", "type": "integer", "required": false, "default": 5, "max": 10 } + ] + }, + "logic": [ + "Normalize mode/time_range/lang/limit, then call xAI ResponsesWebSearch API.", + "Parse structured output into summary/key_points/cited_sources and normalize citations.", + "If mode=brief, truncate summary to about 100 characters.", + "Return citations also as items list for generic clients." + ], + "errors": [ + { "code": "invalid_param", "when": "query is empty" }, + { "code": "config_load_failed", "when": "xAI key or app config missing" } + ] + }, + { + "name": "news_feed_get_exchange_announcements", + "category": "news_feed", + "description": "[Read] Venue-published exchange notices: listings, delistings, maintenance. Media rumors or general crypto headlines -> search_news.", + "input_rules": { + "required_policy": "no strict required params", + "params": [ + { "name": "exchange", "type": "string", "required": false, "notes": "Used as platform fallback if platform is empty." }, + { "name": "platform", "type": "string", "required": false, "notes": "Wins over exchange for downstream platform param." }, + { "name": "query", "type": "string", "required": false }, + { "name": "coin", "type": "string", "required": false }, + { "name": "announcement_type", "type": "string", "required": false, "enum": ["listing", "delisting", "maintenance", "all"] }, + { "name": "limit", "type": "integer", "required": false, "max": 100, "notes": "Only forwarded when >0." }, + { "name": "from", "type": "integer", "required": false, "notes": "Unix sec lower bound; local post-filter also applied." }, + { "name": "to", "type": "integer", "required": false, "notes": "Unix sec upper bound; local post-filter also applied." } + ] + }, + "logic": [ + "Build request to exchange_notice_sql endpoint with selected params.", + "platform param is resolved as platform > exchange priority.", + "Parse compatible list shapes (notice_list/news_list/items/list/data).", + "Apply local time filtering for from/to and sort by time desc." + ], + "errors": [ + { "code": "config_load_failed", "when": "API config missing" } + ] + }, + { + "name": "news_feed_get_social_sentiment", + "category": "news_feed", + "description": "[Read] Aggregate per-coin social sentiment for a time range: overall sentiment, positive/negative split, mention count, and sample tweets. X/Twitter post search or tweet-level evidence -> search_x. Multi-platform social thread search -> search_ugc.", + "input_rules": { + "required_policy": "no strict required params", + "params": [ + { "name": "coin", "type": "string", "required": false, "default": "BTC" }, + { "name": "time_range", "type": "string", "required": false, "default": "24h", "enum": ["1h", "24h", "7d"], "notes": "Invalid values are normalized to 24h." } + ] + }, + "logic": [ + "Normalize coin/time_range and convert to Unix start/end window.", + "Run 3 downstream calls in parallel: sentiment_score, positive_ratio, sentiment_analysis.", + "Compute overall_sentiment + normalized sentiment_label + mention_count + distribution + top_tweets.", + "top_tweets are parsed from sentiment_analysis response and returned in unified structure." + ], + "errors": [ + { "code": "config_load_failed", "when": "API config missing" } + ] + }, + { + "name": "news_feed_get_mention_burst", + "category": "news_feed", + "description": "[Read] Get a coin's 24h multi-platform social mention burst signal, growth, sentiment direction, platform breakdown, and display eligibility. For general sentiment ratios and sample tweets use get_social_sentiment; for individual social discussions use search_ugc.", + "input_rules": { + "required_policy": "coin is required", + "params": [ + { "name": "coin", "type": "string", "required": true, "notes": "Trimmed and normalized to uppercase. No coin-dictionary validation is performed; unknown or no-data tickers may return an empty result with hide_reason=no_data." }, + { "name": "window", "type": "string", "required": false, "default": "24h", "enum": ["24h"] }, + { "name": "platforms", "type": "string", "required": false, "default": "all", "notes": "Comma-separated. Supported: all, gate_square, binance_square, twitter, telegram, youtube, reddit, discord. all cannot be combined with another platform." } + ] + }, + "logic": [ + "Normalize coin, window, and platforms, then call news_feed_get_mention_burst through the News MCP backend.", + "Return current and previous mention counts, weighted growth, sentiment direction, display color, and per-platform breakdown.", + "hide_reason explains no_data, insufficient_baseline, insufficient_sample, or not_burst; nullable numeric fields are preserved." + ], + "errors": [ + { "code": "invalid_param", "when": "coin is empty, window is not 24h, or platforms contains an unsupported value" }, + { "code": "internal", "when": "the upstream social API call fails" } + ] + }, + { + "name": "news_feed_get_hot_topics", + "category": "news_feed", + "description": "[Read] Get the top 2-4 social discussion themes for one coin over the latest 4h, including direction, influence, sentiment, platforms, and representative evidence posts. For arbitrary social search use search_ugc; for X/Twitter-only narrative research use search_x.", + "input_rules": { + "required_policy": "coin is required", + "params": [ + { "name": "coin", "type": "string", "required": true, "notes": "Trimmed and normalized to uppercase. No coin-dictionary validation is performed; unknown or no-data tickers may return an empty result with hide_reason=no_data." }, + { "name": "window", "type": "string", "required": false, "default": "4h", "enum": ["4h"] }, + { "name": "limit", "type": "integer", "required": false, "default": 4, "min": 2, "max": 4 }, + { "name": "platforms", "type": "string", "required": false, "default": "all", "notes": "Comma-separated. Supported: all, gate_square, binance_square, twitter, telegram, youtube, reddit, discord. all cannot be combined with another platform." } + ] + }, + "logic": [ + "Normalize coin, fixed 4h window, topic limit, and platforms, then call news_feed_get_hot_topics through the News MCP backend.", + "Return qualified themes with direction, titles, summary, influence, sentiment, platforms, and representative evidence posts.", + "Fewer than two qualified themes produces an empty topics array and a hide_reason from the MCP service." + ], + "errors": [ + { "code": "invalid_param", "when": "coin is empty, window is not 4h, limit is outside 2-4, or platforms contains an unsupported value" }, + { "code": "internal", "when": "the upstream social API call fails" } + ] + }, + { + "name": "news_events_get_latest_events", + "category": "news_events", + "description": "[Read] Filtered event list or timeline; each row includes event_id. One event_id detail -> get_event_detail. Headline/news feed -> search_news.", + "input_rules": { + "required_policy": "no strict required params", + "params": [ + { "name": "event_type", "type": "string", "required": false, "notes": "all or empty means no type filter." }, + { "name": "coin", "type": "string", "required": false, "notes": "Comma-separated; expanded to variant terms for related_coins/symbols filtering." }, + { "name": "time_range", "type": "string", "required": false, "enum": ["1h", "24h", "7d"], "notes": "Mutually exclusive with start_time/end_time." }, + { "name": "start_time", "type": "string", "required": false, "notes": "Mutually exclusive with time_range." }, + { "name": "end_time", "type": "string", "required": false, "notes": "Mutually exclusive with time_range." }, + { "name": "cursor", "type": "string", "required": false, "notes": "Reserved; not used by current OpenSearch implementation (no pagination)." }, + { "name": "limit", "type": "integer", "required": false, "default": 20, "max": 100, "notes": "Omitted or <=0 -> 20; >100 -> invalid_size." } + ] + }, + "logic": [ + "Validate request: limit default 20, max 100; time_range enum check; time_range cannot coexist with start/end.", + "Build OpenSearch bool query with event_type/coin/time filters and sort by event time desc.", + "If no time params: use service policy (optional all-time mode or default 24h).", + "Map source docs to event list schema and return total/count/items." + ], + "errors": [ + { "code": "invalid_param", "when": "time_range invalid or mixed with start/end" }, + { "code": "invalid_size", "when": "limit > 100" }, + { "code": "internal", "when": "OpenSearch query fails (MCP maps opensearch_query_failed to internal)" } + ], + "placeholder_when_unconfigured": true + }, + { + "name": "news_events_get_event_detail", + "category": "news_events", + "description": "[Read] Full detail for one event_id only. Filtered event list or timeline -> get_latest_events. Unknown id returns not found.", + "input_rules": { + "required_policy": "event_id is required", + "params": [ + { "name": "event_id", "type": "string", "required": true, "max_length": 512, "pattern": "^[A-Za-z0-9:_-]+$", "notes": "Cannot contain unsafe chars like quotes/semicolon/backslash." } + ] + }, + "logic": [ + "Validate event_id non-empty and safe format.", + "Query OpenSearch by configured id field and map result into detail response.", + "Return resource_not_found if id has no hit (message echoes event_id)." + ], + "errors": [ + { "code": "invalid_event_id", "when": "event_id empty, too long, or illegal characters" }, + { "code": "resource_not_found", "when": "valid event_id but no document in index" }, + { "code": "internal", "when": "OpenSearch query fails (MCP maps opensearch_query_failed to internal)" } + ], + "placeholder_when_unconfigured": true + }, + { + "name": "news_events_explain_market_move", + "category": "news_events", + "description": "[Read] Explain what drove a crypto asset's price move in a given time window.\nReturns a concise summary (from real-time Tavily search), the latest high-priority real-time events, and supporting internal event pool items, plus data-completeness status.\nThis tool is a data aggregator; the downstream agent performs the final attribution reasoning.", + "input_rules": { + "required_policy": "query and coin are required", + "params": [ + { "name": "query", "type": "string", "required": true, "notes": "User question, e.g. why did BTC surge; whitespace-trimmed; empty rejected." }, + { "name": "coin", "type": "string", "required": true, "notes": "Target symbol; whitespace-trimmed; empty -> missing_coin. Passed through event.NormalizeCoin (alias/full name -> standard ticker); unknown aliases are kept, not rejected." }, + { "name": "time_range", "type": "string", "required": false, "default": "2h", "enum": ["30m", "1h", "2h", "4h", "24h"], "notes": "Only these literals are preserved (case-insensitive); any other value including empty or 7d normalizes to 2h. Drives Tavily recency (days) and maps upward for internal pool window (see logic)." }, + { "name": "mode", "type": "string", "required": false, "default": "auto", "enum": ["auto", "price_move", "event_impact"], "notes": "Normalized at handler; forwarded on Request but not read by explain service today (reserved)." }, + { "name": "lang", "type": "string", "required": false, "default": "zh", "enum": ["zh", "en"], "notes": "Normalized at handler; forwarded on Request but not read by explain service today (reserved)." } + ] + }, + "logic": [ + "Registration: live when explain.Service wired (Tavily + optional eventSvc); no placeholder tool when unconfigured.", + "Handler validates query/coin, normalizes coin via NormalizeCoin, normalizes time_range/lang/mode, then calls explain.Service.ExplainMarketMove.", + "Two goroutines in parallel: (A) Tavily multi-query search, (B) event.Service.GetLatestEvents with limit=2.", + "Tavily path: skipped with data_status missing_sources tavily_realtime_search if client nil. Handler normalizes time_range first (only 30m/1h/2h/4h/24h kept; other values including 7d -> 2h). Tavily days from normalized range: 30m/1h/24h->1, 2h/4h->3 (Tavily default). Per-query timeout from explain YAML (btc vs non-BTC sub-queries).", + "Tavily results: collect result.Answer strings into summary; map result.Results to MarketMoveEvent (title, truncated content summary, source from URL host, url, relevance_score adjusted by days factor). Dedupe by URL else title (keep highest score), sort by relevance_score desc, cap count to btcResultLimit (BTC) or nonBtcResultLimit (non-BTC) from YAML.", + "Internal pool path: skipped with missing_sources internal_event_pool if eventSvc nil. Else maps normalized time_range to Tool-1 window: 30m->1h, 1h->1h, 2h/4h->24h, 24h->24h; calls GetLatestEvents(coin, that time_range, limit=2). Maps each item to MarketMoveEvent (title, summary from context else impact_analysis, source 'Internal Event Pool', first_seen_time from event_time, relevance_score from event_type + time decay).", + "Merge: summary = Tavily answers joined by newline; data_status.is_partial if any missing_sources; notes aggregate Tavily/internal notes; if both lists empty and not partial, note 'No relevant events found'." + ], + "errors": [ + { "code": "invalid_param", "when": "query is empty after trim" }, + { "code": "missing_coin", "when": "coin is empty after trim" }, + { "code": "internal", "when": "ExplainMarketMove returns error (current implementation usually returns success with partial empty slices)" } + ] + }, + { + "name": "news_events_get_market_move_report", + "category": "news_events", + "description": "[Read] Query a stored market-move attribution report by symbol, optional report_id, or optional event_id. Lookup priority is report_id > event_id > latest report for symbol. Returned event/report timestamps are UTC0 unless the field name ends in _utc8. This tool never requests report regeneration and does not expose is_make_new.", + "input_rules": { + "required_policy": "symbol is required", + "params": [ + { "name": "symbol", "type": "string", "required": true, "max_length": 20, "notes": "Trimmed and normalized to uppercase by the MCP service." }, + { "name": "report_id", "type": "string", "required": false, "notes": "Exact stored-report lookup; takes priority over event_id." }, + { "name": "event_id", "type": "string", "required": false, "notes": "Returns the latest report for this event when report_id is omitted. Omit both IDs for the latest symbol report." } + ] + }, + "logic": [ + "Validate symbol and call service-ai-pe POST /api/v1/market-move-attribution/get-report without authentication.", + "The request body contains only symbol and non-empty report_id/event_id. is_make_new is not a schema field, CLI flag, or forwarded API parameter.", + "Return the stored report status, Markdown report_info, evidence summary, market-move fields, and timestamps. event_time, window_start, window_end, created_at, generated_at, updated_at, and display_expire_time are UTC0 strings without a timezone suffix; window_start_utc8 and window_end_utc8 carry an explicit +08:00 offset. JSON-string evidence from older storage rows is decoded into the same structured evidence object. Lookup priority is report_id > event_id > symbol." + ], + "errors": [ + { "code": "invalid_param", "when": "symbol is empty or longer than 20 characters; CLI JSON fallback explicitly includes is_make_new" }, + { "code": "config_load_failed", "when": "api.peUrl is empty" }, + { "code": "internal", "when": "service-ai-pe request, response decoding, or business response fails" } + ] + }, + { + "name": "news_events_list_market_move_reports", + "category": "news_events", + "description": "[Read] List stored market-move attribution reports for one symbol, filtering by an inclusive UTC0 updated_at range and sorting results by event_time descending. Reports may be updated after their market event, so updated_at can be later than event_time. Timezone-less inputs are UTC0; inputs with an explicit offset are converted to UTC0 before the read-only service-ai-pe query.", + "input_rules": { + "required_policy": "symbol, start_time, and end_time are required", + "params": [ + { "name": "symbol", "type": "string", "required": true, "max_length": 20, "notes": "Trimmed and normalized to uppercase by the MCP service." }, + { "name": "start_time", "type": "string", "required": true, "notes": "Inclusive lower bound for report updated_at in UTC0; ISO 8601 or YYYY-MM-DD HH:MM:SS. No timezone means UTC0; an explicit offset is converted to UTC0." }, + { "name": "end_time", "type": "string", "required": true, "notes": "Inclusive upper bound for report updated_at in UTC0; ISO 8601 or YYYY-MM-DD HH:MM:SS. No timezone means UTC0; an explicit offset is converted to UTC0; cannot precede start_time." }, + { "name": "limit", "type": "integer", "required": false, "default": 20, "min": 0, "max": 100, "notes": "0 has the same behavior as omitted and defaults to 20; nonzero values must be 1-100." } + ] + }, + "logic": [ + "Validate symbol, parse both updated_at bounds, interpret timezone-less values as UTC0, convert explicit offsets to UTC0, require start_time <= end_time, and normalize omitted or zero limit to 20.", + "Call service-ai-pe POST /api/v1/market-move-attribution/report-list without authentication; no report-generation parameter is exposed or sent.", + "Filter inclusively by updated_at, then return status (ready/generating/not_found/failed), trace_id, reports ordered by event_time DESC, and duration_ms. Because reports can be updated one or more times after generation, updated_at may be later than event_time; event_time does not indicate whether a row falls inside the requested filter window." + ], + "errors": [ + { "code": "invalid_param", "when": "required input is missing, datetime is invalid, start_time > end_time, or CLI JSON fallback includes is_make_new" }, + { "code": "invalid_size", "when": "limit is negative or greater than 100" }, + { "code": "config_load_failed", "when": "api.peUrl is empty" }, + { "code": "internal", "when": "service-ai-pe request, response decoding, or business response fails" } + ] + }, + { + "name": "news_prediction_get_volume_delta_ranking", + "category": "news_prediction", + "description": "[Read] Daily venue/overall ranking by volume delta (UTC rank_date). Optional venue[], category (exact term on rank index), status (active/closed/resolved/all; default active). Requires opensearch.predictionRankIndex.", + "input_rules": { + "required_policy": "no strict required params", + "params": [ + { "name": "date_utc", "type": "string", "required": false, "default": "today_utc (UTC YYYY-MM-DD)", "pattern": "^\\d{4}-\\d{2}-\\d{2}$", "notes": "Empty or omitted -> current UTC date." }, + { "name": "limit", "type": "integer", "required": false, "default": 20, "max": 100, "notes": "Omitted or null -> 20; explicit <=0 -> invalid_size; >100 -> invalid_size." }, + { "name": "venue", "type": "string[]", "required": false, "enum": ["polymarket", "predict_fun"], "notes": "Each element must be allowed; empty or omitted -> no venue filter (all venues). Multiple values -> OpenSearch terms filter (exact)." }, + { "name": "category", "type": "string", "required": false, "notes": "Free-text exact term on rank index field category; no server-side enum check. Empty or omitted -> no filter. Non-empty and not 'all' (case-sensitive) -> term filter. Unknown values -> empty overall, not invalid_param." }, + { "name": "status", "type": "string", "required": false, "default": "active", "enum": ["active", "closed", "resolved", "all"], "notes": "Empty or omitted -> treated as 'active' before query. 'all' -> no status filter. Otherwise term filter on field status (exact)." } + ] + }, + "logic": [ + "Validate: limit omitted -> 20; limit in 1..100; limit <=0 or >100 -> invalid_size; date_utc must be YYYY-MM-DD when set; each non-empty venue in whitelist (polymarket/predict_fun); category trimmed only (no enum validation); status empty -> active; status must be active/closed/resolved/all.", + "OpenSearch bool filter: term rank_type=volume_delta; range on part_date for the UTC calendar day; optional terms on venue; optional term on category when set and not 'all'; optional term on status when not 'all'.", + "OpenSearch sort: volume_delta_usd_today desc only; client sortRankingItems tie-breaks by venue_event_id then rank_no. Size = limit. Filter field part_date for UTC calendar day of date_utc.", + "Map _source to RankingItem; event_name prefers event_name else market_title; market_name similar; parse excluded_reasons_json to excluded_reasons (invalid JSON -> []).", + "Response: overall = ordered hits; by_venue groups by venue (default keys include polymarket, predict_fun; additional keys appear only when present in hits); generated_at = max(calc_time) from hits else UTC now; duration_ms; partial/source_data_status/excluded_reasons aggregated per implementation.", + "Do not drop hits for null volume_delta_usd_today on this rank_type (all matching docs returned up to limit)." + ], + "errors": [ + { "code": "invalid_param", "when": "date_utc not YYYY-MM-DD; any non-empty venue not polymarket/predict_fun; status not in active/closed/resolved/all" }, + { "code": "invalid_size", "when": "limit <= 0 or limit > 100" }, + { "code": "internal", "when": "OpenSearch query fails (MCP maps opensearch to internal)" }, + { "code": "not_implemented", "when": "opensearch.predictionRankIndex unset or prediction service not wired (placeholder tool)" } + ], + "placeholder_when_unconfigured": true + }, + { + "name": "news_prediction_get_fastest_rising_ranking", + "category": "news_prediction", + "description": "[Read] Daily venue/overall ranking by probability rise (UTC rank_date). Optional venue[], category, status (same as volume_delta ranking). Drops rows missing open_mid_probability_utc or probability_delta_today. Requires predictionRankIndex.", + "input_rules": { + "required_policy": "no strict required params", + "params": [ + { "name": "date_utc", "type": "string", "required": false, "default": "today_utc (UTC YYYY-MM-DD)", "pattern": "^\\d{4}-\\d{2}-\\d{2}$", "notes": "Empty or omitted -> current UTC date." }, + { "name": "limit", "type": "integer", "required": false, "default": 20, "max": 100, "notes": "Same as volume_delta: omitted -> 20; <=0 or >100 -> invalid_size." }, + { "name": "venue", "type": "string[]", "required": false, "enum": ["polymarket", "predict_fun"], "notes": "Each element must be allowed; empty or omitted -> no venue filter. Multiple values -> terms filter (exact)." }, + { "name": "category", "type": "string", "required": false, "notes": "Same as volume_delta: free-text exact term; no enum validation; empty/omit -> no filter; not 'all' -> term filter." }, + { "name": "status", "type": "string", "required": false, "default": "active", "enum": ["active", "closed", "resolved", "all"], "notes": "Same semantics as volume_delta tool: empty -> active; 'all' -> no status filter." } + ] + }, + "logic": [ + "Same validation as volume_delta (category free-text, no enum check; status enum only) and same OpenSearch filter shape, except rank_type=fastest_rising.", + "OpenSearch sort: probability_delta_today desc only; client tie-break same as volume_delta. Size = limit.", + "Post-filter from result set: drop items where open_mid_probability_utc is null or probability_delta_today is null (not eligible for fastest_rising list); excluded_reasons from dropped rows may contribute to partial/excluded_reasons aggregation.", + "Map remaining docs to RankingItem; parse excluded_reasons_json as volume_delta tool.", + "Response shape same family as volume_delta (overall, by_venue, generated_at, duration_ms, partial, etc.)." + ], + "errors": [ + { "code": "invalid_param", "when": "date_utc not YYYY-MM-DD; any non-empty venue not polymarket/predict_fun; status not in active/closed/resolved/all" }, + { "code": "invalid_size", "when": "limit <= 0 or limit > 100" }, + { "code": "internal", "when": "OpenSearch query fails (MCP maps opensearch to internal)" }, + { "code": "not_implemented", "when": "opensearch.predictionRankIndex unset or prediction service not wired (placeholder tool)" } + ], + "placeholder_when_unconfigured": true + }, + { + "name": "news_prediction_get_market_orderbook", + "category": "news_prediction", + "description": "[Read] Live current order book only (mode=current). depth 1-20 (default 20). polymarket: market_id=venue_market_id; needs opensearch.predictionMarketIndex for token lookup (else not_implemented); yes/no CLOB /book in parallel—partial if one side fails, tool error only if both fail. predict_fun: official numeric market_id (not polymarket ids); needs predictFunAPIKey—empty/missing config returns partial_not_configured (no HTTP); API/parse errors return partial (not internal); 404 resource_not_found. Rejects history/granularity/time/page_token. snapshot_time/book_levels/best_* may be null when partial. Event list/signal -> search_events / get_event_signal.", + "input_rules": { + "required_policy": "venue and market_id are required", + "params": [ + { "name": "venue", "type": "string", "required": true, "enum": ["polymarket", "predict_fun"], "notes": "Trimmed; must be in whitelist." }, + { "name": "market_id", "type": "string", "required": true, "notes": "polymarket: venue_market_id in dws_prediction_market_hf (requires predictionMarketIndex). predict_fun: official numeric market id (e.g. 356640), not polymarket venue_market_id." }, + { "name": "depth", "type": "integer", "required": false, "default": 20, "min": 1, "max": 20, "notes": "Levels returned in yes_bids/yes_asks (top-N from each side)." }, + { "name": "mode", "type": "string", "required": false, "default": "current (implicit)", "enum": ["current", ""], "notes": "history rejected; empty treated as current." }, + { "name": "granularity", "type": "string", "required": false, "notes": "Unsupported; non-empty -> invalid_param." }, + { "name": "start_time", "type": "string", "required": false, "notes": "Unsupported; non-empty -> invalid_param." }, + { "name": "end_time", "type": "string", "required": false, "notes": "Unsupported; non-empty -> invalid_param." }, + { "name": "page_token", "type": "string", "required": false, "notes": "Unsupported; non-empty -> invalid_param." } + ] + }, + "logic": [ + "Registration: live when predictionSvc non-nil and OrderbookEnabled (obHTTP from ApplyOrderbookConfig in main); placeholder when predictionSvc nil or orderbook disabled. Live polymarket still requires predictionMarketIndex for token lookup.", + "Validate venue/market_id/depth/mode; reject history and any of granularity/start_time/end_time/page_token.", + "Optional in-memory cache (venue+market_id+depth) when prediction.orderbookCacheSeconds > 0; only caches complete non-partial responses.", + "polymarket: term lookup on predictionMarketIndex by venue.keyword+venue_market_id; fetch yes/no token CLOB books in parallel; BookLevels from both books (best_yes/no bid/ask, spreads, top5_depth_* on yes book, yes_bids/yes_asks). partial if either fetch fails or any best price missing; hard error only if BOTH fetches fail.", + "predict_fun: if predictFunAPIKey empty -> partial, missing_sources=[predict_fun_not_configured], source_data_status=partial_not_configured (no HTTP call). Else GET official orderbook (parse success/data envelope; 401/unauthorized -> partial_not_configured not internal); yes book only, best_no_* derived as complement of yes ask/bid; API/parse failure -> partial + predict_fun_orderbook_failed (nil tool error); 404 -> resource_not_found.", + "Response: partial, missing_sources, venue, market_id, source_api, mode=current, snapshot_time, book_levels, source_data_status (complete|partial|partial_not_configured), duration_ms. Not items/total/count." + ], + "errors": [ + { "code": "invalid_param", "when": "venue/market_id empty; venue not polymarket/predict_fun; depth < 1 or > 20; mode=history or other unsupported mode; granularity/start_time/end_time/page_token set" }, + { "code": "config_load_failed", "when": "obHTTP nil; polymarket CLOB base URL empty on fetch" }, + { "code": "not_implemented", "when": "placeholder (predictionSvc nil); polymarket when predictionMarketIndex unset" }, + { "code": "internal", "when": "polymarket market index OpenSearch query fails (MCP maps opensearch to internal)" }, + { "code": "internal", "when": "polymarket CLOB: both yes and no book fetches fail (single-side fail -> partial, no tool error)" }, + { "code": "resource_not_found", "when": "polymarket: no market hit or missing yes/no token_id; predict_fun: HTTP 404 market_id=… venue=predict_fun" }, + { "code": "(success partial)", "when": "predict_fun: empty API key, 401/unauthorized, or API/parse failure -> partial + missing_sources (predict_fun_not_configured|predict_fun_orderbook_failed); not internal" } + ], + "placeholder_when_unconfigured": true + }, + { + "name": "news_prediction_search_events", + "category": "news_prediction", + "description": "[Read] Search prediction events (dws_prediction_event_signal_hf; collapse per pk_id). At least one of query, coin, or category required. Default sort_by=recently_listed; status defaults active (only coin with no query/category -> all). coin uses related_coins/symbols (may return 0 rows if index lacks them). category is enum (crypto_price, sports, …). with_markets attaches dws_prediction_market_hf summaries. Single event_ref detail -> get_event_signal; live order book -> get_market_orderbook.", + "input_rules": { + "required_policy": "at least one of query, coin, or category is required", + "params": [ + { "name": "query", "type": "string", "required": false, "notes": "Trimmed; wildcard on venue_event_title (keyword field). Pure-digit query also term venue_event_id." }, + { "name": "coin", "type": "string", "required": false, "notes": "Normalized via event.NormalizeCoin. Omitted status: all only when coin set and query/category both empty; coin+category without query still defaults active. terms on related_coins and symbols (not coins/tokens)." }, + { "name": "category", "type": "string", "required": false, "enum": ["crypto_event", "crypto_price", "culture", "earnings", "elections", "finance", "geopolitics", "macro_economy", "mentions", "other", "politics", "sports", "tech_ai", "weather_climate", "world"], "notes": "Maps to index field event_category_primary (term). Optional market pre-query on market_category_primary." }, + { "name": "status", "type": "string", "required": false, "default": "active when omitted (except coin-only with no query/category -> all)", "enum": ["active", "closed", "resolved", "all"], "notes": "Omitted: active unless query empty AND coin set AND category empty (then all). coin+category without query still defaults active. Filter maps to event_status. Response status_tags from status_json_array|status_tags|event_status." }, + { "name": "venue", "type": "string[]", "required": false, "enum": ["polymarket", "predict_fun"], "notes": "Each non-empty element validated; blank skipped; filter uses venue.keyword." }, + { "name": "sort_by", "type": "string", "required": false, "default": "recently_listed", "enum": ["attention", "volume", "liquidity", "recently_listed", "probability_change", "volume_delta_today"], "notes": "Signal index fields: attention→attention_score; volume|volume_delta_today→total_volume_usd_24h; liquidity→total_liquidity_usd; recently_listed→create_time+part_hour; probability_change→|probability_change_6h|. ES 400 fallback chain per implementation." }, + { "name": "limit", "type": "integer", "required": false, "default": 20, "max": 100, "notes": "Values <1 or >100 invalid. OpenSearch size=limit+1; return first limit hits." }, + { "name": "page_token", "type": "string", "required": false, "notes": "Base64 JSON {sort_by, search_after}; invalid decode or sort_by mismatch (when both sides non-empty) -> invalid_param." }, + { "name": "with_markets", "type": "boolean", "required": false, "default": false, "notes": "When true attach markets from predictionMarketIndex; when false omit markets. On attach skip/fail: missing_sources markets_index_not_configured|markets_attach_failed; partial stays false." } + ] + }, + "logic": [ + "Registration: live when EventVenueSearchEnabled (opensearch.predictionEventSignalIndex non-empty); placeholder when index unset or predictionSvc nil.", + "Validate; default limit=20; status omitted -> active unless query empty and coin set and category empty (then all); sort_by=recently_listed with ES sort fallback.", + "OpenSearch predictionEventSignalIndex (dws_prediction_event_signal_hf): bool filter venue.keyword + event_status; must query/coin; category term event_category_primary when set.", + "collapse field pk_id so each event returns one row (latest row per sort keys).", + "status filter: term event_status (active/closed/resolved aliases: close/closed, open/active, etc.).", + "query: wildcard venue_event_title; pure-digit query term venue_event_id.", + "category: term event_category_primary; optional market pre-query on dws_prediction_market_hf market_category_primary.", + "Category pre-query failure: log warn and continue without market event ids (degraded, not fatal).", + "search_after from page_token; next_page_token from hit.sort or rebuilt from _source.", + "Map hits: event_ref=venue:venue_event_id; status_tags from status_json_array then status_tags then event_status (normalize close->closed); category from event_category_primary; lead_yes_probability from lead_yes_probability|latest_mid_probability|mid_yes_probability.", + "recently_listed sort: create_time, part_hour, then legacy first_seen_time/open_time.", + "Response: partial=false on success, missing_sources when with_markets attach skipped/failed, next_page_token|null, events[], duration_ms.", + "Missing sort fields -> OpenSearch 400 triggers sort_by fallback chain; MCP client error code internal (no index/status leakage).", + "Pagination tie-break: venue.keyword asc, venue_event_id asc.", + "coin filter: terms related_coins and symbols only (often empty on signal index -> zero hits, not an error)." + ], + "errors": [ + { "code": "invalid_param", "when": "query/coin/category all empty; category/sort_by/status/venue invalid; page_token invalid or sort_by mismatch" }, + { "code": "invalid_size", "when": "limit < 1 or > 100" }, + { "code": "internal", "when": "signal index search fails after sort fallbacks (MCP client; server logs retain detail)" }, + { "code": "not_implemented", "when": "opensearch.predictionEventSignalIndex unset or predictionSvc nil (placeholder)" } + ], + "placeholder_when_unconfigured": true + }, + { + "name": "news_prediction_get_event_signal", + "category": "news_prediction", + "description": "[Read] Single event signal from dws_external_event_signal_hf by event_ref (venue:venue_event_id). window 1h/24h/7d (default 24h). Returns outcome_probabilities, volume_flow, directional_context, optional markets[] (default include_markets=true). depth_summary always null—use get_market_orderbook for live depth. daily_ranking when rank index configured. Discover event_ref -> search_events. include_orderbook_summary ignored.", + "input_rules": { + "required_policy": "event_ref is required", + "params": [ + { "name": "event_ref", "type": "string", "required": true, "pattern": "^[^:]+:[^:]+$", "notes": "Split on first ':' -> venue + venue_event_id (id may contain further colons). Normalized to venue:venue_event_id. Venue must be polymarket or predict_fun. Index may also store event_ref field." }, + { "name": "window", "type": "string", "required": false, "default": "24h", "enum": ["1h", "24h", "7d"], "notes": "Case-insensitive. Filters docs: part_hour >= now - window (1h uses extended lookback +3h on part_hour filter)." }, + { "name": "venue", "type": "string[]", "required": false, "enum": ["polymarket", "predict_fun"], "notes": "Optional; each non-empty value must equal event_ref venue or invalid_param." }, + { "name": "include_markets", "type": "boolean|null", "required": false, "default": true, "notes": "Omitted/null -> true. Parse markets (external index) or source_markets_json (legacy); if empty, fallback predictionMarketIndex. false omits markets[]. Invalid JSON -> partial + markets_json_invalid." }, + { "name": "include_orderbook_summary", "type": "boolean", "required": false, "default": false, "notes": "Deprecated/unread in handler." } + ] + }, + "logic": [ + "Registration: live when ExternalEventSignalEnabled (opensearch.externalEventSignalIndex non-empty); placeholder when unset or predictionSvc nil.", + "Query externalEventSignalIndex (dws_external_event_signal_hf): filter venue.keyword + venue_event_id + part_hour range; sort part_hour desc, etl_time desc; size 1 (8 for 1h). No hit in window -> resource_not_found (detail: event_ref=venue:id). 1h strict miss may use latest doc -> missing_sources signal_window_fallback.", + "Map index fields (external primary, legacy fallbacks): status_tags from status_tags JSON string or status_json_array; signal_time from signal_time|signal_minute|part_hour; yes/no from yes_probability|no_probability (mid_* fallback); directional_context from directional_context|directional_context_json; markets from markets|source_markets_json; category_ai_primary top-level; source_data_status from index (e.g. partial_price_gap); depth_summary always null in mapper (use get_market_orderbook for live depth); daily_ranking from index or rank-index enrichment when configured.", + "volume_flow.source = dws_external_event_signal_hf.", + "Enrichment parallel: predictionMarketIndex when include_markets and embedded markets empty; external index lookup for title/category/status fill; rank index for daily_ranking when configured.", + "include_markets=true: empty markets with market_count>0 may set markets_index_empty + partial_markets_gap.", + "finalizeEventSignalPartial: may upgrade partial_probability_gap, partial_markets_gap, partial_volume_gap when fields missing.", + "MCP client: OpenSearch/API failures return code internal without vendor or HTTP status in message.", + "Response: partial, missing_sources, event_identity, signal_time, source_data_status, outcome_probabilities, depth_summary, volume_flow, daily_ranking?, cross_venue_divergence, directional_context, markets? (omitempty when include_markets false), window, duration_ms." + ], + "errors": [ + { "code": "invalid_param", "when": "event_ref empty; not venue:id shape; venue not allowed; window not 1h/24h/7d; venue[] filter mismatches event_ref venue" }, + { "code": "internal", "when": "external signal index query fails (server opensearch_query_failed; MCP client receives internal)" }, + { "code": "resource_not_found", "when": "no document in window (detail: event_ref=venue:id)" }, + { "code": "not_implemented", "when": "opensearch.externalEventSignalIndex unset or predictionSvc nil (placeholder)" } + ], + "placeholder_when_unconfigured": true + } + ] +} diff --git a/internal/mcpspec/leaf_help.go b/internal/mcpspec/leaf_help.go new file mode 100644 index 0000000..2b595c2 --- /dev/null +++ b/internal/mcpspec/leaf_help.go @@ -0,0 +1,350 @@ +// Leaf help text for info/news cobra Long is built from embedded MCP JSON. +// +// Default is compact: omits the Parameters block (cobra Flags already list type/default/enum/max). +// Set GATE_INTEL_LEAF_HELP=full or detailed to embed per-field notes from the MCP JSON spec. +package mcpspec + +import ( + "fmt" + "os" + "strings" + "sync" +) + +var ( + infoLongMu sync.Mutex + newsLongMu sync.Mutex + infoLongBy map[string]string + newsLongBy map[string]string + infoLongCacheMode string + newsLongCacheMode string +) + +// InfoLeafLongAppend returns MCP-spec narrative for an Info tool (required JSON, logic; per-field list only when GATE_INTEL_LEAF_HELP=full). +// Empty string if the tool is absent from the embedded document. Safe for cobra.Command.Long. +func InfoLeafLongAppend(toolName string) string { + mode := leafHelpCacheMode() + infoLongMu.Lock() + if infoLongBy == nil || infoLongCacheMode != mode { + infoLongBy = buildInfoLongByTool(leafHelpOmitForMode(mode)) + infoLongCacheMode = mode + } + m := infoLongBy + infoLongMu.Unlock() + if s := m[toolName]; s != "" { + return s + } + return "" +} + +// NewsLeafLongAppend returns MCP-spec narrative for a News tool (description, policy, logic, errors; Parameters block only when GATE_INTEL_LEAF_HELP=full). +func NewsLeafLongAppend(toolName string) string { + mode := leafHelpCacheMode() + newsLongMu.Lock() + if newsLongBy == nil || newsLongCacheMode != mode { + newsLongBy = buildNewsLongByTool(leafHelpOmitForMode(mode)) + newsLongCacheMode = mode + } + m := newsLongBy + newsLongMu.Unlock() + if s := m[toolName]; s != "" { + return s + } + return "" +} + +func buildInfoLongByTool(omitParamDetails bool) map[string]string { + doc, err := InfoInputsLogic() + if err != nil { + return nil + } + m, ok := doc.(map[string]interface{}) + if !ok { + return nil + } + raw, ok := m["tools"].([]interface{}) + if !ok { + return nil + } + omit := omitParamDetails + out := make(map[string]string, len(raw)) + for _, t := range raw { + tm, ok := t.(map[string]interface{}) + if !ok { + continue + } + name, _ := tm["tool_name"].(string) + if name == "" { + continue + } + out[name] = formatInfoToolLong(tm, omit) + } + return out +} + +func buildNewsLongByTool(omitParamDetails bool) map[string]string { + doc, err := NewsToolsArgs() + if err != nil { + return nil + } + m, ok := doc.(map[string]interface{}) + if !ok { + return nil + } + raw, ok := m["tools"].([]interface{}) + if !ok { + return nil + } + omit := omitParamDetails + out := make(map[string]string, len(raw)) + for _, t := range raw { + tm, ok := t.(map[string]interface{}) + if !ok { + continue + } + name, _ := tm["name"].(string) + if name == "" { + continue + } + out[name] = formatNewsToolLong(tm, omit) + } + return out +} + +// leafHelpCacheMode buckets env for help-text caching (compact vs full parameter blocks). +func leafHelpCacheMode() string { + v := strings.TrimSpace(strings.ToLower(os.Getenv("GATE_INTEL_LEAF_HELP"))) + if v == "full" || v == "detailed" { + return "full" + } + return "compact" +} + +func leafHelpOmitForMode(mode string) bool { + return mode != "full" +} + +func formatInfoToolLong(tm map[string]interface{}, omitParamDetails bool) string { + var b strings.Builder + appendInfoToolDescription(&b, tm) + if v, ok := tm["domain"].(string); ok && strings.TrimSpace(v) != "" { + fmt.Fprintf(&b, "Domain: %s\n", strings.TrimSpace(v)) + } + if v, ok := tm["request_type"].(string); ok && strings.TrimSpace(v) != "" { + fmt.Fprintf(&b, "Request type: %s\n\n", strings.TrimSpace(v)) + } + if rq := tm["required"]; rq != nil { + fmt.Fprintf(&b, "Required fields (JSON): %s\n\n", compactJSON(rq)) + } + if cr := tm["conditional_required"]; cr != nil { + fmt.Fprintf(&b, "Conditional required: %s\n\n", compactJSON(cr)) + } + if !omitParamDetails { + if fields, ok := tm["fields"].([]interface{}); ok && len(fields) > 0 { + b.WriteString("Parameters:\n") + for _, f := range fields { + fm, ok := f.(map[string]interface{}) + if !ok { + continue + } + if line := formatInfoFieldLong(fm); line != "" { + b.WriteString(line) + b.WriteString("\n") + } + } + b.WriteString("\n") + } + } + if logic := tm["logic"]; logic != nil { + b.WriteString("Logic:\n") + b.WriteString(formatInfoLogicLong(logic)) + } + if errs, ok := tm["errors"].([]interface{}); ok && len(errs) > 0 { + b.WriteString("\nErrors:\n") + for _, e := range errs { + if em, ok := e.(map[string]interface{}); ok { + code, _ := em["code"].(string) + when, _ := em["when"].(string) + if code != "" || when != "" { + fmt.Fprintf(&b, "- %s: %s\n", code, when) + continue + } + } + fmt.Fprintf(&b, "- %v\n", e) + } + } + appendResponseFieldsLong(&b, tm) + return strings.TrimSpace(b.String()) +} + +// appendInfoToolDescription prints English description from the embedded (shipped) spec. +func appendInfoToolDescription(b *strings.Builder, tm map[string]interface{}) { + if d, _ := tm["description"].(string); strings.TrimSpace(d) != "" { + fmt.Fprintf(b, "%s\n\n", strings.TrimSpace(d)) + } +} + +func appendResponseFieldsLong(b *strings.Builder, tm map[string]interface{}) { + if rf, ok := tm["response_fields"].([]interface{}); ok && len(rf) > 0 { + b.WriteString("\nResponse fields (JSON):\n") + for _, x := range rf { + if s, ok := x.(string); ok && strings.TrimSpace(s) != "" { + fmt.Fprintf(b, "- %s\n", strings.TrimSpace(s)) + } + } + } +} + +func formatInfoFieldLong(fm map[string]interface{}) string { + name, _ := fm["name"].(string) + if name == "" { + return "" + } + typ, _ := fm["type"].(string) + req := "" + if v, ok := fm["required"].(bool); ok && v { + req = ", required" + } + var lines []string + lines = append(lines, fmt.Sprintf("- %s (%s%s)", name, typ, req)) + if ev := fieldEnumStrings(fm); len(ev) > 0 { + lines = append(lines, fmt.Sprintf(" enum: %s", strings.Join(ev, ", "))) + } + if s, _ := fm["enum_note"].(string); strings.TrimSpace(s) != "" { + lines = append(lines, fmt.Sprintf(" enum_note: %s", strings.TrimSpace(s))) + } + if s, _ := fm["notes"].(string); strings.TrimSpace(s) != "" { + lines = append(lines, fmt.Sprintf(" notes: %s", strings.TrimSpace(s))) + } + for _, key := range []string{"default", "max", "min", "max_items"} { + if v, ok := fm[key]; ok { + lines = append(lines, fmt.Sprintf(" %s: %v", key, v)) + } + } + if cv, ok := fm["common_values"].([]interface{}); ok && len(cv) > 0 { + lines = append(lines, fmt.Sprintf(" common_values: %s", joinIfaceStrings(cv))) + } + return strings.Join(lines, "\n") +} + +func fieldEnumStrings(fm map[string]interface{}) []string { + raw, ok := fm["enum"].([]interface{}) + if !ok || len(raw) == 0 { + return nil + } + out := make([]string, 0, len(raw)) + for _, v := range raw { + out = append(out, fmt.Sprint(v)) + } + return out +} + +func formatInfoLogicLong(v interface{}) string { + switch x := v.(type) { + case string: + return strings.TrimSpace(x) + case []interface{}: + var lines []string + for _, item := range x { + lines = append(lines, fmt.Sprintf("- %v", item)) + } + return strings.Join(lines, "\n") + default: + return strings.TrimSpace(fmt.Sprint(v)) + } +} + +func formatNewsToolLong(tm map[string]interface{}, omitParamDetails bool) string { + var b strings.Builder + if d, _ := tm["description"].(string); strings.TrimSpace(d) != "" { + fmt.Fprintf(&b, "%s\n\n", strings.TrimSpace(d)) + } + if c, _ := tm["category"].(string); strings.TrimSpace(c) != "" { + fmt.Fprintf(&b, "Category: %s\n\n", strings.TrimSpace(c)) + } + if ir, ok := tm["input_rules"].(map[string]interface{}); ok { + if pol, _ := ir["required_policy"].(string); strings.TrimSpace(pol) != "" { + fmt.Fprintf(&b, "Required policy: %s\n", strings.TrimSpace(pol)) + } + if !omitParamDetails { + if params, ok := ir["params"].([]interface{}); ok && len(params) > 0 { + b.WriteString("\nParameters:\n") + for _, p := range params { + pm, ok := p.(map[string]interface{}) + if !ok { + continue + } + b.WriteString(formatNewsParamLong(pm)) + b.WriteString("\n") + } + } + } + } + if logic, ok := tm["logic"].([]interface{}); ok && len(logic) > 0 { + b.WriteString("\nLogic:\n") + for _, line := range logic { + fmt.Fprintf(&b, "- %v\n", line) + } + } + if errs, ok := tm["errors"].([]interface{}); ok && len(errs) > 0 { + b.WriteString("\nErrors:\n") + for _, e := range errs { + if em, ok := e.(map[string]interface{}); ok { + code, _ := em["code"].(string) + when, _ := em["when"].(string) + if code != "" || when != "" { + fmt.Fprintf(&b, "- %s: %s\n", code, when) + continue + } + } + fmt.Fprintf(&b, "- %v\n", e) + } + } + return strings.TrimSpace(b.String()) +} + +func formatNewsParamLong(pm map[string]interface{}) string { + name, _ := pm["name"].(string) + if name == "" { + return "" + } + typ, _ := pm["type"].(string) + req := "" + if v, ok := pm["required"].(bool); ok && v { + req = ", required" + } + var lines []string + lines = append(lines, fmt.Sprintf("- %s (%s%s)", name, typ, req)) + if ev := fieldEnumStrings(pm); len(ev) > 0 { + lines = append(lines, fmt.Sprintf(" enum: %s", strings.Join(ev, ", "))) + } + if s, _ := pm["notes"].(string); strings.TrimSpace(s) != "" { + lines = append(lines, fmt.Sprintf(" notes: %s", strings.TrimSpace(s))) + } + for _, key := range []string{"default", "max", "min", "max_items"} { + if v, ok := pm[key]; ok { + lines = append(lines, fmt.Sprintf(" %s: %v", key, v)) + } + } + return strings.Join(lines, "\n") +} + +func joinIfaceStrings(v []interface{}) string { + parts := make([]string, 0, len(v)) + for _, x := range v { + parts = append(parts, fmt.Sprint(x)) + } + return strings.Join(parts, ", ") +} + +func compactJSON(v interface{}) string { + // Short, stable enough for help text (no sensitive data in these specs). + switch x := v.(type) { + case []interface{}: + return joinIfaceStrings(x) + case string: + return x + default: + return fmt.Sprint(v) + } +} diff --git a/internal/mcpspec/leaf_help_test.go b/internal/mcpspec/leaf_help_test.go new file mode 100644 index 0000000..9e26ff6 --- /dev/null +++ b/internal/mcpspec/leaf_help_test.go @@ -0,0 +1,105 @@ +package mcpspec + +import ( + "strings" + "testing" +) + +func TestInfoLeafLongAppendCoinInfo_defaultCompact(t *testing.T) { + t.Setenv("GATE_INTEL_LEAF_HELP", "") + s := InfoLeafLongAppend("info_coin_get_coin_info") + if strings.Contains(s, "Parameters:") { + t.Fatalf("default should omit Parameters (flags list types; use GATE_INTEL_LEAF_HELP=full for field notes): %s", s) + } + if !strings.Contains(s, "Required fields (JSON):") || !strings.Contains(s, "query") { + t.Fatalf("expected required JSON line: %s", s) + } + if !strings.Contains(s, "[Read]") || !strings.Contains(s, "get_coin_rankings") { + t.Fatalf("expected English description from spec: %s", s) + } + if !strings.Contains(s, "Logic:") || !strings.Contains(s, "coinSearcher") { + t.Fatalf("expected logic from spec: %s", s) + } +} + +func TestInfoLeafLongAppendCoinInfo_fullParams(t *testing.T) { + t.Setenv("GATE_INTEL_LEAF_HELP", "full") + s := InfoLeafLongAppend("info_coin_get_coin_info") + if !strings.Contains(s, "Parameters:") || !strings.Contains(s, "query") { + t.Fatalf("full mode should list parameters: %s", s) + } +} + +func TestInfoLeafLongAppendInstitutionalMetrics_routingDescription(t *testing.T) { + t.Setenv("GATE_INTEL_LEAF_HELP", "") + s := InfoLeafLongAppend("info_marketsnapshot_get_institutional_metrics") + if !strings.Contains(s, "ETF/CFTC") || !strings.Contains(s, "get_market_snapshot") { + t.Fatalf("expected institutional routing description: %s", s) + } +} + +func TestNewsLeafLongAppendSearchNews_defaultCompact(t *testing.T) { + t.Setenv("GATE_INTEL_LEAF_HELP", "") + s := NewsLeafLongAppend("news_feed_search_news") + if !strings.Contains(s, "platform news index") { + t.Fatalf("missing description: %s", s) + } + if strings.Contains(s, "Parameters:") { + t.Fatalf("default should omit Parameters: %s", s) + } + if !strings.Contains(s, "Logic:") || !strings.Contains(s, "time_range") { + t.Fatalf("expected logic mentioning time window: %s", s) + } +} + +func TestInfoLeafLongAppendOnchainAddressInfo_ErrorsSection(t *testing.T) { + t.Setenv("GATE_INTEL_LEAF_HELP", "") + s := InfoLeafLongAppend("info_onchain_get_address_info") + if !strings.Contains(s, "Errors:") || !strings.Contains(s, "invalid_chain") { + t.Fatalf("expected onchain NE errors in help: %s", s) + } + if !strings.Contains(s, "asset_summary") { + t.Fatalf("expected updated logic mentioning asset_summary: %s", s) + } + if !strings.Contains(s, "Response fields") || !strings.Contains(s, "multi_chain_token_balances") { + t.Fatalf("expected response_fields in help: %s", s) + } +} + +func TestInfoLeafLongAppendAddressTransactions_ErrorsAndResponse(t *testing.T) { + t.Setenv("GATE_INTEL_LEAF_HELP", "") + s := InfoLeafLongAppend("info_onchain_get_address_transactions") + if !strings.Contains(s, "partial_upstream_response") { + t.Fatalf("expected partial_upstream_response in help: %s", s) + } + if !strings.Contains(s, "Response fields") || !strings.Contains(s, "items") { + t.Fatalf("expected response_fields in help: %s", s) + } +} + +func TestFormatNewsToolLongCompactOmitsParams(t *testing.T) { + t.Parallel() + tm := map[string]interface{}{ + "description": "One-line summary.", + "category": "news_events", + "input_rules": map[string]interface{}{ + "required_policy": "none", + "params": []interface{}{ + map[string]interface{}{"name": "limit", "type": "integer", "notes": "verbose note not in flags"}, + }, + }, + "logic": []interface{}{"step one"}, + "errors": []interface{}{map[string]interface{}{"code": "bad", "when": "never"}}, + } + full := formatNewsToolLong(tm, false) + if !strings.Contains(full, "Parameters:") || !strings.Contains(full, "verbose note") { + t.Fatalf("full mode should list parameters: %s", full) + } + comp := formatNewsToolLong(tm, true) + if strings.Contains(comp, "Parameters:") || strings.Contains(comp, "verbose note") { + t.Fatalf("compact should omit parameters block: %s", comp) + } + if !strings.Contains(comp, "Logic:") || !strings.Contains(comp, "Errors:") || !strings.Contains(comp, "bad") { + t.Fatalf("compact should keep logic and errors: %s", comp) + } +} diff --git a/internal/mcpspec/spec.go b/internal/mcpspec/spec.go new file mode 100644 index 0000000..413f671 --- /dev/null +++ b/internal/mcpspec/spec.go @@ -0,0 +1,50 @@ +// Package mcpspec embeds MCP tool input documents shipped with the CLI for offline +// agent/LLM use (parameter enums, defaults, bounds, logic text). Bundled JSON must +// stay byte-identical to specs/mcp/*.json; see TestBundledMatchesSpecs. +package mcpspec + +import ( + _ "embed" + "encoding/json" + "sync" +) + +//go:embed bundled/info-mcp-tools-inputs-logic.json +var infoInputsLogicJSON []byte + +//go:embed bundled/news-tools-args-and-logic.json +var newsToolsArgsJSON []byte + +func init() { + for _, raw := range [][]byte{infoInputsLogicJSON, newsToolsArgsJSON} { + var v interface{} + if err := json.Unmarshal(raw, &v); err != nil { + panic("mcpspec: invalid embedded JSON: " + err.Error()) + } + } +} + +var ( + infoParsedOnce sync.Once + newsParsedOnce sync.Once + infoParsed interface{} + newsParsed interface{} + infoParseErr error + newsParseErr error +) + +// InfoInputsLogic returns the parsed Info MCP inputs/spec document embedded in release binaries (internal/mcpspec/bundled). +func InfoInputsLogic() (interface{}, error) { + infoParsedOnce.Do(func() { + infoParseErr = json.Unmarshal(infoInputsLogicJSON, &infoParsed) + }) + return infoParsed, infoParseErr +} + +// NewsToolsArgs returns the parsed News tools args/logic document (same shape as specs/mcp/news-tools-args-and-logic.json). +func NewsToolsArgs() (interface{}, error) { + newsParsedOnce.Do(func() { + newsParseErr = json.Unmarshal(newsToolsArgsJSON, &newsParsed) + }) + return newsParsed, newsParseErr +} diff --git a/internal/mcpspec/spec_normalize.go b/internal/mcpspec/spec_normalize.go new file mode 100644 index 0000000..710def2 --- /dev/null +++ b/internal/mcpspec/spec_normalize.go @@ -0,0 +1,61 @@ +package mcpspec + +import "encoding/json" + +// stripInfoToolDescriptions returns a copy of the info MCP spec with description and +// description_zh removed from tools and meta.tool_entry_keys. Used to compare local +// specs/mcp (QC, not shipped) with the embedded bundled document on logic/fields only. +func stripInfoToolDescriptions(doc interface{}) (interface{}, error) { + raw, err := json.Marshal(doc) + if err != nil { + return nil, err + } + var m map[string]interface{} + if err := json.Unmarshal(raw, &m); err != nil { + return nil, err + } + if meta, ok := m["meta"].(map[string]interface{}); ok { + if keys, ok := meta["tool_entry_keys"].(map[string]interface{}); ok { + delete(keys, "description") + delete(keys, "description_zh") + } + // gate-cli release extensions; local specs/mcp QC copy does not carry these. + delete(meta, "cli_baseline_tools") + delete(meta, "spec_only_tools") + } + rawTools, ok := m["tools"].([]interface{}) + if !ok { + return m, nil + } + for _, item := range rawTools { + tm, ok := item.(map[string]interface{}) + if !ok { + continue + } + delete(tm, "description") + delete(tm, "description_zh") + } + return m, nil +} + +// infoSpecEqualExcludingDescriptions reports whether spec and bundled match after +// removing description fields from both (local QC spec must not drive release text). +func infoSpecEqualExcludingDescriptions(specDoc, bundledDoc interface{}) (bool, error) { + strippedSpec, err := stripInfoToolDescriptions(specDoc) + if err != nil { + return false, err + } + strippedBundled, err := stripInfoToolDescriptions(bundledDoc) + if err != nil { + return false, err + } + a, err := json.Marshal(strippedSpec) + if err != nil { + return false, err + } + b, err := json.Marshal(strippedBundled) + if err != nil { + return false, err + } + return string(a) == string(b), nil +} diff --git a/internal/mcpspec/spec_test.go b/internal/mcpspec/spec_test.go new file mode 100644 index 0000000..1736d6a --- /dev/null +++ b/internal/mcpspec/spec_test.go @@ -0,0 +1,180 @@ +package mcpspec + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func repoRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for { + if st, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil && !st.IsDir() { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatal("go.mod not found from cwd") + } + dir = parent + } +} + +func TestBundledMatchesSpecs(t *testing.T) { + t.Parallel() + root := repoRoot(t) + pairs := []struct { + spec, bundled string + }{ + { + filepath.Join(root, "specs", "mcp", "info-mcp-tools-inputs-logic.json"), + filepath.Join(root, "internal", "mcpspec", "bundled", "info-mcp-tools-inputs-logic.json"), + }, + { + filepath.Join(root, "specs", "mcp", "news-tools-args-and-logic.json"), + filepath.Join(root, "internal", "mcpspec", "bundled", "news-tools-args-and-logic.json"), + }, + } + for _, p := range pairs { + // specs/ is gitignored, so this parity check only runs where the + // author keeps the source spec. CI has the bundled copy only and + // must skip rather than Fatal. + a, err := os.ReadFile(p.spec) + if err != nil { + if os.IsNotExist(err) { + t.Skipf("skip parity: spec %s not present (expected in CI)", p.spec) + } + t.Fatalf("read spec %s: %v", p.spec, err) + } + b, err := os.ReadFile(p.bundled) + if err != nil { + t.Fatalf("read bundled %s: %v", p.bundled, err) + } + if p.bundled == filepath.Join(root, "internal", "mcpspec", "bundled", "info-mcp-tools-inputs-logic.json") { + var specDoc, bundledDoc interface{} + if err := json.Unmarshal(a, &specDoc); err != nil { + t.Fatalf("parse spec %s: %v", p.spec, err) + } + if err := json.Unmarshal(b, &bundledDoc); err != nil { + t.Fatalf("parse bundled %s: %v", p.bundled, err) + } + ok, err := infoSpecEqualExcludingDescriptions(specDoc, bundledDoc) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatalf("bundled out of sync with local spec on logic/fields (descriptions are bundled-only); run scripts/patch-info-spec-descriptions.py — %s vs %s", p.spec, p.bundled) + } + continue + } + if string(a) != string(b) { + t.Fatalf("bundled out of sync with spec; cp specs/mcp/*.json internal/mcpspec/bundled/ — compare %s vs %s", p.spec, p.bundled) + } + } +} + +func TestBundledSocialInsightCoinSemantics(t *testing.T) { + t.Parallel() + doc, err := NewsToolsArgs() + if err != nil { + t.Fatal(err) + } + root := doc.(map[string]interface{}) + wanted := map[string]bool{ + "news_feed_get_mention_burst": false, + "news_feed_get_hot_topics": false, + } + for _, rawTool := range root["tools"].([]interface{}) { + tool := rawTool.(map[string]interface{}) + name, _ := tool["name"].(string) + if _, ok := wanted[name]; !ok { + continue + } + wanted[name] = true + inputRules := tool["input_rules"].(map[string]interface{}) + for _, rawParam := range inputRules["params"].([]interface{}) { + param := rawParam.(map[string]interface{}) + if param["name"] != "coin" { + continue + } + notes, _ := param["notes"].(string) + if strings.Contains(strings.ToLower(notes), "validated by") || strings.Contains(strings.ToLower(notes), "recognized by") { + t.Errorf("%s coin notes overstate validation: %q", name, notes) + } + if !strings.Contains(notes, "hide_reason=no_data") { + t.Errorf("%s coin notes miss unknown/no-data behavior: %q", name, notes) + } + } + } + for name, found := range wanted { + if !found { + t.Errorf("bundled spec missing %s", name) + } + } +} + +func TestStripInfoToolDescriptions(t *testing.T) { + t.Parallel() + in := map[string]interface{}{ + "meta": map[string]interface{}{ + "tool_entry_keys": map[string]interface{}{ + "description": "en", + "description_zh": "zh", + }, + }, + "tools": []interface{}{ + map[string]interface{}{ + "tool_name": "info_coin_get_coin_info", + "description": "en tool", + "description_zh": "zh tool", + "logic": "stay", + }, + }, + } + out, err := stripInfoToolDescriptions(in) + if err != nil { + t.Fatal(err) + } + m := out.(map[string]interface{}) + keys := m["meta"].(map[string]interface{})["tool_entry_keys"].(map[string]interface{}) + if _, ok := keys["description"]; ok { + t.Fatal("meta.tool_entry_keys should drop description") + } + tool := m["tools"].([]interface{})[0].(map[string]interface{}) + if _, ok := tool["description"]; ok { + t.Fatal("tool should drop description") + } + if tool["logic"] != "stay" { + t.Fatalf("logic kept: %v", tool["logic"]) + } +} + +func TestInfoInputsLogicParses(t *testing.T) { + t.Parallel() + v, err := InfoInputsLogic() + if err != nil { + t.Fatal(err) + } + m, ok := v.(map[string]interface{}) + if !ok || m["tools"] == nil { + t.Fatalf("unexpected shape %#v", v) + } +} + +func TestNewsToolsArgsParses(t *testing.T) { + t.Parallel() + v, err := NewsToolsArgs() + if err != nil { + t.Fatal(err) + } + m, ok := v.(map[string]interface{}) + if !ok || m["tools"] == nil { + t.Fatalf("unexpected shape %#v", v) + } +} diff --git a/internal/migration/doctor.go b/internal/migration/doctor.go index 125ed4e..a30d5a1 100644 --- a/internal/migration/doctor.go +++ b/internal/migration/doctor.go @@ -10,7 +10,7 @@ import ( "github.com/gate/gate-cli/internal/version" ) -const MinDoctorVersion = "0.3.0" +const MinDoctorVersion = "0.6.0" type DoctorCheck struct { ID string `json:"id"` diff --git a/internal/migration/preflight_test.go b/internal/migration/preflight_test.go index 35110e5..14ccfd2 100644 --- a/internal/migration/preflight_test.go +++ b/internal/migration/preflight_test.go @@ -10,7 +10,7 @@ func TestBuildPreflightReady(t *testing.T) { Installed: func(string) bool { return true }, - Version: "0.3.0", + Version: "0.6.0", }) if res.Status != "ready" { t.Fatalf("expected ready, got %s", res.Status) @@ -28,7 +28,7 @@ func TestBuildPreflightFallbackToMCP(t *testing.T) { Installed: func(string) bool { return false }, - Version: "0.3.0", + Version: "0.6.0", }) // with no legacy entries it should be install required if res.Status != "install_cli_required" { @@ -41,7 +41,7 @@ func TestBuildPreflightInstallHintTemplate(t *testing.T) { FallbackEnabled: false, Scanner: NewScannerWithHome(t.TempDir()), Installed: func(string) bool { return false }, - Version: "0.3.0", + Version: "0.6.0", }) if res.ActionCode != "SHOW_INSTALL_HINT" { t.Fatalf("expected SHOW_INSTALL_HINT") @@ -70,7 +70,7 @@ func TestBuildPreflightMigrateHintTemplate(t *testing.T) { FallbackEnabled: true, Scanner: NewScannerWithHome(home), Installed: func(string) bool { return true }, - Version: "0.3.0", + Version: "0.6.0", }) // If no legacy was found due to path not existing, skip strict assertion. if res.Status == "ready_with_migration_warning" && res.UserMessage != PreflightMsgMigrateHint { diff --git a/internal/output/errconvergence.go b/internal/output/errconvergence.go new file mode 100644 index 0000000..44b7a80 --- /dev/null +++ b/internal/output/errconvergence.go @@ -0,0 +1,66 @@ +package output + +import "github.com/gate/gate-cli/internal/agentfeature" + +// FillAgentErrorType sets ErrorType when omitted so PrintError JSON is agent-convergable. +func FillAgentErrorType(ge *GateError) { + if ge == nil || ge.ErrorType != "" { + return + } + ge.ErrorType = ClassifyCLIError(ge.Status, ge.Label, ge.Message) +} + +// FillAgentErrorConvergence sets error_type, retryable, and suggested_next_action for agent stderr JSON. +func FillAgentErrorConvergence(ge *GateError) { + if ge == nil { + return + } + FillAgentErrorType(ge) + if ge.ErrorType == "" { + ge.ErrorType = "UNKNOWN" + } + if ge.SuggestedNextAction == "" { + ge.SuggestedNextAction = defaultSuggestedNextAction(ge.ErrorType, ge.Label) + } + ge.Retryable = defaultRetryable(ge.ErrorType, ge.Label) +} + +func defaultRetryable(errorType, label string) bool { + switch label { + case "PREFLIGHT_BLOCKED", "DOCTOR_FAILED", "MIGRATE_FAILED": + return false + } + switch errorType { + case "INVALID_ARGS": + return true + default: + return false + } +} + +func defaultSuggestedNextAction(errorType, label string) string { + switch label { + case "PREFLIGHT_BLOCKED": + return "fix CLI install/version or Intel MCP config; do not retry the same preflight until resolved" + case "DOCTOR_FAILED": + return "fix failing doctor checks (config, connectivity, legacy MCP); do not retry doctor until resolved" + case "MIGRATE_FAILED": + return "fix migrate failures (backup, provider config); do not retry migrate until resolved" + } + switch errorType { + case "INVALID_ARGS": + return "fix flags or query; you may check the leaf command --help once, then retry with corrected args" + case "AUTH_ERROR": + return "stop retrying; configure GATE_API_KEY/GATE_API_SECRET or Intel GATE_INTEL_* bearer tokens" + case "PERMISSION_DENIED": + return "stop retrying; verify API key permissions or Intel bearer scope" + case "EMPTY_RESULT": + return "answer that no records matched; do not blindly retry the same command" + case "NETWORK_ERROR": + return "stop business retries; report service unavailable or retry later without changing args" + case "COMMAND_NOT_FOUND": + return agentfeature.DiscoveryResolveOrLeavesAction() + default: + return "inspect stderr message and gate_cli_diagnostic if present; avoid repeated identical retries" + } +} diff --git a/internal/output/errconvergence_shield_test.go b/internal/output/errconvergence_shield_test.go new file mode 100644 index 0000000..deaf6ab --- /dev/null +++ b/internal/output/errconvergence_shield_test.go @@ -0,0 +1,22 @@ +//go:build !agent + +package output + +import ( + "strings" + "testing" +) + +func TestCommandNotFoundActionOmitsAgentCommands(t *testing.T) { + t.Parallel() + ge := &GateError{ + Status: 404, + Label: "NOT_FOUND", + Message: `unknown command "x"`, + ErrorType: "COMMAND_NOT_FOUND", + } + FillAgentErrorConvergence(ge) + if strings.Contains(ge.SuggestedNextAction, "agent-") { + t.Fatalf("must not suggest agent commands without -tags agent: %q", ge.SuggestedNextAction) + } +} diff --git a/internal/output/errconvergence_test.go b/internal/output/errconvergence_test.go new file mode 100644 index 0000000..2b584e8 --- /dev/null +++ b/internal/output/errconvergence_test.go @@ -0,0 +1,58 @@ +package output + +import ( + "testing" +) + +func TestFillAgentErrorConvergenceInvalidArgs(t *testing.T) { + t.Parallel() + ge := &GateError{ + Status: 400, + Label: "INVALID_ARGUMENTS", + Message: "size must be <= 500", + } + FillAgentErrorConvergence(ge) + if ge.ErrorType != "INVALID_ARGS" { + t.Fatalf("error_type=%q", ge.ErrorType) + } + if !ge.Retryable { + t.Fatal("expected retryable for INVALID_ARGS") + } + if ge.SuggestedNextAction == "" { + t.Fatal("expected suggested_next_action") + } +} + +func TestFillAgentErrorConvergenceAuthError(t *testing.T) { + t.Parallel() + ge := &GateError{Status: 401, Label: "AUTH", Message: "api key required"} + FillAgentErrorConvergence(ge) + if ge.ErrorType != "AUTH_ERROR" { + t.Fatalf("error_type=%q", ge.ErrorType) + } + if ge.Retryable { + t.Fatal("AUTH_ERROR must not be retryable") + } +} + +func TestFillAgentErrorConvergenceGovernanceNotRetryable(t *testing.T) { + t.Parallel() + for _, label := range []string{"PREFLIGHT_BLOCKED", "DOCTOR_FAILED", "MIGRATE_FAILED"} { + ge := &GateError{Status: 422, Label: label, Message: "blocked"} + FillAgentErrorConvergence(ge) + if ge.Retryable { + t.Fatalf("%s must not be retryable", label) + } + if ge.SuggestedNextAction == "" { + t.Fatalf("%s missing suggested_next_action", label) + } + } +} + +func TestInvalidArgsErrorIncludesConvergenceFields(t *testing.T) { + t.Parallel() + ge := InvalidArgsError("coin is required") + if ge.ErrorType != "INVALID_ARGS" || !ge.Retryable || ge.SuggestedNextAction == "" { + t.Fatalf("got %#v", ge) + } +} diff --git a/internal/output/errtype.go b/internal/output/errtype.go new file mode 100644 index 0000000..a7b9cc5 --- /dev/null +++ b/internal/output/errtype.go @@ -0,0 +1,79 @@ +package output + +import ( + "net/http" + "strings" +) + +// ClassifyGateAPIError maps HTTP status and Gate labels to agent-facing error_type values. +func ClassifyGateAPIError(status int, label, message string) string { + switch status { + case http.StatusUnauthorized: + return "AUTH_ERROR" + case http.StatusForbidden: + return "PERMISSION_DENIED" + case http.StatusNotFound: + return "EMPTY_RESULT" + case http.StatusRequestTimeout, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout: + return "NETWORK_ERROR" + case http.StatusTooManyRequests: + return "NETWORK_ERROR" + } + blob := strings.ToLower(label + " " + message) + switch { + case strings.Contains(blob, "invalid key"), strings.Contains(blob, "api key"), strings.Contains(blob, "authentication"): + return "AUTH_ERROR" + case strings.Contains(blob, "permission"), strings.Contains(blob, "forbidden"): + return "PERMISSION_DENIED" + case strings.Contains(blob, "not found"), strings.Contains(blob, "no record"), + strings.Contains(blob, "no results"), strings.Contains(blob, "no matching"), + strings.Contains(blob, "empty result"), strings.Contains(blob, "zero results"): + return "EMPTY_RESULT" + } + if status >= 500 { + return "NETWORK_ERROR" + } + return "" +} + +// ClassifyCLIError extends gate API mapping with CLI/Intel labels for stderr JSON. +func ClassifyCLIError(status int, label, message string) string { + if t := ClassifyGateAPIError(status, label, message); t != "" { + return t + } + blob := strings.ToLower(label + " " + message) + switch { + case strings.Contains(blob, "invalid_argument"), strings.Contains(blob, "unsupported_format"): + return "INVALID_ARGS" + case strings.Contains(blob, "intel_transport"), strings.Contains(blob, "timeout"), strings.Contains(blob, "network"): + return "NETWORK_ERROR" + case strings.Contains(blob, "intel_protocol"), strings.Contains(blob, "intel_result"): + return "UNKNOWN" + } + switch status { + case http.StatusBadRequest: + return "INVALID_ARGS" + case http.StatusUnauthorized: + return "AUTH_ERROR" + case http.StatusForbidden: + return "PERMISSION_DENIED" + case http.StatusNotFound: + return "EMPTY_RESULT" + } + if status >= 500 { + return "NETWORK_ERROR" + } + return "UNKNOWN" +} + +// InvalidArgsError is a caller-fixable validation error (HTTP 400). +func InvalidArgsError(message string) *GateError { + ge := &GateError{ + Status: http.StatusBadRequest, + Label: "INVALID_ARGUMENTS", + Message: message, + ErrorType: "INVALID_ARGS", + } + FillAgentErrorConvergence(ge) + return ge +} diff --git a/internal/output/errtype_test.go b/internal/output/errtype_test.go new file mode 100644 index 0000000..c41538d --- /dev/null +++ b/internal/output/errtype_test.go @@ -0,0 +1,31 @@ +package output + +import "testing" + +func TestClassifyCLIErrorInvalidArgs(t *testing.T) { + t.Parallel() + if got := ClassifyCLIError(400, "INVALID_ARGUMENTS", "bad"); got != "INVALID_ARGS" { + t.Fatalf("got %q", got) + } +} + +func TestInvalidArgsErrorHasErrorType(t *testing.T) { + t.Parallel() + ge := InvalidArgsError("missing symbol") + if ge.ErrorType != "INVALID_ARGS" { + t.Fatalf("got %q", ge.ErrorType) + } +} + +func TestClassifyGateAPIError(t *testing.T) { + t.Parallel() + if got := ClassifyGateAPIError(401, "INVALID_KEY", ""); got != "AUTH_ERROR" { + t.Fatalf("got %q", got) + } + if got := ClassifyGateAPIError(403, "", ""); got != "PERMISSION_DENIED" { + t.Fatalf("got %q", got) + } + if got := ClassifyGateAPIError(404, "", ""); got != "EMPTY_RESULT" { + t.Fatalf("got %q", got) + } +} diff --git a/internal/output/limit.go b/internal/output/limit.go new file mode 100644 index 0000000..fc97886 --- /dev/null +++ b/internal/output/limit.go @@ -0,0 +1,20 @@ +package output + +import "encoding/json" + +// TruncateDataIfNeeded replaces data with a compact placeholder when serialized size exceeds maxBytes. +func TruncateDataIfNeeded(data interface{}, maxBytes int64) (interface{}, bool) { + if maxBytes <= 0 || data == nil { + return data, false + } + b, err := json.Marshal(data) + if err != nil || int64(len(b)) <= maxBytes { + return data, false + } + return map[string]interface{}{ + "truncated": true, + "message": "stdout payload exceeded --max-output-bytes; use a narrower query or higher limit", + "original_size_bytes": len(b), + "max_output_bytes": maxBytes, + }, true +} diff --git a/internal/output/output.go b/internal/output/output.go index a9dabd6..e651faf 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -53,21 +53,25 @@ type RequestInfo struct { // GateError is a unified error representation for all Gate API errors. type GateError struct { - Status int `json:"status"` - Label string `json:"label,omitempty"` - Message string `json:"message"` - TraceID string `json:"trace_id,omitempty"` - RequestID string `json:"request_id,omitempty"` - ToolName string `json:"tool_name,omitempty"` - JSONRPCCode *int `json:"jsonrpc_code,omitempty"` - Request *RequestInfo `json:"request,omitempty"` + Status int `json:"status"` + ErrorType string `json:"error_type,omitempty"` + Label string `json:"label,omitempty"` + Message string `json:"message"` + Retryable bool `json:"retryable"` + SuggestedNextAction string `json:"suggested_next_action,omitempty"` + TraceID string `json:"trace_id,omitempty"` + RequestID string `json:"request_id,omitempty"` + ToolName string `json:"tool_name,omitempty"` + JSONRPCCode *int `json:"jsonrpc_code,omitempty"` + Request *RequestInfo `json:"request,omitempty"` } // Printer writes structured output to stdout and errors to stderr. type Printer struct { - out io.Writer - errOut io.Writer - format Format + out io.Writer + errOut io.Writer + format Format + maxOutputBytes int64 } // New creates a Printer that writes errors to os.Stderr. @@ -75,6 +79,11 @@ func New(out io.Writer, format Format) *Printer { return &Printer{out: out, errOut: os.Stderr, format: format} } +// NewWithLimit creates a Printer that truncates oversized successful Print payloads. +func NewWithLimit(out io.Writer, format Format, maxOutputBytes int64) *Printer { + return &Printer{out: out, errOut: os.Stderr, format: format, maxOutputBytes: maxOutputBytes} +} + // NewWithStderr creates a Printer with custom stderr writer (useful for testing). func NewWithStderr(out, errOut io.Writer, format Format) *Printer { return &Printer{out: out, errOut: errOut, format: format} @@ -99,13 +108,13 @@ func (p *Printer) Format() Format { // JSON mode uses compact encoding (single line, plus trailing newline) for piping and jq. // Pretty/table mode uses indented JSON for readability. func (p *Printer) Print(data interface{}) error { - var b []byte - var err error - if p.format == FormatJSON { - b, err = json.Marshal(data) - } else { - b, err = json.MarshalIndent(data, "", " ") + toPrint := data + if p.maxOutputBytes > 0 { + if trimmed, truncated := TruncateDataIfNeeded(data, p.maxOutputBytes); truncated { + toPrint = trimmed + } } + b, err := p.marshalData(toPrint) if err != nil { return err } @@ -113,6 +122,13 @@ func (p *Printer) Print(data interface{}) error { return err } +func (p *Printer) marshalData(data interface{}) ([]byte, error) { + if p.format == FormatJSON { + return json.Marshal(data) + } + return json.MarshalIndent(data, "", " ") +} + // WritePretty writes human-oriented text to stdout (for --format pretty or --format table). // Do not use in JSON mode; machine-readable output must use Print. func (p *Printer) WritePretty(s string) error { @@ -177,6 +193,7 @@ func (p *Printer) PrintError(gateErr *GateError) { Message: "unknown error", } } + FillAgentErrorConvergence(gateErr) if p.format == FormatJSON { out := map[string]interface{}{"error": gateErr} b, _ := json.Marshal(out) @@ -188,7 +205,11 @@ func (p *Printer) PrintError(gateErr *GateError) { if label == "" { label = http.StatusText(gateErr.Status) } - _, _ = fmt.Fprintf(p.errOut, "Error [%d %s]: %s\n", gateErr.Status, label, gateErr.Message) + if gateErr.ErrorType != "" { + _, _ = fmt.Fprintf(p.errOut, "Error [%d %s] (%s): %s\n", gateErr.Status, label, gateErr.ErrorType, gateErr.Message) + } else { + _, _ = fmt.Fprintf(p.errOut, "Error [%d %s]: %s\n", gateErr.Status, label, gateErr.Message) + } if gateErr.TraceID != "" { _, _ = fmt.Fprintf(p.errOut, "Trace ID: %s\n", gateErr.TraceID) } @@ -204,4 +225,7 @@ func (p *Printer) PrintError(gateErr *GateError) { if gateErr.Request != nil { _, _ = fmt.Fprintf(p.errOut, "Request: %s %s\n", gateErr.Request.Method, gateErr.Request.URL) } + if gateErr.SuggestedNextAction != "" { + _, _ = fmt.Fprintf(p.errOut, "Next: %s\n", gateErr.SuggestedNextAction) + } } diff --git a/internal/output/output_test.go b/internal/output/output_test.go index c78dc61..bc189dc 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -91,6 +91,7 @@ func TestErrorJSONGateStandard(t *testing.T) { Body: `{"currency_pair":"INVALID"}`, }, } + gateErr.ErrorType = "INVALID_ARGS" p.PrintError(gateErr) var result map[string]interface{} @@ -98,6 +99,9 @@ func TestErrorJSONGateStandard(t *testing.T) { require.NoError(t, err) errObj := result["error"].(map[string]interface{}) assert.Equal(t, float64(400), errObj["status"]) + assert.Equal(t, "INVALID_ARGS", errObj["error_type"]) + assert.Equal(t, true, errObj["retryable"]) + assert.NotEmpty(t, errObj["suggested_next_action"]) assert.Equal(t, "INVALID_PARAM_VALUE", errObj["label"]) assert.Equal(t, "abc123", errObj["trace_id"]) assert.Equal(t, "req-1", errObj["request_id"]) diff --git a/internal/toolargs/agent_defaults.go b/internal/toolargs/agent_defaults.go new file mode 100644 index 0000000..4c696b8 --- /dev/null +++ b/internal/toolargs/agent_defaults.go @@ -0,0 +1,69 @@ +package toolargs + +import ( + "github.com/gate/gate-cli/internal/cmdhint" +) + +// applyAgentArgumentDefaults fills safe defaults when GATE_CLI_AGENT=1 and the caller omitted bounds. +// Runs after alias normalization, before ValidateForTool. +func applyAgentArgumentDefaults(toolName string, arguments map[string]interface{}) map[string]interface{} { + if !cmdhint.AgentModeEnabled() || arguments == nil { + return arguments + } + out := arguments + copied := false + ensure := func(key string, val interface{}) { + if v, ok := out[key]; ok && !isEmptyValue(v) { + return + } + if !copied { + out = copyArgMap(arguments) + copied = true + } + out[key] = val + } + switch toolName { + case "info_markettrend_get_kline": + ensure("size", 200) + case "info_marketdetail_get_kline": + ensure("limit", 200) + case "news_feed_search_news": + ensure("time_range", "24h") + ensureIntKey(&out, &copied, arguments, "limit", 20) + case "news_feed_search_x": + ensure("time_range", "24h") + case "news_feed_search_ugc": + ensure("time_range", "24h") + ensureIntKey(&out, &copied, arguments, "limit", 10) + case "news_feed_web_search": + ensure("time_range", "24h") + ensureIntKey(&out, &copied, arguments, "limit", 5) + case "news_events_get_latest_events": + ensure("time_range", "24h") + ensureIntKey(&out, &copied, arguments, "limit", 20) + case "news_events_explain_market_move": + ensure("time_range", "2h") + case "news_feed_get_social_sentiment": + ensure("time_range", "24h") + } + return out +} + +func ensureIntKey(out *map[string]interface{}, copied *bool, src map[string]interface{}, key string, def int) { + if v, ok := (*out)[key]; ok && !isEmptyValue(v) { + return + } + if !*copied { + *out = copyArgMap(src) + *copied = true + } + (*out)[key] = def +} + +func copyArgMap(in map[string]interface{}) map[string]interface{} { + out := make(map[string]interface{}, len(in)) + for k, v := range in { + out[k] = v + } + return out +} diff --git a/internal/toolargs/agent_defaults_test.go b/internal/toolargs/agent_defaults_test.go new file mode 100644 index 0000000..50ad484 --- /dev/null +++ b/internal/toolargs/agent_defaults_test.go @@ -0,0 +1,37 @@ +//go:build agent + +package toolargs + +import "testing" + +func TestApplyAgentDefaults_SearchNews(t *testing.T) { + t.Setenv("GATE_CLI_AGENT", "1") + got := NormalizeForTool("news_feed_search_news", map[string]interface{}{ + "coin": "BTC", + }) + if got["time_range"] != "24h" { + t.Fatalf("time_range=%v", got["time_range"]) + } + if got["limit"] != 20 { + t.Fatalf("limit=%v", got["limit"]) + } +} + +func TestApplyAgentDefaults_KlineSize(t *testing.T) { + t.Setenv("GATE_CLI_AGENT", "1") + got := NormalizeForTool("info_markettrend_get_kline", map[string]interface{}{ + "symbol": "BTC", + "timeframe": "1h", + }) + if got["size"] != 200 { + t.Fatalf("size=%v", got["size"]) + } +} + +func TestApplyAgentDefaultsDisabled(t *testing.T) { + t.Setenv("GATE_CLI_AGENT", "") + got := NormalizeForTool("news_feed_search_news", map[string]interface{}{"coin": "BTC"}) + if _, ok := got["time_range"]; ok { + t.Fatal("should not inject time_range without agent mode") + } +} diff --git a/internal/toolargs/info_batch_snapshot_validate.go b/internal/toolargs/info_batch_snapshot_validate.go new file mode 100644 index 0000000..c5810e8 --- /dev/null +++ b/internal/toolargs/info_batch_snapshot_validate.go @@ -0,0 +1,23 @@ +package toolargs + +import ( + "errors" + "strings" +) + +func validateInfoBatchMarketSnapshot(arguments map[string]interface{}) error { + syms := stringSliceArg(arguments, "symbols") + nonEmpty := 0 + for _, s := range syms { + if strings.TrimSpace(s) != "" { + nonEmpty++ + } + } + if nonEmpty == 0 { + return errors.New("missing required field: symbols") + } + if nonEmpty > 20 { + return errInvalidArguments("symbols must contain at most 20 pairs") + } + return nil +} diff --git a/internal/toolargs/info_cex_orderbook_validate.go b/internal/toolargs/info_cex_orderbook_validate.go new file mode 100644 index 0000000..946ba4c --- /dev/null +++ b/internal/toolargs/info_cex_orderbook_validate.go @@ -0,0 +1,36 @@ +package toolargs + +import ( + "errors" + "strings" +) + +var cexOrderbookMarketTypes = map[string]struct{}{ + "spot": {}, "perp": {}, "perps": {}, "futures": {}, "future": {}, +} + +var cexOrderbookDataScopes = map[string]struct{}{ + "exchange": {}, "market": {}, +} + +func validateInfoCexOrderbookDepth(arguments map[string]interface{}) error { + if !nonEmptyStringArg(arguments, "symbol") { + return errors.New("missing required field: symbol") + } + if mt := strings.TrimSpace(strings.ToLower(stringArg(arguments, "market_type"))); mt != "" { + if _, ok := cexOrderbookMarketTypes[mt]; !ok { + return errInvalidArgumentsf("market_type must be spot, perp, perps, futures, or future (got %q)", stringArg(arguments, "market_type")) + } + } + if ds := strings.TrimSpace(strings.ToLower(stringArg(arguments, "data_scope"))); ds != "" { + if _, ok := cexOrderbookDataScopes[ds]; !ok { + return errInvalidArgumentsf("data_scope must be exchange or market (got %q)", stringArg(arguments, "data_scope")) + } + } + if limit, ok := intArg(arguments, "limit"); ok { + if limit < 1 || limit > 100 { + return errInvalidArguments("limit must be between 1 and 100") + } + } + return nil +} diff --git a/internal/toolargs/info_errors.go b/internal/toolargs/info_errors.go new file mode 100644 index 0000000..382d0ee --- /dev/null +++ b/internal/toolargs/info_errors.go @@ -0,0 +1,11 @@ +package toolargs + +import "fmt" + +func errInvalidArguments(msg string) error { + return fmt.Errorf("invalid arguments: %s", msg) +} + +func errInvalidArgumentsf(format string, args ...interface{}) error { + return fmt.Errorf("invalid arguments: "+format, args...) +} diff --git a/internal/toolargs/info_exchange_reserves_validate.go b/internal/toolargs/info_exchange_reserves_validate.go new file mode 100644 index 0000000..801fe52 --- /dev/null +++ b/internal/toolargs/info_exchange_reserves_validate.go @@ -0,0 +1,31 @@ +package toolargs + +import "strings" + +func validateInfoExchangeReserves(arguments map[string]interface{}) error { + scope, _, err := infoScopeBasicOrFull(arguments, "scope") + if err != nil { + return err + } + if boolArgTrue(arguments, "include_history") && scope != "full" { + return errInvalidArguments("include_history requires scope=full") + } + hw := strings.TrimSpace(stringArg(arguments, "history_window")) + if hw != "" { + if !boolArgTrue(arguments, "include_history") { + return errInvalidArguments("history_window only applies when include_history=true") + } + if strings.ToLower(hw) != "quarter" { + return errInvalidArgumentsf("history_window must be quarter (got %q)", hw) + } + } + if asset := strings.TrimSpace(stringArg(arguments, "asset")); asset != "" { + u := strings.ToUpper(asset) + switch u { + case "BTC", "ETH", "USDT", "USDC": + default: + return errInvalidArgumentsf("asset must be BTC, ETH, USDT, or USDC (got %q)", asset) + } + } + return nil +} diff --git a/internal/toolargs/info_institutional_metrics_validate.go b/internal/toolargs/info_institutional_metrics_validate.go new file mode 100644 index 0000000..0574eb0 --- /dev/null +++ b/internal/toolargs/info_institutional_metrics_validate.go @@ -0,0 +1,83 @@ +package toolargs + +import ( + "encoding/json" + "fmt" + "math" + "strings" + "time" +) + +func validateInfoInstitutionalMetrics(arguments map[string]interface{}) error { + if asset := strings.TrimSpace(stringArg(arguments, "asset")); asset != "" { + switch strings.ToLower(asset) { + case "btc", "eth", "all": + default: + return errInvalidArgumentsf("asset must be BTC, ETH, or all (got %q)", asset) + } + } + if channel := strings.TrimSpace(stringArg(arguments, "channel")); channel != "" { + switch strings.ToLower(channel) { + case "all", "etf", "cme", "cftc": + default: + return errInvalidArgumentsf("channel must be all, etf, cme, or cftc (got %q)", channel) + } + } + + start, hasStart, err := parseOptionalInfoDate(arguments, "start_date") + if err != nil { + return err + } + end, hasEnd, err := parseOptionalInfoDate(arguments, "end_date") + if err != nil { + return err + } + if hasStart && hasEnd && start.After(end) { + return errInvalidArguments("start_date must be on or before end_date") + } + + if limit, ok, err := institutionalMetricsLimitArg(arguments); err != nil { + return err + } else if ok && (limit < 1 || limit > 366) { + return errInvalidArguments("limit must be between 1 and 366") + } + return nil +} + +func institutionalMetricsLimitArg(arguments map[string]interface{}) (int, bool, error) { + v, ok := arguments["limit"] + if !ok || v == nil { + return 0, false, nil + } + switch n := v.(type) { + case int: + return n, true, nil + case int64: + return int(n), true, nil + case float64: + if math.Trunc(n) != n { + return 0, true, errInvalidArgumentsf("limit must be an integer (got %v)", n) + } + return int(n), true, nil + case json.Number: + i, err := n.Int64() + if err != nil { + return 0, true, errInvalidArgumentsf("limit must be an integer (got %q)", n.String()) + } + return int(i), true, nil + default: + return 0, true, errInvalidArgumentsf("limit must be an integer (got %q)", fmt.Sprint(v)) + } +} + +func parseOptionalInfoDate(arguments map[string]interface{}, key string) (time.Time, bool, error) { + raw := strings.TrimSpace(stringArg(arguments, key)) + if raw == "" { + return time.Time{}, false, nil + } + v, err := time.Parse("2006-01-02", raw) + if err != nil { + return time.Time{}, false, errInvalidArgumentsf("%s must be YYYY-MM-DD (got %q)", key, raw) + } + return v, true, nil +} diff --git a/internal/toolargs/info_kline_validate.go b/internal/toolargs/info_kline_validate.go new file mode 100644 index 0000000..7670095 --- /dev/null +++ b/internal/toolargs/info_kline_validate.go @@ -0,0 +1,22 @@ +package toolargs + +import "errors" + +const infoKlineMaxBars = 500 + +func validateInfoMarkettrendGetKline(arguments map[string]interface{}) error { + return validateInfoKlineSizeLimit(arguments) +} + +func validateInfoMarketdetailGetKline(arguments map[string]interface{}) error { + return validateInfoKlineSizeLimit(arguments) +} + +func validateInfoKlineSizeLimit(arguments map[string]interface{}) error { + for _, key := range []string{"size", "limit"} { + if n, ok := intArg(arguments, key); ok && n > infoKlineMaxBars { + return errors.New("invalid arguments: " + key + " must be <= 500 (use a smaller window to limit agent stdout/token cost)") + } + } + return nil +} diff --git a/internal/toolargs/info_kline_validate_test.go b/internal/toolargs/info_kline_validate_test.go new file mode 100644 index 0000000..a0803c0 --- /dev/null +++ b/internal/toolargs/info_kline_validate_test.go @@ -0,0 +1,38 @@ +package toolargs + +import "testing" + +func TestValidateForTool_MarkettrendKlineRejectsOversize(t *testing.T) { + t.Parallel() + err := ValidateForTool("info_markettrend_get_kline", map[string]interface{}{ + "symbol": "BTC", + "timeframe": "1h", + "size": 2000, + }) + if err == nil { + t.Fatal("expected error for size > 500") + } +} + +func TestValidateForTool_MarketdetailKlineRejectsOversize(t *testing.T) { + t.Parallel() + err := ValidateForTool("info_marketdetail_get_kline", map[string]interface{}{ + "symbol": "BTC", + "timeframe": "1h", + "limit": 900, + }) + if err == nil { + t.Fatal("expected error") + } +} + +func TestValidateForTool_MarkettrendKlineAllowsDefaultWindow(t *testing.T) { + t.Parallel() + if err := ValidateForTool("info_markettrend_get_kline", map[string]interface{}{ + "symbol": "BTC", + "timeframe": "1h", + "size": 200, + }); err != nil { + t.Fatalf("unexpected: %v", err) + } +} diff --git a/internal/toolargs/info_onchain_macro_validate.go b/internal/toolargs/info_onchain_macro_validate.go new file mode 100644 index 0000000..933f054 --- /dev/null +++ b/internal/toolargs/info_onchain_macro_validate.go @@ -0,0 +1,229 @@ +package toolargs + +import ( + "errors" + "strings" +) + +var onchainTxTimeRanges = map[string]struct{}{ + "1h": {}, "24h": {}, "1d": {}, "7d": {}, "30d": {}, "90d": {}, +} + +var macroIndicatorModes = map[string]struct{}{ + "latest": {}, "timeseries": {}, +} + +var coinRankingTypes = map[string]struct{}{ + "popular": {}, "top_gainers": {}, "top_losers": {}, "twitter_hot": {}, "airdrop": {}, "new_listing": {}, "market_pulse_hot": {}, +} + +var yieldPoolsScopes = map[string]struct{}{ + "basic": {}, "full": {}, +} + +var coinRankingTimeRanges = map[string]struct{}{ + "1h": {}, "24h": {}, "7d": {}, +} + +func validateInfoOnchainGetAddressInfo(arguments map[string]interface{}) error { + if !nonEmptyStringArg(arguments, "address") { + return errors.New("missing required field: address") + } + return nil +} + +func validateInfoOnchainGetAddressTransactions(arguments map[string]interface{}) error { + if !nonEmptyStringArg(arguments, "address") { + return errors.New("missing required field: address") + } + if tr := strings.TrimSpace(strings.ToLower(stringArg(arguments, "time_range"))); tr != "" { + if _, ok := onchainTxTimeRanges[tr]; !ok { + return errInvalidArgumentsf("time_range is not supported (got %q)", stringArg(arguments, "time_range")) + } + } + if limit, ok := intArg(arguments, "limit"); ok && limit > 200 { + return errInvalidArguments("limit must be at most 200") + } + return nil +} + +func validateInfoOnchainGetTransaction(arguments map[string]interface{}) error { + if !nonEmptyStringArg(arguments, "tx_hash") { + return errors.New("missing required field: tx_hash") + } + return nil +} + +func validateInfoOnchainGetTokenOnchain(arguments map[string]interface{}) error { + if !nonEmptyStringArg(arguments, "token") { + return errors.New("missing required field: token") + } + return nil +} + +func validateInfoMacroGetMacroIndicator(arguments map[string]interface{}) error { + if !nonEmptyStringArg(arguments, "indicator") { + return errors.New("missing required field: indicator") + } + if mode := strings.TrimSpace(strings.ToLower(stringArg(arguments, "mode"))); mode != "" { + if _, ok := macroIndicatorModes[mode]; !ok { + return errInvalidArgumentsf("mode must be latest or timeseries (got %q)", stringArg(arguments, "mode")) + } + } + if size, ok := intArg(arguments, "size"); ok && size > 200 { + return errInvalidArguments("size must be at most 200") + } + return nil +} + +func validateInfoMacroGetEconomicCalendar(arguments map[string]interface{}) error { + start, hasStart, err := parseOptionalInfoDate(arguments, "start_date") + if err != nil { + return err + } + end, hasEnd, err := parseOptionalInfoDate(arguments, "end_date") + if err != nil { + return err + } + if hasStart && hasEnd && start.After(end) { + return errInvalidArguments("start_date must be on or before end_date") + } + if size, ok := intArg(arguments, "size"); ok && size > 200 { + return errInvalidArguments("size must be at most 200") + } + return nil +} + +func validateInfoPlatformmetricsDefiOverview(arguments map[string]interface{}) error { + // Spec: unknown category strings pass through to ES; no closed enum reject. + return nil +} + +func validateInfoPlatformmetricsBridgeMetrics(arguments map[string]interface{}) error { + if limit, ok := intArg(arguments, "limit"); ok && limit > 100 { + return errInvalidArguments("limit must be at most 100") + } + return nil +} + +func validateInfoPlatformmetricsYieldPools(arguments map[string]interface{}) error { + if scope := strings.TrimSpace(strings.ToLower(stringArg(arguments, "scope"))); scope != "" { + if _, ok := yieldPoolsScopes[scope]; !ok { + return errInvalidArgumentsf("scope must be basic or full (got %q)", stringArg(arguments, "scope")) + } + } + if limit, ok := intArg(arguments, "limit"); ok && limit > 100 { + return errInvalidArguments("limit must be at most 100") + } + return nil +} + +func validateInfoPlatformmetricsLiquidationHeatmap(arguments map[string]interface{}) error { + if !nonEmptyStringArg(arguments, "symbol") { + return errors.New("missing required field: symbol") + } + return nil +} + +var chainActivityMetricGroups = map[string]struct{}{ + "staking": {}, + "l2": {}, + "btc_l2": {}, +} + +var chainActivityLookbacks = map[string]struct{}{ + "30d": {}, + "90d": {}, + "1y": {}, +} + +var chainActivityStakingChains = map[string]struct{}{ + "eth": {}, + "ethereum": {}, +} + +var chainActivityL2Chains = map[string]struct{}{ + "base": {}, + "arbitrum": {}, + "optimism": {}, + "linea": {}, + "zksync_era": {}, + "zksync": {}, + "blast": {}, +} + +var chainActivityBtcL2Chains = map[string]struct{}{ + "btc": {}, +} + +func validateInfoPlatformmetricsChainActivity(arguments map[string]interface{}) error { + mg := strings.TrimSpace(strings.ToLower(stringArg(arguments, "metric_group"))) + if mg == "" { + return errors.New("missing required field: metric_group") + } + if _, ok := chainActivityMetricGroups[mg]; !ok { + return errInvalidArgumentsf("metric_group is not supported (got %q)", stringArg(arguments, "metric_group")) + } + if chain := strings.TrimSpace(strings.ToLower(stringArg(arguments, "chain"))); chain != "" { + switch mg { + case "staking": + if _, ok := chainActivityStakingChains[chain]; !ok { + return errInvalidArgumentsf("chain must be eth or ethereum for metric_group=staking (got %q)", stringArg(arguments, "chain")) + } + case "l2": + if _, ok := chainActivityL2Chains[chain]; !ok { + return errInvalidArgumentsf("chain %q is not supported for metric_group=l2; supported: base, arbitrum, optimism, linea, zksync_era (alias: zksync), blast", stringArg(arguments, "chain")) + } + case "btc_l2": + if _, ok := chainActivityBtcL2Chains[chain]; !ok { + return errInvalidArgumentsf("chain must be btc for metric_group=btc_l2 (got %q)", stringArg(arguments, "chain")) + } + } + } + if lb := strings.TrimSpace(strings.ToLower(stringArg(arguments, "lookback"))); lb != "" { + if _, ok := chainActivityLookbacks[lb]; !ok { + return errInvalidArgumentsf("lookback must be 30d, 90d, or 1y (got %q)", stringArg(arguments, "lookback")) + } + } + start, hasStart, err := parseOptionalInfoDate(arguments, "start_date") + if err != nil { + return err + } + end, hasEnd, err := parseOptionalInfoDate(arguments, "end_date") + if err != nil { + return err + } + if hasStart && hasEnd && start.After(end) { + return errInvalidArguments("start_date must be on or before end_date") + } + return nil +} + +func validateInfoCoinGetCoinRankings(arguments map[string]interface{}) error { + rt := strings.TrimSpace(strings.ToLower(stringArg(arguments, "ranking_type"))) + if rt == "" { + return errors.New("missing required field: ranking_type") + } + if _, ok := coinRankingTypes[rt]; !ok { + return errInvalidArgumentsf("ranking_type is not supported (got %q)", stringArg(arguments, "ranking_type")) + } + if tr := strings.TrimSpace(strings.ToLower(stringArg(arguments, "time_range"))); tr != "" { + if _, ok := coinRankingTimeRanges[tr]; !ok { + return errInvalidArgumentsf("time_range must be 1h, 24h, or 7d (got %q)", stringArg(arguments, "time_range")) + } + if rt != "top_gainers" && rt != "top_losers" { + return errInvalidArguments("time_range is only valid for ranking_type top_gainers or top_losers") + } + } + if rt != "new_listing" { + if nonEmptyStringArg(arguments, "listing_query") || + arguments["listing_from"] != nil || + nonEmptyStringArg(arguments, "listing_tickers") { + return errInvalidArguments("listing_query, listing_from, and listing_tickers are only valid for ranking_type new_listing") + } + } + if limit, ok := intArg(arguments, "limit"); ok && limit > 100 { + return errInvalidArguments("limit must be at most 100") + } + return nil +} diff --git a/internal/toolargs/info_platform_info_validate.go b/internal/toolargs/info_platform_info_validate.go new file mode 100644 index 0000000..7c12d68 --- /dev/null +++ b/internal/toolargs/info_platform_info_validate.go @@ -0,0 +1,24 @@ +package toolargs + +import "strings" + +var platformInfoScopes = map[string]struct{}{ + "basic": {}, "with_chain_breakdown": {}, "full": {}, "detailed": {}, +} + +func validateInfoPlatformInfo(arguments map[string]interface{}) error { + scopeRaw := strings.TrimSpace(stringArg(arguments, "scope")) + scope := strings.ToLower(scopeRaw) + if scope == "" { + scope = "basic" + } else if _, ok := platformInfoScopes[scope]; !ok { + return errInvalidArgumentsf("scope must be basic, with_chain_breakdown, full, or detailed (got %q)", scopeRaw) + } + if boolArgTrue(arguments, "include_oi_symbol_detail") && scope != "full" { + return errInvalidArguments("include_oi_symbol_detail requires scope=full") + } + if limit, ok := intArg(arguments, "oi_symbol_limit"); ok && limit > 100 { + return errInvalidArguments("oi_symbol_limit must be at most 100") + } + return nil +} diff --git a/internal/toolargs/info_stablecoin_validate.go b/internal/toolargs/info_stablecoin_validate.go new file mode 100644 index 0000000..61d2b41 --- /dev/null +++ b/internal/toolargs/info_stablecoin_validate.go @@ -0,0 +1,121 @@ +package toolargs + +import ( + "strings" +) + +// validateInfoStablecoinInfo mirrors MCP spec cross-field rules for extension sections +// (see specs/mcp/info-mcp-tools-inputs-logic.json info_platformmetrics_get_stablecoin_info). +func validateInfoStablecoinInfo(arguments map[string]interface{}) error { + scope, _, err := infoScopeBasicOrFull(arguments, "scope") + if err != nil { + return err + } + + sections := stablecoinSectionsArg(arguments) + if len(sections) > 0 { + if scope != "full" { + return errInvalidArguments("sections requires scope=full") + } + hasIssuanceFlow := false + hasUsageStructure := false + hasDepegEvents := false + for _, sec := range sections { + switch strings.ToLower(strings.TrimSpace(sec)) { + case "issuance_flow": + hasIssuanceFlow = true + case "usage_structure": + hasUsageStructure = true + case "depeg_events": + hasDepegEvents = true + default: + return errInvalidArgumentsf("sections must be issuance_flow, usage_structure, or depeg_events (got %q)", sec) + } + } + if sym := strings.TrimSpace(stringArg(arguments, "symbol")); sym != "" { + u := strings.ToUpper(sym) + if hasIssuanceFlow && u != "USDT" && u != "USDC" { + return errInvalidArgumentsf("symbol must be USDT or USDC when requesting issuance_flow (got %q)", sym) + } + if hasUsageStructure && !stablecoinUsageStructureSymbolAllowed(u) { + return errInvalidArgumentsf("symbol must be USDT, USDC, DAI, FDUSD, or PYUSD when requesting usage_structure (got %q)", sym) + } + } + if chain := strings.TrimSpace(stringArg(arguments, "chain")); chain != "" && !stablecoinExtensionChainAllowed(chain) { + return errInvalidArgumentsf("chain is invalid for stablecoin extension sections (got %q)", chain) + } + if hasDepegEvents { + if v, ok := floatArg(arguments, "min_deviation"); ok && (v < 0.001 || v > 0.2) { + return errInvalidArguments("min_deviation must be between 0.001 and 0.2") + } + if v := strings.TrimSpace(stringArg(arguments, "review_status")); v != "" { + switch strings.ToLower(v) { + case "candidate", "approved", "rejected": + default: + return errInvalidArgumentsf("review_status must be candidate, approved, or rejected (got %q)", v) + } + } + } else { + if _, ok := floatArg(arguments, "min_deviation"); ok { + return errInvalidArguments("min_deviation requires sections=depeg_events") + } + if v := strings.TrimSpace(stringArg(arguments, "review_status")); v != "" { + return errInvalidArguments("review_status requires sections=depeg_events") + } + } + } + + if nonEmptyStringArg(arguments, "start_date") || nonEmptyStringArg(arguments, "end_date") { + if scope != "full" || !stablecoinSectionsHasExtension(sections) { + return errInvalidArguments("start_date and end_date require scope=full and sections=issuance_flow, usage_structure, or depeg_events") + } + } + + // Spec: omit or <=0 -> server default 10; only reject explicit out-of-range positives. + if limit, ok := intArg(arguments, "limit"); ok && limit > 0 && limit > 400 { + return errInvalidArguments("limit must be between 1 and 400") + } + return nil +} + +func stablecoinSectionsArg(arguments map[string]interface{}) []string { + v, ok := arguments["sections"] + if !ok || v == nil { + return nil + } + switch s := v.(type) { + case string: + return normalizeFlagStringList([]string{s}) + default: + return stringSliceArg(arguments, "sections") + } +} + +func stablecoinSectionsHasExtension(sections []string) bool { + for _, sec := range sections { + switch strings.ToLower(strings.TrimSpace(sec)) { + case "issuance_flow", "usage_structure", "depeg_events": + return true + } + } + return false +} + +func stablecoinUsageStructureSymbolAllowed(symbol string) bool { + switch symbol { + case "USDT", "USDC", "DAI", "FDUSD", "PYUSD": + return true + default: + return false + } +} + +func stablecoinExtensionChainAllowed(chain string) bool { + switch strings.ToLower(strings.TrimSpace(chain)) { + case "all", "ethereum", "omni", "tron", "solana", "bsc", "arbitrum", "optimism", "polygon", "avalanche", + "eth", "sol", "bnb", "arb", "op", "matic", "avax": + return true + default: + return false + } +} diff --git a/internal/toolargs/info_validate.go b/internal/toolargs/info_validate.go new file mode 100644 index 0000000..7c9011a --- /dev/null +++ b/internal/toolargs/info_validate.go @@ -0,0 +1,144 @@ +package toolargs + +import ( + "encoding/json" + "errors" + "strings" +) + +var infoMarketdetailMarketTypes = map[string]struct{}{ + "spot": {}, "futures": {}, "delivery": {}, "options": {}, +} + +var infoSearchPlatformsSortBy = map[string]struct{}{ + "tvl": {}, "volume_24h": {}, "volume_spot_24h": {}, "volume_perps_24h": {}, + "volume_perps_7d": {}, "volume_perps_30d": {}, "volume_perps_qtd": {}, "fees_24h": {}, +} + +var infoIndicatorTimeframes = map[string]struct{}{ + "15m": {}, "1h": {}, "4h": {}, "1d": {}, +} + +func validateInfoCoinGetCoinInfo(arguments map[string]interface{}) error { + if err := requireAtLeastOneString(arguments, []string{"query", "symbol"}, "query or symbol"); err != nil { + return err + } + if limit, ok := intArg(arguments, "limit"); ok && limit > 100 { + return errInvalidArguments("limit must be at most 100") + } + if size, ok := intArg(arguments, "size"); ok && size > 100 { + return errInvalidArguments("size must be at most 100") + } + return nil +} + +func validateInfoMarketSnapshot(arguments map[string]interface{}) error { + if !nonEmptyStringArg(arguments, "symbol") { + return errors.New("missing required field: symbol") + } + return nil +} + +func validateInfoMarkettrendGetTechnicalAnalysis(arguments map[string]interface{}) error { + if !nonEmptyStringArg(arguments, "symbol") { + return errors.New("missing required field: symbol") + } + return nil +} + +func validateInfoMarkettrendGetIndicatorHistory(arguments map[string]interface{}) error { + if missing := missingRequiredStringArgs(arguments, "symbol", "timeframe"); len(missing) > 0 { + return errors.New("missing required fields: " + strings.Join(missing, ", ")) + } + if len(stringSliceArg(arguments, "indicators")) == 0 && !nonEmptyStringArg(arguments, "indicators") { + return errors.New("missing required field: indicators") + } + if tf := strings.TrimSpace(strings.ToLower(stringArg(arguments, "timeframe"))); tf != "" { + if _, ok := infoIndicatorTimeframes[tf]; !ok { + return errInvalidArgumentsf("timeframe must be 15m, 1h, 4h, or 1d (got %q)", stringArg(arguments, "timeframe")) + } + } + if limit, ok := intArg(arguments, "limit"); ok && limit > 500 { + return errInvalidArguments("limit must be at most 500") + } + return nil +} + +func validateInfoMarketdetailOrderbook(arguments map[string]interface{}) error { + if !nonEmptyStringArg(arguments, "symbol") { + return errors.New("missing required field: symbol") + } + if mt := strings.TrimSpace(strings.ToLower(stringArg(arguments, "market_type"))); mt != "" { + if _, ok := infoMarketdetailMarketTypes[mt]; !ok { + return errInvalidArgumentsf("market_type must be spot, futures, delivery, or options (got %q)", stringArg(arguments, "market_type")) + } + } + if depth, ok := intArg(arguments, "depth"); ok && depth > 100 { + return errInvalidArguments("depth must be at most 100") + } + return nil +} + +func validateInfoMarketdetailRecentTrades(arguments map[string]interface{}) error { + if !nonEmptyStringArg(arguments, "symbol") { + return errors.New("missing required field: symbol") + } + if mt := strings.TrimSpace(strings.ToLower(stringArg(arguments, "market_type"))); mt != "" { + if _, ok := infoMarketdetailMarketTypes[mt]; !ok { + return errInvalidArgumentsf("market_type must be spot, futures, delivery, or options (got %q)", stringArg(arguments, "market_type")) + } + } + if limit, ok := intArg(arguments, "limit"); ok && limit > 1000 { + return errInvalidArguments("limit must be at most 1000") + } + return nil +} + +func validateInfoCoinSearchCoins(arguments map[string]interface{}) error { + hasFilter := nonEmptyStringArg(arguments, "category") || + nonEmptyStringArg(arguments, "chain") || + nonEmptyStringArg(arguments, "asset_type") || + floatArgPresent(arguments, "market_cap_min") || + floatArgPresent(arguments, "market_cap_max") + if !hasFilter { + return errors.New("missing required fields: provide at least one filter (category, chain, asset_type, or market_cap_min/max)") + } + if limit, ok := intArg(arguments, "limit"); ok && limit > 100 { + return errInvalidArguments("limit must be at most 100") + } + return nil +} + +func validateInfoPlatformmetricsSearchPlatforms(arguments map[string]interface{}) error { + if sb := strings.TrimSpace(strings.ToLower(stringArg(arguments, "sort_by"))); sb != "" { + if _, ok := infoSearchPlatformsSortBy[sb]; !ok { + return errInvalidArgumentsf("sort_by is not supported (got %q)", stringArg(arguments, "sort_by")) + } + } + if so := strings.TrimSpace(strings.ToLower(stringArg(arguments, "sort_order"))); so != "" && so != "asc" && so != "desc" { + return errInvalidArgumentsf("sort_order must be asc or desc (got %q)", stringArg(arguments, "sort_order")) + } + if limit, ok := intArg(arguments, "limit"); ok && limit > 100 { + return errInvalidArguments("limit must be at most 100") + } + return nil +} + +func floatArgPresent(arguments map[string]interface{}, key string) bool { + v, ok := arguments[key] + if !ok || v == nil { + return false + } + switch x := v.(type) { + case float64: + return true + case int: + return true + case int64: + return true + case json.Number: + return strings.TrimSpace(x.String()) != "" + default: + return false + } +} diff --git a/internal/toolargs/info_validate_common.go b/internal/toolargs/info_validate_common.go new file mode 100644 index 0000000..c2ee6e7 --- /dev/null +++ b/internal/toolargs/info_validate_common.go @@ -0,0 +1,32 @@ +package toolargs + +import "strings" + +func infoScopeBasicOrFull(arguments map[string]interface{}, key string) (normalized string, raw string, err error) { + raw = strings.TrimSpace(stringArg(arguments, key)) + normalized = strings.ToLower(raw) + if normalized == "" { + return "basic", raw, nil + } + if normalized != "basic" && normalized != "full" { + return "", raw, errInfoScopeBasicOrFull(raw) + } + return normalized, raw, nil +} + +func errInfoScopeBasicOrFull(got string) error { + return errInvalidArgumentsf("scope must be basic or full (got %q)", got) +} + +func boolArgTrue(arguments map[string]interface{}, key string) bool { + v, ok := arguments[key] + if !ok || v == nil { + return false + } + switch b := v.(type) { + case bool: + return b + default: + return false + } +} diff --git a/internal/toolargs/info_validate_test.go b/internal/toolargs/info_validate_test.go new file mode 100644 index 0000000..54529e4 --- /dev/null +++ b/internal/toolargs/info_validate_test.go @@ -0,0 +1,36 @@ +package toolargs + +import "testing" + +func TestValidateInfoIndicatorHistory(t *testing.T) { + t.Parallel() + if err := ValidateForTool("info_markettrend_get_indicator_history", map[string]interface{}{}); err == nil { + t.Fatal("expected error") + } + args := map[string]interface{}{ + "symbol": "BTC", "timeframe": "1h", "indicators": []interface{}{"rsi"}, + } + if ValidateForTool("info_markettrend_get_indicator_history", args) != nil { + t.Fatal("expected nil") + } +} + +func TestValidateInfoMarketdetailOrderbook(t *testing.T) { + t.Parallel() + if ValidateForTool("info_marketdetail_get_orderbook", map[string]interface{}{"symbol": "BTC_USDT"}) != nil { + t.Fatal("expected nil") + } + if err := ValidateForTool("info_marketdetail_get_orderbook", map[string]interface{}{"depth": 200}); err == nil { + t.Fatal("expected depth/symbol errors") + } +} + +func TestValidateInfoSearchCoinsRequiresFilter(t *testing.T) { + t.Parallel() + if err := ValidateForTool("info_coin_search_coins", map[string]interface{}{}); err == nil { + t.Fatal("expected filter error") + } + if ValidateForTool("info_coin_search_coins", map[string]interface{}{"category": "defi"}) != nil { + t.Fatal("expected nil") + } +} diff --git a/internal/toolargs/merge_test.go b/internal/toolargs/merge_test.go index 2b0c9bc..9033740 100644 --- a/internal/toolargs/merge_test.go +++ b/internal/toolargs/merge_test.go @@ -72,6 +72,30 @@ func TestMergeFromCommand_ArrayJSONToken(t *testing.T) { assert.Equal(t, []string{"rsi", "ema30"}, got["indicators"]) } +func TestMergeFromCommand_ArraySingleToken(t *testing.T) { + cmd := &cobra.Command{Use: "call"} + cmd.Flags().String("params", "", "") + cmd.Flags().String("args-json", "", "") + cmd.Flags().String("args-file", "", "") + schema := map[string]interface{}{ + "properties": map[string]interface{}{ + "sections": map[string]interface{}{ + "type": "array", + "description": "sections", + "items": map[string]interface{}{"type": "string"}, + }, + }, + } + toolschema.ApplyInputSchemaFlags(cmd, schema) + require.NoError(t, cmd.Flags().Parse([]string{"--sections", "usage_structure"})) + + got, err := MergeFromCommand(cmd, MergeOptions{ReservedFlags: map[string]struct{}{ + "params": {}, "args-json": {}, "args-file": {}, + }}) + require.NoError(t, err) + assert.Equal(t, []string{"usage_structure"}, got["sections"]) +} + func TestMergeFromCommand_BooleanSpaceSeparatedTrue(t *testing.T) { cmd := &cobra.Command{Use: "call"} cmd.Flags().String("params", "", "") @@ -163,6 +187,29 @@ func TestMergeFromCommand_ArgsFileRelativeUnderCwd(t *testing.T) { assert.Equal(t, float64(2), got["k"]) } +func TestMergeFromCommand_SearchEventsOmitsStatusWhenFlagUnset(t *testing.T) { + schema := map[string]interface{}{ + "properties": map[string]interface{}{ + "coin": map[string]interface{}{"type": "string", "description": "coin"}, + "status": map[string]interface{}{"type": "string", "description": "status", "enum": []interface{}{"active", "all"}}, + }, + } + cmd := &cobra.Command{Use: "news"} + cmd.Flags().String("params", "", "") + cmd.Flags().String("args-json", "", "") + cmd.Flags().String("args-file", "", "") + toolschema.ApplyInputSchemaFlags(cmd, schema) + require.NoError(t, cmd.Flags().Parse([]string{"--coin", "BTC"})) + + got, err := MergeFromCommand(cmd, MergeOptions{ReservedFlags: map[string]struct{}{ + "params": {}, "args-json": {}, "args-file": {}, + }}) + require.NoError(t, err) + assert.Equal(t, "BTC", got["coin"]) + _, hasStatus := got["status"] + assert.False(t, hasStatus, "unset --status must not appear in MCP arguments") +} + func TestMergeFromCommand_ArgsFileRejectsParentEscape(t *testing.T) { dir := t.TempDir() parent := filepath.Dir(dir) diff --git a/internal/toolargs/news_feed_validate.go b/internal/toolargs/news_feed_validate.go new file mode 100644 index 0000000..ff63d83 --- /dev/null +++ b/internal/toolargs/news_feed_validate.go @@ -0,0 +1,308 @@ +package toolargs + +import ( + "errors" + "strings" + "time" + "unicode/utf8" +) + +var searchNewsTimeRanges = map[string]struct{}{ + "1h": {}, "24h": {}, "7d": {}, "30d": {}, +} + +var sentimentTimeRanges = map[string]struct{}{ + "1h": {}, "24h": {}, "7d": {}, +} + +var searchXTimeRanges = map[string]struct{}{ + "1h": {}, "24h": {}, "7d": {}, +} + +var explainMarketMoveTimeRanges = map[string]struct{}{ + "30m": {}, "1h": {}, "2h": {}, "4h": {}, "24h": {}, +} + +var latestEventsTimeRanges = map[string]struct{}{ + "1h": {}, "24h": {}, "7d": {}, +} + +var ( + ugcPlatforms = map[string]struct{}{ + "reddit": {}, "discord": {}, "telegram": {}, "youtube": {}, "all": {}, + } + ugcDomains = map[string]struct{}{ + "crypto": {}, "defi": {}, "finance": {}, "macro": {}, "ai_agent": {}, "web3_dev": {}, "all": {}, + } + ugcQualityTiers = map[string]struct{}{ + "a": {}, "b": {}, "all": {}, + } + ugcTimeRanges = map[string]struct{}{ + "1h": {}, "24h": {}, "7d": {}, "30d": {}, "all": {}, + } + webSearchTimeRanges = map[string]struct{}{ + "1h": {}, "24h": {}, "7d": {}, "30d": {}, + } + socialInsightPlatforms = map[string]struct{}{ + "all": {}, "gate_square": {}, "binance_square": {}, "twitter": {}, + "telegram": {}, "youtube": {}, "reddit": {}, "discord": {}, + } +) + +func validateNewsFeedSearchNews(arguments map[string]interface{}) error { + if err := requireAtLeastOneString(arguments, []string{"query", "coin"}, "query or coin"); err != nil { + return err + } + if tr := strings.TrimSpace(strings.ToLower(stringArg(arguments, "time_range"))); tr != "" { + if _, ok := searchNewsTimeRanges[tr]; !ok { + return errInvalidArgumentsf("time_range must be 1h, 24h, 7d, or 30d (got %q)", stringArg(arguments, "time_range")) + } + } + if limit, ok := intArg(arguments, "limit"); ok && limit > 100 { + return errInvalidArguments("limit must be at most 100") + } + return nil +} + +func validateNewsEventsExplainMarketMove(arguments map[string]interface{}) error { + if missing := missingRequiredStringArgs(arguments, "query", "coin"); len(missing) > 0 { + return errors.New("missing required fields: " + strings.Join(missing, ", ")) + } + if tr := strings.TrimSpace(strings.ToLower(stringArg(arguments, "time_range"))); tr != "" { + if _, ok := explainMarketMoveTimeRanges[tr]; !ok { + return errInvalidArgumentsf("time_range must be 30m, 1h, 2h, 4h, or 24h (got %q)", stringArg(arguments, "time_range")) + } + } + return nil +} + +func validateNewsEventsGetMarketMoveReport(arguments map[string]interface{}) error { + if _, exists := arguments["is_make_new"]; exists { + return errInvalidArguments("is_make_new is not supported by the read-only MCP/CLI tool") + } + return validateMarketMoveReportSymbol(arguments) +} + +func validateNewsEventsListMarketMoveReports(arguments map[string]interface{}) error { + if err := validateMarketMoveReportSymbol(arguments); err != nil { + return err + } + if _, exists := arguments["is_make_new"]; exists { + return errInvalidArguments("is_make_new is not supported by the read-only MCP/CLI tool") + } + if missing := missingRequiredStringArgs(arguments, "start_time", "end_time"); len(missing) > 0 { + return errors.New("missing required fields: " + strings.Join(missing, ", ")) + } + start, err := parseMarketMoveReportTime(stringArg(arguments, "start_time")) + if err != nil { + return errInvalidArguments("start_time (updated_at lower bound) must be an ISO 8601 or YYYY-MM-DD HH:MM:SS UTC0 time") + } + end, err := parseMarketMoveReportTime(stringArg(arguments, "end_time")) + if err != nil { + return errInvalidArguments("end_time (updated_at upper bound) must be an ISO 8601 or YYYY-MM-DD HH:MM:SS UTC0 time") + } + if start.After(end) { + return errInvalidArguments("start_time must not be after end_time for updated_at filtering") + } + if limit, ok := intArg(arguments, "limit"); ok && (limit < 0 || limit > 100) { + return errInvalidArgumentsf("limit must be 0 (default 20) or between 1 and 100 (got %d)", limit) + } + return nil +} + +func validateMarketMoveReportSymbol(arguments map[string]interface{}) error { + symbol := strings.TrimSpace(stringArg(arguments, "symbol")) + if symbol == "" { + return errors.New("missing required field: symbol") + } + if utf8.RuneCountInString(symbol) > 20 { + return errInvalidArguments("symbol must contain at most 20 characters") + } + return nil +} + +func parseMarketMoveReportTime(raw string) (time.Time, error) { + raw = strings.TrimSpace(raw) + for _, layout := range []string{ + time.RFC3339Nano, + "2006-01-02 15:04:05Z07:00", + "2006-01-02T15:04:05", + "2006-01-02 15:04:05", + } { + var ( + parsed time.Time + err error + ) + if layout == "2006-01-02T15:04:05" || layout == "2006-01-02 15:04:05" { + parsed, err = time.ParseInLocation(layout, raw, time.UTC) + } else { + parsed, err = time.Parse(layout, raw) + } + if err == nil { + return parsed, nil + } + } + return time.Time{}, errors.New("unsupported datetime") +} + +func validateNewsFeedSearchX(arguments map[string]interface{}) error { + if !nonEmptyStringArg(arguments, "query") { + allowed := nonEmptyStringSlice(arguments, "allowed_handles") + excluded := nonEmptyStringSlice(arguments, "excluded_handles") + if len(allowed) == 0 && len(excluded) == 0 { + return errors.New("missing required field: query (or set allowed_handles / excluded_handles)") + } + } + allowed := nonEmptyStringSlice(arguments, "allowed_handles") + excluded := nonEmptyStringSlice(arguments, "excluded_handles") + if len(allowed) > 0 && len(excluded) > 0 { + return errInvalidArguments("allowed_handles and excluded_handles cannot both be set") + } + if tr := strings.TrimSpace(strings.ToLower(stringArg(arguments, "time_range"))); tr != "" { + if _, ok := searchXTimeRanges[tr]; !ok { + return errInvalidArgumentsf("time_range must be 1h, 24h, or 7d (got %q)", stringArg(arguments, "time_range")) + } + } + return nil +} + +func validateNewsFeedSearchUGC(arguments map[string]interface{}) error { + if err := requireAtLeastOneString(arguments, []string{"query", "coin"}, "query or coin"); err != nil { + return err + } + if p := strings.TrimSpace(strings.ToLower(stringArg(arguments, "platform"))); p != "" { + if _, ok := ugcPlatforms[p]; !ok { + return errInvalidArgumentsf("platform must be reddit, discord, telegram, youtube, or all (got %q)", stringArg(arguments, "platform")) + } + } + if d := strings.TrimSpace(strings.ToLower(stringArg(arguments, "domain"))); d != "" { + if _, ok := ugcDomains[d]; !ok { + return errInvalidArgumentsf("domain is not supported (got %q)", stringArg(arguments, "domain")) + } + } + if q := strings.TrimSpace(strings.ToUpper(stringArg(arguments, "quality_tier"))); q != "" { + if _, ok := ugcQualityTiers[strings.ToLower(q)]; !ok { + return errInvalidArgumentsf("quality_tier must be A, B, or all (got %q)", stringArg(arguments, "quality_tier")) + } + } + if tr := strings.TrimSpace(strings.ToLower(stringArg(arguments, "time_range"))); tr != "" { + if _, ok := ugcTimeRanges[tr]; !ok { + return errInvalidArgumentsf("time_range is not supported (got %q)", stringArg(arguments, "time_range")) + } + } + if limit, ok := intArg(arguments, "limit"); ok && limit > 50 { + return errInvalidArguments("limit must be at most 50") + } + return nil +} + +func validateNewsFeedWebSearch(arguments map[string]interface{}) error { + if !nonEmptyStringArg(arguments, "query") { + return errors.New("missing required field: query") + } + if tr := strings.TrimSpace(strings.ToLower(stringArg(arguments, "time_range"))); tr != "" { + if _, ok := webSearchTimeRanges[tr]; !ok { + return errInvalidArgumentsf("time_range must be 1h, 24h, 7d, or 30d (got %q)", stringArg(arguments, "time_range")) + } + } + if limit, ok := intArg(arguments, "limit"); ok && limit > 10 { + return errInvalidArguments("limit must be at most 10") + } + return nil +} + +func validateNewsFeedExchangeAnnouncements(arguments map[string]interface{}) error { + if err := requireAtLeastOneString(arguments, []string{"coin", "query", "exchange", "platform"}, "coin, query, exchange, or platform"); err != nil { + return err + } + if limit, ok := intArg(arguments, "limit"); ok && limit > 100 { + return errInvalidArguments("limit must be at most 100") + } + return nil +} + +func validateNewsFeedSocialSentiment(arguments map[string]interface{}) error { + if tr := strings.TrimSpace(strings.ToLower(stringArg(arguments, "time_range"))); tr != "" { + if _, ok := sentimentTimeRanges[tr]; !ok { + return errInvalidArgumentsf("time_range must be 1h, 24h, or 7d (got %q)", stringArg(arguments, "time_range")) + } + } + return nil +} + +func validateNewsFeedMentionBurst(arguments map[string]interface{}) error { + if !nonEmptyStringArg(arguments, "coin") { + return errors.New("missing required field: coin") + } + if window := strings.TrimSpace(strings.ToLower(stringArg(arguments, "window"))); window != "" && window != "24h" { + return errInvalidArgumentsf("window only supports 24h (got %q)", stringArg(arguments, "window")) + } + return validateSocialInsightPlatforms(arguments) +} + +func validateNewsFeedHotTopics(arguments map[string]interface{}) error { + if !nonEmptyStringArg(arguments, "coin") { + return errors.New("missing required field: coin") + } + if window := strings.TrimSpace(strings.ToLower(stringArg(arguments, "window"))); window != "" && window != "4h" { + return errInvalidArgumentsf("window only supports 4h (got %q)", stringArg(arguments, "window")) + } + if limit, ok := intArg(arguments, "limit"); ok && (limit < 2 || limit > 4) { + return errInvalidArgumentsf("limit must be between 2 and 4 (got %d)", limit) + } + return validateSocialInsightPlatforms(arguments) +} + +func validateSocialInsightPlatforms(arguments map[string]interface{}) error { + raw := strings.ReplaceAll(stringArg(arguments, "platforms"), ",", ",") + if strings.TrimSpace(raw) == "" { + return nil + } + seen := map[string]struct{}{} + for _, part := range strings.Split(raw, ",") { + platform := strings.TrimSpace(strings.ToLower(part)) + if platform == "" { + continue + } + if _, ok := socialInsightPlatforms[platform]; !ok { + return errInvalidArgumentsf("unsupported platform %q", platform) + } + seen[platform] = struct{}{} + } + if _, hasAll := seen["all"]; hasAll && len(seen) > 1 { + return errInvalidArguments("all cannot be combined with another platform") + } + return nil +} + +func validateNewsEventsGetLatestEvents(arguments map[string]interface{}) error { + tr := strings.TrimSpace(strings.ToLower(stringArg(arguments, "time_range"))) + hasStart := nonEmptyStringArg(arguments, "start_time") + hasEnd := nonEmptyStringArg(arguments, "end_time") + if tr != "" { + if _, ok := latestEventsTimeRanges[tr]; !ok { + return errInvalidArgumentsf("time_range must be 1h, 24h, or 7d (got %q)", stringArg(arguments, "time_range")) + } + if hasStart || hasEnd { + return errInvalidArguments("time_range cannot be used with start_time or end_time") + } + } + if limit, ok := intArg(arguments, "limit"); ok && limit > 100 { + return errInvalidArguments("limit must be at most 100") + } + return nil +} + +func nonEmptyStringSlice(arguments map[string]interface{}, key string) []string { + raw := stringSliceArg(arguments, key) + if len(raw) == 0 { + return nil + } + out := make([]string, 0, len(raw)) + for _, s := range raw { + if t := strings.TrimSpace(s); t != "" { + out = append(out, t) + } + } + return out +} diff --git a/internal/toolargs/news_feed_validate_test.go b/internal/toolargs/news_feed_validate_test.go new file mode 100644 index 0000000..3a366ff --- /dev/null +++ b/internal/toolargs/news_feed_validate_test.go @@ -0,0 +1,143 @@ +package toolargs + +import "testing" + +func TestValidateForTool_SearchNewsLimit(t *testing.T) { + t.Parallel() + if err := ValidateForTool("news_feed_search_news", map[string]interface{}{ + "coin": "BTC", + "limit": 101, + }); err == nil { + t.Fatal("expected limit cap error") + } + if err := ValidateForTool("news_feed_search_news", map[string]interface{}{ + "coin": "BTC", + "time_range": "90d", + }); err == nil { + t.Fatal("expected time_range error") + } + if err := ValidateForTool("news_feed_search_news", map[string]interface{}{ + "coin": "BTC", + }); err != nil { + t.Fatalf("unexpected: %v", err) + } +} + +func TestValidateForTool_SocialSentimentTimeRange(t *testing.T) { + t.Parallel() + if err := ValidateForTool("news_feed_get_social_sentiment", map[string]interface{}{ + "coin": "BTC", + "time_range": "30d", + }); err == nil { + t.Fatal("expected sentiment time_range to reject 30d") + } + if err := ValidateForTool("news_feed_get_social_sentiment", map[string]interface{}{ + "coin": "BTC", + "time_range": "7d", + }); err != nil { + t.Fatalf("unexpected: %v", err) + } +} + +func TestValidateForTool_MentionBurst(t *testing.T) { + t.Parallel() + valid := map[string]interface{}{ + "coin": "BTC", "window": "24h", "platforms": "twitter,reddit", + } + if err := ValidateForTool("news_feed_get_mention_burst", valid); err != nil { + t.Fatalf("valid mention burst args: %v", err) + } + for _, args := range []map[string]interface{}{ + {}, + {"coin": "BTC", "window": "12h"}, + {"coin": "BTC", "platforms": "all,twitter"}, + {"coin": "BTC", "platforms": "unknown"}, + } { + if err := ValidateForTool("news_feed_get_mention_burst", args); err == nil { + t.Fatalf("expected mention burst validation error for %#v", args) + } + } +} + +func TestValidateForTool_HotTopics(t *testing.T) { + t.Parallel() + valid := map[string]interface{}{ + "coin": "ETH", "window": "4h", "limit": int64(3), "platforms": "all", + } + if err := ValidateForTool("news_feed_get_hot_topics", valid); err != nil { + t.Fatalf("valid hot topics args: %v", err) + } + for _, args := range []map[string]interface{}{ + {}, + {"coin": "ETH", "window": "24h"}, + {"coin": "ETH", "limit": int64(1)}, + {"coin": "ETH", "limit": int64(5)}, + {"coin": "ETH", "platforms": "twitter,unknown"}, + } { + if err := ValidateForTool("news_feed_get_hot_topics", args); err == nil { + t.Fatalf("expected hot topics validation error for %#v", args) + } + } +} + +func TestValidateForTool_ExplainMarketMoveTimeRange(t *testing.T) { + t.Parallel() + if err := ValidateForTool("news_events_explain_market_move", map[string]interface{}{ + "query": "why pump", + "coin": "BTC", + "time_range": "7d", + }); err == nil { + t.Fatal("expected invalid time_range") + } + if err := ValidateForTool("news_events_explain_market_move", map[string]interface{}{ + "query": "why pump", + "coin": "BTC", + "time_range": "2h", + }); err != nil { + t.Fatalf("unexpected: %v", err) + } +} + +func TestValidateForTool_GetMarketMoveReport(t *testing.T) { + t.Parallel() + if err := ValidateForTool("news_events_get_market_move_report", map[string]interface{}{"symbol": "TAIKO", "report_id": "r1"}); err != nil { + t.Fatalf("unexpected: %v", err) + } + for _, args := range []map[string]interface{}{ + {}, + {"symbol": "THIS_SYMBOL_IS_LONGER_THAN_TWENTY"}, + {"symbol": "TAIKO", "is_make_new": true}, + } { + if err := ValidateForTool("news_events_get_market_move_report", args); err == nil { + t.Fatalf("expected validation error for %#v", args) + } + } +} + +func TestValidateForTool_ListMarketMoveReports(t *testing.T) { + t.Parallel() + valid := map[string]interface{}{ + "symbol": "ETH", "start_time": "2026-07-09 22:00:00", "end_time": "2026-07-10 03:15:00", "limit": int64(10), + } + if err := ValidateForTool("news_events_list_market_move_reports", valid); err != nil { + t.Fatalf("unexpected: %v", err) + } + withDefaultLimit := map[string]interface{}{ + "symbol": "ETH", "start_time": "2026-07-10T06:00:00+08:00", "end_time": "2026-07-10T11:15:00+08:00", "limit": int64(0), + } + if err := ValidateForTool("news_events_list_market_move_reports", withDefaultLimit); err != nil { + t.Fatalf("limit=0 and explicit timezone offsets must be valid: %v", err) + } + for _, args := range []map[string]interface{}{ + {"symbol": "ETH", "start_time": valid["start_time"]}, + {"symbol": "ETH", "start_time": "bad", "end_time": valid["end_time"]}, + {"symbol": "ETH", "start_time": valid["end_time"], "end_time": valid["start_time"]}, + {"symbol": "ETH", "start_time": valid["start_time"], "end_time": valid["end_time"], "limit": int64(-1)}, + {"symbol": "ETH", "start_time": valid["start_time"], "end_time": valid["end_time"], "limit": int64(101)}, + {"symbol": "ETH", "start_time": valid["start_time"], "end_time": valid["end_time"], "is_make_new": false}, + } { + if err := ValidateForTool("news_events_list_market_move_reports", args); err == nil { + t.Fatalf("expected validation error for %#v", args) + } + } +} diff --git a/internal/toolargs/normalize.go b/internal/toolargs/normalize.go index 1794599..c0e6c6e 100644 --- a/internal/toolargs/normalize.go +++ b/internal/toolargs/normalize.go @@ -10,7 +10,7 @@ func NormalizeForTool(toolName string, arguments map[string]interface{}) map[str } rules := aliasRules[toolName] if len(rules) == 0 { - return arguments + return normalizeNoAlias(toolName, arguments) } out := make(map[string]interface{}, len(arguments)) for k, v := range arguments { @@ -30,7 +30,12 @@ func NormalizeForTool(toolName string, arguments map[string]interface{}) map[str } delete(out, rule.FromKey) } - return out + return applyAgentArgumentDefaults(toolName, out) +} + +// Without alias rules, still apply agent defaults for known tools. +func normalizeNoAlias(toolName string, arguments map[string]interface{}) map[string]interface{} { + return applyAgentArgumentDefaults(toolName, arguments) } type argAliasRule struct { diff --git a/internal/toolargs/prediction_validate.go b/internal/toolargs/prediction_validate.go new file mode 100644 index 0000000..96a2cc9 --- /dev/null +++ b/internal/toolargs/prediction_validate.go @@ -0,0 +1,275 @@ +package toolargs + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "time" +) + +var ( + predictionVenues = map[string]struct{}{ + "polymarket": {}, + "predict_fun": {}, + } + searchEventsCategories = map[string]struct{}{ + "crypto_event": {}, "crypto_price": {}, "culture": {}, "earnings": {}, + "elections": {}, "finance": {}, "geopolitics": {}, "macro_economy": {}, + "mentions": {}, "other": {}, "politics": {}, "sports": {}, "tech_ai": {}, + "weather_climate": {}, "world": {}, + } + searchEventsStatus = map[string]struct{}{ + "active": {}, "closed": {}, "resolved": {}, "all": {}, + } + searchEventsSortBy = map[string]struct{}{ + "attention": {}, "volume": {}, "liquidity": {}, "recently_listed": {}, + "probability_change": {}, "volume_delta_today": {}, + } + eventSignalWindows = map[string]struct{}{ + "1h": {}, "24h": {}, "7d": {}, + } + predictionRankingStatus = map[string]struct{}{ + "active": {}, "closed": {}, "resolved": {}, "all": {}, + } +) + +func validateNewsPredictionOrderbook(arguments map[string]interface{}) error { + if missing := missingRequiredStringArgs(arguments, "venue", "market_id"); len(missing) > 0 { + return errors.New("missing required fields: " + strings.Join(missing, ", ")) + } + venue := strings.TrimSpace(stringArg(arguments, "venue")) + if !predictionVenueAllowed(venue) { + return errors.New("invalid arguments: venue must be polymarket or predict_fun") + } + if depth, ok := intArg(arguments, "depth"); ok { + if depth < 1 || depth > 20 { + return errors.New("invalid arguments: depth must be between 1 and 20") + } + } + mode := strings.TrimSpace(strings.ToLower(stringArg(arguments, "mode"))) + if mode == "history" { + return errors.New("invalid arguments: mode history is not supported") + } + if mode != "" && mode != "current" { + return fmt.Errorf("invalid arguments: mode must be current (got %q)", stringArg(arguments, "mode")) + } + for _, key := range []string{"granularity", "start_time", "end_time", "page_token"} { + if nonEmptyStringArg(arguments, key) { + return fmt.Errorf("invalid arguments: %s is not supported", key) + } + } + return nil +} + +func validateNewsPredictionRanking(arguments map[string]interface{}) error { + if date := strings.TrimSpace(stringArg(arguments, "date_utc")); date != "" { + if _, err := time.Parse("2006-01-02", date); err != nil { + return errInvalidArgumentsf("date_utc must be YYYY-MM-DD (got %q)", date) + } + } + for _, v := range stringSliceArg(arguments, "venue") { + if v == "" { + continue + } + if !predictionVenueAllowed(v) { + return fmt.Errorf("invalid arguments: venue must be polymarket or predict_fun (got %q)", v) + } + } + if st := strings.TrimSpace(strings.ToLower(stringArg(arguments, "status"))); st != "" { + if _, ok := predictionRankingStatus[st]; !ok { + return fmt.Errorf("invalid arguments: status must be active, closed, resolved, or all (got %q)", stringArg(arguments, "status")) + } + } + if limit, ok := intArg(arguments, "limit"); ok { + if limit < 1 || limit > 100 { + return errors.New("invalid arguments: limit must be between 1 and 100") + } + } + return nil +} + +func validateNewsPredictionSearchEvents(arguments map[string]interface{}) error { + if err := requireAtLeastOneString(arguments, []string{"query", "coin", "category"}, "query, coin, or category"); err != nil { + return err + } + if cat := strings.TrimSpace(stringArg(arguments, "category")); cat != "" { + if _, ok := searchEventsCategories[cat]; !ok { + return fmt.Errorf("invalid arguments: category must be one of the supported event_category_primary values (got %q)", cat) + } + } + if st := strings.TrimSpace(stringArg(arguments, "status")); st != "" { + if _, ok := searchEventsStatus[st]; !ok { + return fmt.Errorf("invalid arguments: status must be active, closed, resolved, or all (got %q)", st) + } + } + if sortBy := strings.TrimSpace(stringArg(arguments, "sort_by")); sortBy != "" { + if _, ok := searchEventsSortBy[sortBy]; !ok { + return fmt.Errorf("invalid arguments: sort_by is not supported (got %q)", sortBy) + } + } + for _, v := range stringSliceArg(arguments, "venue") { + if v == "" { + continue + } + if !predictionVenueAllowed(v) { + return fmt.Errorf("invalid arguments: venue must be polymarket or predict_fun (got %q)", v) + } + } + if limit, ok := intArg(arguments, "limit"); ok { + if limit < 1 || limit > 100 { + return errors.New("invalid arguments: limit must be between 1 and 100") + } + } + if err := validateSearchEventsPageToken(arguments); err != nil { + return err + } + return nil +} + +func validateSearchEventsPageToken(arguments map[string]interface{}) error { + raw := strings.TrimSpace(stringArg(arguments, "page_token")) + if raw == "" { + return nil + } + decoded, err := base64.StdEncoding.DecodeString(raw) + if err != nil { + return errInvalidArguments("page_token must be valid base64 JSON") + } + var payload map[string]interface{} + if err := json.Unmarshal(decoded, &payload); err != nil { + return errInvalidArguments("page_token must be valid base64 JSON") + } + tokenSort, _ := payload["sort_by"].(string) + tokenSort = strings.TrimSpace(tokenSort) + reqSort := strings.TrimSpace(stringArg(arguments, "sort_by")) + if tokenSort != "" && reqSort != "" && !strings.EqualFold(tokenSort, reqSort) { + return errInvalidArgumentsf("page_token sort_by %q does not match request sort_by %q", tokenSort, reqSort) + } + return nil +} + +func validateNewsPredictionEventSignal(arguments map[string]interface{}) error { + if !nonEmptyStringArg(arguments, "event_ref") { + return errors.New("missing required field: event_ref") + } + venue, _, ok := parsePredictionEventRef(stringArg(arguments, "event_ref")) + if !ok { + return errors.New("invalid arguments: event_ref must be venue:venue_event_id (first colon separates venue)") + } + if !predictionVenueAllowed(venue) { + return fmt.Errorf("invalid arguments: event_ref venue must be polymarket or predict_fun (got %q)", venue) + } + if win := strings.TrimSpace(stringArg(arguments, "window")); win != "" { + if _, allowed := eventSignalWindows[strings.ToLower(win)]; !allowed { + return fmt.Errorf("invalid arguments: window must be 1h, 24h, or 7d (got %q)", win) + } + } + for _, v := range stringSliceArg(arguments, "venue") { + if v == "" { + continue + } + if v != venue { + return fmt.Errorf("invalid arguments: venue filter %q must match event_ref venue %q", v, venue) + } + } + return nil +} + +func predictionVenueAllowed(venue string) bool { + _, ok := predictionVenues[strings.TrimSpace(venue)] + return ok +} + +func parsePredictionEventRef(eventRef string) (venue, eventID string, ok bool) { + eventRef = strings.TrimSpace(eventRef) + i := strings.Index(eventRef, ":") + if i <= 0 || i >= len(eventRef)-1 { + return "", "", false + } + return strings.TrimSpace(eventRef[:i]), eventRef[i+1:], true +} + +func stringArg(arguments map[string]interface{}, key string) string { + v, ok := arguments[key] + if !ok || v == nil { + return "" + } + switch s := v.(type) { + case string: + return s + default: + return fmt.Sprint(v) + } +} + +func stringSliceArg(arguments map[string]interface{}, key string) []string { + v, ok := arguments[key] + if !ok || v == nil { + return nil + } + switch xs := v.(type) { + case []string: + return xs + case []interface{}: + out := make([]string, 0, len(xs)) + for _, item := range xs { + if item == nil { + continue + } + out = append(out, strings.TrimSpace(fmt.Sprint(item))) + } + return out + default: + return nil + } +} + +func intArg(arguments map[string]interface{}, key string) (int, bool) { + v, ok := arguments[key] + if !ok || v == nil { + return 0, false + } + switch n := v.(type) { + case int: + return n, true + case int64: + return int(n), true + case float64: + return int(n), true + case json.Number: + i, err := n.Int64() + if err != nil { + return 0, true + } + return int(i), true + default: + return 0, false + } +} + +func floatArg(arguments map[string]interface{}, key string) (float64, bool) { + v, ok := arguments[key] + if !ok || v == nil { + return 0, false + } + switch n := v.(type) { + case float64: + return n, true + case float32: + return float64(n), true + case int: + return float64(n), true + case int64: + return float64(n), true + case json.Number: + f, err := n.Float64() + if err != nil { + return 0, true + } + return f, true + default: + return 0, false + } +} diff --git a/internal/toolargs/validate.go b/internal/toolargs/validate.go new file mode 100644 index 0000000..7f508ea --- /dev/null +++ b/internal/toolargs/validate.go @@ -0,0 +1,186 @@ +package toolargs + +import ( + "encoding/json" + "errors" + "strings" +) + +// ValidateForTool applies static argument rules before MCP tools/call (caller-fixable input). +// Server-side JSON Schema and business validation remain authoritative at runtime. +// +// Covered tools (local 400 / INVALID_ARGUMENTS, no MCP round-trip): +// - info_compliance_check_token_security: token XOR address +// - info_platformmetrics_get_platform_history: platform_name XOR exchange_slug +// - info_marketsnapshot_get_institutional_metrics: asset/channel/date/limit bounds +// - info_platformmetrics_get_stablecoin_info: scope/sections/date cross-field rules for extension sections +// - info_platformmetrics_get_exchange_reserves: include_history/history_window/asset vs scope +// - info_platformmetrics_get_platform_info: include_oi_symbol_detail/oi_symbol_limit vs scope +// - info_platformmetrics_get_cex_orderbook_depth: symbol required; market_type/data_scope/limit bounds +// - info_marketsnapshot_batch_market_snapshot: symbols required, max 20 +// - info_markettrend_get_kline: size/limit max 500 (agent stdout guard; baseline default 200) +// - news_feed_search_news: query or coin, time_range enum, limit max 100 +// - news_feed_search_x: handles XOR, time_range enum +// - news_feed_search_ugc: query/coin, enums, limit max 50 +// - news_feed_web_search: query, time_range, limit max 10 +// - news_events_get_latest_events: time_range vs start/end, limit max +// - news_events_get_event_detail, news_events_explain_market_move +// - news_events_get_market_move_report, news_events_list_market_move_reports +// - news_prediction_get_volume_delta_ranking, get_fastest_rising_ranking: date_utc, venue, status, limit +// - news_prediction_search_events, get_market_orderbook, get_event_signal +// - info_coin_get_coin_info: query or symbol; size/limit caps +// - info_marketsnapshot_get_market_snapshot: symbol required +// - news_feed_get_exchange_announcements: at least one filter; limit max 100 +// - news_feed_get_social_sentiment: time_range enum +// - news_feed_get_mention_burst, news_feed_get_hot_topics: coin, fixed window, platform enum, topic limit +// - info_markettrend_get_indicator_history, info_marketdetail_get_orderbook/recent_trades +// - info_coin_search_coins, info_platformmetrics_search_platforms +// - info_onchain_* (address/tx_hash/token), info_macro_* (indicator, calendar dates) +// - info_platformmetrics defi/bridge/yield/liquidation/chain_activity, info_coin_get_coin_rankings +func ValidateForTool(toolName string, arguments map[string]interface{}) error { + if arguments == nil { + arguments = map[string]interface{}{} + } + switch toolName { + case "info_compliance_check_token_security": + hasToken := nonEmptyStringArg(arguments, "token") + hasAddress := nonEmptyStringArg(arguments, "address") + if !hasToken && !hasAddress { + return errors.New("missing required fields: provide exactly one of token or address") + } + if hasToken && hasAddress { + return errors.New("invalid arguments: provide exactly one of token or address, not both") + } + case "info_platformmetrics_get_platform_history": + if !nonEmptyStringArg(arguments, "platform_name") && !nonEmptyStringArg(arguments, "exchange_slug") { + return errors.New("missing required fields: provide platform_name or exchange_slug (at least one)") + } + case "info_marketsnapshot_get_institutional_metrics": + return validateInfoInstitutionalMetrics(arguments) + case "info_platformmetrics_get_stablecoin_info": + return validateInfoStablecoinInfo(arguments) + case "info_platformmetrics_get_exchange_reserves": + return validateInfoExchangeReserves(arguments) + case "info_platformmetrics_get_platform_info": + return validateInfoPlatformInfo(arguments) + case "info_platformmetrics_get_cex_orderbook_depth": + return validateInfoCexOrderbookDepth(arguments) + case "info_marketsnapshot_batch_market_snapshot": + return validateInfoBatchMarketSnapshot(arguments) + case "info_coin_get_coin_info": + return validateInfoCoinGetCoinInfo(arguments) + case "info_marketsnapshot_get_market_snapshot": + return validateInfoMarketSnapshot(arguments) + case "info_markettrend_get_kline": + return validateInfoMarkettrendGetKline(arguments) + case "info_marketdetail_get_kline": + return validateInfoMarketdetailGetKline(arguments) + case "info_markettrend_get_indicator_history": + return validateInfoMarkettrendGetIndicatorHistory(arguments) + case "info_markettrend_get_technical_analysis": + return validateInfoMarkettrendGetTechnicalAnalysis(arguments) + case "info_marketdetail_get_orderbook": + return validateInfoMarketdetailOrderbook(arguments) + case "info_marketdetail_get_recent_trades": + return validateInfoMarketdetailRecentTrades(arguments) + case "info_coin_search_coins": + return validateInfoCoinSearchCoins(arguments) + case "info_platformmetrics_search_platforms": + return validateInfoPlatformmetricsSearchPlatforms(arguments) + case "info_onchain_get_address_info": + return validateInfoOnchainGetAddressInfo(arguments) + case "info_onchain_get_address_transactions": + return validateInfoOnchainGetAddressTransactions(arguments) + case "info_onchain_get_transaction": + return validateInfoOnchainGetTransaction(arguments) + case "info_onchain_get_token_onchain": + return validateInfoOnchainGetTokenOnchain(arguments) + case "info_macro_get_macro_indicator": + return validateInfoMacroGetMacroIndicator(arguments) + case "info_macro_get_economic_calendar": + return validateInfoMacroGetEconomicCalendar(arguments) + case "info_platformmetrics_get_defi_overview": + return validateInfoPlatformmetricsDefiOverview(arguments) + case "info_platformmetrics_get_bridge_metrics": + return validateInfoPlatformmetricsBridgeMetrics(arguments) + case "info_platformmetrics_get_yield_pools": + return validateInfoPlatformmetricsYieldPools(arguments) + case "info_platformmetrics_get_liquidation_heatmap": + return validateInfoPlatformmetricsLiquidationHeatmap(arguments) + case "info_platformmetrics_get_chain_activity": + return validateInfoPlatformmetricsChainActivity(arguments) + case "info_coin_get_coin_rankings": + return validateInfoCoinGetCoinRankings(arguments) + case "news_feed_search_news": + return validateNewsFeedSearchNews(arguments) + case "news_feed_search_x": + return validateNewsFeedSearchX(arguments) + case "news_feed_search_ugc": + return validateNewsFeedSearchUGC(arguments) + case "news_feed_web_search": + return validateNewsFeedWebSearch(arguments) + case "news_feed_get_exchange_announcements": + return validateNewsFeedExchangeAnnouncements(arguments) + case "news_feed_get_social_sentiment": + return validateNewsFeedSocialSentiment(arguments) + case "news_feed_get_mention_burst": + return validateNewsFeedMentionBurst(arguments) + case "news_feed_get_hot_topics": + return validateNewsFeedHotTopics(arguments) + case "news_events_get_latest_events": + return validateNewsEventsGetLatestEvents(arguments) + case "news_events_get_event_detail": + if !nonEmptyStringArg(arguments, "event_id") { + return errors.New("missing required field: event_id") + } + case "news_events_explain_market_move": + return validateNewsEventsExplainMarketMove(arguments) + case "news_events_get_market_move_report": + return validateNewsEventsGetMarketMoveReport(arguments) + case "news_events_list_market_move_reports": + return validateNewsEventsListMarketMoveReports(arguments) + case "news_prediction_get_volume_delta_ranking", "news_prediction_get_fastest_rising_ranking": + return validateNewsPredictionRanking(arguments) + case "news_prediction_search_events": + return validateNewsPredictionSearchEvents(arguments) + case "news_prediction_get_market_orderbook": + return validateNewsPredictionOrderbook(arguments) + case "news_prediction_get_event_signal": + return validateNewsPredictionEventSignal(arguments) + } + return nil +} + +func requireAtLeastOneString(arguments map[string]interface{}, keys []string, label string) error { + for _, key := range keys { + if nonEmptyStringArg(arguments, key) { + return nil + } + } + return errors.New("missing required fields: provide " + label + " (at least one)") +} + +func missingRequiredStringArgs(arguments map[string]interface{}, keys ...string) []string { + var missing []string + for _, key := range keys { + if !nonEmptyStringArg(arguments, key) { + missing = append(missing, key) + } + } + return missing +} + +func nonEmptyStringArg(arguments map[string]interface{}, key string) bool { + v, ok := arguments[key] + if !ok || v == nil { + return false + } + switch s := v.(type) { + case string: + return strings.TrimSpace(s) != "" + case json.Number: + return strings.TrimSpace(s.String()) != "" + default: + return false + } +} diff --git a/internal/toolargs/validate_test.go b/internal/toolargs/validate_test.go new file mode 100644 index 0000000..4683fef --- /dev/null +++ b/internal/toolargs/validate_test.go @@ -0,0 +1,892 @@ +package toolargs + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestValidateForTool_StablecoinSectionsRequireFullScope(t *testing.T) { + t.Parallel() + err := ValidateForTool("info_platformmetrics_get_stablecoin_info", map[string]interface{}{ + "scope": "basic", + "sections": []string{"issuance_flow"}, + }) + if err == nil || !strings.Contains(err.Error(), "scope=full") { + t.Fatalf("expected sections_requires_full_scope style error, got %v", err) + } + if ValidateForTool("info_platformmetrics_get_stablecoin_info", map[string]interface{}{ + "scope": "full", + "sections": []string{"issuance_flow"}, + }) != nil { + t.Fatal("expected nil for full + issuance_flow") + } + if ValidateForTool("info_platformmetrics_get_stablecoin_info", map[string]interface{}{ + "scope": "full", + "sections": []string{"usage_structure"}, + }) != nil { + t.Fatal("expected nil for full + usage_structure") + } +} + +func TestValidateForTool_StablecoinRejectsUnknownSection(t *testing.T) { + t.Parallel() + if ValidateForTool("info_platformmetrics_get_stablecoin_info", map[string]interface{}{ + "scope": "full", + "sections": []string{"holders"}, + }) == nil { + t.Fatal("expected error for unknown section") + } +} + +func TestValidateForTool_StablecoinSectionsStringForm(t *testing.T) { + t.Parallel() + if ValidateForTool("info_platformmetrics_get_stablecoin_info", map[string]interface{}{ + "scope": "full", + "sections": "issuance_flow,usage_structure", + }) != nil { + t.Fatal("expected nil for comma-separated string sections") + } +} + +func TestValidateForTool_StablecoinDatesRequireExtensionSection(t *testing.T) { + t.Parallel() + if ValidateForTool("info_platformmetrics_get_stablecoin_info", map[string]interface{}{ + "scope": "full", + "start_date": "2026-04-01", + }) == nil { + t.Fatal("expected error when start_date without sections") + } + if ValidateForTool("info_platformmetrics_get_stablecoin_info", map[string]interface{}{ + "scope": "basic", + "sections": []string{"issuance_flow"}, + "start_date": "2026-04-01", + }) == nil { + t.Fatal("expected error when start_date with basic scope") + } + if ValidateForTool("info_platformmetrics_get_stablecoin_info", map[string]interface{}{ + "scope": "full", + "sections": []string{"issuance_flow"}, + "start_date": "2026-04-01", + "end_date": "2026-05-01", + }) != nil { + t.Fatal("expected nil for full + issuance_flow + dates") + } + if ValidateForTool("info_platformmetrics_get_stablecoin_info", map[string]interface{}{ + "scope": "full", + "sections": []string{"usage_structure"}, + "start_date": "2026-04-01", + }) != nil { + t.Fatal("expected nil for full + usage_structure + dates") + } +} + +func TestValidateForTool_StablecoinSymbolWhitelistWithExtensionSections(t *testing.T) { + t.Parallel() + if ValidateForTool("info_platformmetrics_get_stablecoin_info", map[string]interface{}{ + "scope": "full", + "sections": []string{"issuance_flow"}, + "symbol": "DAI", + }) == nil { + t.Fatal("expected error for non-USDT/USDC symbol with issuance_flow") + } + if ValidateForTool("info_platformmetrics_get_stablecoin_info", map[string]interface{}{ + "scope": "full", + "sections": []string{"issuance_flow"}, + "symbol": "usdt", + }) != nil { + t.Fatal("expected nil for usdt with issuance_flow") + } + if ValidateForTool("info_platformmetrics_get_stablecoin_info", map[string]interface{}{ + "scope": "full", + "sections": []string{"usage_structure"}, + "symbol": "DAI", + }) != nil { + t.Fatal("expected nil for DAI with usage_structure") + } + if ValidateForTool("info_platformmetrics_get_stablecoin_info", map[string]interface{}{ + "scope": "full", + "sections": []string{"usage_structure"}, + "symbol": "EUR", + }) == nil { + t.Fatal("expected error for unsupported usage_structure symbol") + } + if ValidateForTool("info_platformmetrics_get_stablecoin_info", map[string]interface{}{ + "scope": "full", + "sections": []string{"issuance_flow", "usage_structure"}, + "symbol": "DAI", + }) == nil { + t.Fatal("expected issuance_flow whitelist to apply when both sections are requested") + } +} + +func TestValidateForTool_StablecoinRejectsInvalidScopeAndLimit(t *testing.T) { + t.Parallel() + if ValidateForTool("info_platformmetrics_get_stablecoin_info", map[string]interface{}{ + "scope": "detailed", + }) == nil { + t.Fatal("expected error for invalid scope") + } + if ValidateForTool("info_platformmetrics_get_stablecoin_info", map[string]interface{}{ + "limit": 500, + }) == nil { + t.Fatal("expected error when limit > 400") + } + if ValidateForTool("info_platformmetrics_get_stablecoin_info", map[string]interface{}{ + "limit": 0, + }) != nil { + t.Fatal("expected nil when limit<=0 (server applies default 10)") + } + if ValidateForTool("info_platformmetrics_get_stablecoin_info", map[string]interface{}{}) != nil { + t.Fatal("expected nil for empty args (server defaults)") + } +} + +func TestValidateForTool_StablecoinSectionsJSONString(t *testing.T) { + t.Parallel() + if ValidateForTool("info_platformmetrics_get_stablecoin_info", map[string]interface{}{ + "scope": "full", + "sections": `["issuance_flow","usage_structure"]`, + }) != nil { + t.Fatal("expected nil for JSON-array string sections") + } +} + +func TestValidateForTool_StablecoinExtensionChainWhitelist(t *testing.T) { + t.Parallel() + if ValidateForTool("info_platformmetrics_get_stablecoin_info", map[string]interface{}{ + "scope": "full", + "sections": []string{"usage_structure"}, + "chain": "eth", + }) != nil { + t.Fatal("expected nil for extension chain alias") + } + if ValidateForTool("info_platformmetrics_get_stablecoin_info", map[string]interface{}{ + "scope": "full", + "sections": []string{"usage_structure"}, + "chain": "moonbeam", + }) == nil { + t.Fatal("expected error for invalid extension chain") + } + if ValidateForTool("info_platformmetrics_get_stablecoin_info", map[string]interface{}{ + "scope": "basic", + "chain": "moonbeam", + }) != nil { + t.Fatal("expected basic stablecoin chain filtering to defer to server") + } +} + +func TestValidateForTool_StablecoinDepegEvents(t *testing.T) { + t.Parallel() + // depeg_events with full scope should pass + if ValidateForTool("info_platformmetrics_get_stablecoin_info", map[string]interface{}{ + "scope": "full", + "sections": []string{"depeg_events"}, + }) != nil { + t.Fatal("expected nil for full + depeg_events") + } + // depeg_events with basic scope should fail + if ValidateForTool("info_platformmetrics_get_stablecoin_info", map[string]interface{}{ + "scope": "basic", + "sections": []string{"depeg_events"}, + }) == nil { + t.Fatal("expected error for basic + depeg_events") + } + // depeg_events with min_deviation in range should pass + if ValidateForTool("info_platformmetrics_get_stablecoin_info", map[string]interface{}{ + "scope": "full", + "sections": []string{"depeg_events"}, + "min_deviation": 0.005, + }) != nil { + t.Fatal("expected nil for depeg_events with valid min_deviation") + } + // min_deviation below range should fail + if ValidateForTool("info_platformmetrics_get_stablecoin_info", map[string]interface{}{ + "scope": "full", + "sections": []string{"depeg_events"}, + "min_deviation": 0.0005, + }) == nil { + t.Fatal("expected error for min_deviation below 0.001") + } + // min_deviation above range should fail + if ValidateForTool("info_platformmetrics_get_stablecoin_info", map[string]interface{}{ + "scope": "full", + "sections": []string{"depeg_events"}, + "min_deviation": 0.3, + }) == nil { + t.Fatal("expected error for min_deviation above 0.2") + } + // valid review_status should pass + if ValidateForTool("info_platformmetrics_get_stablecoin_info", map[string]interface{}{ + "scope": "full", + "sections": []string{"depeg_events"}, + "review_status": "approved", + }) != nil { + t.Fatal("expected nil for depeg_events with valid review_status") + } + // invalid review_status should fail + if ValidateForTool("info_platformmetrics_get_stablecoin_info", map[string]interface{}{ + "scope": "full", + "sections": []string{"depeg_events"}, + "review_status": "invalid", + }) == nil { + t.Fatal("expected error for invalid review_status") + } + // min_deviation without depeg_events should fail + if ValidateForTool("info_platformmetrics_get_stablecoin_info", map[string]interface{}{ + "scope": "full", + "sections": []string{"usage_structure"}, + "min_deviation": 0.005, + }) == nil { + t.Fatal("expected error for min_deviation without depeg_events section") + } + // review_status without depeg_events should fail + if ValidateForTool("info_platformmetrics_get_stablecoin_info", map[string]interface{}{ + "scope": "full", + "sections": []string{"usage_structure"}, + "review_status": "approved", + }) == nil { + t.Fatal("expected error for review_status without depeg_events section") + } + // depeg_events with dates should pass + if ValidateForTool("info_platformmetrics_get_stablecoin_info", map[string]interface{}{ + "scope": "full", + "sections": []string{"depeg_events"}, + "start_date": "2020-01-01", + "end_date": "2026-06-01", + }) != nil { + t.Fatal("expected nil for depeg_events + dates") + } + // depeg_events combined with other sections should pass + if ValidateForTool("info_platformmetrics_get_stablecoin_info", map[string]interface{}{ + "scope": "full", + "sections": []string{"issuance_flow", "depeg_events"}, + }) != nil { + t.Fatal("expected nil for issuance_flow + depeg_events") + } +} + +func TestValidateForTool_InstitutionalMetricsEnumsAndBounds(t *testing.T) { + t.Parallel() + tool := "info_marketsnapshot_get_institutional_metrics" + if ValidateForTool(tool, map[string]interface{}{ + "asset": "eth", + "channel": "CME", + "start_date": "2026-04-01", + "end_date": "2026-05-01", + "limit": 30, + }) != nil { + t.Fatal("expected nil for valid institutional metrics arguments") + } + if ValidateForTool(tool, map[string]interface{}{"asset": "SOL"}) == nil { + t.Fatal("expected error for invalid asset") + } + if ValidateForTool(tool, map[string]interface{}{"channel": "dex"}) == nil { + t.Fatal("expected error for invalid channel") + } + if ValidateForTool(tool, map[string]interface{}{"limit": 0}) == nil { + t.Fatal("expected error for limit below range") + } + if ValidateForTool(tool, map[string]interface{}{"limit": 367}) == nil { + t.Fatal("expected error for limit above range") + } + if ValidateForTool(tool, map[string]interface{}{"limit": 30.5}) == nil { + t.Fatal("expected error for fractional limit") + } + if ValidateForTool(tool, map[string]interface{}{"limit": "30"}) == nil { + t.Fatal("expected error for string limit") + } +} + +func TestValidateForTool_InstitutionalMetricsDates(t *testing.T) { + t.Parallel() + tool := "info_marketsnapshot_get_institutional_metrics" + if ValidateForTool(tool, map[string]interface{}{"start_date": "2026/04/01"}) == nil { + t.Fatal("expected error for invalid date format") + } + if ValidateForTool(tool, map[string]interface{}{ + "start_date": "2026-05-02", + "end_date": "2026-05-01", + }) == nil { + t.Fatal("expected error for start_date after end_date") + } +} + +func TestValidateForTool_ExchangeReservesIncludeHistoryRequiresFull(t *testing.T) { + t.Parallel() + if ValidateForTool("info_platformmetrics_get_exchange_reserves", map[string]interface{}{ + "scope": "basic", + "include_history": true, + }) == nil { + t.Fatal("expected error when include_history without full scope") + } + if ValidateForTool("info_platformmetrics_get_exchange_reserves", map[string]interface{}{ + "scope": "full", + "include_history": true, + }) != nil { + t.Fatal("expected nil for full + include_history") + } +} + +func TestValidateForTool_ExchangeReservesHistoryWindowRules(t *testing.T) { + t.Parallel() + if ValidateForTool("info_platformmetrics_get_exchange_reserves", map[string]interface{}{ + "history_window": "year", + }) == nil { + t.Fatal("expected error for history_window without include_history") + } + if ValidateForTool("info_platformmetrics_get_exchange_reserves", map[string]interface{}{ + "scope": "full", + "include_history": true, + "history_window": "quarter", + }) != nil { + t.Fatal("expected nil for quarter with include_history") + } +} + +func TestValidateForTool_ExchangeReservesAssetEnum(t *testing.T) { + t.Parallel() + if ValidateForTool("info_platformmetrics_get_exchange_reserves", map[string]interface{}{ + "asset": "SOL", + }) == nil { + t.Fatal("expected error for invalid asset") + } +} + +func TestValidateForTool_PlatformInfoOIRequiresFullScope(t *testing.T) { + t.Parallel() + if ValidateForTool("info_platformmetrics_get_platform_info", map[string]interface{}{ + "platform_name": "binance", + "scope": "basic", + "include_oi_symbol_detail": true, + }) == nil { + t.Fatal("expected error when include_oi_symbol_detail without full scope") + } + if ValidateForTool("info_platformmetrics_get_platform_info", map[string]interface{}{ + "platform_name": "binance", + "scope": "full", + "include_oi_symbol_detail": true, + "oi_symbol_limit": 150, + }) == nil { + t.Fatal("expected error when oi_symbol_limit > 100") + } +} + +func TestValidateForTool_TokenSecurityRequiresTokenXORAddress(t *testing.T) { + t.Parallel() + if ValidateForTool("info_compliance_check_token_security", map[string]interface{}{ + "chain": "eth", + }) == nil { + t.Fatal("expected error when token and address are both empty") + } + if ValidateForTool("info_compliance_check_token_security", map[string]interface{}{ + "chain": "eth", + "token": "USDT", + "address": "0xd8dA6BF26964aF9D7eEd9e03E53415dA322193D", + }) == nil { + t.Fatal("expected error when token and address are both set") + } + if ValidateForTool("info_compliance_check_token_security", map[string]interface{}{ + "chain": "eth", + "token": "USDT", + }) != nil { + t.Fatal("expected nil when token is set") + } + if ValidateForTool("info_compliance_check_token_security", map[string]interface{}{ + "chain": "eth", + "address": json.Number("12345"), + }) != nil { + t.Fatal("expected nil when address is a json.Number from decoder UseNumber") + } +} + +func TestValidateForTool_PlatformHistoryRequiresOneIdentifier(t *testing.T) { + t.Parallel() + err := ValidateForTool("info_platformmetrics_get_platform_history", map[string]interface{}{ + "platform_name": " ", + "exchange_slug": "", + }) + if err == nil { + t.Fatal("expected error when both identifiers empty") + } + + if ValidateForTool("info_platformmetrics_get_platform_history", map[string]interface{}{ + "platform_name": "uniswap", + }) != nil { + t.Fatal("expected nil when platform_name set") + } + if ValidateForTool("info_platformmetrics_get_platform_history", map[string]interface{}{ + "exchange_slug": "binance", + }) != nil { + t.Fatal("expected nil when exchange_slug set") + } + if ValidateForTool("info_platformmetrics_get_platform_history", map[string]interface{}{ + "exchange_slug": json.Number("ok"), + }) != nil { + t.Fatal("expected nil when exchange_slug is json.Number from decoder UseNumber") + } + if err := ValidateForTool("info_coin_get_coin_info", map[string]interface{}{}); err == nil { + t.Fatal("expected error when query and symbol are both empty") + } + if ValidateForTool("info_coin_get_coin_info", map[string]interface{}{"symbol": "BTC"}) != nil { + t.Fatal("expected nil when symbol set") + } + if err := ValidateForTool("info_marketsnapshot_get_market_snapshot", map[string]interface{}{}); err == nil { + t.Fatal("expected error when symbol missing") + } + if err := ValidateForTool("news_feed_get_exchange_announcements", map[string]interface{}{}); err == nil { + t.Fatal("expected error when no filter fields set") + } + if ValidateForTool("news_feed_get_exchange_announcements", map[string]interface{}{"coin": "BTC"}) != nil { + t.Fatal("expected nil when coin set") + } +} + +func TestValidateForTool_SearchUGCRequiresQueryOrCoin(t *testing.T) { + t.Parallel() + if err := ValidateForTool("news_feed_search_ugc", map[string]interface{}{}); err == nil { + t.Fatal("expected error when query and coin are both empty") + } + if ValidateForTool("news_feed_search_ugc", map[string]interface{}{"query": "BTC"}) != nil { + t.Fatal("expected nil when query set") + } + if ValidateForTool("news_feed_search_ugc", map[string]interface{}{"coin": "BTC"}) != nil { + t.Fatal("expected nil when coin set") + } +} + +func TestValidateForTool_SearchEventsRequiresFilter(t *testing.T) { + t.Parallel() + if err := ValidateForTool("news_prediction_search_events", map[string]interface{}{}); err == nil { + t.Fatal("expected error when query, coin, and category are all empty") + } + if ValidateForTool("news_prediction_search_events", map[string]interface{}{"category": "crypto_price"}) != nil { + t.Fatal("expected nil when category set") + } + if ValidateForTool("news_prediction_search_events", map[string]interface{}{"coin": "BTC"}) != nil { + t.Fatal("expected nil when coin set") + } +} + +func TestValidateForTool_WebSearchRequiresQuery(t *testing.T) { + t.Parallel() + if err := ValidateForTool("news_feed_web_search", map[string]interface{}{}); err == nil { + t.Fatal("expected error when query empty") + } + if ValidateForTool("news_feed_web_search", map[string]interface{}{"query": " "}) == nil { + t.Fatal("expected error when query whitespace only") + } + if ValidateForTool("news_feed_web_search", map[string]interface{}{"query": "BTC ETF"}) != nil { + t.Fatal("expected nil when query set") + } +} + +func TestValidateForTool_EventDetailRequiresEventID(t *testing.T) { + t.Parallel() + if err := ValidateForTool("news_events_get_event_detail", map[string]interface{}{}); err == nil { + t.Fatal("expected error when event_id empty") + } + if ValidateForTool("news_events_get_event_detail", map[string]interface{}{"event_id": "evt:1"}) != nil { + t.Fatal("expected nil when event_id set") + } +} + +func TestValidateForTool_ExplainMarketMoveRequiresQueryAndCoin(t *testing.T) { + t.Parallel() + if err := ValidateForTool("news_events_explain_market_move", map[string]interface{}{}); err == nil { + t.Fatal("expected error when query and coin empty") + } + if ValidateForTool("news_events_explain_market_move", map[string]interface{}{"query": "why"}) == nil { + t.Fatal("expected error when coin missing") + } + if ValidateForTool("news_events_explain_market_move", map[string]interface{}{ + "query": "why", + "coin": "BTC", + }) != nil { + t.Fatal("expected nil when query and coin set") + } +} + +func TestValidateForTool_OrderbookRequiresVenueAndMarketID(t *testing.T) { + t.Parallel() + if err := ValidateForTool("news_prediction_get_market_orderbook", map[string]interface{}{}); err == nil { + t.Fatal("expected error when venue and market_id empty") + } + if ValidateForTool("news_prediction_get_market_orderbook", map[string]interface{}{"venue": "polymarket"}) == nil { + t.Fatal("expected error when market_id missing") + } + if ValidateForTool("news_prediction_get_market_orderbook", map[string]interface{}{ + "venue": "polymarket", + "market_id": "12345", + }) != nil { + t.Fatal("expected nil when venue and market_id set") + } +} + +func TestValidateForTool_EventSignalRequiresEventRef(t *testing.T) { + t.Parallel() + if err := ValidateForTool("news_prediction_get_event_signal", map[string]interface{}{}); err == nil { + t.Fatal("expected error when event_ref empty") + } + if ValidateForTool("news_prediction_get_event_signal", map[string]interface{}{ + "event_ref": "polymarket:107711", + }) != nil { + t.Fatal("expected nil when event_ref set") + } +} + +func TestValidateForTool_OrderbookRejectsInvalidVenue(t *testing.T) { + t.Parallel() + err := ValidateForTool("news_prediction_get_market_orderbook", map[string]interface{}{ + "venue": "opinion", "market_id": "1", + }) + if err == nil { + t.Fatal("expected error for invalid venue") + } +} + +func TestValidateForTool_OrderbookRejectsDepthOutOfRange(t *testing.T) { + t.Parallel() + if ValidateForTool("news_prediction_get_market_orderbook", map[string]interface{}{ + "venue": "polymarket", "market_id": "1", "depth": 25, + }) == nil { + t.Fatal("expected error when depth > 20") + } +} + +func TestValidateForTool_SearchEventsRejectsInvalidCategory(t *testing.T) { + t.Parallel() + if ValidateForTool("news_prediction_search_events", map[string]interface{}{ + "category": "not_a_real_category", + }) == nil { + t.Fatal("expected error for invalid category") + } +} + +func TestValidateForTool_SearchEventsRejectsInvalidStatus(t *testing.T) { + t.Parallel() + if ValidateForTool("news_prediction_search_events", map[string]interface{}{ + "coin": "BTC", "status": "open", + }) == nil { + t.Fatal("expected error for invalid status") + } +} + +func TestValidateForTool_SearchEventsRejectsInvalidLimit(t *testing.T) { + t.Parallel() + if ValidateForTool("news_prediction_search_events", map[string]interface{}{ + "coin": "BTC", "limit": 200, + }) == nil { + t.Fatal("expected error when limit > 100") + } +} + +func TestValidateForTool_EventSignalRejectsBadEventRef(t *testing.T) { + t.Parallel() + if ValidateForTool("news_prediction_get_event_signal", map[string]interface{}{ + "event_ref": "no-colon", + }) == nil { + t.Fatal("expected error when event_ref has no colon") + } +} + +func TestValidateForTool_EventSignalRejectsVenueMismatch(t *testing.T) { + t.Parallel() + if ValidateForTool("news_prediction_get_event_signal", map[string]interface{}{ + "event_ref": "polymarket:1", + "venue": []string{"predict_fun"}, + }) == nil { + t.Fatal("expected error when venue filter mismatches event_ref") + } +} + +func TestValidateForTool_EventSignalAcceptsCaseInsensitiveWindow(t *testing.T) { + t.Parallel() + if ValidateForTool("news_prediction_get_event_signal", map[string]interface{}{ + "event_ref": "polymarket:1", + "window": "7D", + }) != nil { + t.Fatal("expected nil for case-insensitive window") + } +} + +func TestValidateForTool_SearchXRejectsBothHandleLists(t *testing.T) { + t.Parallel() + err := ValidateForTool("news_feed_search_x", map[string]interface{}{ + "query": "btc", + "allowed_handles": []string{"a"}, + "excluded_handles": []string{"b"}, + }) + if err == nil || !strings.Contains(err.Error(), "allowed_handles") { + t.Fatalf("expected handles conflict error, got %v", err) + } +} + +func TestValidateForTool_SearchXRejectsInvalidTimeRange(t *testing.T) { + t.Parallel() + if ValidateForTool("news_feed_search_x", map[string]interface{}{ + "query": "btc", "time_range": "14d", + }) == nil { + t.Fatal("expected error for 14d time_range") + } +} + +func TestValidateForTool_OrderbookRejectsUnsupportedParams(t *testing.T) { + t.Parallel() + base := map[string]interface{}{"venue": "polymarket", "market_id": "1"} + cases := []map[string]interface{}{ + {"granularity": "1m"}, + {"start_time": "2026-01-01"}, + {"page_token": "x"}, + {"mode": "history"}, + } + for _, extra := range cases { + args := make(map[string]interface{}, len(base)+len(extra)) + for k, v := range base { + args[k] = v + } + for k, v := range extra { + args[k] = v + } + if ValidateForTool("news_prediction_get_market_orderbook", args) == nil { + t.Fatalf("expected error for extra %#v", extra) + } + } +} + +func TestValidateForTool_BatchMarketSnapshotSymbolsBounds(t *testing.T) { + t.Parallel() + if ValidateForTool("info_marketsnapshot_batch_market_snapshot", map[string]interface{}{}) == nil { + t.Fatal("expected error when symbols missing") + } + syms := make([]string, 21) + for i := range syms { + syms[i] = "BTC_USDT" + } + if ValidateForTool("info_marketsnapshot_batch_market_snapshot", map[string]interface{}{ + "symbols": syms, + }) == nil { + t.Fatal("expected error when symbols > 20") + } + if ValidateForTool("info_marketsnapshot_batch_market_snapshot", map[string]interface{}{ + "symbols": []string{"BTC_USDT"}, + }) != nil { + t.Fatal("expected nil for one symbol") + } +} + +func TestValidateForTool_LatestEventsTimeRangeRules(t *testing.T) { + t.Parallel() + if ValidateForTool("news_events_get_latest_events", map[string]interface{}{ + "time_range": "7d", "start_time": "2026-01-01", + }) == nil { + t.Fatal("expected error when time_range mixed with start_time") + } + if ValidateForTool("news_events_get_latest_events", map[string]interface{}{ + "limit": 101, + }) == nil { + t.Fatal("expected error when limit > 100") + } +} + +func TestValidateForTool_PredictionRankingDateAndLimit(t *testing.T) { + t.Parallel() + tool := "news_prediction_get_volume_delta_ranking" + if ValidateForTool(tool, map[string]interface{}{"date_utc": "2026/04/01"}) == nil { + t.Fatal("expected error for bad date_utc") + } + if ValidateForTool(tool, map[string]interface{}{"status": "open"}) == nil { + t.Fatal("expected error for bad status") + } + if ValidateForTool(tool, map[string]interface{}{"venue": []string{"bad"}}) == nil { + t.Fatal("expected error for bad venue") + } + if ValidateForTool(tool, map[string]interface{}{"limit": 0}) == nil { + t.Fatal("expected error for limit < 1") + } +} + +func TestValidateForTool_SearchUGCEnumAndLimit(t *testing.T) { + t.Parallel() + if ValidateForTool("news_feed_search_ugc", map[string]interface{}{ + "query": "x", "platform": "twitter", + }) == nil { + t.Fatal("expected error for bad platform") + } + if ValidateForTool("news_feed_search_ugc", map[string]interface{}{ + "coin": "BTC", "limit": 51, + }) == nil { + t.Fatal("expected error when limit > 50") + } +} + +func TestValidateForTool_WebSearchLimitAndTimeRange(t *testing.T) { + t.Parallel() + if ValidateForTool("news_feed_web_search", map[string]interface{}{ + "query": "btc", "limit": 11, + }) == nil { + t.Fatal("expected error when limit > 10") + } + if ValidateForTool("news_feed_web_search", map[string]interface{}{ + "query": "btc", "time_range": "all", + }) == nil { + t.Fatal("expected error for invalid time_range") + } +} + +func TestValidateForTool_CoinRankingsMarketPulseHot(t *testing.T) { + t.Parallel() + tool := "info_coin_get_coin_rankings" + if err := ValidateForTool(tool, map[string]interface{}{ + "ranking_type": "market_pulse_hot", + }); err != nil { + t.Fatalf("expected valid market_pulse_hot, got %v", err) + } + if ValidateForTool(tool, map[string]interface{}{ + "ranking_type": "not_a_board", + }) == nil { + t.Fatal("expected error for unsupported ranking_type") + } +} + +func TestValidateForTool_CoinRankingsCrossFieldRules(t *testing.T) { + t.Parallel() + tool := "info_coin_get_coin_rankings" + if ValidateForTool(tool, map[string]interface{}{ + "ranking_type": "popular", + "time_range": "24h", + }) == nil { + t.Fatal("expected error when time_range set for non-movers ranking_type") + } + if err := ValidateForTool(tool, map[string]interface{}{ + "ranking_type": "top_gainers", + "time_range": "24h", + }); err != nil { + t.Fatalf("expected valid gainers+time_range, got %v", err) + } + if ValidateForTool(tool, map[string]interface{}{ + "ranking_type": "popular", + "listing_query": "btc", + }) == nil { + t.Fatal("expected error when listing_query set for non-new_listing") + } +} + +func TestValidateForTool_EconomicCalendarOptionalDates(t *testing.T) { + t.Parallel() + tool := "info_macro_get_economic_calendar" + if err := ValidateForTool(tool, map[string]interface{}{}); err != nil { + t.Fatalf("expected zero-arg calendar call, got %v", err) + } + if ValidateForTool(tool, map[string]interface{}{ + "start_date": "2026-05-02", + "end_date": "2026-05-01", + }) == nil { + t.Fatal("expected error when start_date after end_date") + } + if err := ValidateForTool(tool, map[string]interface{}{ + "start_date": "2026-04-01", + }); err != nil { + t.Fatalf("expected valid start_date only, got %v", err) + } +} + +func TestValidateForTool_YieldPoolsScope(t *testing.T) { + t.Parallel() + tool := "info_platformmetrics_get_yield_pools" + if err := ValidateForTool(tool, map[string]interface{}{ + "scope": "full", + }); err != nil { + t.Fatalf("expected valid scope=full, got %v", err) + } + if ValidateForTool(tool, map[string]interface{}{ + "scope": "detailed", + }) == nil { + t.Fatal("expected error for invalid scope") + } +} + +func TestValidateForTool_CexOrderbookDepthRequiresSymbol(t *testing.T) { + t.Parallel() + if ValidateForTool("info_platformmetrics_get_cex_orderbook_depth", map[string]interface{}{}) == nil { + t.Fatal("expected error when symbol missing") + } + if ValidateForTool("info_platformmetrics_get_cex_orderbook_depth", map[string]interface{}{ + "symbol": "BTC_USDT", "market_type": "swap", + }) == nil { + t.Fatal("expected error for bad market_type") + } + if ValidateForTool("info_platformmetrics_get_cex_orderbook_depth", map[string]interface{}{ + "symbol": "BTC_USDT", "limit": 101, + }) == nil { + t.Fatal("expected error when limit > 100") + } +} + +func TestValidateForTool_ChainActivity(t *testing.T) { + t.Parallel() + tool := "info_platformmetrics_get_chain_activity" + if ValidateForTool(tool, map[string]interface{}{}) == nil { + t.Fatal("expected error when metric_group missing") + } + if ValidateForTool(tool, map[string]interface{}{ + "metric_group": "fees", + }) == nil { + t.Fatal("expected error for unsupported metric_group") + } + if ValidateForTool(tool, map[string]interface{}{ + "metric_group": "staking", "chain": "solana", + }) == nil { + t.Fatal("expected error for unsupported chain on staking") + } + if ValidateForTool(tool, map[string]interface{}{ + "metric_group": "staking", "lookback": "7d", + }) == nil { + t.Fatal("expected error for invalid lookback") + } + if ValidateForTool(tool, map[string]interface{}{ + "metric_group": "staking", + "start_date": "2026-05-02", + "end_date": "2026-05-01", + }) == nil { + t.Fatal("expected error when start_date after end_date") + } + if ValidateForTool(tool, map[string]interface{}{ + "metric_group": "staking", + "start_date": "2026/04/01", + }) == nil { + t.Fatal("expected error for invalid start_date format") + } + if err := ValidateForTool(tool, map[string]interface{}{ + "metric_group": "staking", "chain": "eth", "lookback": "90d", + }); err != nil { + t.Fatalf("expected valid args, got %v", err) + } + if err := ValidateForTool(tool, map[string]interface{}{ + "metric_group": "staking", + "start_date": "2026-04-01", + }); err != nil { + t.Fatalf("expected valid args with start_date only, got %v", err) + } +} + +func TestValidateForTool_SearchEventsPageToken(t *testing.T) { + t.Parallel() + tool := "news_prediction_search_events" + if ValidateForTool(tool, map[string]interface{}{ + "coin": "BTC", "page_token": "not-valid-base64!!!", + }) == nil { + t.Fatal("expected error for invalid page_token") + } + tokenOK := "eyJzb3J0X2J5Ijoidm9sdW1lIn0=" // {"sort_by":"volume"} + if ValidateForTool(tool, map[string]interface{}{ + "coin": "BTC", "page_token": tokenOK, "sort_by": "recently_listed", + }) == nil { + t.Fatal("expected error for sort_by mismatch with page_token") + } + if ValidateForTool(tool, map[string]interface{}{ + "coin": "BTC", "page_token": tokenOK, "sort_by": "volume", + }) != nil { + t.Fatal("expected nil when page_token sort_by matches request") + } +} diff --git a/internal/toolrender/agent_json_test.go b/internal/toolrender/agent_json_test.go new file mode 100644 index 0000000..0327aba --- /dev/null +++ b/internal/toolrender/agent_json_test.go @@ -0,0 +1,52 @@ +//go:build agent + +package toolrender + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/gate/gate-cli/internal/output" +) + +func TestPrintJSONToolResultAgentIncludesMeta(t *testing.T) { + t.Setenv("GATE_CLI_AGENT", "1") + var out, errOut bytes.Buffer + p := output.NewWithStderr(&out, &errOut, output.FormatJSON) + env := map[string]interface{}{ + "data": map[string]interface{}{"ok": true}, + "meta": map[string]interface{}{ + "freshness_hints": []string{"stale"}, + }, + } + if err := printJSONToolResult(p, env); err != nil { + t.Fatal(err) + } + var parsed map[string]interface{} + if err := json.Unmarshal(out.Bytes(), &parsed); err != nil { + t.Fatal(err) + } + if _, ok := parsed["meta"]; !ok { + t.Fatalf("want meta wrapper, got %s", out.String()) + } +} + +func TestPrintJSONToolResultAgentEmptyMeta(t *testing.T) { + t.Setenv("GATE_CLI_AGENT", "1") + var out bytes.Buffer + p := output.NewWithStderr(&out, &bytes.Buffer{}, output.FormatJSON) + env := map[string]interface{}{ + "data": map[string]interface{}{"ok": true}, + } + if err := printJSONToolResult(p, env); err != nil { + t.Fatal(err) + } + var parsed map[string]interface{} + if err := json.Unmarshal(out.Bytes(), &parsed); err != nil { + t.Fatal(err) + } + if _, ok := parsed["meta"]; !ok { + t.Fatalf("agent mode should always include meta key: %s", out.String()) + } +} diff --git a/internal/toolrender/empty.go b/internal/toolrender/empty.go new file mode 100644 index 0000000..3570ea9 --- /dev/null +++ b/internal/toolrender/empty.go @@ -0,0 +1,40 @@ +package toolrender + +// AppendResultMeta adds agent-oriented hints for empty payloads and merges with freshness meta. +func AppendResultMeta(toolName string, envelope map[string]interface{}) { + AppendFreshnessMeta(toolName, envelope) + if envelope == nil || !isEffectivelyEmpty(envelope["data"]) { + return + } + meta, _ := envelope["meta"].(map[string]interface{}) + if meta == nil { + meta = map[string]interface{}{} + } else { + meta = cloneMap(meta) + } + meta["result_hint"] = "EMPTY_RESULT" + meta["suggested_next_action"] = "no matching records; adjust coin/time_range/filters or answer that nothing was found (do not retry blindly)" + envelope["meta"] = meta +} + +func isEffectivelyEmpty(data interface{}) bool { + switch x := data.(type) { + case nil: + return true + case map[string]interface{}: + if len(x) == 0 { + return true + } + // Shortcut aggregates may be partial but non-empty structurally. + for _, v := range x { + if !isEffectivelyEmpty(v) { + return false + } + } + return true + case []interface{}: + return len(x) == 0 + default: + return false + } +} diff --git a/internal/toolrender/empty_test.go b/internal/toolrender/empty_test.go new file mode 100644 index 0000000..ebe157f --- /dev/null +++ b/internal/toolrender/empty_test.go @@ -0,0 +1,15 @@ +package toolrender + +import "testing" + +func TestAppendResultMetaEmpty(t *testing.T) { + t.Parallel() + env := map[string]interface{}{ + "data": map[string]interface{}{"items": []interface{}{}}, + } + AppendResultMeta("news_feed_search_news", env) + meta := env["meta"].(map[string]interface{}) + if meta["result_hint"] != "EMPTY_RESULT" { + t.Fatalf("meta=%v", meta) + } +} diff --git a/internal/toolrender/envelope.go b/internal/toolrender/envelope.go index 92f0f78..54b80f0 100644 --- a/internal/toolrender/envelope.go +++ b/internal/toolrender/envelope.go @@ -8,12 +8,11 @@ import ( ) // BuildCLIEnvelope normalizes a tool call result for stable JSON output. -// This is used by info/news commands to keep CLI contract consistent. +// MCP wire tool names are kept internal; user stdout omits protocol wrapper fields. func BuildCLIEnvelope(toolName string, result *mcpclient.CallResult) map[string]interface{} { if result == nil { return map[string]interface{}{ "status": "error", - "tool_name": toolName, "is_error": true, "data_source": "empty", "data": map[string]interface{}{}, @@ -25,7 +24,6 @@ func BuildCLIEnvelope(toolName string, result *mcpclient.CallResult) map[string] data, source, warnings := extractData(result) payload := map[string]interface{}{ "status": "success", - "tool_name": toolName, "is_error": result.IsError, "data_source": source, "data": data, @@ -36,12 +34,15 @@ func BuildCLIEnvelope(toolName string, result *mcpclient.CallResult) map[string] if meta := mergeMeta(result.Meta, warnings); meta != nil { payload["meta"] = meta } + AppendResultMeta(toolName, payload) return payload } func extractData(result *mcpclient.CallResult) (interface{}, string, []string) { - if result.StructuredContent != nil { - return result.StructuredContent, "structured_content", nil + // Gateways may attach structuredContent as {} or a schema-shaped object whose + // fields are all null while the real payload is in content[].text. + if sc := result.StructuredContent; structuredContentHasValue(sc) { + return sc, "structured_content", nil } if len(result.ContentRaw) > 0 { if normalized, warnings, ok := normalizeContentRaw(result.ContentRaw); ok { @@ -55,6 +56,29 @@ func extractData(result *mcpclient.CallResult) (interface{}, string, []string) { return map[string]interface{}{}, "empty", nil } +func structuredContentHasValue(v interface{}) bool { + switch x := v.(type) { + case nil: + return false + case map[string]interface{}: + for _, item := range x { + if structuredContentHasValue(item) { + return true + } + } + return false + case []interface{}: + for _, item := range x { + if structuredContentHasValue(item) { + return true + } + } + return false + default: + return true + } +} + func normalizeContentRaw(items []interface{}) (interface{}, []string, bool) { if len(items) == 0 { return nil, nil, false diff --git a/internal/toolrender/envelope_test.go b/internal/toolrender/envelope_test.go index 0f09500..ccd5559 100644 --- a/internal/toolrender/envelope_test.go +++ b/internal/toolrender/envelope_test.go @@ -13,7 +13,6 @@ func TestBuildCLIEnvelopeParsesTextJSON(t *testing.T) { ContentRaw: []interface{}{map[string]interface{}{"type": "text", "text": `{"ok":true}`}}, }) assert.Equal(t, "success", env["status"]) - assert.Equal(t, "news_feed_search_news", env["tool_name"]) assert.Equal(t, false, env["is_error"]) assert.Equal(t, "content", env["data_source"]) data, ok := env["data"].(map[string]interface{}) @@ -32,6 +31,38 @@ func TestBuildCLIEnvelopeUsesStructuredContentFirst(t *testing.T) { assert.Equal(t, "y", data["x"]) } +func TestBuildCLIEnvelopeFallsBackWhenStructuredContentEmpty(t *testing.T) { + env := BuildCLIEnvelope("info_markettrend_get_indicator_history", &mcpclient.CallResult{ + StructuredContent: map[string]interface{}{}, + ContentRaw: []interface{}{map[string]interface{}{"type": "text", "text": `{"series":[{"t":1}]}`}}, + }) + assert.Equal(t, "content", env["data_source"]) + data, ok := env["data"].(map[string]interface{}) + assert.True(t, ok) + series, ok := data["series"].([]interface{}) + assert.True(t, ok) + assert.Len(t, series, 1) +} + +func TestBuildCLIEnvelopeFallsBackWhenStructuredContentAllNull(t *testing.T) { + env := BuildCLIEnvelope("info_platformmetrics_get_stablecoin_info", &mcpclient.CallResult{ + StructuredContent: map[string]interface{}{ + "symbol": nil, + "usage_structure": map[string]interface{}{"items": nil, "summary": nil}, + }, + ContentRaw: []interface{}{map[string]interface{}{"type": "text", "text": `{"symbol":"USDT","usage_structure":{"items":[{"chain":"ethereum"}]}}`}}, + }) + assert.Equal(t, "content", env["data_source"]) + data, ok := env["data"].(map[string]interface{}) + assert.True(t, ok) + assert.Equal(t, "USDT", data["symbol"]) + usage, ok := data["usage_structure"].(map[string]interface{}) + assert.True(t, ok) + items, ok := usage["items"].([]interface{}) + assert.True(t, ok) + assert.Len(t, items, 1) +} + func TestBuildCLIEnvelopeNormalizesMultiContent(t *testing.T) { env := BuildCLIEnvelope("tool", &mcpclient.CallResult{ ContentRaw: []interface{}{ diff --git a/internal/toolrender/freshness.go b/internal/toolrender/freshness.go new file mode 100644 index 0000000..0b42fe9 --- /dev/null +++ b/internal/toolrender/freshness.go @@ -0,0 +1,175 @@ +package toolrender + +import ( + "fmt" + "strings" + "time" + + "github.com/gate/gate-cli/internal/cmdhint" +) + +const freshnessStaleAfter = 48 * time.Hour + +var freshnessTimeKeys = []string{ + "published_at", "publishedat", "created_at", "createdat", + "event_time", "eventtime", "pub_time", "timestamp", +} + +// AppendFreshnessMeta adds meta.freshness_hints for time-sensitive Intel tools (news). +func AppendFreshnessMeta(toolName string, envelope map[string]interface{}) { + if envelope == nil || !isNewsFreshnessTool(toolName) { + return + } + meta, _ := envelope["meta"].(map[string]interface{}) + if meta == nil { + meta = map[string]interface{}{} + } else { + meta = cloneMap(meta) + } + summary := buildFreshnessSummary(envelope["data"]) + if summary != nil { + meta["freshness_summary"] = summary + } + statusCLI := freshnessStatusCLI(summary) + if statusCLI != "" { + meta["freshness_status_cli"] = statusCLI + } + hints := hintsFromSummary(summary) + if len(hints) == 0 { + if statusCLI == "" || statusCLI == "unknown" { + if summary == nil { + meta["freshness_status_cli"] = "unknown" + hints = []string{"no published_at in payload; treat as unverified for «latest/current» claims (use web_search_verify in Skill)"} + } else if n, ok := summary["items_with_timestamp"].(int); ok && n == 0 { + meta["freshness_status_cli"] = "unknown" + hints = []string{"no published_at in payload; treat as unverified for «latest/current» claims (use web_search_verify in Skill)"} + } + } + } + if len(hints) > 0 { + meta["freshness_hints"] = hints + } + if cmdhint.AgentModeEnabled() { + meta["agent_reminder"] = "Separate tool facts (with published_at) from model opinions in the Skill layer; stale items must not be presented as current events." + } + envelope["meta"] = meta +} + +func freshnessStatusCLI(summary map[string]interface{}) string { + if summary == nil { + return "" + } + if counts, ok := summary["freshness_status_counts"].(map[string]int); ok && len(counts) > 0 { + if counts["stale"] > 0 { + return "stale" + } + if counts["fresh"] > 0 && counts["stale"] == 0 { + return "fresh" + } + for k, n := range counts { + if n > 0 && k != "" { + return k + } + } + } + if stale, ok := summary["newest_is_stale"].(bool); ok { + if stale { + return "stale" + } + if n, ok := summary["items_with_timestamp"].(int); ok && n > 0 { + return "fresh" + } + } + return "" +} + +func hintsFromSummary(summary map[string]interface{}) []string { + if summary == nil { + return nil + } + var hints []string + if stale, ok := summary["newest_is_stale"].(bool); ok && stale { + msg := "newest item is older than 48h" + if hours, ok := summary["newest_age_hours"].(int); ok { + msg = fmt.Sprintf("newest item is older than 48h (~%dh)", hours) + } + hints = append(hints, msg+"; do not describe as «latest» without web verification") + } + if span, ok := summary["span_over_7d"].(bool); ok && span { + hints = append(hints, "payload spans more than 7 days; confirm time_range matches the user question") + } + return hints +} + +func isFreshnessKey(key string) bool { + kl := strings.ToLower(strings.TrimSpace(key)) + for _, w := range freshnessTimeKeys { + if kl == w || strings.HasSuffix(kl, w) { + return true + } + } + return false +} + +func parseTimeValue(v interface{}) (time.Time, bool) { + switch x := v.(type) { + case string: + s := strings.TrimSpace(x) + if s == "" { + return time.Time{}, false + } + if isAllDigits(s) { + if n, err := parseInt64Digits(s); err == nil { + return unixToTime(n) + } + } + for _, layout := range []string{ + time.RFC3339, + "2006-01-02T15:04:05Z07:00", + "2006-01-02 15:04:05", + "2006-01-02", + } { + if t, err := time.Parse(layout, s); err == nil { + return t.UTC(), true + } + } + case float64: + return unixToTime(int64(x)) + case int: + return unixToTime(int64(x)) + case int64: + return unixToTime(x) + } + return time.Time{}, false +} + +func unixToTime(raw int64) (time.Time, bool) { + sec := raw + if sec <= 0 { + return time.Time{}, false + } + for sec > 1_000_000_000_000 { + sec /= 1000 + } + return time.Unix(sec, 0).UTC(), true +} + +func isAllDigits(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} + +func parseInt64Digits(s string) (int64, error) { + var n int64 + for _, r := range s { + n = n*10 + int64(r-'0') + } + return n, nil +} diff --git a/internal/toolrender/freshness_summary.go b/internal/toolrender/freshness_summary.go new file mode 100644 index 0000000..32e9c4e --- /dev/null +++ b/internal/toolrender/freshness_summary.go @@ -0,0 +1,93 @@ +package toolrender + +import ( + "strings" + "time" +) + +type freshnessStats struct { + ItemsWithTimestamp int + StatusCounts map[string]int + Timestamps []time.Time +} + +func buildFreshnessSummary(data interface{}) map[string]interface{} { + st := scanFreshnessStats(data, freshnessStats{ + StatusCounts: make(map[string]int), + }, 64) + if st.ItemsWithTimestamp == 0 && len(st.StatusCounts) == 0 { + return nil + } + summary := map[string]interface{}{ + "items_with_timestamp": st.ItemsWithTimestamp, + } + if len(st.StatusCounts) > 0 { + summary["freshness_status_counts"] = st.StatusCounts + } + if len(st.Timestamps) > 0 { + newest := st.Timestamps[0] + oldest := st.Timestamps[0] + for _, ts := range st.Timestamps[1:] { + if ts.After(newest) { + newest = ts + } + if ts.Before(oldest) { + oldest = ts + } + } + summary["newest_published_at"] = newest.Format(time.RFC3339) + summary["oldest_published_at"] = oldest.Format(time.RFC3339) + age := time.Now().UTC().Sub(newest) + if age > freshnessStaleAfter { + summary["newest_age_hours"] = int(age.Hours()) + summary["newest_is_stale"] = true + } else { + summary["newest_is_stale"] = false + } + if len(st.Timestamps) > 1 && time.Now().UTC().Sub(oldest) > 7*24*time.Hour { + summary["span_over_7d"] = true + } + } + return summary +} + +func scanFreshnessStats(v interface{}, st freshnessStats, budget int) freshnessStats { + if budget <= 0 { + return st + } + switch x := v.(type) { + case map[string]interface{}: + for k, item := range x { + kl := strings.ToLower(strings.TrimSpace(k)) + if kl == "freshness_status" || kl == "freshnessstatus" { + if s := strings.ToLower(strings.TrimSpace(stringFromInterface(item))); s != "" { + st.StatusCounts[s]++ + } + } + if isFreshnessKey(k) { + if ts, ok := parseTimeValue(item); ok { + st.ItemsWithTimestamp++ + st.Timestamps = append(st.Timestamps, ts) + budget-- + } + } + st = scanFreshnessStats(item, st, budget) + budget = 64 - len(st.Timestamps) + } + case []interface{}: + for _, item := range x { + st = scanFreshnessStats(item, st, budget) + budget = 64 - len(st.Timestamps) + } + } + return st +} + +func stringFromInterface(v interface{}) string { + switch x := v.(type) { + case string: + return x + default: + return "" + } +} diff --git a/internal/toolrender/freshness_summary_test.go b/internal/toolrender/freshness_summary_test.go new file mode 100644 index 0000000..9aa6302 --- /dev/null +++ b/internal/toolrender/freshness_summary_test.go @@ -0,0 +1,33 @@ +package toolrender + +import "testing" + +func TestBuildFreshnessSummary(t *testing.T) { + t.Parallel() + data := map[string]interface{}{ + "articles": []interface{}{ + map[string]interface{}{ + "published_at": "2026-06-01T10:00:00Z", + "freshness_status": "fresh", + }, + map[string]interface{}{ + "published_at": "2026-05-20T10:00:00Z", + "freshness_status": "stale", + }, + }, + } + summary := buildFreshnessSummary(data) + if summary == nil { + t.Fatal("expected summary") + } + if summary["items_with_timestamp"] != 2 { + t.Fatalf("items=%v", summary["items_with_timestamp"]) + } + counts, ok := summary["freshness_status_counts"].(map[string]int) + if !ok || counts["stale"] != 1 || counts["fresh"] != 1 { + t.Fatalf("counts=%v", summary["freshness_status_counts"]) + } + if got := freshnessStatusCLI(summary); got != "stale" { + t.Fatalf("freshness_status_cli=%q", got) + } +} diff --git a/internal/toolrender/freshness_test.go b/internal/toolrender/freshness_test.go new file mode 100644 index 0000000..b3ccb9d --- /dev/null +++ b/internal/toolrender/freshness_test.go @@ -0,0 +1,36 @@ +package toolrender + +import ( + "testing" + "time" +) + +func TestAppendFreshnessMetaStaleNews(t *testing.T) { + t.Parallel() + old := time.Now().UTC().Add(-72 * time.Hour).Format(time.RFC3339) + env := map[string]interface{}{ + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"published_at": old, "title": "vote ended"}, + }, + }, + } + AppendFreshnessMeta("news_feed_search_news", env) + meta, ok := env["meta"].(map[string]interface{}) + if !ok { + t.Fatal("expected meta") + } + hints, ok := meta["freshness_hints"].([]string) + if !ok || len(hints) == 0 { + t.Fatalf("hints=%v", meta["freshness_hints"]) + } +} + +func TestAppendFreshnessMetaSkipsInfoKline(t *testing.T) { + t.Parallel() + env := map[string]interface{}{"data": map[string]interface{}{"candles": []interface{}{}}} + AppendFreshnessMeta("info_markettrend_get_kline", env) + if _, ok := env["meta"]; ok { + t.Fatal("kline should not get freshness meta") + } +} diff --git a/internal/toolrender/meta_tool.go b/internal/toolrender/meta_tool.go new file mode 100644 index 0000000..66833c9 --- /dev/null +++ b/internal/toolrender/meta_tool.go @@ -0,0 +1,30 @@ +package toolrender + +import "strings" + +// MetaToolName maps a CLI command path to the pseudo MCP-style name used for envelope meta. +func MetaToolName(commandPath string) string { + commandPath = strings.TrimSpace(commandPath) + if commandPath == "" { + return "" + } + if !strings.Contains(commandPath, "/+") { + return commandPath + } + parts := strings.SplitN(commandPath, "/", 2) + if len(parts) != 2 { + return commandPath + } + backend := parts[0] + path := strings.TrimPrefix(parts[1], "+") + path = strings.ReplaceAll(path, "/", "_") + path = strings.ReplaceAll(path, "-", "_") + if backend == "" { + return "shortcut_" + path + } + return backend + "_shortcut_" + path +} + +func isNewsFreshnessTool(toolName string) bool { + return strings.HasPrefix(toolName, "news_") +} diff --git a/internal/toolrender/meta_tool_test.go b/internal/toolrender/meta_tool_test.go new file mode 100644 index 0000000..aed0099 --- /dev/null +++ b/internal/toolrender/meta_tool_test.go @@ -0,0 +1,38 @@ +package toolrender + +import "testing" + +func TestMetaToolNameNewsBrief(t *testing.T) { + t.Parallel() + if got := MetaToolName("news/+brief"); got != "news_shortcut_brief" { + t.Fatalf("got %q", got) + } +} + +func TestMetaToolNameLeavesMCPWireName(t *testing.T) { + t.Parallel() + name := "news_feed_search_news" + if got := MetaToolName(name); got != name { + t.Fatalf("got %q", got) + } +} + +func TestAppendFreshnessMetaNewsShortcut(t *testing.T) { + t.Parallel() + stale := "2020-01-01T00:00:00Z" + env := map[string]interface{}{ + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"published_at": stale}, + }, + }, + } + AppendFreshnessMeta(MetaToolName("news/+brief"), env) + meta, _ := env["meta"].(map[string]interface{}) + if meta == nil { + t.Fatal("expected meta") + } + if _, ok := meta["freshness_summary"]; !ok { + t.Fatalf("meta=%v", meta) + } +} diff --git a/internal/toolrender/onchain_pretty.go b/internal/toolrender/onchain_pretty.go new file mode 100644 index 0000000..797ff7c --- /dev/null +++ b/internal/toolrender/onchain_pretty.go @@ -0,0 +1,415 @@ +package toolrender + +import ( + "fmt" + "sort" + "strings" +) + +const ( + toolInfoOnchainGetAddressInfo = "info_onchain_get_address_info" + toolInfoOnchainGetAddressTransactions = "info_onchain_get_address_transactions" +) + +func prettyOnchainToolResult(toolName string, data map[string]interface{}) (string, bool) { + if data == nil { + return "", false + } + switch toolName { + case toolInfoOnchainGetAddressInfo: + return formatAddressInfoPretty(data), true + case toolInfoOnchainGetAddressTransactions: + return formatAddressTransactionsPretty(data), true + default: + return "", false + } +} + +func formatAddressInfoPretty(data map[string]interface{}) string { + var b strings.Builder + addr := stringField(data, "address") + chain := stringField(data, "chain") + if addr != "" || chain != "" { + b.WriteString("Address Profile\n\n") + if addr != "" { + fmt.Fprintf(&b, " address: %s\n", sanitizeTerminalText(addr)) + } + if chain != "" { + fmt.Fprintf(&b, " chain: %s\n", sanitizeTerminalText(chain)) + } + if dc := stringSliceField(data, "detected_chains"); len(dc) > 0 { + fmt.Fprintf(&b, " detected_chains: %s\n", strings.Join(dc, ", ")) + } + b.WriteByte('\n') + } + + if sum, ok := data["asset_summary"].(map[string]interface{}); ok && len(sum) > 0 { + b.WriteString("Asset Summary\n\n") + writeKV(&b, " ", "total_usd_value", formatFloatish(sum["total_usd_value"])) + writeKV(&b, " ", "native_usd_value", formatFloatish(sum["native_usd_value"])) + writeKV(&b, " ", "token_usd_value", formatFloatish(sum["token_usd_value"])) + writeKV(&b, " ", "token_num", formatFloatish(sum["token_num"])) + writeKV(&b, " ", "have_multi_chain_asset", formatFloatish(sum["have_multi_chain_asset"])) + writeKV(&b, " ", "native_balance", stringField(sum, "native_balance")) + writeKV(&b, " ", "token_value_usd", formatFloatish(sum["token_value_usd"])) + writeKV(&b, " ", "lamport", stringField(sum, "lamport")) + writeKV(&b, " ", "lamport_usd", formatFloatish(sum["lamport_usd"])) + writeKV(&b, " ", "total_value_usd", formatFloatish(sum["total_value_usd"])) + writeKV(&b, " ", "exist_address", formatFloatish(sum["exist_address"])) + b.WriteByte('\n') + } + + if rows := tokenBalanceRows(data); len(rows) > 0 { + b.WriteString("Top Token Balances\n\n") + limit := 5 + if len(rows) < limit { + limit = len(rows) + } + for i := 0; i < limit; i++ { + writeTokenBalanceRow(&b, rows[i]) + } + b.WriteByte('\n') + } + + if mc := multiChainRows(data); len(mc) > 0 { + b.WriteString("Multi-chain Assets\n\n") + limit := 5 + if len(mc) < limit { + limit = len(mc) + } + for i := 0; i < limit; i++ { + r := mc[i] + fmt.Fprintf(&b, " - chain=%s symbol=%s amount=%s usd_value=%s", + r.chain, r.symbol, r.amount, r.usdValue) + if r.pct != "" { + fmt.Fprintf(&b, " percentage=%s", r.pct) + } + b.WriteByte('\n') + } + b.WriteByte('\n') + } + + if isSolanaChain(chain) { + if solRows := solanaTokenAccountRows(data); len(solRows) > 0 { + b.WriteString("Solana Token Accounts\n\n") + limit := 5 + if len(solRows) < limit { + limit = len(solRows) + } + for i := 0; i < limit; i++ { + r := solRows[i] + fmt.Fprintf(&b, " - token_account=%s mint_account=%s token_address=%s\n", + r.tokenAccount, r.mintAccount, r.tokenAddress) + } + b.WriteByte('\n') + } + } + + rest := compactJSONFallback(data) + if rest != "" { + b.WriteString("Full JSON\n\n") + b.WriteString(rest) + } + return strings.TrimSpace(b.String()) +} + +func formatAddressTransactionsPretty(data map[string]interface{}) string { + var b strings.Builder + b.WriteString("Address Transactions\n\n") + writeKV(&b, "", "address", stringField(data, "address")) + writeKV(&b, "", "chain", stringField(data, "chain")) + writeKV(&b, "", "tx_type", stringField(data, "tx_type")) + writeKV(&b, "", "total", formatFloatish(data["total"])) + writeKV(&b, "", "count", formatFloatish(data["count"])) + b.WriteByte('\n') + + items := txItems(data) + if len(items) == 0 { + b.WriteString("No transactions in this page.\n") + return strings.TrimSpace(b.String()) + } + + b.WriteString("Items\n\n") + limit := 5 + if len(items) < limit { + limit = len(items) + } + for i := 0; i < limit; i++ { + it := items[i] + hash := firstNonEmpty(stringField(it, "hash"), stringField(it, "signature")) + fmt.Fprintf(&b, " - hash=%s from=%s to=%s value=%s value_usd=%s tx_time=%s\n", + hash, + truncateField(stringField(it, "from"), 18), + truncateField(stringField(it, "to"), 18), + stringField(it, "value"), + stringField(it, "value_usd"), + formatFloatish(it["tx_time"]), + ) + if st := stringField(it, "tx_status"); st != "" { + fmt.Fprintf(&b, " tx_status=%s\n", st) + } + if n := len(utxoSlice(it, "inputs")); n > 0 { + fmt.Fprintf(&b, " inputs: %d utxo(s)\n", n) + } + if n := len(utxoSlice(it, "outputs")); n > 0 { + fmt.Fprintf(&b, " outputs: %d utxo(s)\n", n) + } + } + if len(items) > limit { + fmt.Fprintf(&b, "\n … and %d more (use --format json)\n", len(items)-limit) + } + return strings.TrimSpace(b.String()) +} + +type tokenBalRow struct { + usdSort float64 + line string +} + +func tokenBalanceRows(data map[string]interface{}) []tokenBalRow { + raw, ok := data["token_balances"].([]interface{}) + if !ok || len(raw) == 0 { + return nil + } + rows := make([]tokenBalRow, 0, len(raw)) + for _, x := range raw { + m, ok := x.(map[string]interface{}) + if !ok { + continue + } + usd, sortKey := usdSortKey(m["value_usd"]) + sym := stringField(m, "symbol") + addr := stringField(m, "token_address") + if addr == "" { + addr = "null" + } + price := firstNonEmpty(stringField(m, "price"), stringField(m, "price_usd")) + vpct := stringField(m, "value_percent") + line := fmt.Sprintf("symbol=%s token_address=%s amount=%s value_usd=%s", + sym, addr, stringField(m, "amount"), usd) + if price != "" { + line += fmt.Sprintf(" price=%s", price) + } + if vpct != "" { + line += fmt.Sprintf(" value_percent=%s", vpct) + } + rows = append(rows, tokenBalRow{ + usdSort: sortKey, + line: line, + }) + } + sort.SliceStable(rows, func(i, j int) bool { return rows[i].usdSort > rows[j].usdSort }) + return rows +} + +type multiChainRow struct { + usdSort float64 + chain string + symbol string + amount string + usdValue string + pct string +} + +func multiChainRows(data map[string]interface{}) []multiChainRow { + raw, ok := data["multi_chain_token_balances"].([]interface{}) + if !ok || len(raw) == 0 { + return nil + } + out := make([]multiChainRow, 0, len(raw)) + for _, x := range raw { + m, ok := x.(map[string]interface{}) + if !ok { + continue + } + _, sortKey := usdSortKey(m["usd_value"]) + out = append(out, multiChainRow{ + usdSort: sortKey, + chain: stringField(m, "chain"), + symbol: firstNonEmpty(stringField(m, "token_symbol"), stringField(m, "symbol")), + amount: stringField(m, "amount"), + usdValue: formatFloatish(m["usd_value"]), + pct: formatFloatish(m["percentage"]), + }) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].usdSort > out[j].usdSort }) + return out +} + +type solanaAcctRow struct { + tokenAccount string + mintAccount string + tokenAddress string +} + +func solanaTokenAccountRows(data map[string]interface{}) []solanaAcctRow { + raw, ok := data["token_balances"].([]interface{}) + if !ok { + return nil + } + var out []solanaAcctRow + for _, x := range raw { + m, ok := x.(map[string]interface{}) + if !ok { + continue + } + ta := stringField(m, "token_account") + ma := stringField(m, "mint_account") + if ta == "" && ma == "" { + continue + } + addr := stringField(m, "token_address") + if addr == "" { + addr = "null" + } + out = append(out, solanaAcctRow{tokenAccount: ta, mintAccount: ma, tokenAddress: addr}) + } + return out +} + +func txItems(data map[string]interface{}) []map[string]interface{} { + if raw, ok := data["items"].([]interface{}); ok && len(raw) > 0 { + return mapsFromSlice(raw) + } + if raw, ok := data["transactions"].([]interface{}); ok && len(raw) > 0 { + return mapsFromSlice(raw) + } + return nil +} + +func mapsFromSlice(raw []interface{}) []map[string]interface{} { + out := make([]map[string]interface{}, 0, len(raw)) + for _, x := range raw { + if m, ok := x.(map[string]interface{}); ok { + out = append(out, m) + } + } + return out +} + +func writeTokenBalanceRow(b *strings.Builder, r tokenBalRow) { + b.WriteString(" - ") + b.WriteString(r.line) + b.WriteByte('\n') +} + +func writeKV(b *strings.Builder, prefix, key, val string) { + if val == "" { + return + } + fmt.Fprintf(b, "%s%s: %s\n", prefix, key, sanitizeTerminalText(val)) +} + +func stringField(m map[string]interface{}, key string) string { + if m == nil { + return "" + } + v, ok := m[key] + if !ok || v == nil { + return "" + } + switch t := v.(type) { + case string: + return strings.TrimSpace(t) + case float64: + if t == float64(int64(t)) { + return fmt.Sprintf("%d", int64(t)) + } + return fmt.Sprintf("%v", t) + case int: + return fmt.Sprintf("%d", t) + case int64: + return fmt.Sprintf("%d", t) + case bool: + if t { + return "true" + } + return "false" + default: + return strings.TrimSpace(fmt.Sprint(v)) + } +} + +func stringSliceField(m map[string]interface{}, key string) []string { + raw, ok := m[key].([]interface{}) + if !ok { + return nil + } + out := make([]string, 0, len(raw)) + for _, x := range raw { + if s, ok := x.(string); ok && strings.TrimSpace(s) != "" { + out = append(out, strings.TrimSpace(s)) + } + } + return out +} + +func formatFloatish(v interface{}) string { + if v == nil { + return "" + } + switch t := v.(type) { + case string: + return strings.TrimSpace(t) + case float64: + return fmt.Sprintf("%v", t) + case int: + return fmt.Sprintf("%d", t) + case int64: + return fmt.Sprintf("%d", t) + case bool: + if t { + return "true" + } + return "false" + default: + return strings.TrimSpace(fmt.Sprint(v)) + } +} + +func usdSortKey(v interface{}) (string, float64) { + s := formatFloatish(v) + if s == "" { + return "", 0 + } + var f float64 + if _, err := fmt.Sscanf(s, "%f", &f); err == nil { + return s, f + } + return s, 0 +} + +func isSolanaChain(chain string) bool { + c := strings.ToLower(strings.TrimSpace(chain)) + return c == "sol" || c == "solana" +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} + +func truncateField(s string, max int) string { + s = strings.TrimSpace(s) + if max <= 0 || len(s) <= max { + return s + } + return s[:max] + "…" +} + +func utxoSlice(it map[string]interface{}, key string) []interface{} { + raw, ok := it[key].([]interface{}) + if !ok { + return nil + } + return raw +} + +func compactJSONFallback(data map[string]interface{}) string { + // Shown only when no structured sections were written (empty profile). + return "" +} diff --git a/internal/toolrender/onchain_pretty_test.go b/internal/toolrender/onchain_pretty_test.go new file mode 100644 index 0000000..e8deee3 --- /dev/null +++ b/internal/toolrender/onchain_pretty_test.go @@ -0,0 +1,137 @@ +package toolrender + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gate/gate-cli/internal/mcpclient" + "github.com/gate/gate-cli/internal/output" +) + +func loadOnchainTestdata(t *testing.T, name string) map[string]interface{} { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", name)) + require.NoError(t, err) + var data map[string]interface{} + require.NoError(t, json.Unmarshal(b, &data)) + return data +} + +func TestFormatAddressInfoPretty_AssetSummaryAndTokens(t *testing.T) { + t.Parallel() + data := map[string]interface{}{ + "address": "0xabc", + "chain": "ethereum", + "asset_summary": map[string]interface{}{ + "total_usd_value": 100.5, + "native_usd_value": 10.0, + "token_usd_value": 90.5, + "token_num": 2, + }, + "token_balances": []interface{}{ + map[string]interface{}{"symbol": "LOW", "token_address": "0x1", "amount": "1", "value_usd": "1"}, + map[string]interface{}{"symbol": "HIGH", "token_address": "0x2", "amount": "2", "value_usd": "50"}, + }, + "multi_chain_token_balances": []interface{}{ + map[string]interface{}{"chain": "polygon", "token_symbol": "USDC", "amount": "3", "usd_value": "5", "percentage": "5"}, + }, + } + out := formatAddressInfoPretty(data) + assert.Contains(t, out, "Asset Summary") + assert.Contains(t, out, "total_usd_value") + assert.Contains(t, out, "Top Token Balances") + assert.True(t, strings.Index(out, "HIGH") < strings.Index(out, "LOW"), "tokens should be sorted by value_usd desc") + assert.Contains(t, out, "Multi-chain Assets") + assert.Contains(t, out, "polygon") +} + +func TestFormatAddressInfoPretty_SolanaTokenAccounts(t *testing.T) { + t.Parallel() + data := map[string]interface{}{ + "chain": "solana", + "token_balances": []interface{}{ + map[string]interface{}{ + "token_account": "acct1", + "mint_account": "mint1", + "token_address": "mint1", + }, + }, + } + out := formatAddressInfoPretty(data) + assert.Contains(t, out, "Solana Token Accounts") + assert.Contains(t, out, "acct1") + assert.Contains(t, out, "mint1") +} + +func TestFormatAddressTransactionsPretty_EmptyItemsNotMisleading(t *testing.T) { + t.Parallel() + out := formatAddressTransactionsPretty(map[string]interface{}{ + "address": "bc1q", + "chain": "bitcoin", + "total": 5, + "count": 0, + }) + assert.Contains(t, out, "No transactions in this page") + assert.NotContains(t, out, "暂无交易") +} + +func TestFormatAddressInfoPretty_GoldenEVMFixture(t *testing.T) { + t.Parallel() + data := loadOnchainTestdata(t, "onchain_address_info_evm.json") + out := formatAddressInfoPretty(data) + assert.Contains(t, out, "Asset Summary") + assert.Contains(t, out, "optimism") + assert.Contains(t, out, "Top Token Balances") + assert.Contains(t, out, "USDC") + assert.Contains(t, out, "Multi-chain Assets") + assert.True(t, strings.Index(out, "USDC") < strings.Index(out, "WETH")) +} + +func TestFormatAddressInfoPretty_GoldenSolanaFixture(t *testing.T) { + t.Parallel() + data := loadOnchainTestdata(t, "onchain_address_info_solana.json") + out := formatAddressInfoPretty(data) + assert.Contains(t, out, "Solana Token Accounts") + assert.Contains(t, out, "TokenAcct11111111111111111111111111111111") +} + +func TestFormatAddressTransactionsPretty_GoldenBTCPartialPage(t *testing.T) { + t.Parallel() + out := formatAddressTransactionsPretty(map[string]interface{}{ + "address": "bc1qexample", + "chain": "bitcoin", + "total": 5, + "count": 0, + }) + golden, err := os.ReadFile(filepath.Join("testdata", "onchain_address_transactions_btc.golden.txt")) + require.NoError(t, err) + assert.Equal(t, strings.TrimSpace(string(golden)), out) +} + +func TestRenderCallResult_PrettyOnchainAddressInfo(t *testing.T) { + var out bytes.Buffer + var errOut bytes.Buffer + p := output.NewWithStderr(&out, &errOut, output.FormatPretty) + + err := RenderCallResult(p, "info", toolInfoOnchainGetAddressInfo, &mcpclient.CallResult{ + StructuredContent: map[string]interface{}{ + "address": "0x1", + "chain": "optimism", + "asset_summary": map[string]interface{}{ + "total_usd_value": 1, + }, + }, + }, 0) + require.NoError(t, err) + body := out.String() + assert.Contains(t, body, "Asset Summary") + assert.Contains(t, body, "optimism") + assert.NotContains(t, body, `"tool_name"`) +} diff --git a/internal/toolrender/payload.go b/internal/toolrender/payload.go new file mode 100644 index 0000000..6404303 --- /dev/null +++ b/internal/toolrender/payload.go @@ -0,0 +1,31 @@ +package toolrender + +import ( + "encoding/json" + + "github.com/gate/gate-cli/internal/output" +) + +// RenderIntelPayload prints shortcut or aggregated Intel data with the same limits/meta as tool calls. +func RenderIntelPayload(p *output.Printer, commandPath string, data interface{}, maxBytes int64) error { + if p == nil { + return nil + } + envelope := map[string]interface{}{ + "status": "success", + "data": data, + } + AppendResultMeta(MetaToolName(commandPath), envelope) + dataJSON, err := json.Marshal(envelope["data"]) + if err != nil { + return err + } + envelope, displayJSON := ApplyOutputLimitWithData(envelope, maxBytes, dataJSON) + if !p.IsJSON() && maxBytes <= 0 { + return writePrettyToolResult(p, commandPath, envelope, nil) + } + if p.IsJSON() { + return printJSONToolResult(p, envelope) + } + return writePrettyToolResult(p, commandPath, envelope, displayJSON) +} diff --git a/internal/toolrender/platformmetrics_pretty.go b/internal/toolrender/platformmetrics_pretty.go new file mode 100644 index 0000000..42b18c9 --- /dev/null +++ b/internal/toolrender/platformmetrics_pretty.go @@ -0,0 +1,268 @@ +package toolrender + +import ( + "fmt" + "strings" +) + +const toolInfoPlatformmetricsGetChainActivity = "info_platformmetrics_get_chain_activity" + +func prettyPlatformmetricsToolResult(toolName string, data map[string]interface{}) (string, bool) { + if data == nil { + return "", false + } + switch toolName { + case toolInfoPlatformmetricsGetChainActivity: + return formatChainActivityPretty(data), true + default: + return "", false + } +} + +func formatChainActivityPretty(data map[string]interface{}) string { + mg := stringField(data, "metric_group") + var b strings.Builder + + switch mg { + case "l2": + b.WriteString("Chain Activity (L2)\n\n") + case "btc_l2": + b.WriteString("Chain Activity (BTC L2)\n\n") + default: + b.WriteString("Chain Activity (Staking)\n\n") + } + + chain := firstNonEmpty(stringField(data, "normalized_chain"), stringField(data, "chain")) + writeKV(&b, "", "chain", chain) + writeKV(&b, "", "metric_group", mg) + writeKV(&b, "", "start_date", stringField(data, "start_date")) + writeKV(&b, "", "end_date", stringField(data, "end_date")) + writeKV(&b, "", "lookback", stringField(data, "lookback")) + writeKV(&b, "", "total", stringField(data, "total")) + writeKV(&b, "", "count", stringField(data, "count")) + writeKV(&b, "", "data_status", stringField(data, "data_status")) + writePrettyBoolFlag(&b, data, "range_truncated") + writePrettyBoolFlag(&b, data, "start_date_capped") + writePrettyBoolFlag(&b, data, "end_date_capped") + b.WriteByte('\n') + + switch mg { + case "btc_l2": + formatBtcL2Items(&b, data) + case "l2": + formatL2Series(&b, data) + default: + formatStakingSeries(&b, data) + } + + return strings.TrimSpace(b.String()) +} + +func formatStakingSeries(b *strings.Builder, data map[string]interface{}) { + series := chainActivityStakingSeries(data) + if len(series) == 0 { + b.WriteString("Staking Series\n\n") + b.WriteString(" No series points in response.\n") + return + } + + b.WriteString("Latest Snapshot\n\n") + writeChainActivityPoint(b, " ", series[0]) + b.WriteByte('\n') + + b.WriteString("Recent Series\n\n") + limit := 7 + if len(series) < limit { + limit = len(series) + } + for i := 0; i < limit; i++ { + writeChainActivitySeriesLine(b, series[i]) + } +} + +func formatL2Series(b *strings.Builder, data map[string]interface{}) { + series := chainActivityL2Series(data) + if len(series) == 0 { + b.WriteString("L2 Series\n\n") + b.WriteString(" No series points in response.\n") + return + } + + b.WriteString("Recent Series\n\n") + for i := range series { + writeL2SeriesLine(b, series[i]) + } +} + +func formatBtcL2Items(b *strings.Builder, data map[string]interface{}) { + items := chainActivityBtcL2Items(data) + if len(items) == 0 { + b.WriteString("BTC L2 Projects\n\n") + b.WriteString(" No project items in response.\n") + return + } + + b.WriteString("BTC L2 Projects\n\n") + for _, it := range items { + writeKV(b, " ", "project", stringField(it, "project_key")) + writeKV(b, " ", "name", stringField(it, "project_name")) + writeKV(b, " ", "category", stringField(it, "category")) + writeKV(b, " ", "main_chain", stringField(it, "main_chain")) + writeKV(b, " ", "tvl_usd", stringField(it, "tvl_usd")) + writeKV(b, " ", "protocol_count", stringField(it, "protocol_count")) + writeKV(b, " ", "tx_count_1d", stringField(it, "tx_count_1d")) + writeKV(b, " ", "active_addresses_1d", stringField(it, "active_addresses_1d")) + writeKV(b, " ", "bridge_volume_24h_usd", stringField(it, "bridge_volume_24h_usd")) + writeKV(b, " ", "data_status", stringField(it, "data_status")) + if missing := stringSliceField(it, "missing_required_fields"); len(missing) > 0 { + writeKV(b, " ", "missing_required_fields", strings.Join(missing, ", ")) + } + b.WriteByte('\n') + } +} + +func chainActivityStakingSeries(data map[string]interface{}) []map[string]interface{} { + sm, ok := data["staking_metrics"].(map[string]interface{}) + if !ok { + return nil + } + raw, ok := sm["series"].([]interface{}) + if !ok { + return nil + } + return mapsFromSlice(raw) +} + +func chainActivityL2Series(data map[string]interface{}) []map[string]interface{} { + lm, ok := data["l2_metrics"].(map[string]interface{}) + if !ok { + return nil + } + raw, ok := lm["series"].([]interface{}) + if !ok { + return nil + } + return mapsFromSlice(raw) +} + +func chainActivityBtcL2Items(data map[string]interface{}) []map[string]interface{} { + bm, ok := data["btc_l2_metrics"].(map[string]interface{}) + if !ok { + return nil + } + raw, ok := bm["items"].([]interface{}) + if !ok { + return nil + } + return mapsFromSlice(raw) +} + +func writeL2SeriesLine(b *strings.Builder, pt map[string]interface{}) { + date := stringField(pt, "date") + if date == "" { + date = "?" + } + parts := []string{ + fmt.Sprintf("date=%s", date), + kvPart("chain", stringField(pt, "chain")), + kvPart("tps_1d", stringField(pt, "tps_avg_1d")), + kvPart("active_addr_1d", stringField(pt, "active_addresses_1d")), + kvPart("blob_cost_usd_1d", stringField(pt, "blob_cost_usd_1d")), + kvPart("seq_rev_usd_1d", stringField(pt, "sequencer_revenue_usd_1d")), + kvPart("stage", stringField(pt, "stage_label")), + kvPart("tvl_usd", stringField(pt, "tvl_usd")), + kvPart("data_source", stringField(pt, "data_source")), + } + line := " - " + joinNonEmptyParts(parts) + if status := stringField(pt, "data_status"); status != "" && status != "ok" { + line += fmt.Sprintf(" status=%s", status) + } + b.WriteString(line) + b.WriteByte('\n') +} + +func writeChainActivityPoint(b *strings.Builder, prefix string, pt map[string]interface{}) { + writeKV(b, prefix, "date", stringField(pt, "date")) + writeKV(b, prefix, "validator_active", stringField(pt, "validator_active")) + writeKV(b, prefix, "total_value_staked_eth", stringField(pt, "total_value_staked_eth")) + writeKV(b, prefix, "staking_rate", stringField(pt, "staking_rate")) + writeKV(b, prefix, "eth_supply", stringField(pt, "eth_supply")) + writeKV(b, prefix, "staking_apr_7d", stringField(pt, "staking_apr_7d")) + writeKV(b, prefix, "entry_queue_eth", stringField(pt, "entry_queue_eth")) + writeKV(b, prefix, "exit_queue_eth", stringField(pt, "exit_queue_eth")) + writeKV(b, prefix, "entry_wait_days", stringField(pt, "entry_wait_days")) + writeKV(b, prefix, "exit_wait_days", stringField(pt, "exit_wait_days")) + writeKV(b, prefix, "entry_queue_validator_estimate", stringField(pt, "entry_queue_validator_estimate")) + writeKV(b, prefix, "exit_queue_validator_estimate", stringField(pt, "exit_queue_validator_estimate")) + writeKV(b, prefix, "data_status", stringField(pt, "data_status")) + writeKV(b, prefix, "quality_note", stringField(pt, "quality_note")) + if missing := stringSliceField(pt, "missing_fields"); len(missing) > 0 { + writeKV(b, prefix, "missing_fields", strings.Join(missing, ", ")) + } +} + +func writeChainActivitySeriesLine(b *strings.Builder, pt map[string]interface{}) { + date := stringField(pt, "date") + if date == "" { + date = "?" + } + parts := []string{ + fmt.Sprintf("date=%s", date), + kvPart("validators", stringField(pt, "validator_active")), + kvPart("staked_eth", stringField(pt, "total_value_staked_eth")), + kvPart("rate", stringField(pt, "staking_rate")), + kvPart("eth_supply", stringField(pt, "eth_supply")), + kvPart("apr_7d", stringField(pt, "staking_apr_7d")), + kvPart("entry_q_eth", stringField(pt, "entry_queue_eth")), + kvPart("exit_q_eth", stringField(pt, "exit_queue_eth")), + kvPart("entry_wait_d", stringField(pt, "entry_wait_days")), + kvPart("exit_wait_d", stringField(pt, "exit_wait_days")), + } + line := " - " + joinNonEmptyParts(parts) + if status := stringField(pt, "data_status"); status != "" && status != "ok" { + line += fmt.Sprintf(" status=%s", status) + } + if note := stringField(pt, "quality_note"); note != "" { + line += fmt.Sprintf(" note=%s", truncateField(note, 48)) + } + b.WriteString(line) + b.WriteByte('\n') +} + +func writePrettyBoolFlag(b *strings.Builder, data map[string]interface{}, key string) { + v, ok := data[key] + if !ok || v == nil { + return + } + switch t := v.(type) { + case bool: + if t { + writeKV(b, "", key, "true") + } + case string: + if strings.EqualFold(strings.TrimSpace(t), "true") { + writeKV(b, "", key, "true") + } + default: + if s := formatFloatish(v); strings.EqualFold(s, "true") || s == "1" { + writeKV(b, "", key, s) + } + } +} + +func kvPart(label, val string) string { + if val == "" { + return "" + } + return label + "=" + val +} + +func joinNonEmptyParts(parts []string) string { + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p != "" { + out = append(out, p) + } + } + return strings.Join(out, " ") +} diff --git a/internal/toolrender/platformmetrics_pretty_test.go b/internal/toolrender/platformmetrics_pretty_test.go new file mode 100644 index 0000000..f3215f8 --- /dev/null +++ b/internal/toolrender/platformmetrics_pretty_test.go @@ -0,0 +1,95 @@ +package toolrender + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gate/gate-cli/internal/mcpclient" + "github.com/gate/gate-cli/internal/output" +) + +func TestFormatChainActivityPretty_LatestAndSeries(t *testing.T) { + t.Parallel() + out := formatChainActivityPretty(map[string]interface{}{ + "chain": "ethereum", + "normalized_chain": "ethereum", + "metric_group": "staking", + "lookback": "30d", + "total": 30, + "count": 2, + "data_status": "ok", + "staking_metrics": map[string]interface{}{ + "series": []interface{}{ + map[string]interface{}{ + "date": "2026-06-03", + "validator_active": 1000000, + "total_value_staked_eth": "32000000", + "staking_rate": 28.1, + "eth_supply": "120000000", + "staking_apr_7d": 3.2, + "entry_queue_eth": "1024", + "exit_queue_eth": "512", + "entry_wait_days": 5, + "exit_wait_days": 2, + "data_status": "ok", + }, + map[string]interface{}{ + "date": "2026-06-02", + "validator_active": 999000, + "data_status": "partial", + "missing_fields": []interface{}{"staking_apr_7d"}, + }, + }, + }, + }) + assert.Contains(t, out, "Chain Activity (Staking)") + assert.Contains(t, out, "Latest Snapshot") + assert.Contains(t, out, "eth_supply") + assert.Contains(t, out, "staking_apr_7d") + assert.Contains(t, out, "entry_wait_days") + assert.Contains(t, out, "exit_wait_days") + assert.Contains(t, out, "Recent Series") + assert.Contains(t, out, "date=2026-06-03") + assert.Contains(t, out, "date=2026-06-02") +} + +func TestFormatChainActivityPretty_EmptySeries(t *testing.T) { + t.Parallel() + out := formatChainActivityPretty(map[string]interface{}{ + "metric_group": "staking", + "staking_metrics": map[string]interface{}{ + "series": []interface{}{}, + }, + }) + assert.Contains(t, out, "No series points") +} + +func TestRenderCallResult_PrettyChainActivity(t *testing.T) { + t.Parallel() + var out bytes.Buffer + var errOut bytes.Buffer + p := output.NewWithStderr(&out, &errOut, output.FormatPretty) + + err := RenderCallResult(p, "info", toolInfoPlatformmetricsGetChainActivity, &mcpclient.CallResult{ + StructuredContent: map[string]interface{}{ + "metric_group": "staking", + "staking_metrics": map[string]interface{}{ + "series": []interface{}{ + map[string]interface{}{ + "date": "2026-06-03", + "validator_active": 1, + "eth_supply": "120", + }, + }, + }, + }, + }, 0) + require.NoError(t, err) + body := out.String() + assert.Contains(t, body, "Latest Snapshot") + assert.Contains(t, body, "eth_supply") + assert.NotContains(t, body, `"tool_name"`) +} diff --git a/internal/toolrender/render.go b/internal/toolrender/render.go index e23785e..21092f4 100644 --- a/internal/toolrender/render.go +++ b/internal/toolrender/render.go @@ -6,6 +6,7 @@ import ( "fmt" "strings" + "github.com/gate/gate-cli/internal/cmdhint" "github.com/gate/gate-cli/internal/mcpclient" "github.com/gate/gate-cli/internal/output" ) @@ -13,13 +14,13 @@ import ( // RenderCallResult writes call results via standard printer. // JSON mode prints the business data value only (PRD §3.7.8). Pretty mode uses fixed sections // without protocol wrapper fields (PRD §3.7.5). -func RenderCallResult(p *output.Printer, toolName string, result *mcpclient.CallResult, maxBytes int64) error { +func RenderCallResult(p *output.Printer, backend, toolName string, result *mcpclient.CallResult, maxBytes int64) error { if result == nil { return fmt.Errorf("nil tools/call result") } envelope := BuildCLIEnvelope(toolName, result) if !p.IsJSON() && maxBytes <= 0 { - return writePrettyToolResult(p, envelope, nil) + return writePrettyToolResult(p, toolName, envelope, nil) } dataJSON, err := json.Marshal(envelope["data"]) if err != nil { @@ -27,12 +28,25 @@ func RenderCallResult(p *output.Printer, toolName string, result *mcpclient.Call } envelope, displayJSON := ApplyOutputLimitWithData(envelope, maxBytes, dataJSON) if p.IsJSON() { - return p.Print(envelope["data"]) + return printJSONToolResult(p, envelope) } - return writePrettyToolResult(p, envelope, displayJSON) + return writePrettyToolResult(p, toolName, envelope, displayJSON) } -func writePrettyToolResult(p *output.Printer, envelope map[string]interface{}, compactJSON []byte) error { +func writePrettyToolResult(p *output.Printer, toolName string, envelope map[string]interface{}, compactJSON []byte) error { + data, _ := envelope["data"].(map[string]interface{}) + sectioned, ok := prettyOnchainToolResult(toolName, data) + if !ok { + sectioned, ok = prettyPlatformmetricsToolResult(toolName, data) + } + if ok && strings.TrimSpace(sectioned) != "" { + var b strings.Builder + b.WriteString(sectioned) + b.WriteByte('\n') + writePrettyNotes(&b, envelope) + return p.WritePretty(b.String()) + } + if len(compactJSON) == 0 { var err error compactJSON, err = json.Marshal(envelope["data"]) @@ -40,41 +54,77 @@ func writePrettyToolResult(p *output.Printer, envelope map[string]interface{}, c return err } } - var pretty bytes.Buffer - if err := json.Indent(&pretty, compactJSON, "", " "); err != nil { + var prettyJSON bytes.Buffer + if err := json.Indent(&prettyJSON, compactJSON, "", " "); err != nil { return err } var b strings.Builder b.WriteString("Result\n\n") - b.Write(pretty.Bytes()) + b.Write(prettyJSON.Bytes()) b.WriteByte('\n') - if ws := parseWarningsFromEnvelope(envelope); len(ws) > 0 { - b.WriteString("\nNotes\n\n") - for _, w := range ws { - b.WriteString("- ") - b.WriteString(w) - b.WriteByte('\n') + writePrettyNotes(&b, envelope) + return p.WritePretty(b.String()) +} + +func writePrettyNotes(b *strings.Builder, envelope map[string]interface{}) { + notes := append(parseWarningsFromEnvelope(envelope), parseNotesFromEnvelope(envelope)...) + if len(notes) == 0 { + return + } + b.WriteString("\nNotes\n\n") + for _, w := range notes { + b.WriteString("- ") + b.WriteString(w) + b.WriteByte('\n') + } +} + +func printJSONToolResult(p *output.Printer, envelope map[string]interface{}) error { + if cmdhint.AgentModeEnabled() { + meta, _ := envelope["meta"].(map[string]interface{}) + if meta == nil { + meta = map[string]interface{}{} } + return p.Print(map[string]interface{}{ + "data": envelope["data"], + "meta": meta, + }) } - return p.WritePretty(b.String()) + return p.Print(envelope["data"]) } func parseWarningsFromEnvelope(envelope map[string]interface{}) []string { + return metaStringList(envelope, "parse_warnings") +} + +func parseNotesFromEnvelope(envelope map[string]interface{}) []string { + var notes []string + for _, key := range []string{"freshness_hints", "agent_reminder"} { + notes = append(notes, metaStringList(envelope, key)...) + } + return notes +} + +func metaStringList(envelope map[string]interface{}, key string) []string { meta, ok := envelope["meta"].(map[string]interface{}) if !ok { return nil } - raw, ok := meta["parse_warnings"] + raw, ok := meta[key] if !ok { return nil } switch v := raw.(type) { case []string: return v + case string: + if strings.TrimSpace(v) != "" { + return []string{v} + } case []interface{}: out := make([]string, 0, len(v)) for _, x := range v { - if s, ok := x.(string); ok { + if s, ok := x.(string); ok && strings.TrimSpace(s) != "" { out = append(out, s) } } @@ -82,4 +132,5 @@ func parseWarningsFromEnvelope(envelope map[string]interface{}) []string { default: return nil } + return nil } diff --git a/internal/toolrender/render_test.go b/internal/toolrender/render_test.go index ca8f0a3..2cf7706 100644 --- a/internal/toolrender/render_test.go +++ b/internal/toolrender/render_test.go @@ -16,7 +16,7 @@ func TestRenderCallResult_JSONMode(t *testing.T) { var errOut bytes.Buffer p := output.NewWithStderr(&out, &errOut, output.FormatJSON) - err := RenderCallResult(p, "news_feed_search_news", &mcpclient.CallResult{ + err := RenderCallResult(p, "news", "news_feed_search_news", &mcpclient.CallResult{ ContentRaw: []interface{}{ map[string]interface{}{"type": "text", "text": `{"ok":true}`}, }, @@ -32,7 +32,7 @@ func TestRenderCallResult_PrettyModeUsesSegmentedBusinessOutput(t *testing.T) { var errOut bytes.Buffer p := output.NewWithStderr(&out, &errOut, output.FormatPretty) - err := RenderCallResult(p, "info_coin_get_coin_info", &mcpclient.CallResult{ + err := RenderCallResult(p, "info", "info_coin_get_coin_info", &mcpclient.CallResult{ Raw: map[string]interface{}{"v": 1}, }, 0) require.NoError(t, err) @@ -47,7 +47,7 @@ func TestRenderCallResult_PrettyModeNotesSectionForParseWarnings(t *testing.T) { var errOut bytes.Buffer p := output.NewWithStderr(&out, &errOut, output.FormatPretty) - err := RenderCallResult(p, "tool", &mcpclient.CallResult{ + err := RenderCallResult(p, "", "tool", &mcpclient.CallResult{ ContentRaw: []interface{}{ map[string]interface{}{"type": "text", "text": `{"a":1}`}, map[string]interface{}{"type": "text", "text": "plain"}, diff --git a/internal/toolrender/sanitize.go b/internal/toolrender/sanitize.go new file mode 100644 index 0000000..d93ed82 --- /dev/null +++ b/internal/toolrender/sanitize.go @@ -0,0 +1,18 @@ +package toolrender + +import "strings" + +// sanitizeTerminalText strips C0 control characters except tab and newline for safe terminal output. +func sanitizeTerminalText(s string) string { + if s == "" { + return s + } + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + if r == '\n' || r == '\t' || (r >= 32 && r != 127) { + b.WriteRune(r) + } + } + return b.String() +} diff --git a/internal/toolrender/testdata/onchain_address_info_evm.json b/internal/toolrender/testdata/onchain_address_info_evm.json new file mode 100644 index 0000000..ff9b77e --- /dev/null +++ b/internal/toolrender/testdata/onchain_address_info_evm.json @@ -0,0 +1,34 @@ +{ + "address": "0x4200000000000000000000000000000000000006", + "chain": "optimism", + "asset_summary": { + "total_usd_value": 1234.56, + "native_usd_value": 100, + "token_usd_value": 1134.56, + "token_num": 2 + }, + "token_balances": [ + { + "symbol": "USDC", + "token_address": "0x7f5c764cbc14f9669b88837ca1490cca17c31607", + "amount": "500", + "value_usd": "900" + }, + { + "symbol": "WETH", + "token_address": "0x4200000000000000000000000000000000000006", + "amount": "0.1", + "value_usd": "234.56" + } + ], + "multi_chain_token_balances": [ + { + "chain": "polygon", + "token_symbol": "USDC", + "amount": "10", + "usd_value": "10", + "percentage": "1" + } + ], + "duration_ms": 42 +} diff --git a/internal/toolrender/testdata/onchain_address_info_solana.json b/internal/toolrender/testdata/onchain_address_info_solana.json new file mode 100644 index 0000000..b8fc7da --- /dev/null +++ b/internal/toolrender/testdata/onchain_address_info_solana.json @@ -0,0 +1,17 @@ +{ + "address": "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM", + "chain": "solana", + "token_balances": [ + { + "symbol": "SOL", + "token_account": "TokenAcct11111111111111111111111111111111", + "mint_account": "So11111111111111111111111111111111111111112", + "token_address": "So11111111111111111111111111111111111111112", + "amount": "1.5", + "value_usd": "200", + "value_percent": "80", + "total_value_usd": "200" + } + ], + "duration_ms": 10 +} diff --git a/internal/toolrender/testdata/onchain_address_transactions_btc.golden.txt b/internal/toolrender/testdata/onchain_address_transactions_btc.golden.txt new file mode 100644 index 0000000..2665907 --- /dev/null +++ b/internal/toolrender/testdata/onchain_address_transactions_btc.golden.txt @@ -0,0 +1,8 @@ +Address Transactions + +address: bc1qexample +chain: bitcoin +total: 5 +count: 0 + +No transactions in this page. diff --git a/internal/toolschema/flexbool.go b/internal/toolschema/flexbool.go index f1d8853..7c47339 100644 --- a/internal/toolschema/flexbool.go +++ b/internal/toolschema/flexbool.go @@ -5,10 +5,15 @@ import ( "strings" ) -// flexBool is a pflag.Value for JSON-schema boolean fields so that "--flag true" -// and "--flag=false" work. Native Bool flags do not consume a spaced "true" token. -// Note: do not use NoOptDefVal on this flag: pflag would always substitute the -// default and never read the following token, breaking "--flag true". +// flexBool is a pflag.Value for JSON-schema boolean fields. Callers pair it with +// NoOptDefVal="true" so bare "--flag" means true (cobra/pflag bool ergonomics) and +// "--flag=true|false" still works. +// +// The spaced form "--flag true|false" is normally rejected by pflag once NoOptDefVal +// is set, because pflag uses the default and never consumes the next argv token. To +// preserve backward compatibility with legacy scripts, gate-cli rewrites the spaced +// form into "--flag=value" at the argv layer for flexBool flags only; see +// internal/intelcmd.RewriteFlexBoolSpaceArgs. type flexBool struct { v bool } @@ -28,6 +33,12 @@ func (b *flexBool) Set(s string) error { func (b *flexBool) String() string { return strconv.FormatBool(b.v) } +// FlexBoolTypeName is the value returned by flexBool.Type(). It is exported so +// out-of-package code (notably internal/intelcmd.RewriteFlexBoolSpaceArgs) can match +// flexBool flags via pflag.Flag.Value.Type() without depending on the literal string, +// giving us a compile-time contract between the two packages. +const FlexBoolTypeName = "flexBool" + // Type must not be "bool" or pflag applies boolean-flag parsing and drops the // separate "true"/"false" token (breaking "--flag true"). -func (b *flexBool) Type() string { return "flexBool" } +func (b *flexBool) Type() string { return FlexBoolTypeName } diff --git a/internal/toolschema/schema.go b/internal/toolschema/schema.go index 426becd..0dbabf2 100644 --- a/internal/toolschema/schema.go +++ b/internal/toolschema/schema.go @@ -227,19 +227,24 @@ func ApplyInputSchemaFlags(cmd *cobra.Command, schemaAny interface{}) { def, _ := spec["default"].(bool) fb := newFlexBool(def) cmd.Flags().Var(fb, flagName, desc) - // Do not set NoOptDefVal: pflag treats it as "optional value" and will - // never consume the next argv token, so "--flag true" leaves "true" as - // a positional (Cobra reports unknown subcommand). Use "--flag=true" or - // "--flag true" with flexBool (non-native bool) instead. + // Set NoOptDefVal="true" so bare "--flag" means true (standard cobra/pflag + // boolean ergonomics) and "--flag=true|false" still works. The spaced form + // "--flag true|false" would be dropped by pflag once NoOptDefVal is set; + // gate-cli compensates by rewriting it to "--flag=value" at the argv layer + // (internal/intelcmd.RewriteFlexBoolSpaceArgs), keeping legacy scripts working. + fl := cmd.Flags().Lookup(flagName) + if fl != nil { + fl.NoOptDefVal = "true" + } case "integer": def := 0 - if v, ok := spec["default"].(float64); ok { - def = int(v) + if v, ok := coerceIntDefault(spec["default"]); ok { + def = v } cmd.Flags().Int(flagName, def, desc) case "number": def := 0.0 - if v, ok := spec["default"].(float64); ok { + if v, ok := coerceFloatDefault(spec["default"]); ok { def = v } cmd.Flags().Float64(flagName, def, desc) @@ -291,6 +296,24 @@ func enrichUsage(base string, spec map[string]interface{}, required bool) string if def, ok := schemaDefault(spec); ok { parts = append(parts, "default="+def) } + for _, pair := range []struct { + key string + label string + }{ + {"minimum", "min"}, + {"maximum", "max"}, + {"minLength", "minLen"}, + {"maxLength", "maxLen"}, + {"minItems", "minItems"}, + {"maxItems", "maxItems"}, + } { + if s := formatSchemaBound(spec[pair.key]); s != "" { + parts = append(parts, pair.label+"="+s) + } + } + if pat, ok := spec["pattern"].(string); ok && strings.TrimSpace(pat) != "" { + parts = append(parts, "pattern="+strings.TrimSpace(pat)) + } return strings.TrimSpace(base) + " [" + strings.Join(parts, ", ") + "]" } @@ -314,3 +337,64 @@ func schemaDefault(spec map[string]interface{}) (string, bool) { } return fmt.Sprint(v), true } + +// coerceIntDefault accepts JSON-number shapes used in hand-written and unmarshaled schemas. +func coerceIntDefault(v interface{}) (int, bool) { + switch x := v.(type) { + case float64: + return int(x), true + case int: + return x, true + case int64: + return int(x), true + case json.Number: + i, err := x.Int64() + if err != nil { + return 0, false + } + return int(i), true + default: + return 0, false + } +} + +func coerceFloatDefault(v interface{}) (float64, bool) { + switch x := v.(type) { + case float64: + return x, true + case int: + return float64(x), true + case int64: + return float64(x), true + case json.Number: + f, err := x.Float64() + if err != nil { + return 0, false + } + return f, true + default: + return 0, false + } +} + +// formatSchemaBound renders JSON Schema numeric keywords for flag usage (integers and whole floats). +func formatSchemaBound(v interface{}) string { + if v == nil { + return "" + } + switch x := v.(type) { + case float64: + if x == float64(int64(x)) { + return strconv.FormatInt(int64(x), 10) + } + return strconv.FormatFloat(x, 'g', -1, 64) + case int: + return strconv.Itoa(x) + case int64: + return strconv.FormatInt(x, 10) + case json.Number: + return strings.TrimSpace(x.String()) + default: + return "" + } +} diff --git a/internal/toolschema/schema_test.go b/internal/toolschema/schema_test.go index a8233ce..8f7cb17 100644 --- a/internal/toolschema/schema_test.go +++ b/internal/toolschema/schema_test.go @@ -87,7 +87,8 @@ func TestApplyInputSchemaFlagsAddsRichUsage(t *testing.T) { }, "limit": map[string]interface{}{ "type": "integer", - "default": float64(10), + "default": 10, + "maximum": 100, }, }, } @@ -114,7 +115,7 @@ func TestApplyInputSchemaFlagsAddsRichUsage(t *testing.T) { if limit == nil { t.Fatal("missing limit flag") } - if !stringsContainsAll(limit.Usage, []string{"type=integer", "default=10"}) { + if !stringsContainsAll(limit.Usage, []string{"type=integer", "default=10", "max=100"}) { t.Fatalf("unexpected limit usage: %s", limit.Usage) } } diff --git a/internal/toolschema/verify.go b/internal/toolschema/verify.go index bb8fb4c..bc050c9 100644 --- a/internal/toolschema/verify.go +++ b/internal/toolschema/verify.go @@ -175,11 +175,21 @@ func valueMatchesType(v interface{}, t string) bool { _, ok := v.(string) return ok case "integer": - f, ok := v.(float64) - return ok && f == float64(int64(f)) + switch x := v.(type) { + case float64: + return x == float64(int64(x)) + case int: + return true + case int64: + return true + } + return false case "number": - _, ok := v.(float64) - return ok + switch v.(type) { + case float64, int, int64: + return true + } + return false case "boolean": _, ok := v.(bool) return ok diff --git a/internal/toolschema/verify_test.go b/internal/toolschema/verify_test.go index 44626ab..dd67bb9 100644 --- a/internal/toolschema/verify_test.go +++ b/internal/toolschema/verify_test.go @@ -63,6 +63,31 @@ func TestValidateToolsStatusOK(t *testing.T) { } } +func TestValidateToolsIntegerDefaultIntLiteralOK(t *testing.T) { + tools := []ToolSummary{ + { + Name: "bounded", + HasInputSchema: true, + InputSchema: map[string]interface{}{ + "properties": map[string]interface{}{ + "limit": map[string]interface{}{ + "type": "integer", + "default": 10, + "maximum": 50, + }, + }, + }, + }, + } + report := ValidateTools("news", tools, true) + if report.WarningCount != 0 { + t.Fatalf("expected no warnings, got %+v", report.Warnings) + } + if report.Status != "ok" { + t.Fatalf("expected status ok, got %s", report.Status) + } +} + func TestVerifyReportStrictFieldsDefaultFalse(t *testing.T) { report := ValidateTools("info", nil, true) if report.StrictMode { diff --git a/internal/useragent/useragent.go b/internal/useragent/useragent.go index 0d163ec..e473059 100644 --- a/internal/useragent/useragent.go +++ b/internal/useragent/useragent.go @@ -40,8 +40,8 @@ var knownDetectors = []detector{ // Detect identifies the calling environment from environment variables. func Detect() AgentInfo { - // Priority 1: explicit override - if name := os.Getenv("GATE_CLI_AGENT"); name != "" { + // Priority 1: explicit override (skip agent-mode truthy values; those enable JSON defaults, not UA names). + if name := os.Getenv("GATE_CLI_AGENT"); name != "" && !isAgentModeTruthy(name) { return AgentInfo{ Name: name, Extra: envOrDefault("GATE_CLI_AGENT_VERSION", "-"), @@ -111,6 +111,15 @@ func ExtractCmdPath(commandPath string) string { return strings.Join(parts[1:], "/") } +func isAgentModeTruthy(v string) bool { + switch strings.TrimSpace(strings.ToLower(v)) { + case "1", "true", "yes": + return true + default: + return false + } +} + func envOrDefault(key, fallback string) string { if key == "" { return fallback diff --git a/internal/useragent/useragent_test.go b/internal/useragent/useragent_test.go index 02ec771..a14691d 100644 --- a/internal/useragent/useragent_test.go +++ b/internal/useragent/useragent_test.go @@ -63,6 +63,12 @@ func TestDetect(t *testing.T) { wantName: "ci-runner", wantExtra: "-", }, + { + name: "agent mode truthy skips UA override", + envs: map[string]string{"GATE_CLI_AGENT": "1", "CURSOR_AGENT": "1"}, + wantName: "cursor", + wantExtra: "-", + }, { name: "Claude Code CLI", envs: map[string]string{"CLAUDECODE": "1", "CLAUDE_CODE_ENTRYPOINT": "cli"},