@vaultgradient/pipequery-cli 0.3.3 → 0.6.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vaultgradient/pipequery-cli",
3
- "version": "0.3.3",
3
+ "version": "0.6.0",
4
4
  "description": "CLI for PipeQuery — data sources, aggregations, APIs, and terminal dashboards",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,20 +13,30 @@
13
13
  "test:watch": "vitest"
14
14
  },
15
15
  "dependencies": {
16
+ "@anthropic-ai/sdk": "^0.91.1",
16
17
  "@fastify/cors": "^11.2.0",
18
+ "@modelcontextprotocol/sdk": "^1.29.0",
19
+ "better-sqlite3": "^12.9.0",
17
20
  "chalk": "^5.6.2",
18
21
  "chokidar": "^5.0.0",
19
22
  "commander": "^14.0.3",
20
23
  "csv-parse": "^6.2.1",
21
24
  "fastify": "^5.8.5",
25
+ "grammy": "^1.42.0",
22
26
  "ink": "^7.0.1",
27
+ "kafkajs": "^2.2.4",
28
+ "mysql2": "^3.22.2",
23
29
  "ora": "^9.3.0",
30
+ "pg": "^8.20.0",
24
31
  "react": "^19.2.5",
25
32
  "ws": "^8.20.0",
26
- "yaml": "^2.8.3"
33
+ "yaml": "^2.8.3",
34
+ "zod": "^4.3.6"
27
35
  },
