File size: 14,290 Bytes
3464008
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
// Pure, dependency-free logic for the WorldMonitor CLI: argument parsing,
// request planning, and output formatting. Nothing here touches the network or
// the process — every export is unit-testable in isolation, and the thin
// network/IO wrapper lives in ./run.mjs.
//
// The CLI is MCP-first. The MCP server (https://worldmonitor.app/mcp) is the
// live, documented agent surface: `tools/list` is public, and `tools/call`
// (used by the curated data commands) authenticates with a user API key. A
// small REST escape hatch (`health`, `get <path>`) and an OpenAPI listing
// (`list`) round it out for host-relative and self-hosted use.

export const VERSION = '0.1.3';

// Cloudflare's WAF challenges generic library User-Agents (node, curl,
// python-requests, empty) on the API edge, so we always identify ourselves.
export const USER_AGENT = `worldmonitor-cli/${VERSION} (+https://worldmonitor.app)`;

export const DEFAULT_BASE_URL = 'https://api.worldmonitor.app';
export const DEFAULT_MCP_URL = 'https://worldmonitor.app/mcp';
export const DEFAULT_SPEC_URL = 'https://worldmonitor.app/openapi.json';

// Header the API accepts for a user-issued key (alias: X-Api-Key).
export const API_KEY_HEADER = 'X-WorldMonitor-Key';

// JSON-RPC error code the MCP server returns when a call needs authentication.
export const MCP_AUTH_ERROR_CODE = -32001;

// Shown on any auth failure (MCP -32001 or a REST 401) so the fix is always one
// hint away, whichever surface the user hit.
export const AUTH_HINT =
  'Hint: this call needs a key — pass --api-key or set WORLDMONITOR_API_KEY (get one at https://worldmonitor.app/pro).';

// Thrown for bad invocations so run.mjs can exit with a distinct status (2) and
// print usage rather than a stack trace.
export class UsageError extends Error {
  constructor(message) {
    super(message);
    this.name = 'UsageError';
  }
}

// Ergonomic shortcuts over the highest-traffic MCP tools. Every other tool is
// reachable via `call <tool>`, so this table stays small. `args` maps positional
// arguments onto named tool arguments; anything else (`--key value`) is merged
// into the tool's arguments too, which is how `--limit`, `--jmespath`,
// `--country`, etc. flow through with no per-tool wiring.
export const CURATED_COMMANDS = {
  world: { tool: 'get_world_brief', args: [], summary: 'Live global situation brief.' },
  country: {
    tool: 'get_country_brief',
    args: [{ name: 'country_code', required: true }],
    summary: 'AI strategic brief for a country (ISO 3166-1 alpha-2 code).',
  },
  risk: {
    tool: 'get_country_risk',
    args: [{ name: 'country_code', required: true }],
    summary: 'Country risk / resilience scores (ISO 3166-1 alpha-2 code).',
  },
  markets: { tool: 'get_market_data', args: [], summary: 'Equities, commodities, crypto and FX quotes.' },
  conflicts: {
    tool: 'get_conflict_events',
    args: [],
    summary: 'Recent conflict events (--country, --min_fatalities, --limit).',
  },
  cyber: {
    tool: 'get_cyber_threats',
    args: [],
    summary: 'Cyber-threat indicators (--min_severity, --threat_type, --country).',
  },
  news: {
    tool: 'get_news_intelligence',
    args: [],
    summary: 'Classified news intelligence (--topic, --country, --alerts_only).',
  },
  disasters: {
    tool: 'get_natural_disasters',
    args: [],
    summary: 'Earthquakes, fires and storms (--dataset, --active_only, --min_magnitude).',
  },
  sanctions: {
    tool: 'get_sanctions_data',
    args: [],
    summary: 'Sanctions designations (--country, --entity_type, --query).',
  },
  forecasts: {
    tool: 'get_forecast_predictions',
    args: [],
    summary: 'Scenario forecasts (--domain, --region).',
  },
  maritime: {
    tool: 'get_maritime_activity',
    args: [{ name: 'country_code', required: true }],
    summary: 'Maritime / port activity for a country (ISO 3166-1 alpha-2 code).',
  },
};

// Flags that consume the following token as a value, mapped to their canonical
// option key. Anything else beginning with `--` is treated as an API/tool
// parameter.
const VALUE_FLAGS = new Map([
  ['api-key', 'apiKey'],
  ['apikey', 'apiKey'],
  ['key', 'apiKey'],
  ['base-url', 'baseUrl'],
  ['mcp-url', 'mcpUrl'],
  ['spec-url', 'specUrl'],
  ['args', 'args'],
  ['arg-json', 'args'],
  ['timeout', 'timeout'],
]);

const BOOL_FLAGS = new Map([
  ['raw', 'raw'],
  ['compact', 'compact'],
  ['help', 'help'],
  ['h', 'help'],
  ['version', 'version'],
  ['v', 'version'],
]);

