@leroylabs/cli 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,9 +6,11 @@ The CLI is the command-line surface for Leroy's research-only MCP service.
6
6
 
7
7
  ```bash
8
8
  npm install --global @leroylabs/cli
9
- leroy connect
10
9
  ```
11
10
 
11
+ After installation, run `leroy connect` when prompted to connect the CLI to
12
+ your Leroy account.
13
+
12
14
  `leroy connect` validates the endpoint and stores the API key in
13
15
  `~/.config/leroy/config.json` with owner-only permissions. The CLI never
14
16
  submits orders and never has broker authority.
@@ -20,6 +22,8 @@ From the repository root:
20
22
  ```bash
21
23
  npm run leroy -- help
22
24
  npm run leroy -- ask buy ABNB
25
+ npm run leroy -- watch buy ABNB
26
+ npm run leroy -- ask buy ABNB --demo
23
27
  ```
24
28
 
25
29
  The command reads `LEROY_MCP_URL` and `LEROY_API_KEY` when present. Otherwise,
@@ -28,11 +32,42 @@ Leroy config.
28
32
 
29
33
  The default endpoint is `https://getleroy.com/api/mcp`.
30
34
 
31
- The CLI supports the authenticated `evaluate_current_setup` MCP tool. Use
32
- `--json` for agent and shell integrations, or the default terminal format for
33
- human-readable evidence. A normal request reports current-frame freshness,
34
- the registered and evaluated strategy counts, exact versus broader Market
35
- Memory evidence, completed-label counts, and the research-only boundary.
35
+ Use `--demo` with `ask` or `watch` to render local randomized evidence without
36
+ an API key or network request. Each demo evaluation generates 41 individual
37
+ strategy cells plus varied market context, match strength, and outcomes so the
38
+ interactive UI can be explored outside market hours.
39
+
40
+ The CLI supports the authenticated `evaluate_current_setup` MCP tool. In an
41
+ interactive terminal, the default `leroy ask` view renders the evidence card
42
+ immediately and replaces its loading state with the authenticated result. Use
43
+ `--json` for agent and shell integrations, or `--setup`/`--verbose` when you
44
+ need the deterministic text formatter. A normal request reports current-frame
45
+ freshness, the registered and evaluated strategy counts, exact versus broader
46
+ Market Memory evidence, completed-label counts, and the research-only
47
+ boundary.
48
+
49
+ Use `leroy watch buy ABNB` in an interactive terminal to keep the evidence view
50
+ open while the current tape advances. It refreshes once per minute by default,
51
+ which matches the one-minute tape cadence; use `--interval SECONDS` to choose a
52
+ different polling interval. Each refresh is an authenticated evaluation request
53
+ and may count toward the account's lookup allowance.
54
+
55
+ ## Match strength
56
+
57
+ Leroy selects the strongest available evidence tier and does not pool lower
58
+ tiers into it:
59
+
60
+ - `5/5`: exact ticker setup
61
+ - `4/5`: same setup on other tickers
62
+ - `3/5`: same 41-sensor state
63
+ - `2/5`: same side with strategy and market context
64
+ - `1/5`: same side baseline
65
+
66
+ The human-readable output shows `Exact Matches` only for `5/5`. Lower tiers
67
+ show only their own `Comparable Matches` count. Use `--verbose` for the match
68
+ basis, or `--json` for the complete structured response.
69
+
70
+ The browser reference is available at [getleroy.com/cli](https://getleroy.com/cli).
36
71
 
37
72
  ## Exit codes
38
73
 
package/bin/leroy.mjs CHANGED
@@ -2,12 +2,15 @@
2
2
 
3
3
  import { createInterface } from "node:readline/promises";
4
4
  import { stdin as input, stdout as output } from "node:process";
5
+ import React from "react";
5
6
 
6
7
  import { parseArgs } from "../src/args.mjs";
7
8
  import { LeroyMcpClient } from "../src/client.mjs";
8
9
  import { configPath, resolvedConfig, writeConfig } from "../src/config.mjs";
10
+ import { createDemoResponse } from "../src/demo.mjs";
9
11
  import { usageError } from "../src/errors.mjs";
10
12
  import { formatEvaluation, formatHelp } from "../src/format.mjs";
13
+ import { LiveEvidenceApp, render } from "../src/live-ui.mjs";
11
14
 
12
15
  function print(value = "") {
13
16
  output.write(`${value}\n`);
@@ -35,21 +38,75 @@ async function connect(options) {
35
38
  }
36
39
 
37
40
  async function ask(options) {
38
- const config = await resolvedConfig(options);
39
- if (!config.apiKey) {
40
- throw usageError("No API key found. Run `leroy connect` or set LEROY_API_KEY.");
41
- }
42
- const client = new LeroyMcpClient({ endpoint: config.endpoint, apiKey: config.apiKey });
43
- const response = await client.evaluate({
41
+ const request = {
44
42
  symbol: options.symbol,
45
43
  side: options.side,
46
44
  holding_horizons: options.horizons,
47
45
  entry_price: options.entryPrice,
48
46
  stop_price: options.stopPrice,
49
47
  target_price: options.targetPrice,
50
- });
48
+ };
49
+ let client;
50
+ if (options.demo) {
51
+ client = { evaluate: async () => createDemoResponse(request) };
52
+ } else {
53
+ const config = await resolvedConfig(options);
54
+ if (!config.apiKey) {
55
+ throw usageError("No API key found. Run `leroy connect` or set LEROY_API_KEY.");
56
+ }
57
+ client = new LeroyMcpClient({ endpoint: config.endpoint, apiKey: config.apiKey });
58
+ }
59
+ if (input.isTTY && output.isTTY && !options.json && !options.setup && !options.verbose) {
60
+ const app = render(React.createElement(LiveEvidenceApp, {
61
+ client,
62
+ request,
63
+ colorEnabled: !options.noColor,
64
+ polling: false,
65
+ }), { exitOnCtrlC: true });
66
+ await app.waitUntilExit();
67
+ return 0;
68
+ }
69
+ const response = await client.evaluate(request);
51
70
  if (options.json) print(JSON.stringify(response, null, 2));
52
- else print(formatEvaluation(response, { colorEnabled: !options.noColor && output.isTTY, horizon: options.horizons.includes(30) ? 30 : options.horizons[options.horizons.length - 1] }));
71
+ else print(formatEvaluation(response, {
72
+ colorEnabled: !options.noColor && output.isTTY,
73
+ horizon: options.horizons.includes(30) ? 30 : options.horizons[options.horizons.length - 1],
74
+ setup: options.setup,
75
+ verbose: options.verbose,
76
+ layout: "grid",
77
+ }));
78
+ return 0;
79
+ }
80
+
81
+ async function watch(options) {
82
+ if (!input.isTTY || !output.isTTY) {
83
+ throw usageError("`leroy watch` requires an interactive terminal. Use `leroy ask` for one-shot output.");
84
+ }
85
+ const request = {
86
+ symbol: options.symbol,
87
+ side: options.side,
88
+ holding_horizons: options.horizons,
89
+ ...(Number.isFinite(options.entryPrice) ? { entry_price: options.entryPrice } : {}),
90
+ ...(Number.isFinite(options.stopPrice) ? { stop_price: options.stopPrice } : {}),
91
+ ...(Number.isFinite(options.targetPrice) ? { target_price: options.targetPrice } : {}),
92
+ };
93
+ let client;
94
+ if (options.demo) {
95
+ client = { evaluate: async () => createDemoResponse(request) };
96
+ } else {
97
+ const config = await resolvedConfig(options);
98
+ if (!config.apiKey) {
99
+ throw usageError("No API key found. Run `leroy connect` or set LEROY_API_KEY.");
100
+ }
101
+ client = new LeroyMcpClient({ endpoint: config.endpoint, apiKey: config.apiKey });
102
+ }
103
+ const app = render(React.createElement(LiveEvidenceApp, {
104
+ client,
105
+ request,
106
+ colorEnabled: !options.noColor,
107
+ intervalMs: options.intervalSeconds * 1_000,
108
+ }), { exitOnCtrlC: true });
109
+ await app.waitUntilExit();
53
110
  return 0;
54
111
  }
55
112
 
@@ -62,6 +119,7 @@ async function main() {
62
119
  }
63
120
  if (options.command === "connect") return await connect(options);
64
121
  if (options.command === "ask") return await ask(options);
122
+ if (options.command === "watch") return await watch(options);
65
123
  throw usageError("Unknown command: " + options.command + ". Run `leroy help`.");
66
124
  } catch (error) {
67
125
  console.error(`leroy: ${error instanceof Error ? error.message : String(error)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leroylabs/cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Command-line access to Leroy market evidence.",
5
5
  "type": "module",
6
6
  "files": [
@@ -19,6 +19,11 @@
19
19
  "bin": {
20
20
  "leroy": "bin/leroy.mjs"
21
21
  },
22
+ "dependencies": {
23
+ "cli-table3": "^0.6.5",
24
+ "ink": "^7.1.1",
25
+ "react": "^19.2.8"
26
+ },
22
27
  "engines": {
23
28
  "node": ">=22"
24
29
  }
package/src/args.mjs CHANGED
@@ -18,9 +18,13 @@ export function parseArgs(argv) {
18
18
  apiKey: null,
19
19
  json: false,
20
20
  noColor: false,
21
+ setup: false,
22
+ verbose: false,
23
+ demo: false,
21
24
  entryPrice: null,
22
25
  stopPrice: null,
23
26
  targetPrice: null,
27
+ intervalSeconds: 60,
24
28
  };
25
29
  const positional = [];
26
30
  for (let index = 0; index < rest.length; index += 1) {
@@ -30,6 +34,30 @@ export function parseArgs(argv) {
30
34
  continue;
31
35
  }
32
36
  const [flag, inline] = value.split("=", 2);
37
+ if (flag === "--json") {
38
+ options.json = true;
39
+ continue;
40
+ }
41
+ if (flag === "--no-color") {
42
+ options.noColor = true;
43
+ continue;
44
+ }
45
+ if (flag === "--setup") {
46
+ options.setup = true;
47
+ continue;
48
+ }
49
+ if (flag === "--verbose") {
50
+ options.verbose = true;
51
+ continue;
52
+ }
53
+ if (flag === "--demo") {
54
+ options.demo = true;
55
+ continue;
56
+ }
57
+ if (flag === "--help" || flag === "-h") {
58
+ options.command = "help";
59
+ continue;
60
+ }
33
61
  const next = inline ?? rest[++index];
34
62
  if (flag === "--side") options.side = next;
35
63
  else if (flag === "--horizon") options.horizons = [Number(next)];
@@ -38,20 +66,24 @@ export function parseArgs(argv) {
38
66
  else if (flag === "--entry-price") options.entryPrice = numberValue(next, flag);
39
67
  else if (flag === "--stop-price") options.stopPrice = numberValue(next, flag);
40
68
  else if (flag === "--target-price") options.targetPrice = numberValue(next, flag);
41
- else if (flag === "--json") options.json = true;
42
- else if (flag === "--no-color") options.noColor = true;
43
- else if (flag === "--help" || flag === "-h") options.command = "help";
69
+ else if (flag === "--interval") options.intervalSeconds = numberValue(next, flag);
44
70
  else throw usageError(`Unknown option: ${flag}`);
45
71
  }
46
- if (command === "ask" && ["buy", "sell", "both"].includes(positional[0]?.toLowerCase())) {
72
+ if (["ask", "watch"].includes(command) && ["buy", "sell", "both"].includes(positional[0]?.toLowerCase())) {
47
73
  if (options.side === "buy") options.side = positional[0].toLowerCase();
48
74
  positional.shift();
49
75
  }
50
76
  options.symbol = positional[0] ? positional[0].toUpperCase() : null;
51
- if (options.command === "ask") {
52
- if (!options.symbol || !/^[A-Z][A-Z0-9.-]{0,11}$/.test(options.symbol)) throw usageError("Usage: leroy ask buy|sell|both SYMBOL");
77
+ if (["ask", "watch"].includes(options.command)) {
78
+ if (!options.symbol || !/^[A-Z][A-Z0-9.-]{0,11}$/.test(options.symbol)) {
79
+ throw usageError(`Usage: leroy ${options.command} buy|sell|both SYMBOL`);
80
+ }
53
81
  if (!["buy", "sell", "both"].includes(options.side)) throw usageError("--side must be buy, sell, or both.");
54
82
  if (!options.horizons.every((horizon) => DEFAULT_HORIZONS.includes(horizon))) throw usageError("--horizon must be 5, 15, 30, or 60.");
83
+ if (options.command === "watch" && options.json) throw usageError("`leroy watch` cannot be combined with --json.");
84
+ }
85
+ if (options.demo && !["ask", "watch"].includes(options.command)) {
86
+ throw usageError("`--demo` can only be used with `leroy ask` or `leroy watch`.");
55
87
  }
56
88
  return options;
57
89
  }
package/src/client.mjs CHANGED
@@ -39,6 +39,7 @@ export class LeroyMcpClient {
39
39
  this.fetchImpl = fetchImpl;
40
40
  this.sessionId = null;
41
41
  this.requestId = 0;
42
+ this.initialized = false;
42
43
  }
43
44
 
44
45
  async request(method, params = {}, { notification = false } = {}) {
@@ -70,31 +71,39 @@ export class LeroyMcpClient {
70
71
  }
71
72
 
72
73
  async initialize() {
74
+ if (this.initialized) return null;
73
75
  const envelope = await this.request("initialize", {
74
76
  protocolVersion: MCP_PROTOCOL_VERSION,
75
77
  capabilities: {},
76
78
  clientInfo: { name: "leroy-cli", version: "0.1.0" },
77
79
  });
78
80
  await this.request("notifications/initialized", {}, { notification: true }).catch(() => undefined);
81
+ this.initialized = true;
79
82
  return envelope;
80
83
  }
81
84
 
82
85
  async evaluate({ symbol, side, holding_horizons = [5, 15, 30, 60], entry_price, stop_price, target_price }) {
83
86
  await this.initialize();
84
- const envelope = await this.request("tools/call", {
85
- name: "evaluate_current_setup",
86
- arguments: {
87
- symbol: symbol.trim().toUpperCase(),
88
- side,
89
- holding_horizons,
90
- ...(Number.isFinite(entry_price) ? { entry_price } : {}),
91
- ...(Number.isFinite(stop_price) ? { stop_price } : {}),
92
- ...(Number.isFinite(target_price) ? { target_price } : {}),
93
- client_request_id: `leroy-cli-${randomUUID()}`,
94
- },
95
- });
96
- const content = structuredContent(envelope);
97
- if (!content) throw new Error("Leroy returned an MCP response without structured evaluation data.");
98
- return content;
87
+ try {
88
+ const envelope = await this.request("tools/call", {
89
+ name: "evaluate_current_setup",
90
+ arguments: {
91
+ symbol: symbol.trim().toUpperCase(),
92
+ side,
93
+ holding_horizons,
94
+ ...(Number.isFinite(entry_price) ? { entry_price } : {}),
95
+ ...(Number.isFinite(stop_price) ? { stop_price } : {}),
96
+ ...(Number.isFinite(target_price) ? { target_price } : {}),
97
+ client_request_id: `leroy-cli-${randomUUID()}`,
98
+ },
99
+ });
100
+ const content = structuredContent(envelope);
101
+ if (!content) throw new Error("Leroy returned an MCP response without structured evaluation data.");
102
+ return content;
103
+ } catch (error) {
104
+ this.initialized = false;
105
+ this.sessionId = null;
106
+ throw error;
107
+ }
99
108
  }
100
109
  }
package/src/colors.mjs ADDED
@@ -0,0 +1,14 @@
1
+ export const LEROY_COLORS = Object.freeze({
2
+ green: "#00D41B",
3
+ inactive: "#29332B",
4
+ neutral: "#8F9591",
5
+ red: "#F2562F",
6
+ });
7
+
8
+ export function ansiForeground(hex) {
9
+ const value = hex.replace(/^#/, "");
10
+ const red = Number.parseInt(value.slice(0, 2), 16);
11
+ const green = Number.parseInt(value.slice(2, 4), 16);
12
+ const blue = Number.parseInt(value.slice(4, 6), 16);
13
+ return `\u001b[38;2;${red};${green};${blue}m`;
14
+ }
package/src/demo.mjs ADDED
@@ -0,0 +1,144 @@
1
+ const DEMO_PRICE_BASES = {
2
+ ABNB: 183,
3
+ AAPL: 229,
4
+ AMZN: 231,
5
+ GOOG: 214,
6
+ META: 736,
7
+ MSFT: 505,
8
+ NVDA: 181,
9
+ TSLA: 339,
10
+ };
11
+
12
+ const MATCH_TIERS = [
13
+ null,
14
+ { match_kind: "side", scope: "all_market_memory_observations" },
15
+ { match_kind: "forward_overlay_context", scope: "forward_overlay_current_context" },
16
+ { match_kind: "strategy", scope: "exact_41_sensor_state" },
17
+ { match_kind: "cross_ticker_context", scope: "cross_ticker_exact_state" },
18
+ { match_kind: "context", scope: "same_ticker_exact" },
19
+ ];
20
+
21
+ function unit(rng) {
22
+ const value = Number(rng());
23
+ return Number.isFinite(value) ? Math.min(0.999999, Math.max(0, value)) : 0;
24
+ }
25
+
26
+ function randomInt(rng, minimum, maximum) {
27
+ return minimum + Math.floor(unit(rng) * (maximum - minimum + 1));
28
+ }
29
+
30
+ function randomBetween(rng, minimum, maximum, digits = 2) {
31
+ const value = minimum + unit(rng) * (maximum - minimum);
32
+ return Number(value.toFixed(digits));
33
+ }
34
+
35
+ function shuffled(values, rng) {
36
+ const result = [...values];
37
+ for (let index = result.length - 1; index > 0; index -= 1) {
38
+ const swapIndex = randomInt(rng, 0, index);
39
+ [result[index], result[swapIndex]] = [result[swapIndex], result[index]];
40
+ }
41
+ return result;
42
+ }
43
+
44
+ function strategyStates(rng) {
45
+ const buy = randomInt(rng, 0, 18);
46
+ const sell = randomInt(rng, 0, Math.min(18, 41 - buy));
47
+ const neutral = 41 - buy - sell;
48
+ return shuffled([
49
+ ...Array.from({ length: buy }, () => "buy"),
50
+ ...Array.from({ length: neutral }, () => "neutral"),
51
+ ...Array.from({ length: sell }, () => "sell"),
52
+ ], rng);
53
+ }
54
+
55
+ function outcomeFor(rng, horizon, score) {
56
+ const direction = unit(rng) > 0.42 ? 1 : -1;
57
+ const magnitude = horizon / 60 * randomBetween(rng, 0.25, 1.35);
58
+ const mean = Number((direction * magnitude).toFixed(2));
59
+ const winRate = randomBetween(rng, direction > 0 ? 0.48 : 0.34, direction > 0 ? 0.72 : 0.56, 2);
60
+ const lift = Number((direction * randomBetween(rng, 1.5, 18)).toFixed(2));
61
+ return {
62
+ sample_size: score > 0 ? randomInt(rng, 40, 980000000) : 0,
63
+ mean_return_pct: mean,
64
+ win_rate: winRate,
65
+ lift_vs_baseline_bps: lift,
66
+ };
67
+ }
68
+
69
+ function matchFor(rng, score, outcomes) {
70
+ const tier = MATCH_TIERS[score];
71
+ if (!tier) return null;
72
+ const rowCount = score === 5
73
+ ? randomInt(rng, 250000000, 980000000)
74
+ : score === 4
75
+ ? randomInt(rng, 100000, 9000000)
76
+ : score === 3
77
+ ? randomInt(rng, 5000, 250000)
78
+ : score === 2
79
+ ? randomInt(rng, 1000, 50000)
80
+ : randomInt(rng, 250, 5000000000);
81
+ return {
82
+ ...tier,
83
+ row_count: rowCount,
84
+ first_session: "2010-03-31",
85
+ last_session: "2026-08-24",
86
+ outcomes,
87
+ };
88
+ }
89
+
90
+ export function createDemoResponse(request = {}, rng = Math.random) {
91
+ const symbol = typeof request.symbol === "string" ? request.symbol.toUpperCase() : "ABNB";
92
+ const side = ["buy", "sell", "both"].includes(request.side) ? request.side : "buy";
93
+ const score = randomInt(rng, 0, 5);
94
+ const states = strategyStates(rng);
95
+ const sourceTime = new Date(Date.now() - randomInt(rng, 0, 45) * 60_000).toISOString();
96
+ const basePrice = DEMO_PRICE_BASES[symbol] ?? randomBetween(rng, 35, 480);
97
+ const last = Number((basePrice + randomBetween(rng, -4.5, 4.5)).toFixed(2));
98
+ const outcomes = score > 0
99
+ ? Object.fromEntries([15, 30, 60].map((horizon) => [String(horizon), outcomeFor(rng, horizon, score)]))
100
+ : {};
101
+ const match = matchFor(rng, score, outcomes);
102
+ const marketContext = {
103
+ session_return_pct: randomBetween(rng, -4.2, 4.2),
104
+ vwap_distance_pct: randomBetween(rng, -2.4, 2.4),
105
+ atr_pct: randomBetween(rng, 0.05, 2.6),
106
+ volume_ratio: randomBetween(rng, 0.35, 2.1),
107
+ };
108
+ const benchmarkContext = {
109
+ session_return_pct: randomBetween(rng, -1.8, 1.8),
110
+ vwap_distance_pct: randomBetween(rng, -0.9, 0.9),
111
+ atr_pct: randomBetween(rng, 0.08, 1.4),
112
+ volume_ratio: randomBetween(rng, 0.55, 1.65),
113
+ };
114
+
115
+ return {
116
+ status: "evaluated",
117
+ request: { symbol, side, holding_horizons: request.holding_horizons ?? [5, 15, 30, 60] },
118
+ current_market_frame: {
119
+ symbol,
120
+ source_time: sourceTime,
121
+ market_session_status: unit(rng) > 0.5 ? "open" : "closed",
122
+ quote: { last },
123
+ market_context: marketContext,
124
+ benchmark: { symbol: "SPY", market_context: benchmarkContext },
125
+ },
126
+ strategy_states: states.map((state) => ({ state })),
127
+ historical_evidence: score > 0
128
+ ? [15, 30, 60].map((horizon_minutes) => ({
129
+ horizon_minutes,
130
+ availability: "available",
131
+ ...outcomes[String(horizon_minutes)],
132
+ }))
133
+ : [],
134
+ market_memory: {
135
+ status: score > 0 ? "historical_match" : "insufficient_context",
136
+ coverage: {
137
+ date_start: "2010-03-31",
138
+ date_end: "2026-08-24",
139
+ strategy_evaluations: 105447109561,
140
+ },
141
+ matches: match ? [{ side, matches: [match] }] : [],
142
+ },
143
+ };
144
+ }