28
36
  "devDependencies": {
37
+ "@types/better-sqlite3": "^7.6.13",
29
38
  "@types/node": "^25.6.0",
39
+ "@types/pg": "^8.20.0",
30
40
  "@types/react": "^19.2.14",
31
41
  "@types/ws": "^8.18.1",
32
42
  "tsup": "^8.5.1",
@@ -0,0 +1,87 @@
1
+ import { loadConfig } from '../config/loader.js';
2
+ import { log } from '../utils/logger.js';
3
+ import { AttachedProvider, LocalProvider, type Provider } from '../mcp/provider.js';
4
+ import { runStdio } from '../mcp/stdio.js';
5
+ import { runHttp } from '../mcp/http.js';
6
+ import { createMcpServer } from '../mcp/server.js';
7
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
8
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
9
+
10
+ interface McpServeOptions {
11
+ http?: boolean;
12
+ port?: number;
13
+ host?: string;
14
+ attach?: string;
15
+ authToken?: string;
16
+ }
17
+
18
+ export async function mcpServeCommand(opts: McpServeOptions): Promise<void> {
19
+ const provider = await buildProvider(opts.attach);
20
+ const isStdio = !opts.http;
21
+
22
+ if (isStdio) {
23
+ // In stdio mode, stdout is reserved for JSON-RPC. All logs go to stderr.
24
+ await runStdio(provider);
25
+ return;
26
+ }
27
+
28
+ const port = opts.port ?? 3001;
29
+ const host = opts.host ?? '127.0.0.1';
30
+ // CLI flag takes precedence over env var. No token => unauthenticated
31
+ // (runHttp warns loudly at startup).
32
+ const authToken = opts.authToken ?? process.env.PIPEQUERY_MCP_TOKEN;
33
+ const handle = await runHttp(provider, { port, host, authToken });
34
+
35
+ log.success(`PipeQuery MCP server listening on ${handle.url}`);
36
+ log.info('Connect any MCP client to the URL above (Claude, Cursor, custom agents).');
37
+ log.info('Press Ctrl+C to stop.');
38
+
39
+ const shutdown = async () => {
40
+ log.info('Shutting down MCP server...');
41
+ await handle.close();
42
+ process.exit(0);
43
+ };
44
+
45
+ process.on('SIGINT', () => void shutdown());
46
+ process.on('SIGTERM', () => void shutdown());
47
+ }
48
+
49
+ export async function mcpInspectCommand(opts: { attach?: string }): Promise<void> {
50
+ const provider = await buildProvider(opts.attach);
51
+ const mcp = createMcpServer(provider);
52
+
53
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
54
+ await mcp.connect(serverTransport);
55
+
56
+ const client = new Client({ name: 'pq-mcp-inspect', version: '0.1.0' });
57
+ await client.connect(clientTransport);
58
+
59
+ try {
60
+ const { tools } = await client.listTools();
61
+ const summary = {
62
+ server: 'pipequery',
63
+ toolCount: tools.length,
64
+ tools: tools.map((t) => ({
65
+ name: t.name,
66
+ title: t.title,
67
+ description: t.description,
68
+ inputSchema: t.inputSchema,
69
+ })),
70
+ };
71
+ process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
72
+ } finally {
73
+ await client.close();
74
+ await mcp.close();
75
+ await provider.dispose();
76
+ }
77
+ }
78
+
79
+ async function buildProvider(attachUrl?: string): Promise<Provider> {
80
+ if (attachUrl) {
81
+ return new AttachedProvider(attachUrl);
82
+ }
83
+
84
+ const cwd = process.cwd();
85
+ const { config } = await loadConfig(cwd);
86
+ return new LocalProvider(config, cwd);
87
+ }
@@ -51,6 +51,16 @@ export async function sourceAddCommand(name: string, opts: {
51
51
  dataPath?: string;
52
52
  path?: string;
53
53
  watch?: boolean;
54
+ query?: string;
55
+ ssl?: string;
56
+ maxRows?: string;
57
+ readonly?: boolean;
58
+ brokers?: string;
59
+ topic?: string;
60
+ groupId?: string;
61
+ fromBeginning?: boolean;
62
+ valueFormat?: string;
63
+ maxBuffer?: string;
54
64
  }) {
55
65
  const cwd = process.cwd();
56
66
  const { config, path: configPath } = await loadConfig(cwd);
@@ -79,8 +89,60 @@ export async function sourceAddCommand(name: string, opts: {
79
89
  ...(opts.watch ? { watch: true } : {}),
80
90
  };
81
91
  break;
92
+ case 'postgres':
93
+ if (!opts.url) throw new Error('--url is required for Postgres sources');
94
+ if (!opts.query) throw new Error('--query is required for Postgres sources');
95
+ sourceConfig = {
96
+ type: 'postgres',
97
+ url: opts.url,
98
+ query: opts.query,
99
+ interval: opts.interval ?? '30s',
100
+ ...(opts.ssl === 'no-verify' ? { ssl: 'no-verify' as const } : {}),
101
+ ...(opts.ssl === 'false' ? { ssl: false as const } : {}),
102
+ ...(opts.maxRows ? { maxRows: Number.parseInt(opts.maxRows, 10) } : {}),
103
+ };
104
+ break;
105
+ case 'mysql':
106
+ if (!opts.url) throw new Error('--url is required for MySQL sources');
107
+ if (!opts.query) throw new Error('--query is required for MySQL sources');
108
+ sourceConfig = {
109
+ type: 'mysql',
110
+ url: opts.url,
111
+ query: opts.query,
112
+ interval: opts.interval ?? '30s',
113
+ ...(opts.ssl === 'no-verify' ? { ssl: 'no-verify' as const } : {}),
114
+ ...(opts.ssl === 'false' ? { ssl: false as const } : {}),
115
+ ...(opts.maxRows ? { maxRows: Number.parseInt(opts.maxRows, 10) } : {}),
116
+ };
117
+ break;
118
+ case 'sqlite':
119
+ if (!opts.path) throw new Error('--path is required for SQLite sources');
120
+ if (!opts.query) throw new Error('--query is required for SQLite sources');
121
+ sourceConfig = {
122
+ type: 'sqlite',
123
+ path: opts.path,
124
+ query: opts.query,
125
+ interval: opts.interval ?? '30s',
126
+ ...(opts.maxRows ? { maxRows: Number.parseInt(opts.maxRows, 10) } : {}),
127
+ ...(opts.readonly === false ? { readonly: false } : {}),
128
+ };
129
+ break;
130
+ case 'kafka':
131
+ if (!opts.brokers) throw new Error('--brokers is required for Kafka sources (comma-separated)');
132
+ if (!opts.topic) throw new Error('--topic is required for Kafka sources');
133
+ sourceConfig = {
134
+ type: 'kafka',
135
+ brokers: opts.brokers,
136
+ topic: opts.topic,
137
+ ...(opts.groupId ? { groupId: opts.groupId } : {}),
138
+ ...(opts.fromBeginning ? { fromBeginning: true } : {}),
139
+ ...(opts.maxBuffer ? { maxBuffer: Number.parseInt(opts.maxBuffer, 10) } : {}),
140
+ ...(opts.valueFormat ? { valueFormat: opts.valueFormat as 'json' | 'string' | 'raw' } : {}),
141
+ ...(opts.ssl === 'true' ? { ssl: true } : {}),
142
+ };
143
+ break;
82
144
  default:
83
- throw new Error(`Unknown source type: ${opts.type}. Use: rest, websocket, file`);
145
+ throw new Error(`Unknown source type: ${opts.type}. Use: rest, websocket, file, postgres, mysql, sqlite, kafka`);
84
146
  }
85
147
 
86
148
  // Save to config
@@ -0,0 +1,65 @@
1
+ import { loadConfig } from '../config/loader.js';
2
+ import { log } from '../utils/logger.js';
3
+ import { LocalProvider, AttachedProvider, type Provider } from '../mcp/provider.js';
4
+ import { buildBot } from '../telegram/bot.js';
5
+ import { createNLTranslator } from '../telegram/nl.js';
6
+
7
+ interface TelegramServeOptions {
8
+ botToken?: string;
9
+ attach?: string;
10
+ allowUser?: string[];
11
+ anthropicKey?: string;
12
+ }
13
+
14
+ export async function telegramServeCommand(opts: TelegramServeOptions): Promise<void> {
15
+ const token = opts.botToken ?? process.env.PIPEQUERY_TG_BOT_TOKEN;
16
+ if (!token) {
17
+ log.error(
18
+ 'Bot token required. Pass --bot-token <token> or set PIPEQUERY_TG_BOT_TOKEN.',
19
+ );
20
+ process.exit(1);
21
+ }
22
+
23
+ const anthropicKey = opts.anthropicKey ?? process.env.ANTHROPIC_API_KEY;
24
+ const provider = await buildProvider(opts.attach);
25
+ const nl = anthropicKey
26
+ ? createNLTranslator(provider, { apiKey: anthropicKey })
27
+ : undefined;
28
+
29
+ const bot = buildBot(token, provider, {
30
+ allowUsers: opts.allowUser ?? [],
31
+ nl,
32
+ });
33
+
34
+ const me = await bot.api.getMe();
35
+ log.success(`Telegram bot listening as @${me.username} (id ${me.id})`);
36
+ if (nl) {
37
+ log.info('Natural-language translation enabled (claude-haiku-4-5).');
38
+ } else {
39
+ log.dim('Natural-language translation disabled. Pass --anthropic-key or set ANTHROPIC_API_KEY to enable.');
40
+ }
41
+ if (!opts.allowUser || opts.allowUser.length === 0) {
42
+ log.warn('No --allow-user set: anyone with the bot username can query. Add allowlist for production.');
43
+ }
44
+ log.info('Press Ctrl+C to stop.');
45
+
46
+ const shutdown = async () => {
47
+ log.info('Stopping bot...');
48
+ await bot.stop();
49
+ await provider.dispose();
50
+ process.exit(0);
51
+ };
52
+ process.on('SIGINT', () => void shutdown());
53
+ process.on('SIGTERM', () => void shutdown());
54
+
55
+ await bot.start();
56
+ }
57
+
58
+ async function buildProvider(attachUrl?: string): Promise<Provider> {
59
+ if (attachUrl) {
60
+ return new AttachedProvider(attachUrl);
61
+ }
62
+ const cwd = process.cwd();
63
+ const { config } = await loadConfig(cwd);
64
+ return new LocalProvider(config, cwd);
65
+ }
@@ -0,0 +1,84 @@
1
+ import chalk from 'chalk';
2
+ import { loadConfig, saveConfig } from '../config/loader.js';
3
+ import { log } from '../utils/logger.js';
4
+ import type { WatchConfig } from '../config/schema.js';
5
+
6
+ export async function watchListCommand(): Promise<void> {
7
+ const cwd = process.cwd();
8
+ const { config } = await loadConfig(cwd);
9
+ const watches = config.watches ?? {};
10
+ const entries = Object.entries(watches);
11
+ if (entries.length === 0) {
12
+ log.dim('No watches configured. Add one with: pq watch add <name>');
13
+ return;
14
+ }
15
+ console.log(chalk.bold('Watches:\n'));
16
+ for (const [name, w] of entries) {
17
+ const interval = w.interval ?? '60s';
18
+ const fireWhen = w.fireWhen ?? 'when_non_empty';
19
+ const channels = Object.keys(w.notify).join(', ') || '<none>';
20
+ console.log(` ${chalk.bold(name)} ${chalk.dim(`every ${interval} • ${fireWhen}`)}`);
21
+ console.log(` ${chalk.dim('query: ')}${w.query}`);
22
+ console.log(` ${chalk.dim('notify: ')}${channels}`);
23
+ }
24
+ }
25
+
26
+ export async function watchAddCommand(name: string, opts: {
27
+ query: string;
28
+ interval?: string;
29
+ fireWhen?: string;
30
+ telegramChatId?: string;
31
+ telegramMessage?: string;
32
+ telegramBotToken?: string;
33
+ }): Promise<void> {
34
+ const cwd = process.cwd();
35
+ const { config, path: configPath } = await loadConfig(cwd);
36
+
37
+ if (!opts.telegramChatId) {
38
+ throw new Error('--telegram-chat-id is required (only Telegram notifier supported in v1)');
39
+ }
40
+ const fireWhen = opts.fireWhen ?? 'when_non_empty';
41
+ if (!['when_non_empty', 'when_empty', 'on_change'].includes(fireWhen)) {
42
+ throw new Error(`--fire-when must be one of: when_non_empty, when_empty, on_change`);
43
+ }
44
+
45
+ const watch: WatchConfig = {
46
+ query: opts.query,
47
+ ...(opts.interval ? { interval: opts.interval } : {}),
48
+ fireWhen: fireWhen as WatchConfig['fireWhen'],
49
+ notify: {
50
+ telegram: {
51
+ chatId: parseChatId(opts.telegramChatId),
52
+ ...(opts.telegramBotToken ? { botToken: opts.telegramBotToken } : {}),
53
+ ...(opts.telegramMessage ? { message: opts.telegramMessage } : {}),
54
+ },
55
+ },
56
+ };
57
+
58
+ config.watches = config.watches ?? {};
59
+ config.watches[name] = watch;
60
+ await saveConfig(config, configPath);
61
+ log.success(`Watch "${name}" added. Restart \`pq serve\` to activate.`);
62
+ }
63
+
64
+ export async function watchRemoveCommand(name: string): Promise<void> {
65
+ const cwd = process.cwd();
66
+ const { config, path: configPath } = await loadConfig(cwd);
67
+ if (!config.watches?.[name]) {
68
+ log.error(`Watch "${name}" not found`);
69
+ return;
70
+ }
71
+ delete config.watches[name];
72
+ await saveConfig(config, configPath);
73
+ log.success(`Watch "${name}" removed. Restart \`pq serve\` to apply.`);
74
+ }
75
+
76
+ function parseChatId(input: string): string | number {
77
+ // Numeric Telegram chat IDs (e.g. -1001234567890) are large negative
78
+ // integers that fit in safe-integer range. Keep negatives as numbers.
79
+ if (/^-?\d+$/.test(input)) {
80
+ const n = Number(input);
81
+ if (Number.isFinite(n) && Number.isSafeInteger(n)) return n;
82
+ }
83
+ return input;
84
+ }
@@ -4,6 +4,7 @@ export interface PipeQueryConfig {
4
4
  sources: Record<string, SourceConfig>;
5
5
  endpoints: Record<string, EndpointConfig>;
6
6
  dashboards: Record<string, DashboardConfig>;
7
+ watches?: Record<string, WatchConfig>;
7
8
  }
8
9
 
9
10
  export interface ServerConfig {
@@ -21,7 +22,11 @@ export type SourceConfig =
21
22
  | RestSourceConfig
22
23
  | WebSocketSourceConfig
23
24
  | FileSourceConfig
24
- | StaticSourceConfig;
25
+ | StaticSourceConfig
26
+ | PostgresSourceConfig
27
+ | MysqlSourceConfig
28
+ | SqliteSourceConfig
29
+ | KafkaSourceConfig;
25
30
 
26
31
  export interface RestSourceConfig {
27
32
  type: 'rest';
@@ -50,6 +55,109 @@ export interface StaticSourceConfig {
50
55
  data: unknown[];
51
56
  }
52
57
 
58
+ export interface PostgresSourceConfig {
59
+ type: 'postgres';
60
+ /**
61
+ * Postgres connection URL. Supports `${ENV_VAR}` interpolation so
62
+ * credentials can live in the environment instead of the yaml.
63
+ * Example: `postgres://${DB_USER}:${DB_PASS}@db.internal:5432/app`.
64
+ */
65
+ url: string;
66
+ /**
67
+ * The SELECT query to run on each poll. Must be a single statement.
68
+ * It's the caller's responsibility to add LIMIT/WHERE clauses that
69
+ * bound the result set; `maxRows` below is a hard safety cap.
70
+ */
71
+ query: string;
72
+ /** Poll interval, e.g. "30s", "5m". Defaults to "30s". */
73
+ interval?: string;
74
+ /**
75
+ * SSL policy. `"require"` verifies the cert (production default for
76
+ * most hosted Postgres). `"no-verify"` skips verification (useful for
77
+ * self-signed dev setups). `false` disables TLS entirely.
78
+ */
79
+ ssl?: 'require' | 'no-verify' | false;
80
+ /** Safety cap. If the query returns more rows, the fetch errors. Default 10000. */
81
+ maxRows?: number;
82
+ }
83
+
84
+ export interface MysqlSourceConfig {
85
+ type: 'mysql';
86
+ /**
87
+ * MySQL connection URL. Supports `${ENV_VAR}` interpolation so
88
+ * credentials can live in the environment instead of the yaml.
89
+ * Example: `mysql://${DB_USER}:${DB_PASS}@db.internal:3306/app`.
90
+ */
91
+ url: string;
92
+ /** The SELECT query to run on each poll. */
93
+ query: string;
94
+ /** Poll interval, e.g. "30s", "5m". Defaults to "30s". */
95
+ interval?: string;
96
+ /**
97
+ * SSL policy. `"require"` verifies the cert (production default).
98
+ * `"no-verify"` skips verification. `false` disables TLS.
99
+ */
100
+ ssl?: 'require' | 'no-verify' | false;
101
+ /** Safety cap. Default 10000. */
102
+ maxRows?: number;
103
+ }
104
+
105
+ export interface KafkaSourceConfig {
106
+ type: 'kafka';
107
+ /**
108
+ * Comma-separated list of Kafka bootstrap brokers, or an array. Supports
109
+ * `${ENV_VAR}` interpolation. Example: `"broker1:9092,broker2:9092"`.
110
+ */
111
+ brokers: string | string[];
112
+ /** Topic to subscribe to. Only one topic per source; use multiple sources for multiple topics. */
113
+ topic: string;
114
+ /**
115
+ * Consumer group ID. If omitted, an ephemeral per-process group is generated
116
+ * so this pipequery instance sees the full firehose. Set explicitly to share
117
+ * a partitioned subscription across pipequery replicas.
118
+ */
119
+ groupId?: string;
120
+ /** Start reading from the topic's beginning on first connect. Default false (latest). */
121
+ fromBeginning?: boolean;
122
+ /** Ring buffer size. Oldest messages are evicted. Default 1000. */
123
+ maxBuffer?: number;
124
+ /**
125
+ * How to decode the message value.
126
+ * - `"json"` (default): `JSON.parse(buffer.toString())`; non-JSON messages are skipped.
127
+ * - `"string"`: UTF-8 string, stored as `{ value }`.
128
+ * - `"raw"`: base64-encoded bytes, stored as `{ value }`.
129
+ */
130
+ valueFormat?: 'json' | 'string' | 'raw';
131
+ /** SSL / TLS toggle. Default false (plaintext). */
132
+ ssl?: boolean;
133
+ /**
134
+ * SASL authentication. Supports `${ENV_VAR}` interpolation in username/password.
135
+ */
136
+ sasl?: {
137
+ mechanism: 'plain' | 'scram-sha-256' | 'scram-sha-512';
138
+ username: string;
139
+ password: string;
140
+ };
141
+ }
142
+
143
+ export interface SqliteSourceConfig {
144
+ type: 'sqlite';
145
+ /**
146
+ * Path to the SQLite database file, relative to the directory
147
+ * containing pipequery.yaml. Use `:memory:` for an in-memory database
148
+ * (useful for tests; data will not persist across restarts).
149
+ */
150
+ path: string;
151
+ /** The SELECT query to run on each poll. */
152
+ query: string;
153
+ /** Poll interval, e.g. "30s", "5m". Defaults to "30s". */
154
+ interval?: string;
155
+ /** Open the database read-only. Recommended for query-only sources. Default true. */
156
+ readonly?: boolean;
157
+ /** Safety cap. Default 10000. */
158
+ maxRows?: number;
159
+ }
160
+
53
161
  // ─── Endpoint config ─────────────────────────────────────────────────────────
54
162
 
55
163
  export interface EndpointConfig {
@@ -71,3 +179,47 @@ export interface PanelConfig {
71
179
  viz: 'table' | 'bar' | 'sparkline' | 'stat' | 'orderbook' | 'heatmap' | 'candle' | 'auto';
72
180
  size?: 'full' | 'half' | 'stat';
73
181
  }
182
+
183
+ // ─── Watches (alerts triggered by a query result) ────────────────────────────
184
+
185
+ export interface WatchConfig {
186
+ /** PipeQuery expression to evaluate on each tick. */
187
+ query: string;
188
+ /** Poll interval, e.g. "30s", "5m". Defaults to "60s". */
189
+ interval?: string;
190
+ /**
191
+ * When to fire a notification:
192
+ * - `when_non_empty` (default): fires on the transition from empty/null
193
+ * to non-empty. Doesn't re-fire while still non-empty; re-fires after
194
+ * going empty and then non-empty again. Standard alerting shape.
195
+ * - `when_empty`: inverse — fires when transitioning to empty (e.g., a
196
+ * liveness check that fails when no rows match).
197
+ * - `on_change`: fires on any change in the result content. Useful for
198
+ * "tell me when this number moves" but can be chatty.
199
+ */
200
+ fireWhen?: 'when_non_empty' | 'when_empty' | 'on_change';
201
+ /** Notification channels. At least one must be configured. */
202
+ notify: WatchNotify;
203
+ }
204
+
205
+ export interface WatchNotify {
206
+ telegram?: TelegramNotifyConfig;
207
+ }
208
+
209
+ export interface TelegramNotifyConfig {
210
+ /**
211
+ * Bot token; supports `${ENV_VAR}` interpolation. If omitted, defaults
212
+ * to the `PIPEQUERY_TG_BOT_TOKEN` environment variable.
213
+ */
214
+ botToken?: string;
215
+ /** Chat / channel / group ID to post to. */
216
+ chatId: string | number;
217
+ /**
218
+ * Optional message template. `{{ .field }}` expands to the named field
219
+ * of the FIRST row of the result (most alerts care about a single
220
+ * triggering row). `{{ .count }}` is special and yields the total row
221
+ * count. If omitted, the bot posts a default summary line plus the
222
+ * full result rendered as a Markdown table.
223
+ */
224
+ message?: string;
225
+ }
package/src/index.ts CHANGED
@@ -8,11 +8,17 @@ import { endpointListCommand, endpointAddCommand, endpointRemoveCommand } from '
8
8
  import { dashboardCommand } from './commands/dashboard.js';
9
9
  import { monitorCommand } from './commands/monitor.js';
10
10
  import { remoteDeployCommand, remoteConnectCommand, remoteStatusCommand } from './commands/remote.js';
11
+ import { mcpServeCommand, mcpInspectCommand } from './commands/mcp.js';
12
+ import { telegramServeCommand } from './commands/telegram.js';
13
+ import { watchListCommand, watchAddCommand, watchRemoveCommand } from './commands/watch.js';
11
14
  import { completionCommand } from './commands/completion.js';
12
15
  import { stopCommand } from './commands/stop.js';
13
16
  import { startRepl } from './commands/repl.js';
14
17
  import { printBanner } from './utils/banner.js';
15
18
 
19
+ // Commander helper: accumulate `--allow-user @a --allow-user @b` into an array.
20
+ const collectRepeated = (value: string, acc: string[]): string[] => [...acc, value];
21
+
16
22
  const program = new Command();
17
23
 
18
24
  program
@@ -65,12 +71,22 @@ source
65
71
  source
66
72
  .command('add <name>')
67
73
  .description('Add a data source')
68
- .requiredOption('-t, --type <type>', 'Source type: rest, websocket, file')
69
- .option('-u, --url <url>', 'URL for REST or WebSocket sources')
70
- .option('-i, --interval <interval>', 'Poll interval for REST (e.g., 30s, 5m)', '30s')
71
- .option('-d, --data-path <path>', 'Dot-path to extract data from response')
72
- .option('-p, --path <path>', 'File path for file sources')
73
- .option('-w, --watch', 'Watch file for changes')
74
+ .requiredOption('-t, --type <type>', 'Source type: rest, websocket, file, postgres, mysql, sqlite, kafka')
75
+ .option('-u, --url <url>', 'Connection URL for REST / WebSocket / Postgres / MySQL sources')
76
+ .option('-i, --interval <interval>', 'Poll interval (e.g., 30s, 5m)', '30s')
77
+ .option('-d, --data-path <path>', 'Dot-path to extract data from REST response')
78
+ .option('-p, --path <path>', 'File path for file or sqlite sources')
79
+ .option('-w, --watch', 'Watch file for changes (file sources)')
80
+ .option('-q, --query <sql>', 'SELECT query for Postgres / MySQL / SQLite sources')
81
+ .option('--ssl <mode>', 'Postgres / MySQL SSL: require (default), no-verify, false; Kafka: true / false')
82
+ .option('--max-rows <n>', 'DB sources: safety cap on rows per fetch (default 10000)')
83
+ .option('--no-readonly', 'SQLite: open the database read-write instead of read-only')
84
+ .option('-b, --brokers <list>', 'Kafka: comma-separated bootstrap brokers (supports ${ENV_VAR})')
85
+ .option('--topic <topic>', 'Kafka topic to subscribe to')
86
+ .option('--group-id <id>', 'Kafka consumer group (default: ephemeral per-process)')
87
+ .option('--from-beginning', 'Kafka: read topic from the start instead of latest')
88
+ .option('--value-format <fmt>', 'Kafka value decoding: json (default), string, raw')
89
+ .option('--max-buffer <n>', 'Kafka / WebSocket: ring buffer size (default 1000)')
74
90
  .action(sourceAddCommand);
75
91
 
76
92
  source
@@ -138,6 +154,64 @@ remote
138
154
  .description('Check remote server health')
139
155
  .action(remoteStatusCommand);
140
156
 
157
+ // ─── pq mcp ──────────────────────────────────────────────────────────────────
158
+
159
+ const mcp = program.command('mcp').description('Model Context Protocol server for AI clients');
160
+
161
+ mcp
162
+ .command('serve')
163
+ .description('Start an MCP server exposing pipequery as tools (for Claude, Cursor, Copilot, etc.)')
164
+ .option('--http', 'Serve over HTTP/SSE instead of stdio')
165
+ .option('-p, --port <port>', 'HTTP port (when --http is set)', parseInt)
166
+ .option('-H, --host <host>', 'HTTP host (when --http is set)')
167
+ .option('--attach <url>', 'Attach to a running `pq serve` instance at the given URL instead of loading pipequery.yaml locally')
168
+ .option('--auth-token <token>', 'Require Authorization: Bearer <token> on HTTP requests. Falls back to $PIPEQUERY_MCP_TOKEN. Without either, the endpoint is unauthenticated.')
169
+ .action(mcpServeCommand);
170
+
171
+ mcp
172
+ .command('inspect')
173
+ .description('Print the MCP tool schemas as JSON (useful for directory submission / debugging)')
174
+ .option('--attach <url>', 'Attach to a running `pq serve` instance at the given URL instead of loading pipequery.yaml locally')
175
+ .action(mcpInspectCommand);
176
+
177
+ // ─── pq telegram ─────────────────────────────────────────────────────────────
178
+
179
+ const telegram = program.command('telegram').description('Telegram bot transport for pipequery');
180
+
181
+ telegram
182
+ .command('serve')
183
+ .description('Run a Telegram bot that exposes pipequery commands (/query, /sources, /describe, /endpoints, /call) plus natural-language queries when --anthropic-key is set')
184
+ .option('-t, --bot-token <token>', 'Telegram bot token (or set $PIPEQUERY_TG_BOT_TOKEN)')
185
+ .option('--attach <url>', 'Attach to a running `pq serve` instance instead of loading pipequery.yaml locally')
186
+ .option('--allow-user <handle>', 'Allowlist a user (numeric id or @username); repeatable. With no allowlist, anyone with the bot username can query.', collectRepeated, [])
187
+ .option('--anthropic-key <key>', 'Anthropic API key (or set $ANTHROPIC_API_KEY) — enables natural-language → pipequery translation on plain-text messages via claude-haiku-4-5.')
188
+ .action(telegramServeCommand);
189
+
190
+ // ─── pq watch ────────────────────────────────────────────────────────────────
191
+
192
+ const watch = program.command('watch').description('Manage alert watches (query → notification when condition fires)');
193
+
194
+ watch
195
+ .command('list')
196
+ .description('List configured watches')
197
+ .action(watchListCommand);
198
+
199
+ watch
200
+ .command('add <name>')
201
+ .description('Add a watch that fires a notification when its query result triggers the configured condition')
202
+ .requiredOption('-q, --query <expression>', 'PipeQuery expression to evaluate on each tick')
203
+ .option('-i, --interval <duration>', 'Poll interval (e.g., 30s, 5m). Default 60s.')
204
+ .option('-f, --fire-when <mode>', 'Fire condition: when_non_empty (default), when_empty, on_change')
205
+ .option('--telegram-chat-id <id>', 'Telegram chat / channel / group ID to notify')
206
+ .option('--telegram-bot-token <token>', 'Bot token override (defaults to $PIPEQUERY_TG_BOT_TOKEN)')
207
+ .option('--telegram-message <template>', 'Optional message template; supports {{ .field }} from the first row, plus {{ .count }}, {{ .watchName }}, {{ .reason }}')
208
+ .action(watchAddCommand);
209
+
210
+ watch
211
+ .command('remove <name>')
212
+ .description('Remove a watch')
213
+ .action(watchRemoveCommand);
214
+
141
215
  // ─── pq stop ────────────────────────────────────────────────────────────────
142
216
 
143
217
  program