function isFlag(token) {
  if (token.startsWith('--')) return true;
  // single-dash short flag (-h, -v) but not a negative number (-5, -1.2)
  return token.length === 2 && token[0] === '-' && !/[0-9]/.test(token[1]);
}

// Split argv into { command, positionals, options, params }. `options` holds
// recognised global flags; `params` holds every other `--key value` pair and
// becomes the request's tool arguments (MCP) or query string (REST). This is
// what makes `worldmonitor conflicts --country IR --limit 5` work with no
// per-command wiring.
export function parseArgs(argv) {
  const options = {};
  const params = {};
  const positionals = [];

  for (let i = 0; i < argv.length; i++) {
    const token = argv[i];
    if (token === '--') {
      positionals.push(...argv.slice(i + 1));
      break;
    }
    if (!isFlag(token)) {
      positionals.push(token);
      continue;
    }

    let name = token.replace(/^--?/, '');
    let inlineValue;
    const eq = name.indexOf('=');
    if (eq !== -1) {
      inlineValue = name.slice(eq + 1);
      name = name.slice(0, eq);
    }
    const key = name.toLowerCase();

    if (BOOL_FLAGS.has(key)) {
      options[BOOL_FLAGS.get(key)] = inlineValue === undefined ? true : inlineValue !== 'false';
      continue;
    }
    if (VALUE_FLAGS.has(key)) {
      options[VALUE_FLAGS.get(key)] = inlineValue !== undefined ? inlineValue : argv[++i];
      continue;
    }
    // Unknown flag → an API/tool parameter. A bare flag with no value is
    // recorded as boolean true.
    if (inlineValue !== undefined) params[name] = inlineValue;
    else if (i + 1 < argv.length && !isFlag(argv[i + 1])) params[name] = argv[++i];
    else params[name] = true;
  }

  const command = positionals.shift();
  return { command, positionals, options, params };
}

// Read defaults from the environment so keys and hosts don't have to be passed
// on every call.
export function resolveConfig(env = {}) {
  return {
    apiKey: env.WORLDMONITOR_API_KEY || env.WM_API_KEY,
    baseUrl: env.WORLDMONITOR_BASE_URL,
    mcpUrl: env.WORLDMONITOR_MCP_URL,
    specUrl: env.WORLDMONITOR_SPEC_URL,
  };
}

function trimTrailingSlash(url) {
  return url.replace(/\/+$/, '');
}

function baseHeaders(apiKey) {
  const headers = { 'user-agent': USER_AGENT };
  if (apiKey) headers[API_KEY_HEADER] = apiKey;
  return headers;
}

// Tool/query arguments: an explicit --args JSON object wins; otherwise the
// collected --key value params, with bare boolean flags kept as true.
function collectArgs(parsed) {
  if (parsed.options.args !== undefined) {
    try {
      return JSON.parse(parsed.options.args);
    } catch (err) {
      throw new UsageError(`--args must be valid JSON: ${err.message}`);
    }
  }
  const args = {};
  for (const [k, v] of Object.entries(parsed.params)) args[k] = v === true ? true : String(v);
  return args;
}

function mcpPlan(method, rpcParams, options, config, extra = {}) {
  const mcpUrl = options.mcpUrl || config.mcpUrl || DEFAULT_MCP_URL;
  const apiKey = options.apiKey || config.apiKey;
  const rpc = { jsonrpc: '2.0', id: 1, method };
  if (rpcParams !== undefined) rpc.params = rpcParams;
  return {
    kind: 'mcp',
    url: mcpUrl,
    method: 'POST',
    headers: {
      ...baseHeaders(apiKey),
      'content-type': 'application/json',
      accept: 'application/json, text/event-stream',
    },
    body: JSON.stringify(rpc),
    rpc,
    ...extra,
  };
}

function restPlan(path, params, options, config) {
  const baseUrl = trimTrailingSlash(options.baseUrl || config.baseUrl || DEFAULT_BASE_URL);
  const apiKey = options.apiKey || config.apiKey;
  const url = new URL(baseUrl + path);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v === true ? 'true' : String(v));
  return {
    kind: 'rest',
    url: url.toString(),
    method: 'GET',
    headers: { ...baseHeaders(apiKey), accept: 'application/json' },
  };
}

