Skip to content

Commit 8bbf4e9

Browse files
committed
feat: introduce standalone PMXT CLI
1 parent d40f87c commit 8bbf4e9

100 files changed

Lines changed: 8042 additions & 99 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/publish.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,11 @@ jobs:
118118
env:
119119
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
120120

121+
- name: Publish @pmxt/cli
122+
run: npm publish --workspace=@pmxt/cli --provenance --access public --tag ${{ steps.get_version.outputs.npm_tag }}
123+
env:
124+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
125+
121126
publish-python:
122127
name: Publish pmxt (Python)
123128
runs-on: ubuntu-latest
@@ -243,6 +248,7 @@ jobs:
243248
**npm:**
244249
```bash
245250
npm install pmxtjs@${{ steps.get_version.outputs.version }}
251+
npm install -g @pmxt/cli@${{ steps.get_version.outputs.version }}
246252
```
247253
248254
**PyPI:**
@@ -252,6 +258,7 @@ jobs:
252258
253259
## Links
254260
- [npm: pmxtjs](https://www.npmjs.com/package/pmxtjs/v/${{ steps.get_version.outputs.version }})
261+
- [npm: @pmxt/cli](https://www.npmjs.com/package/@pmxt/cli/v/${{ steps.get_version.outputs.version }})
255262
- [npm: pmxt-core](https://www.npmjs.com/package/pmxt-core/v/${{ steps.get_version.outputs.version }})
256263
- [PyPI: pmxt](https://pypi.org/project/pmxt/${{ steps.get_version.outputs.version }}/)
257264
prerelease: ${{ steps.get_version.outputs.is_prerelease }}

.github/workflows/test-publish.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ on:
1010

1111
jobs:
1212
test-js-packages:
13-
name: Test JS Packages (Core & SDK)
13+
name: Test JS Packages (Core, SDK & CLI)
1414
runs-on: ubuntu-latest
1515
steps:
1616
- uses: actions/checkout@v4
@@ -52,6 +52,9 @@ jobs:
5252
- name: Dry Run Publish pmxtjs
5353
run: npm publish --workspace=pmxtjs --dry-run
5454

55+
- name: Dry Run Publish @pmxt/cli
56+
run: npm publish --workspace=@pmxt/cli --dry-run
57+
5558
test-python-package:
5659
name: Test Python Package
5760
runs-on: ubuntu-latest

changelog.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,27 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [2.45.0] - 2026-05-25
6+
7+
### Added
8+
9+
- **CLI**: Introduced the standalone `@pmxt/cli` package with the `pmxt` executable. It can be installed globally with `npm install -g @pmxt/cli` or run with `npx @pmxt/cli`.
10+
- **CLI commands**: Added command coverage for the documented PMXT API surface, including markets, events, order books, trades, balances, positions, order build/create/submit/cancel/get, router matches, data feeds, feed streaming, WebSocket watch commands, enterprise commands, and local server management.
11+
- **CLI aliases**: Added an explicit alias layer for exchange-first UX such as `pmxt polymarket fetchMarkets --query Trump`, direct camelCase method aliases such as `pmxt fetchMarkets`, and space-separated command groups such as `pmxt order create` and `pmxt feed fetchTicker`.
12+
- **CLI auth**: Added `pmxt auth` commands for PMXT API keys and exchange credentials. Commands support saved auth, environment variables, and one-shot flags so automation can avoid interactive prompts.
13+
- **CLI packaging**: Added a dedicated `sdks/cli` workspace, oclif command discovery, package validation, and npm package metadata for publishing `@pmxt/cli`.
14+
15+
### Changed
16+
17+
- **TypeScript SDK**: Decoupled the command-line interface from `pmxtjs`. Installing `pmxtjs` now provides the SDK only; installing `@pmxt/cli` provides the CLI.
18+
- **Release workflow**: Updated CI/CD versioning, dry-run publishing, npm publishing, local release scripts, and GitHub release notes to include `@pmxt/cli` alongside `pmxt-core`, `pmxtjs`, and the Python `pmxt` package.
19+
20+
### Fixed
21+
22+
- **Package metadata**: Normalized `pmxt-core` npm metadata so publish dry-runs no longer rely on npm auto-correcting repository and bin path fields.
23+
- **Release dry run**: Updated the local version-update dry-run helper to validate CLI package versioning, `pmxtjs` dependency pinning, Python `__init__` versioning, and generated SDK version arguments.
24+
- **CLI auth**: `pmxt auth status` now dispatches correctly while preserving the existing `pmxt auth:status` command.
25+
526
## [2.44.7] - 2026-05-25
627

728
### Fixed

core/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@
66
"types": "dist/index.d.ts",
77
"repository": {
88
"type": "git",
9-
"url": "https://github.com/pmxt-dev/pmxt.git"
9+
"url": "git+https://github.com/pmxt-dev/pmxt.git"
1010
},
1111
"bin": {
12-
"pmxt-server": "./dist/server/index.js",
13-
"pmxt-ensure-server": "./bin/pmxt-ensure-server"
12+
"pmxt-server": "dist/server/index.js",
13+
"pmxt-ensure-server": "bin/pmxt-ensure-server"
1414
},
1515
"files": [
1616
"dist",

core/src/exchanges/mock/index.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ import { SeededRng } from './seededRng';
2323

2424
const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
2525
const round = (n: number, decimals = 3) => parseFloat(n.toFixed(decimals));
26+
const toTimestamp = (value: Date | string | number | undefined): number | undefined => {
27+
if (value === undefined) return undefined;
28+
if (value instanceof Date) return value.getTime();
29+
const parsed = typeof value === 'number' ? value : new Date(value).getTime();
30+
if (!Number.isFinite(parsed)) {
31+
throw new Error(`Invalid date value: ${String(value)}`);
32+
}
33+
return parsed;
34+
};
2635

2736
const CATEGORIES = ['Politics', 'Sports', 'Crypto', 'Finance', 'Science', 'Entertainment', 'Tech', 'World'];
2837
const LOREM = `lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor
@@ -375,8 +384,8 @@ export class MockExchange extends PredictionMarketExchange {
375384
};
376385
const step = resolutionMs[params.resolution] ?? 3_600_000;
377386
const limit = params.limit ?? 100;
378-
const end = params.end ? params.end.getTime() : Date.now();
379-
const start = params.start ? params.start.getTime() : end - step * limit;
387+
const end = toTimestamp(params.end) ?? Date.now();
388+
const start = toTimestamp(params.start) ?? end - step * limit;
380389
const candles: PriceCandle[] = [];
381390
let price = round(f.float(0.2, 0.8), 3);
382391
let t = start;

core/src/exchanges/polymarket/websocket.ts

Lines changed: 129 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
POLYMARKET_DEFAULT_SUBSCRIPTION,
1515
} from '../../subscriber/external/goldsky';
1616
import { AddressWatcher, WatcherConfig } from '../../subscriber/watcher';
17-
import { OrderBook, OrderLevel, QueuedPromise, Trade } from '../../types';
17+
import { OrderBook, QueuedPromise, Trade } from '../../types';
1818
import { DEFAULT_WATCH_TIMEOUT_MS, withWatchTimeout } from '../../utils/watch-timeout';
1919

2020

@@ -63,9 +63,12 @@ export interface PolymarketWebSocketConfig {
6363
userChannelCreds?: PolymarketUserChannelCreds;
6464
/** Timeout in ms for WebSocket connections to open (default: 30000). */
6565
connectionTimeoutMs?: number;
66+
/** Time to wait for an initial market-channel book before using a REST snapshot fallback. */
67+
snapshotFallbackMs?: number;
6668
}
6769

6870
const DEFAULT_CONNECTION_TIMEOUT_MS = 30_000;
71+
const DEFAULT_SNAPSHOT_FALLBACK_MS = 3_000;
6972
const MAX_PENDING_TRADES_PER_ASSET = 1000;
7073
const MAX_USER_CALLBACKS = 100;
7174

@@ -85,8 +88,11 @@ export class PolymarketWebSocket {
8588
private orderBooks = new Map<string, OrderBook>();
8689
private config: PolymarketWebSocketConfig;
8790
private initializationPromise?: Promise<void>;
91+
private marketPingInterval: ReturnType<typeof setInterval> | null = null;
92+
private readonly callApi: (operationId: string, params?: Record<string, any>) => Promise<any>;
8893

8994
constructor(callApi: (operationId: string, params?: Record<string, any>) => Promise<any>, config: PolymarketWebSocketConfig = {}) {
95+
this.callApi = callApi;
9096
this.config = config;
9197
const watcherConfig = this.config.watcherConfig;
9298
const subscriber = new GoldSkySubscriber({
@@ -106,14 +112,22 @@ export class PolymarketWebSocket {
106112
await this.ensureInitialized();
107113
await this.subscribe([outcomeId]);
108114

109-
// Return a promise that resolves on the next orderbook update
115+
// Return a promise that resolves on the next orderbook update.
116+
// If the upstream market channel accepts the subscription but stays
117+
// quiet, return a real REST snapshot instead of hanging indefinitely.
118+
const resolverEntry: QueuedPromise<OrderBook> = {
119+
resolve: () => {},
120+
reject: () => {},
121+
};
110122
const dataPromise = new Promise<OrderBook>((resolve, reject) => {
123+
resolverEntry.resolve = resolve;
124+
resolverEntry.reject = reject;
111125
const existing = this.orderBookResolvers.get(outcomeId) ?? [];
112-
this.orderBookResolvers.set(outcomeId, [...existing, { resolve, reject }]);
126+
this.orderBookResolvers.set(outcomeId, [...existing, resolverEntry]);
113127
});
114128

115129
return withWatchTimeout(
116-
dataPromise,
130+
this.withSnapshotFallback(outcomeId, dataPromise, resolverEntry),
117131
this.config.watchTimeoutMs ?? DEFAULT_WATCH_TIMEOUT_MS,
118132
`watchOrderBook('${outcomeId}')`,
119133
);
@@ -323,6 +337,7 @@ export class PolymarketWebSocket {
323337
this.ws.close();
324338
this.ws = null;
325339
}
340+
this.stopMarketHeartbeat();
326341
this.subscribedAssets.clear();
327342
this.closeUserChannel();
328343
this.watcher.close();
@@ -361,12 +376,15 @@ export class PolymarketWebSocket {
361376

362377
this.ws.on('open', () => {
363378
clearTimeout(timeout);
379+
this.startMarketHeartbeat();
364380
resolve();
365381
});
366382

367383
this.ws.on('message', (raw: any) => {
368384
try {
369-
const msgs = JSON.parse(raw.toString());
385+
const text = raw.toString();
386+
if (text === 'PONG' || text === 'PING') return;
387+
const msgs = JSON.parse(text);
370388
const arr = Array.isArray(msgs) ? msgs : [msgs];
371389
for (const msg of arr) {
372390
const type = msg.event_type;
@@ -384,10 +402,13 @@ export class PolymarketWebSocket {
384402
this.ws.on('error', (err: Error) => {
385403
clearTimeout(timeout);
386404
logger.error('[polymarket-ws] WebSocket error', { error: err.message });
405+
this.rejectPendingMarketResolvers(err);
387406
reject(err);
388407
});
389408

390409
this.ws.on('close', () => {
410+
this.stopMarketHeartbeat();
411+
this.rejectPendingMarketResolvers(new Error('Polymarket market channel closed'));
391412
this.initializationPromise = undefined;
392413
this.ws = null;
393414
});
@@ -399,24 +420,117 @@ export class PolymarketWebSocket {
399420
private handleBookSnapshot(event: any) {
400421
const id = event.asset_id;
401422

402-
const bids: OrderLevel[] = event.bids.map((b: any) => ({
403-
price: parseFloat(b.price),
404-
size: parseFloat(b.size),
423+
const orderBook = this.normalizeRawOrderBook(event);
424+
425+
this.orderBooks.set(id, orderBook);
426+
this.resolveOrderBook(id, orderBook);
427+
}
428+
429+
private withSnapshotFallback(
430+
outcomeId: string,
431+
dataPromise: Promise<OrderBook>,
432+
resolverEntry: QueuedPromise<OrderBook>,
433+
): Promise<OrderBook> {
434+
const fallbackMs = this.config.snapshotFallbackMs ?? DEFAULT_SNAPSHOT_FALLBACK_MS;
435+
if (fallbackMs <= 0) return dataPromise;
436+
437+
let timer: ReturnType<typeof setTimeout>;
438+
const fallbackPromise = new Promise<OrderBook>((resolve, reject) => {
439+
timer = setTimeout(async () => {
440+
this.removeOrderBookResolver(outcomeId, resolverEntry);
441+
try {
442+
resolve(await this.fetchOrderBookSnapshot(outcomeId));
443+
} catch (error) {
444+
reject(error);
445+
}
446+
}, fallbackMs);
447+
});
448+
449+
return Promise.race([dataPromise, fallbackPromise]).finally(() => {
450+
clearTimeout(timer);
451+
});
452+
}
453+
454+
private removeOrderBookResolver(outcomeId: string, resolverEntry: QueuedPromise<OrderBook>): void {
455+
const resolvers = this.orderBookResolvers.get(outcomeId);
456+
if (!resolvers) return;
457+
const filtered = resolvers.filter((entry) => entry !== resolverEntry);
458+
if (filtered.length > 0) {
459+
this.orderBookResolvers.set(outcomeId, filtered);
460+
} else {
461+
this.orderBookResolvers.delete(outcomeId);
462+
}
463+
}
464+
465+
private async fetchOrderBookSnapshot(outcomeId: string): Promise<OrderBook> {
466+
const raw = await this.callApi('getBook', { token_id: outcomeId });
467+
const orderBook = this.normalizeRawOrderBook({
468+
...raw,
469+
asset_id: raw?.asset_id ?? outcomeId,
470+
});
471+
this.orderBooks.set(outcomeId, orderBook);
472+
return orderBook;
473+
}
474+
475+
private normalizeRawOrderBook(raw: any): OrderBook {
476+
const bids = (raw?.bids || []).map((level: any) => ({
477+
price: parseFloat(level.price),
478+
size: parseFloat(level.size),
405479
})).sort((a: any, b: any) => b.price - a.price);
406480

407-
const asks: OrderLevel[] = event.asks.map((a: any) => ({
408-
price: parseFloat(a.price),
409-
size: parseFloat(a.size),
481+
const asks = (raw?.asks || []).map((level: any) => ({
482+
price: parseFloat(level.price),
483+
size: parseFloat(level.size),
410484
})).sort((a: any, b: any) => a.price - b.price);
411485

412-
const orderBook: OrderBook = {
486+
return {
413487
bids,
414488
asks,
415-
timestamp: event.timestamp ? (isNaN(Number(event.timestamp)) ? new Date(event.timestamp).getTime() : Number(event.timestamp)) : Date.now(),
489+
timestamp: this.parseTimestamp(raw?.timestamp),
416490
};
491+
}
417492

418-
this.orderBooks.set(id, orderBook);
419-
this.resolveOrderBook(id, orderBook);
493+
private parseTimestamp(value: unknown): number {
494+
if (typeof value === 'number') return value;
495+
if (typeof value === 'string' && value.length > 0) {
496+
const numeric = Number(value);
497+
return Number.isNaN(numeric) ? new Date(value).getTime() : numeric;
498+
}
499+
return Date.now();
500+
}
501+
502+
private startMarketHeartbeat(): void {
503+
this.stopMarketHeartbeat();
504+
this.marketPingInterval = setInterval(() => {
505+
if (this.ws && this.ws.readyState === 1) {
506+
try {
507+
this.ws.send('PING');
508+
} catch (error) {
509+
logger.warn('[polymarket-ws] market heartbeat failed', {
510+
error: error instanceof Error ? error.message : String(error),
511+
});
512+
}
513+
}
514+
}, 10_000);
515+
}
516+
517+
private stopMarketHeartbeat(): void {
518+
if (this.marketPingInterval) {
519+
clearInterval(this.marketPingInterval);
520+
this.marketPingInterval = null;
521+
}
522+
}
523+
524+
private rejectPendingMarketResolvers(error: Error): void {
525+
for (const [, resolvers] of this.orderBookResolvers) {
526+
for (const resolver of resolvers) resolver.reject(error);
527+
}
528+
this.orderBookResolvers.clear();
529+
530+
for (const [, resolvers] of this.tradeResolvers) {
531+
for (const resolver of resolvers) resolver.reject(error);
532+
}
533+
this.tradeResolvers.clear();
420534
}
421535

422536
private handlePriceChange(event: any) {

core/src/router/Router.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,10 @@ export class Router extends PredictionMarketExchange {
287287

288288
// Lookup mode: find matches for a specific market.
289289
const response = await this.client.getMarketMatches(params);
290-
const matches = response.matches ?? [];
290+
if (!response || !Array.isArray(response.matches)) {
291+
throw new Error('fetchMarketMatches returned an unexpected response shape: missing matches array');
292+
}
293+
const matches = response.matches;
291294
return matches.map((m: any) => ({
292295
market: m.market,
293296
relation: m.relation,
@@ -337,7 +340,12 @@ export class Router extends PredictionMarketExchange {
337340
const hasIdentifier = params.eventId || params.slug;
338341
if (!hasIdentifier) {
339342
const results = await this.client.browseEventMatches(params);
340-
return Array.isArray(results) ? results : [];
343+
if (!Array.isArray(results)) {
344+
throw new Error(
345+
`browseEventMatches returned unexpected type '${typeof results}'`
346+
);
347+
}
348+
return results;
341349
}
342350

343351
if (await this.resolveLocalMockEventLookup(params)) {
@@ -346,7 +354,10 @@ export class Router extends PredictionMarketExchange {
346354

347355
// Lookup mode: find matches for a specific event.
348356
const response = await this.client.getEventMatches(params);
349-
return response.matches ?? [];
357+
if (!response || !Array.isArray(response.matches)) {
358+
throw new Error('fetchEventMatches returned an unexpected response shape: missing matches array');
359+
}
360+
return response.matches;
350361
}
351362

352363
// -----------------------------------------------------------------------

0 commit comments

Comments
 (0)