// Turn a parsed invocation into a concrete, executable plan. Returns one of:
//   { kind: 'mcp',  url, method, headers, body, rpc, needsKey? }
//   { kind: 'rest', url, method, headers }
//   { kind: 'list', specUrl, service }
// Throws UsageError for malformed input.
export function planRequest(parsed, config = {}) {
  const { command, positionals, options, params } = parsed;

  if (command === 'list' || command === 'endpoints') {
    return {
      kind: 'list',
      specUrl: options.specUrl || config.specUrl || DEFAULT_SPEC_URL,
      service: positionals[0],
    };
  }

  if (command === 'health') {
    return restPlan('/api/health', {}, options, config);
  }
  if (command === 'get') {
    const path = positionals[0];
    if (!path || !path.startsWith('/')) {
      throw new UsageError('`get` needs an API path, e.g. `worldmonitor get /api/health`');
    }
    return restPlan(path, params, options, config);
  }

  if (command === 'tools') return mcpPlan('tools/list', undefined, options, config);
  if (command === 'prompts') return mcpPlan('prompts/list', undefined, options, config);
  if (command === 'resources') return mcpPlan('resources/list', undefined, options, config);

  if (command === 'call') {
    const tool = positionals[0];
    if (!tool) {
      throw new UsageError(
        '`call` needs a tool name, e.g. `worldmonitor call get_country_risk --country_code IR`',
      );
    }
    return mcpPlan('tools/call', { name: tool, arguments: collectArgs(parsed) }, options, config, {
      needsKey: true,
    });
  }

  if (Object.hasOwn(CURATED_COMMANDS, command)) {
    const spec = CURATED_COMMANDS[command];
    const args = {};
    for (const [k, v] of Object.entries(params)) args[k] = v === true ? true : String(v);
    spec.args.forEach((a, idx) => {
      if (positionals[idx] !== undefined) args[a.name] = positionals[idx];
    });
    for (const a of spec.args) {
      if (a.required && args[a.name] === undefined) {
        throw new UsageError(`\`${command}\` needs <${a.name}>. Usage: worldmonitor ${command} <${a.name}>`);
      }
    }
    return mcpPlan('tools/call', { name: spec.tool, arguments: args }, options, config, {
      needsKey: true,
    });
  }

  throw new UsageError(`Unknown command: ${command || '(none)'}. Run \`worldmonitor --help\`.`);
}

export function formatOutput(value, options = {}) {
  if (options.raw && typeof value === 'string') return value;
  if (options.compact) return JSON.stringify(value);
  return JSON.stringify(value, null, 2);
}

// Flatten an OpenAPI document into printable operation rows, optionally scoped
// to one service (the first path segment after /api/).
export function summarizeSpec(spec, serviceFilter) {
  const paths = (spec && spec.paths) || {};
  const rows = [];
  for (const p of Object.keys(paths).sort()) {
    const match = p.match(/^\/api\/([^/]+)\/v\d+\//);
    const service = match ? match[1] : '(other)';
    if (serviceFilter && service !== serviceFilter) continue;
    for (const method of Object.keys(paths[p])) {
      if (!['get', 'post', 'put', 'delete', 'patch'].includes(method)) continue;
      rows.push({
        service,
        method: method.toUpperCase(),
        path: p,
        summary: paths[p][method].summary || '',
      });
    }
  }
  return rows;
}

export function renderListing(rows) {
  if (!rows.length) return 'No operations found.';
  const lines = rows.map(
    (r) => `${r.method.padEnd(5)} ${r.path}${r.summary ? `  — ${r.summary}` : ''}`,
  );
  return `${rows.length} operation(s):\n${lines.join('\n')}`;
}

export const HELP = `worldmonitor — command-line client for the World Monitor global-intelligence API

USAGE
  worldmonitor <command> [arguments] [--flags]

DATA COMMANDS (MCP tools/call — need --api-key)
  world                    Live global situation brief
  country <ISO>            AI strategic brief for a country (ISO alpha-2)
  risk <ISO>               Country risk / resilience scores
  markets                  Equities, commodities, crypto, FX quotes
  conflicts                Recent conflict events (--country, --limit…)
  cyber                    Cyber-threat indicators (--min_severity…)
  news                     Classified news intelligence (--topic, --country…)
  disasters                Earthquakes, fires, storms (--active_only…)
  sanctions                Sanctions designations (--country, --query…)
  forecasts                Scenario forecasts (--domain, --region…)
  maritime <ISO>           Maritime / port activity for a country

MCP
  tools                    List every MCP tool (public — no key needed)
  call <tool> [--arg val]  Call any MCP tool (--args '<json>' for typed args)
  prompts | resources      List MCP prompt / resource templates

REST
  health                   API status / health check (needs --api-key)
  get <path> [--param val] Call a raw REST path (host-relative /api/…)
  list [service]           List documented REST operations (from the live spec)

FLAGS
  --api-key <key>          User API key (or env WORLDMONITOR_API_KEY)
  --mcp-url <url>          MCP endpoint (default ${DEFAULT_MCP_URL})
  --base-url <url>         REST base (default ${DEFAULT_BASE_URL})
  --args <json>            Typed arguments object for a tool call
  --timeout <ms>           Request timeout (default 30000)
  --raw                    Print the response body verbatim
  --compact                Print single-line JSON
  -h, --help               Show this help
  -v, --version            Print version

Any other --key value pair becomes a tool/request parameter, e.g.
  worldmonitor risk IR --api-key wm_xxx
  worldmonitor conflicts --country IR --limit 5 --api-key wm_xxx
  worldmonitor call get_market_data --asset_class crypto --api-key wm_xxx
  worldmonitor tools

Get an API key at https://worldmonitor.app/pro · docs https://worldmonitor.app/docs/cli`;