@vaultgradient/pipequery-cli 0.4.0 → 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/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { Command } from "commander";
5
- import chalk8 from "chalk";
5
+ import chalk9 from "chalk";
6
6
 
7
7
  // src/commands/init.ts
8
8
  import { writeFile } from "fs/promises";
@@ -942,19 +942,8 @@ var SourceManager = class {
942
942
  }
943
943
  };
944
944
 
945
- // src/server/routes/health.ts
946
- function registerHealthRoutes(app, sourceManager, startedAt) {
947
- app.get("/health", async () => {
948
- return { status: "ok", uptime: Date.now() - startedAt.getTime() };
949
- });
950
- app.get("/status", async () => {
951
- return {
952
- status: "ok",
953
- uptime: Date.now() - startedAt.getTime(),
954
- sources: sourceManager.getAllStatuses()
955
- };
956
- });
957
- }
945
+ // src/server/watches/manager.ts
946
+ import { createHash } from "crypto";
958
947
 
959
948
  // ../src/engine/types.ts
960
949
  var AGGREGATE_NAMES = /* @__PURE__ */ new Set([
@@ -2782,6 +2771,333 @@ function query(context, expression) {
2782
2771
  return fn(context);
2783
2772
  }
2784
2773
 
2774
+ // src/server/activity-log.ts
2775
+ var MAX_ENTRIES = 100;
2776
+ var entries = [];
2777
+ function logActivity(level, message) {
2778
+ entries.push({
2779
+ time: (/* @__PURE__ */ new Date()).toISOString(),
2780
+ level,
2781
+ message
2782
+ });
2783
+ if (entries.length > MAX_ENTRIES) {
2784
+ entries.splice(0, entries.length - MAX_ENTRIES);
2785
+ }
2786
+ }
2787
+ function getActivityLog(since) {
2788
+ if (!since) return [...entries];
2789
+ return entries.filter((e) => e.time > since);
2790
+ }
2791
+ function info(msg) {
2792
+ logActivity("info", msg);
2793
+ }
2794
+ function warn(msg) {
2795
+ logActivity("warn", msg);
2796
+ }
2797
+ function error(msg) {
2798
+ logActivity("error", msg);
2799
+ }
2800
+
2801
+ // src/telegram/notifier.ts
2802
+ import { Bot } from "grammy";
2803
+
2804
+ // src/telegram/format.ts
2805
+ var TELEGRAM_TEXT_LIMIT = 4096;
2806
+ var FENCE_OVERHEAD = 32;
2807
+ var TRUNCATE_BUDGET = TELEGRAM_TEXT_LIMIT - FENCE_OVERHEAD;
2808
+ var MAX_ROWS_INLINE = 30;
2809
+ var MAX_COL_WIDTH = 24;
2810
+ function formatResult(result, opts = {}) {
2811
+ const maxRows = opts.maxRows ?? MAX_ROWS_INLINE;
2812
+ if (Array.isArray(result)) {
2813
+ if (result.length === 0) return "<i>(no rows)</i>";
2814
+ const allObjects = result.every(
2815
+ (r) => r !== null && typeof r === "object" && !Array.isArray(r)
2816
+ );
2817
+ if (allObjects) {
2818
+ return renderTable(result, result.length, maxRows, opts);
2819
+ }
2820
+ return renderJson(result.slice(0, maxRows), result.length, maxRows);
2821
+ }
2822
+ return renderJson(result, 1, 1);
2823
+ }
2824
+ function formatError(err) {
2825
+ const msg = err instanceof Error ? err.message : String(err);
2826
+ return `\u274C <b>Error</b>
2827
+ <pre>${escapeHtml(msg)}</pre>`;
2828
+ }
2829
+ function renderTable(rows, totalCount, maxRows, opts) {
2830
+ const maxColWidth = opts.maxColWidth ?? MAX_COL_WIDTH;
2831
+ const sample = rows.slice(0, maxRows);
2832
+ const columns = [];
2833
+ const seen = /* @__PURE__ */ new Set();
2834
+ for (const r of sample) {
2835
+ for (const k of Object.keys(r)) {
2836
+ if (!seen.has(k)) {
2837
+ seen.add(k);
2838
+ columns.push(k);
2839
+ }
2840
+ }
2841
+ }
2842
+ const widths = columns.map(
2843
+ (c) => Math.min(
2844
+ maxColWidth,
2845
+ Math.max(c.length, ...sample.map((r) => stringifyCell(r[c]).length))
2846
+ )
2847
+ );
2848
+ const headerRow = columns.map((c, i) => padCell(c, widths[i])).join(" ");
2849
+ const sep = widths.map((w) => "\u2500".repeat(w)).join(" ");
2850
+ const dataRows = sample.map(
2851
+ (r) => columns.map((c, i) => padCell(stringifyCell(r[c]), widths[i])).join(" ")
2852
+ );
2853
+ const lines = [headerRow, sep, ...dataRows];
2854
+ let body = lines.join("\n");
2855
+ if (body.length > TRUNCATE_BUDGET) {
2856
+ body = body.slice(0, TRUNCATE_BUDGET - 30) + "\n... (truncated)";
2857
+ }
2858
+ let out = `<pre>${escapeHtml(body)}</pre>`;
2859
+ if (totalCount > maxRows) {
2860
+ out += `
2861
+ <i>showing ${maxRows} of ${totalCount} rows \u2014 narrow your query</i>`;
2862
+ } else {
2863
+ out += `
2864
+ <i>${totalCount} row${totalCount === 1 ? "" : "s"}</i>`;
2865
+ }
2866
+ return out;
2867
+ }
2868
+ function stringifyCell(value) {
2869
+ if (value === null || value === void 0) return "";
2870
+ if (typeof value === "string") return value;
2871
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
2872
+ return JSON.stringify(value);
2873
+ }
2874
+ function padCell(s, width) {
2875
+ if (s.length > width) return s.slice(0, width - 1) + "\u2026";
2876
+ return s.padEnd(width, " ");
2877
+ }
2878
+ function renderJson(data, totalCount, maxRows) {
2879
+ const text = JSON.stringify(data, null, 2);
2880
+ let body = text;
2881
+ if (body.length > TRUNCATE_BUDGET) {
2882
+ body = body.slice(0, TRUNCATE_BUDGET - 30) + "\n... (truncated)";
2883
+ }
2884
+ let out = `<pre>${escapeHtml(body)}</pre>`;
2885
+ if (Array.isArray(data) && totalCount > maxRows) {
2886
+ out += `
2887
+ <i>showing ${maxRows} of ${totalCount} rows \u2014 narrow your query</i>`;
2888
+ }
2889
+ return out;
2890
+ }
2891
+ var HTML_ENTITIES = {
2892
+ "&": "&amp;",
2893
+ "<": "&lt;",
2894
+ ">": "&gt;"
2895
+ };
2896
+ function escapeHtml(s) {
2897
+ return s.replace(/[&<>]/g, (c) => HTML_ENTITIES[c] ?? c);
2898
+ }
2899
+
2900
+ // src/telegram/notifier.ts
2901
+ var botCache = /* @__PURE__ */ new Map();
2902
+ function expandEnv4(input) {
2903
+ return input.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name) => process.env[name] ?? "");
2904
+ }
2905
+ function getBot(token) {
2906
+ let bot = botCache.get(token);
2907
+ if (!bot) {
2908
+ bot = new Bot(token);
2909
+ botCache.set(token, bot);
2910
+ }
2911
+ return bot;
2912
+ }
2913
+ var TelegramNotifier = class {
2914
+ token;
2915
+ chatId;
2916
+ template;
2917
+ constructor(cfg) {
2918
+ const tokenSource = cfg.botToken ?? process.env.PIPEQUERY_TG_BOT_TOKEN;
2919
+ if (!tokenSource) {
2920
+ throw new Error(
2921
+ "TelegramNotifier: no bot token. Set notify.telegram.botToken or PIPEQUERY_TG_BOT_TOKEN."
2922
+ );
2923
+ }
2924
+ this.token = expandEnv4(tokenSource);
2925
+ this.chatId = cfg.chatId;
2926
+ this.template = cfg.message;
2927
+ }
2928
+ async notify(payload) {
2929
+ const bot = getBot(this.token);
2930
+ const text = this.renderMessage(payload);
2931
+ await bot.api.sendMessage(this.chatId, text, {
2932
+ parse_mode: "HTML",
2933
+ link_preview_options: { is_disabled: true }
2934
+ });
2935
+ }
2936
+ renderMessage(payload) {
2937
+ if (this.template) {
2938
+ return renderTemplate(this.template, payload);
2939
+ }
2940
+ const header = `\u{1F514} <b>Watch fired:</b> <code>${escapeHtml(payload.watchName)}</code>`;
2941
+ const reason = `<i>${escapeHtml(payload.reason)}</i>`;
2942
+ const body = formatResult(payload.result);
2943
+ return [header, reason, body].join("\n");
2944
+ }
2945
+ };
2946
+ function renderTemplate(tmpl, payload) {
2947
+ const result = payload.result;
2948
+ const rows = Array.isArray(result) ? result : [result];
2949
+ const firstRow = rows[0];
2950
+ const count = rows.length;
2951
+ return tmpl.replace(/\{\{\s*\.([a-zA-Z0-9_]+)\s*\}\}/g, (match, name) => {
2952
+ if (name === "count") return String(count);
2953
+ if (name === "watchName") return payload.watchName;
2954
+ if (name === "reason") return payload.reason;
2955
+ if (firstRow && typeof firstRow === "object" && firstRow !== null) {
2956
+ const value = firstRow[name];
2957
+ if (value === void 0) return match;
2958
+ return String(value);
2959
+ }
2960
+ return match;
2961
+ });
2962
+ }
2963
+
2964
+ // src/server/watches/manager.ts
2965
+ var DEFAULT_INTERVAL_MS4 = 6e4;
2966
+ var WatchManager = class {
2967
+ constructor(sourceManager) {
2968
+ this.sourceManager = sourceManager;
2969
+ }
2970
+ sourceManager;
2971
+ watches = /* @__PURE__ */ new Map();
2972
+ disposed = false;
2973
+ start(configs) {
2974
+ for (const [name, cfg] of Object.entries(configs)) {
2975
+ try {
2976
+ this.startOne(name, cfg);
2977
+ } catch (err) {
2978
+ const msg = err instanceof Error ? err.message : String(err);
2979
+ error(`Watch "${name}" failed to start: ${msg}`);
2980
+ process.stderr.write(`[pipequery-watch] "${name}" failed to start: ${msg}
2981
+ `);
2982
+ }
2983
+ }
2984
+ }
2985
+ startOne(name, cfg) {
2986
+ const notifiers = buildNotifiers(cfg.notify);
2987
+ if (notifiers.length === 0) {
2988
+ throw new Error(`watch has no notification channels configured`);
2989
+ }
2990
+ const intervalMs = cfg.interval ? parseDuration(cfg.interval) : DEFAULT_INTERVAL_MS4;
2991
+ const state = {
2992
+ config: cfg,
2993
+ notifiers,
2994
+ intervalMs,
2995
+ timer: null,
2996
+ lastWasEmpty: null,
2997
+ lastHash: null
2998
+ };
2999
+ this.watches.set(name, state);
3000
+ info(`Watch "${name}" registered (every ${cfg.interval ?? "60s"})`);
3001
+ void this.tick(name);
3002
+ state.timer = setInterval(() => void this.tick(name), intervalMs);
3003
+ }
3004
+ async tick(name) {
3005
+ if (this.disposed) return;
3006
+ const state = this.watches.get(name);
3007
+ if (!state) return;
3008
+ let result;
3009
+ try {
3010
+ const ctx = this.sourceManager.getContext();
3011
+ result = query(ctx, state.config.query);
3012
+ } catch (err) {
3013
+ const msg = err instanceof Error ? err.message : String(err);
3014
+ error(`Watch "${name}" query failed: ${msg}`);
3015
+ return;
3016
+ }
3017
+ const fireWhen = state.config.fireWhen ?? "when_non_empty";
3018
+ const isEmpty = isResultEmpty(result);
3019
+ const hash = hashResult(result);
3020
+ let shouldFire = false;
3021
+ let reason = "";
3022
+ if (fireWhen === "when_non_empty") {
3023
+ if (state.lastWasEmpty !== false && !isEmpty) {
3024
+ shouldFire = true;
3025
+ reason = "result transitioned to non-empty";
3026
+ }
3027
+ } else if (fireWhen === "when_empty") {
3028
+ if (state.lastWasEmpty !== true && isEmpty) {
3029
+ shouldFire = true;
3030
+ reason = "result transitioned to empty";
3031
+ }
3032
+ } else if (fireWhen === "on_change") {
3033
+ if (state.lastHash !== null && state.lastHash !== hash) {
3034
+ shouldFire = true;
3035
+ reason = "result content changed";
3036
+ }
3037
+ }
3038
+ state.lastWasEmpty = isEmpty;
3039
+ state.lastHash = hash;
3040
+ if (!shouldFire) return;
3041
+ info(`Watch "${name}" fired (${reason})`);
3042
+ await Promise.all(
3043
+ state.notifiers.map(
3044
+ (n) => n.notify({ watchName: name, result, reason }).catch((err) => {
3045
+ const msg = err instanceof Error ? err.message : String(err);
3046
+ error(`Watch "${name}" notify failed: ${msg}`);
3047
+ process.stderr.write(`[pipequery-watch] "${name}" notify failed: ${msg}
3048
+ `);
3049
+ })
3050
+ )
3051
+ );
3052
+ }
3053
+ stop(name) {
3054
+ const state = this.watches.get(name);
3055
+ if (!state) return false;
3056
+ if (state.timer) clearInterval(state.timer);
3057
+ this.watches.delete(name);
3058
+ return true;
3059
+ }
3060
+ list() {
3061
+ return [...this.watches.keys()];
3062
+ }
3063
+ dispose() {
3064
+ this.disposed = true;
3065
+ for (const state of this.watches.values()) {
3066
+ if (state.timer) clearInterval(state.timer);
3067
+ }
3068
+ this.watches.clear();
3069
+ }
3070
+ };
3071
+ function buildNotifiers(notify) {
3072
+ const out = [];
3073
+ if (notify.telegram) out.push(new TelegramNotifier(notify.telegram));
3074
+ return out;
3075
+ }
3076
+ function isResultEmpty(result) {
3077
+ if (result === null || result === void 0) return true;
3078
+ if (Array.isArray(result)) return result.length === 0;
3079
+ if (typeof result === "object") return Object.keys(result).length === 0;
3080
+ return false;
3081
+ }
3082
+ function hashResult(result) {
3083
+ const json = JSON.stringify(result, (_, v) => v === void 0 ? null : v);
3084
+ return createHash("sha256").update(json).digest("hex");
3085
+ }
3086
+
3087
+ // src/server/routes/health.ts
3088
+ function registerHealthRoutes(app, sourceManager, startedAt) {
3089
+ app.get("/health", async () => {
3090
+ return { status: "ok", uptime: Date.now() - startedAt.getTime() };
3091
+ });
3092
+ app.get("/status", async () => {
3093
+ return {
3094
+ status: "ok",
3095
+ uptime: Date.now() - startedAt.getTime(),
3096
+ sources: sourceManager.getAllStatuses()
3097
+ };
3098
+ });
3099
+ }
3100
+
2785
3101
  // src/server/routes/control.ts
2786
3102
  function registerControlRoutes(app, sourceManager, endpointRegistry, configCallbacks) {
2787
3103
  app.get("/api/_control/sources", async () => {
@@ -2969,33 +3285,6 @@ function sendAllPanels(res, queries, sourceManager) {
2969
3285
  }
2970
3286
  }
2971
3287
 
2972
- // src/server/activity-log.ts
2973
- var MAX_ENTRIES = 100;
2974
- var entries = [];
2975
- function logActivity(level, message) {
2976
- entries.push({
2977
- time: (/* @__PURE__ */ new Date()).toISOString(),
2978
- level,
2979
- message
2980
- });
2981
- if (entries.length > MAX_ENTRIES) {
2982
- entries.splice(0, entries.length - MAX_ENTRIES);
2983
- }
2984
- }
2985
- function getActivityLog(since) {
2986
- if (!since) return [...entries];
2987
- return entries.filter((e) => e.time > since);
2988
- }
2989
- function info(msg) {
2990
- logActivity("info", msg);
2991
- }
2992
- function warn(msg) {
2993
- logActivity("warn", msg);
2994
- }
2995
- function error(msg) {
2996
- logActivity("error", msg);
2997
- }
2998
-
2999
3288
  // src/server/app.ts
3000
3289
  async function createServer(config, configPath, cwd) {
3001
3290
  const app = Fastify({ logger: false });
@@ -3056,8 +3345,12 @@ async function createServer(config, configPath, cwd) {
3056
3345
  registerControlRoutes(app, sourceManager, { endpoints }, configCallbacks);
3057
3346
  registerSSERoutes(app, sourceManager);
3058
3347
  registerDynamicRoutes(app, sourceManager, endpoints);
3348
+ const watchManager = new WatchManager(sourceManager);
3349
+ if (config.watches && Object.keys(config.watches).length > 0) {
3350
+ watchManager.start(config.watches);
3351
+ }
3059
3352
  info("Server ready");
3060
- return { app, sourceManager, endpoints };
3353
+ return { app, sourceManager, endpoints, watchManager };
3061
3354
  }
3062
3355
 
3063
3356
  // src/utils/banner.ts
@@ -3262,14 +3555,14 @@ async function queryCommand(expression, opts) {
3262
3555
  return;
3263
3556
  }
3264
3557
  if (Array.isArray(result) && result.length > 0 && typeof result[0] === "object") {
3265
- renderTable(result);
3558
+ renderTable2(result);
3266
3559
  } else if (typeof result === "object" && result !== null && !Array.isArray(result)) {
3267
- renderTable([result]);
3560
+ renderTable2([result]);
3268
3561
  } else {
3269
3562
  console.log(result);
3270
3563
  }
3271
3564
  }
3272
- function renderTable(rows) {
3565
+ function renderTable2(rows) {
3273
3566
  if (rows.length === 0) {
3274
3567
  log.dim("(empty result)");
3275
3568
  return;
@@ -5170,6 +5463,579 @@ async function buildProvider(attachUrl) {
5170
5463
  return new LocalProvider(config, cwd);
5171
5464
  }
5172
5465
 
5466
+ // src/telegram/bot.ts
5467
+ import { Bot as Bot2, GrammyError, HttpError } from "grammy";
5468
+ function buildBot(token, provider, opts = {}) {
5469
+ const bot = new Bot2(token);
5470
+ const allowList = normalizeAllowList(opts.allowUsers ?? []);
5471
+ const nl = opts.nl;
5472
+ bot.use(async (ctx, next) => {
5473
+ if (allowList.size === 0) {
5474
+ const id = ctx.from?.id;
5475
+ if (id) trackUnauthSeen(id, ctx.from?.username);
5476
+ await next();
5477
+ return;
5478
+ }
5479
+ if (!isAllowed(ctx, allowList)) {
5480
+ await ctx.reply("\u{1F512} You are not authorized to use this bot.");
5481
+ return;
5482
+ }
5483
+ await next();
5484
+ });
5485
+ bot.command("start", async (ctx) => {
5486
+ await ctx.reply(
5487
+ [
5488
+ "\u{1F44B} <b>pipequery bot</b>",
5489
+ "",
5490
+ "Run pipequery expressions against your configured data sources from chat.",
5491
+ "",
5492
+ "<b>Commands</b>",
5493
+ "/sources \u2014 list configured sources and their health",
5494
+ "/describe &lt;name&gt; \u2014 sample rows + field names",
5495
+ "/endpoints \u2014 list pre-configured endpoints",
5496
+ "/call &lt;path&gt; \u2014 execute a pre-configured endpoint",
5497
+ "/query &lt;expression&gt; \u2014 run an arbitrary pipequery expression",
5498
+ "/help \u2014 this message"
5499
+ ].join("\n"),
5500
+ { parse_mode: "HTML" }
5501
+ );
5502
+ });
5503
+ bot.command("help", async (ctx) => {
5504
+ const lines = [
5505
+ "<b>Examples</b>",
5506
+ "<code>/sources</code>",
5507
+ "<code>/describe products</code>",
5508
+ "<code>/query products | sort(price desc) | first(5)</code>",
5509
+ "<code>/query crypto | where(price &gt; 100) | rollup(avg(price) as avg)</code>",
5510
+ "<code>/call /api/top-coins</code>"
5511
+ ];
5512
+ if (nl) {
5513
+ lines.push(
5514
+ "",
5515
+ "<b>Natural language</b>",
5516
+ "Send any plain-text message (no leading <code>/</code>) and I'll translate it into a pipequery expression and run it.",
5517
+ 'Example: <i>"top 5 most expensive paid orders"</i>'
5518
+ );
5519
+ }
5520
+ lines.push(
5521
+ "",
5522
+ "<b>Tips</b>",
5523
+ "\u2022 Wrap quoted strings carefully \u2014 Telegram's autocorrect can mangle them. Send long expressions as a separate message.",
5524
+ "\u2022 Use <code>/describe &lt;source&gt;</code> to discover field names before constructing a where clause.",
5525
+ "\u2022 Results &gt; 30 rows are truncated with a footer; tighten your query to see all of them."
5526
+ );
5527
+ await ctx.reply(lines.join("\n"), { parse_mode: "HTML" });
5528
+ });
5529
+ bot.command("sources", async (ctx) => {
5530
+ try {
5531
+ const sources = await provider.listSources();
5532
+ if (sources.length === 0) {
5533
+ await ctx.reply("<i>No sources configured.</i>", { parse_mode: "HTML" });
5534
+ return;
5535
+ }
5536
+ const lines = sources.map((s) => {
5537
+ const dot = s.status.healthy ? "\u{1F7E2}" : "\u{1F534}";
5538
+ const rowCount = s.status.rowCount.toLocaleString();
5539
+ const err = s.status.error ? ` <i>(${escapeHtml(s.status.error)})</i>` : "";
5540
+ return `${dot} <b>${escapeHtml(s.name)}</b> \u2014 ${rowCount} rows${err}`;
5541
+ });
5542
+ await ctx.reply(lines.join("\n"), { parse_mode: "HTML" });
5543
+ } catch (err) {
5544
+ await ctx.reply(formatError(err), { parse_mode: "HTML" });
5545
+ }
5546
+ });
5547
+ bot.command("describe", async (ctx) => {
5548
+ const name = ctx.match.trim();
5549
+ if (!name) {
5550
+ await ctx.reply("Usage: <code>/describe &lt;source-name&gt;</code>", { parse_mode: "HTML" });
5551
+ return;
5552
+ }
5553
+ try {
5554
+ const desc = await provider.describeSource(name, 5);
5555
+ if (!desc) {
5556
+ await ctx.reply(`Source <code>${escapeHtml(name)}</code> not found.`, { parse_mode: "HTML" });
5557
+ return;
5558
+ }
5559
+ const fields = desc.fields.length ? desc.fields.map((f) => `<code>${escapeHtml(f)}</code>`).join(", ") : "<i>(none inferred)</i>";
5560
+ const sample = formatResult(desc.sample);
5561
+ await ctx.reply(
5562
+ `<b>${escapeHtml(name)}</b> \u2014 ${desc.status.rowCount.toLocaleString()} rows
5563
+ <b>Fields:</b> ${fields}
5564
+
5565
+ <b>Sample:</b>
5566
+ ${sample}`,
5567
+ { parse_mode: "HTML" }
5568
+ );
5569
+ } catch (err) {
5570
+ await ctx.reply(formatError(err), { parse_mode: "HTML" });
5571
+ }
5572
+ });
5573
+ bot.command("endpoints", async (ctx) => {
5574
+ try {
5575
+ const endpoints = await provider.listEndpoints();
5576
+ if (endpoints.length === 0) {
5577
+ await ctx.reply("<i>No endpoints configured.</i>", { parse_mode: "HTML" });
5578
+ return;
5579
+ }
5580
+ const lines = endpoints.map(
5581
+ (e) => `<b>${escapeHtml(e.path)}</b>
5582
+ <code>${escapeHtml(e.config.query)}</code>`
5583
+ );
5584
+ await ctx.reply(lines.join("\n\n"), { parse_mode: "HTML" });
5585
+ } catch (err) {
5586
+ await ctx.reply(formatError(err), { parse_mode: "HTML" });
5587
+ }
5588
+ });
5589
+ bot.command("call", async (ctx) => {
5590
+ const path = ctx.match.trim();
5591
+ if (!path) {
5592
+ await ctx.reply("Usage: <code>/call &lt;endpoint-path&gt;</code>", { parse_mode: "HTML" });
5593
+ return;
5594
+ }
5595
+ try {
5596
+ const result = await provider.callEndpoint(path);
5597
+ await ctx.reply(formatResult(result), { parse_mode: "HTML" });
5598
+ } catch (err) {
5599
+ await ctx.reply(formatError(err), { parse_mode: "HTML" });
5600
+ }
5601
+ });
5602
+ bot.command("query", async (ctx) => {
5603
+ const expr = ctx.match.trim();
5604
+ if (!expr) {
5605
+ await ctx.reply("Usage: <code>/query &lt;pipequery expression&gt;</code>", { parse_mode: "HTML" });
5606
+ return;
5607
+ }
5608
+ try {
5609
+ const result = await provider.runQuery(expr);
5610
+ await ctx.reply(formatResult(result), { parse_mode: "HTML" });
5611
+ } catch (err) {
5612
+ await ctx.reply(formatError(err), { parse_mode: "HTML" });
5613
+ }
5614
+ });
5615
+ bot.on("message:text", async (ctx) => {
5616
+ const text = ctx.message.text.trim();
5617
+ if (!text || text.startsWith("/")) return;
5618
+ if (!nl) {
5619
+ await ctx.reply(
5620
+ "I don't recognise that. Use <code>/help</code> to see the available commands. Natural-language queries are disabled \u2014 start the bot with <code>--anthropic-key</code> to enable them.",
5621
+ { parse_mode: "HTML" }
5622
+ );
5623
+ return;
5624
+ }
5625
+ try {
5626
+ await ctx.replyWithChatAction("typing");
5627
+ const { expression, explanation } = await nl.translate(text);
5628
+ if (!expression) {
5629
+ const reason = explanation || "I couldn't translate that into a pipequery expression.";
5630
+ await ctx.reply(`\u{1F914} ${escapeHtml(reason)}`, { parse_mode: "HTML" });
5631
+ return;
5632
+ }
5633
+ const result = await provider.runQuery(expression);
5634
+ const header = `<i>${escapeHtml(explanation || "Translated query")}</i>
5635
+ <code>${escapeHtml(expression)}</code>
5636
+
5637
+ `;
5638
+ await ctx.reply(header + formatResult(result), { parse_mode: "HTML" });
5639
+ } catch (err) {
5640
+ await ctx.reply(formatError(err), { parse_mode: "HTML" });
5641
+ }
5642
+ });
5643
+ bot.catch((err) => {
5644
+ if (err.error instanceof GrammyError) {
5645
+ process.stderr.write(`[pipequery-telegram] grammy error: ${err.error.description}
5646
+ `);
5647
+ } else if (err.error instanceof HttpError) {
5648
+ process.stderr.write(`[pipequery-telegram] http error: ${err.error.message}
5649
+ `);
5650
+ } else {
5651
+ process.stderr.write(`[pipequery-telegram] unhandled: ${err.error}
5652
+ `);
5653
+ }
5654
+ });
5655
+ return bot;
5656
+ }
5657
+ function normalizeAllowList(entries2) {
5658
+ const set = /* @__PURE__ */ new Set();
5659
+ for (const raw of entries2) {
5660
+ const trimmed = raw.trim().replace(/^@/, "").toLowerCase();
5661
+ if (trimmed) set.add(trimmed);
5662
+ }
5663
+ return set;
5664
+ }
5665
+ function isAllowed(ctx, allowList) {
5666
+ const from = ctx.from;
5667
+ if (!from) return false;
5668
+ if (allowList.has(String(from.id))) return true;
5669
+ if (from.username && allowList.has(from.username.toLowerCase())) return true;
5670
+ return false;
5671
+ }
5672
+ var seenUnauth = /* @__PURE__ */ new Set();
5673
+ function trackUnauthSeen(id, username) {
5674
+ if (seenUnauth.has(id)) return;
5675
+ seenUnauth.add(id);
5676
+ process.stderr.write(
5677
+ `[pipequery-telegram] WARNING: bot has no allowlist; user "${username ?? id}" can query freely. Restart with --allow-user @yourname to lock it down.
5678
+ `
5679
+ );
5680
+ }
5681
+
5682
+ // src/telegram/nl.ts
5683
+ import Anthropic from "@anthropic-ai/sdk";
5684
+ var MODEL = "claude-haiku-4-5";
5685
+ var MAX_TOKENS = 1024;
5686
+ var SCHEMA_TTL_MS = 6e4;
5687
+ function createNLTranslator(provider, opts) {
5688
+ const client = opts.client ?? new Anthropic({ apiKey: opts.apiKey });
5689
+ const model = opts.model ?? MODEL;
5690
+ let cachedSchema = null;
5691
+ async function getSchemaPreamble() {
5692
+ const now = Date.now();
5693
+ if (cachedSchema && now - cachedSchema.builtAt < SCHEMA_TTL_MS) {
5694
+ return cachedSchema.text;
5695
+ }
5696
+ const text = await buildSchemaPreamble(provider);
5697
+ cachedSchema = { text, builtAt: now };
5698
+ return text;
5699
+ }
5700
+ async function translate(question) {
5701
+ const schema = await getSchemaPreamble();
5702
+ const response = await client.messages.create({
5703
+ model,
5704
+ max_tokens: MAX_TOKENS,
5705
+ system: [
5706
+ // Cache breakpoint #1 — the always-stable DSL grammar + instructions.
5707
+ // This is the largest block and the one that benefits most from being
5708
+ // cached across every translation request the bot ever serves.
5709
+ {
5710
+ type: "text",
5711
+ text: SYSTEM_PROMPT,
5712
+ cache_control: { type: "ephemeral" }
5713
+ },
5714
+ // Cache breakpoint #2 — the per-tenant source schema. Stable until
5715
+ // the yaml changes (or SCHEMA_TTL_MS elapses).
5716
+ {
5717
+ type: "text",
5718
+ text: schema,
5719
+ cache_control: { type: "ephemeral" }
5720
+ }
5721
+ ],
5722
+ messages: [
5723
+ {
5724
+ role: "user",
5725
+ content: question
5726
+ }
5727
+ ]
5728
+ });
5729
+ const text = extractText(response);
5730
+ return parseTranslation(text);
5731
+ }
5732
+ return {
5733
+ translate,
5734
+ invalidateSchema: () => {
5735
+ cachedSchema = null;
5736
+ }
5737
+ };
5738
+ }
5739
+ async function buildSchemaPreamble(provider) {
5740
+ const sources = await provider.listSources();
5741
+ const endpoints = await provider.listEndpoints();
5742
+ const sourceLines = [];
5743
+ for (const s of sources) {
5744
+ let fields = [];
5745
+ try {
5746
+ const desc = await provider.describeSource(s.name, 3);
5747
+ if (desc) fields = desc.fields;
5748
+ } catch {
5749
+ }
5750
+ const fieldStr = fields.length ? ` \u2014 fields: ${fields.join(", ")}` : "";
5751
+ sourceLines.push(`- ${s.name} (${s.status.rowCount} rows)${fieldStr}`);
5752
+ }
5753
+ const endpointLines = endpoints.map((e) => `- ${e.path} \u2192 ${e.config.query}`);
5754
+ return [
5755
+ "## Available data sources",
5756
+ sourceLines.length ? sourceLines.join("\n") : "(no sources configured)",
5757
+ "",
5758
+ "## Pre-defined endpoints",
5759
+ endpointLines.length ? endpointLines.join("\n") : "(no endpoints configured)"
5760
+ ].join("\n");
5761
+ }
5762
+ function extractText(response) {
5763
+ const parts = [];
5764
+ for (const block of response.content) {
5765
+ if (block.type === "text") parts.push(block.text);
5766
+ }
5767
+ return parts.join("").trim();
5768
+ }
5769
+ function parseTranslation(raw) {
5770
+ const cleaned = raw.replace(/^```json\s*/i, "").replace(/^```\s*/i, "").replace(/```\s*$/i, "").trim();
5771
+ const start = cleaned.indexOf("{");
5772
+ const end = cleaned.lastIndexOf("}");
5773
+ if (start === -1 || end === -1 || end < start) {
5774
+ throw new Error(`Translator returned non-JSON: ${truncate(raw, 200)}`);
5775
+ }
5776
+ const slice = cleaned.slice(start, end + 1);
5777
+ let parsed;
5778
+ try {
5779
+ parsed = JSON.parse(slice);
5780
+ } catch (err) {
5781
+ throw new Error(`Translator returned invalid JSON: ${truncate(raw, 200)}`);
5782
+ }
5783
+ if (typeof parsed !== "object" || parsed === null || typeof parsed.expression !== "string") {
5784
+ throw new Error(`Translator response missing "expression" field: ${truncate(raw, 200)}`);
5785
+ }
5786
+ const obj = parsed;
5787
+ return {
5788
+ expression: obj.expression.trim(),
5789
+ explanation: typeof obj.explanation === "string" ? obj.explanation.trim() : ""
5790
+ };
5791
+ }
5792
+ function truncate(s, n) {
5793
+ return s.length > n ? `${s.slice(0, n)}\u2026` : s;
5794
+ }
5795
+ var SYSTEM_PROMPT = `You are a translator that converts natural-language questions into PipeQuery expressions.
5796
+
5797
+ PipeQuery is a small, pipe-style query DSL. Every expression starts with a source name (or a literal array) and pipes into one or more operations using \`|\`. PipeQuery is read-only by construction \u2014 there is no DDL, DML, INSERT, UPDATE, DELETE, or DROP. You will never produce SQL.
5798
+
5799
+ # Output contract
5800
+
5801
+ You MUST respond with a SINGLE JSON object and nothing else. No prose, no markdown, no code fences.
5802
+
5803
+ The object has exactly two fields:
5804
+ - "expression": a string containing the PipeQuery expression to execute.
5805
+ - "explanation": a one-sentence plain-English description of what the expression does.
5806
+
5807
+ Example:
5808
+ {"expression": "orders | where(status == \\"paid\\") | sort(total desc) | first(5)", "explanation": "Top 5 paid orders by total."}
5809
+
5810
+ If the user's question cannot be answered with the available sources, return:
5811
+ {"expression": "", "explanation": "<brief reason why no expression was produced>"}
5812
+
5813
+ # Grammar
5814
+
5815
+ Pipeline:
5816
+ <source_name> | <operation> | <operation> | ...
5817
+
5818
+ Sources are referenced by their bare name (no quotes). The available source names are listed in the schema preamble that follows this prompt.
5819
+
5820
+ # Operations
5821
+
5822
+ ## where(<expression>)
5823
+ Filter rows. The expression is evaluated per row and must return a boolean.
5824
+ - Comparison operators: == != > >= < <=
5825
+ - Logical operators: && || !
5826
+ - Arithmetic: + - * / %
5827
+ - Field reference: bare identifier (e.g., \`price\`, \`status\`)
5828
+ - Strings: double-quoted ("paid")
5829
+ - Numbers: 1, 1.5, -42
5830
+ - Booleans: true, false
5831
+ - Null: null
5832
+
5833
+ Examples:
5834
+ events | where(severity == "high")
5835
+ trades | where(price > 100 && symbol == "BTC")
5836
+ users | where(!banned)
5837
+
5838
+ ## sort(<field> asc|desc, ...)
5839
+ Sort by one or more fields. Direction is optional, defaults to asc.
5840
+ orders | sort(total desc)
5841
+ orders | sort(country asc, total desc)
5842
+
5843
+ ## first(N) / last(N)
5844
+ Take the first / last N rows. N must be a positive integer literal.
5845
+ trades | sort(ts desc) | first(10)
5846
+
5847
+ ## distinct / distinct(field, ...)
5848
+ Deduplicate rows. With no args, dedupes on the full row; with args, dedupes on the listed fields.
5849
+ users | distinct(email)
5850
+
5851
+ ## select(<expression>, ...)
5852
+ Project fields. Each expression can be a bare field, a computed expression, or an alias (\`expr as name\`).
5853
+ orders | select(id, total, total * 0.1 as tax)
5854
+
5855
+ ## groupBy(<field>, ...) and rollup
5856
+ Aggregate. \`groupBy\` keeps groups; \`rollup\` collapses each group to one row with computed aggregates.
5857
+
5858
+ orders | rollup(country, sum(total) as revenue, count() as n)
5859
+ trades | groupBy(symbol) | rollup(avg(price) as avg)
5860
+
5861
+ Aggregate functions: sum, avg, min, max, count, median, stddev, var, percentile, distinct_count, first_value, last_value, vwap, wavg, pct, sharpe, calmar, sortino, info_ratio, drawdown, sum_abs, abs_sum.
5862
+
5863
+ \`count()\` takes no field. \`percentile(field, p)\` and \`vwap(value, weight)\` take two args.
5864
+
5865
+ ## flatten / flatten(field)
5866
+ Unnest array values. With \`flatten(orders)\` each element of \`row.orders\` becomes its own row.
5867
+
5868
+ ## map(<expression>, ...)
5869
+ Transform each row into a new shape (similar to \`select\` but always emits a new object).
5870
+
5871
+ ## reduce(initial, accumulator)
5872
+ Fold rows into a single value. Rare; prefer rollup.
5873
+
5874
+ ## join(<source>, <condition>)
5875
+ Inner join with another source on a boolean condition. Use \`left\` and \`right\` to refer to fields.
5876
+ orders | join(customers, left.customer_id == right.id)
5877
+
5878
+ ## pivot(<field>, <agg>, ...)
5879
+ Pivot rows into columns by a key field.
5880
+
5881
+ # Conventions and best practices
5882
+
5883
+ 1. Prefer \`rollup\` over \`groupBy\` unless the caller wants the per-group rows kept.
5884
+ 2. Always cap result size with \`first(N)\` for "top N" or "show me" questions; default to 10 if the user doesn't specify.
5885
+ 3. Use the EXACT field names from the schema preamble. Do not invent fields.
5886
+ 4. If two sources need to be joined and there's no obvious join key in the schema, prefer to ask for clarification by emitting the empty-expression form rather than guessing.
5887
+ 5. Quote string literals with double quotes; escape internal double quotes with \\".
5888
+ 6. Do not produce SQL, JavaScript, or English in the expression field \u2014 only PipeQuery.
5889
+ 7. Numbers are bare; no thousands separators, no quotes.
5890
+ 8. Comparisons against null use \`== null\` / \`!= null\`.
5891
+
5892
+ # Worked examples
5893
+
5894
+ Question: "What are the top 5 most expensive orders that were paid?"
5895
+ {"expression": "orders | where(status == \\"paid\\") | sort(total desc) | first(5)", "explanation": "Top 5 paid orders by total."}
5896
+
5897
+ Question: "Average BTC price right now"
5898
+ {"expression": "trades | where(symbol == \\"BTC\\") | rollup(avg(price) as avg_price)", "explanation": "Average price across all BTC rows."}
5899
+
5900
+ Question: "Revenue by country"
5901
+ {"expression": "orders | rollup(country, sum(total) as revenue) | sort(revenue desc)", "explanation": "Sum of order totals grouped by country, highest first."}
5902
+
5903
+ Question: "How many distinct users placed an order?"
5904
+ {"expression": "orders | rollup(distinct_count(user_id) as users)", "explanation": "Count of unique user IDs in orders."}
5905
+
5906
+ Question: "Show high-severity events from the last hour"
5907
+ (only answerable if the schema has a timestamp field; otherwise return empty.)
5908
+ {"expression": "events | where(severity == \\"high\\") | sort(ts desc) | first(50)", "explanation": "Most recent high-severity events, capped at 50."}
5909
+
5910
+ Question: "Drop the orders table"
5911
+ {"expression": "", "explanation": "PipeQuery is read-only and cannot drop tables."}
5912
+
5913
+ Question: "How many rows in foo?"
5914
+ (only answerable if a source named \`foo\` exists; otherwise return empty.)
5915
+ {"expression": "foo | rollup(count() as n)", "explanation": "Row count of foo."}
5916
+
5917
+ # Reminder
5918
+
5919
+ - Output exactly ONE JSON object.
5920
+ - The "expression" field must be valid PipeQuery referencing only sources from the schema preamble.
5921
+ - Use double quotes for strings, escape inner quotes as \\".
5922
+ - Default result cap is first(10) for unbounded "show me" questions unless an aggregation makes the result naturally small.
5923
+ - Never produce SQL or destructive operations.
5924
+ `;
5925
+
5926
+ // src/commands/telegram.ts
5927
+ async function telegramServeCommand(opts) {
5928
+ const token = opts.botToken ?? process.env.PIPEQUERY_TG_BOT_TOKEN;
5929
+ if (!token) {
5930
+ log.error(
5931
+ "Bot token required. Pass --bot-token <token> or set PIPEQUERY_TG_BOT_TOKEN."
5932
+ );
5933
+ process.exit(1);
5934
+ }
5935
+ const anthropicKey = opts.anthropicKey ?? process.env.ANTHROPIC_API_KEY;
5936
+ const provider = await buildProvider2(opts.attach);
5937
+ const nl = anthropicKey ? createNLTranslator(provider, { apiKey: anthropicKey }) : void 0;
5938
+ const bot = buildBot(token, provider, {
5939
+ allowUsers: opts.allowUser ?? [],
5940
+ nl
5941
+ });
5942
+ const me = await bot.api.getMe();
5943
+ log.success(`Telegram bot listening as @${me.username} (id ${me.id})`);
5944
+ if (nl) {
5945
+ log.info("Natural-language translation enabled (claude-haiku-4-5).");
5946
+ } else {
5947
+ log.dim("Natural-language translation disabled. Pass --anthropic-key or set ANTHROPIC_API_KEY to enable.");
5948
+ }
5949
+ if (!opts.allowUser || opts.allowUser.length === 0) {
5950
+ log.warn("No --allow-user set: anyone with the bot username can query. Add allowlist for production.");
5951
+ }
5952
+ log.info("Press Ctrl+C to stop.");
5953
+ const shutdown = async () => {
5954
+ log.info("Stopping bot...");
5955
+ await bot.stop();
5956
+ await provider.dispose();
5957
+ process.exit(0);
5958
+ };
5959
+ process.on("SIGINT", () => void shutdown());
5960
+ process.on("SIGTERM", () => void shutdown());
5961
+ await bot.start();
5962
+ }
5963
+ async function buildProvider2(attachUrl) {
5964
+ if (attachUrl) {
5965
+ return new AttachedProvider(attachUrl);
5966
+ }
5967
+ const cwd = process.cwd();
5968
+ const { config } = await loadConfig(cwd);
5969
+ return new LocalProvider(config, cwd);
5970
+ }
5971
+
5972
+ // src/commands/watch.ts
5973
+ import chalk7 from "chalk";
5974
+ async function watchListCommand() {
5975
+ const cwd = process.cwd();
5976
+ const { config } = await loadConfig(cwd);
5977
+ const watches = config.watches ?? {};
5978
+ const entries2 = Object.entries(watches);
5979
+ if (entries2.length === 0) {
5980
+ log.dim("No watches configured. Add one with: pq watch add <name>");
5981
+ return;
5982
+ }
5983
+ console.log(chalk7.bold("Watches:\n"));
5984
+ for (const [name, w] of entries2) {
5985
+ const interval = w.interval ?? "60s";
5986
+ const fireWhen = w.fireWhen ?? "when_non_empty";
5987
+ const channels = Object.keys(w.notify).join(", ") || "<none>";
5988
+ console.log(` ${chalk7.bold(name)} ${chalk7.dim(`every ${interval} \u2022 ${fireWhen}`)}`);
5989
+ console.log(` ${chalk7.dim("query: ")}${w.query}`);
5990
+ console.log(` ${chalk7.dim("notify: ")}${channels}`);
5991
+ }
5992
+ }
5993
+ async function watchAddCommand(name, opts) {
5994
+ const cwd = process.cwd();
5995
+ const { config, path: configPath } = await loadConfig(cwd);
5996
+ if (!opts.telegramChatId) {
5997
+ throw new Error("--telegram-chat-id is required (only Telegram notifier supported in v1)");
5998
+ }
5999
+ const fireWhen = opts.fireWhen ?? "when_non_empty";
6000
+ if (!["when_non_empty", "when_empty", "on_change"].includes(fireWhen)) {
6001
+ throw new Error(`--fire-when must be one of: when_non_empty, when_empty, on_change`);
6002
+ }
6003
+ const watch3 = {
6004
+ query: opts.query,
6005
+ ...opts.interval ? { interval: opts.interval } : {},
6006
+ fireWhen,
6007
+ notify: {
6008
+ telegram: {
6009
+ chatId: parseChatId(opts.telegramChatId),
6010
+ ...opts.telegramBotToken ? { botToken: opts.telegramBotToken } : {},
6011
+ ...opts.telegramMessage ? { message: opts.telegramMessage } : {}
6012
+ }
6013
+ }
6014
+ };
6015
+ config.watches = config.watches ?? {};
6016
+ config.watches[name] = watch3;
6017
+ await saveConfig(config, configPath);
6018
+ log.success(`Watch "${name}" added. Restart \`pq serve\` to activate.`);
6019
+ }
6020
+ async function watchRemoveCommand(name) {
6021
+ const cwd = process.cwd();
6022
+ const { config, path: configPath } = await loadConfig(cwd);
6023
+ if (!config.watches?.[name]) {
6024
+ log.error(`Watch "${name}" not found`);
6025
+ return;
6026
+ }
6027
+ delete config.watches[name];
6028
+ await saveConfig(config, configPath);
6029
+ log.success(`Watch "${name}" removed. Restart \`pq serve\` to apply.`);
6030
+ }
6031
+ function parseChatId(input) {
6032
+ if (/^-?\d+$/.test(input)) {
6033
+ const n = Number(input);
6034
+ if (Number.isFinite(n) && Number.isSafeInteger(n)) return n;
6035
+ }
6036
+ return input;
6037
+ }
6038
+
5173
6039
  // src/commands/completion.ts
5174
6040
  var BASH_COMPLETION = `###-begin-pq-completions-###
5175
6041
  _pq_completions() {
@@ -5385,7 +6251,7 @@ async function stopCommand(opts) {
5385
6251
  // src/commands/repl.ts
5386
6252
  import { createInterface } from "readline";
5387
6253
  import { spawnSync } from "child_process";
5388
- import chalk7 from "chalk";
6254
+ import chalk8 from "chalk";
5389
6255
  var COMMANDS = {
5390
6256
  help: "Show available commands",
5391
6257
  serve: "Start the server (serve -d for daemon, serve --stop to stop)",
@@ -5403,15 +6269,15 @@ async function startRepl() {
5403
6269
  let serverUrl = null;
5404
6270
  try {
5405
6271
  serverUrl = await getServerUrl();
5406
- log.success(`Connected to server at ${chalk7.white.bold(serverUrl)}`);
6272
+ log.success(`Connected to server at ${chalk8.white.bold(serverUrl)}`);
5407
6273
  } catch {
5408
6274
  log.warn("No running server found. Start one with: pq serve");
5409
6275
  }
5410
- console.log(chalk7.dim(' Type "help" for available commands, "exit" to quit.\n'));
6276
+ console.log(chalk8.dim(' Type "help" for available commands, "exit" to quit.\n'));
5411
6277
  const rl = createInterface({
5412
6278
  input: process.stdin,
5413
6279
  output: process.stdout,
5414
- prompt: chalk7.hex("#06b6d4").bold(" pq > "),
6280
+ prompt: chalk8.hex("#06b6d4").bold(" pq > "),
5415
6281
  terminal: true
5416
6282
  });
5417
6283
  rl.prompt();
@@ -5428,7 +6294,7 @@ async function startRepl() {
5428
6294
  case "help":
5429
6295
  console.log();
5430
6296
  for (const [name, desc] of Object.entries(COMMANDS)) {
5431
- console.log(` ${chalk7.hex("#06b6d4")(name.padEnd(12))} ${chalk7.dim(desc)}`);
6297
+ console.log(` ${chalk8.hex("#06b6d4")(name.padEnd(12))} ${chalk8.dim(desc)}`);
5432
6298
  }
5433
6299
  console.log();
5434
6300
  break;
@@ -5475,7 +6341,7 @@ async function startRepl() {
5475
6341
  });
5476
6342
  try {
5477
6343
  serverUrl = await getServerUrl();
5478
- log.success(`Connected to server at ${chalk7.white.bold(serverUrl)}`);
6344
+ log.success(`Connected to server at ${chalk8.white.bold(serverUrl)}`);
5479
6345
  } catch {
5480
6346
  }
5481
6347
  rl.resume();
@@ -5503,7 +6369,7 @@ async function startRepl() {
5503
6369
  break;
5504
6370
  case "exit":
5505
6371
  case "quit":
5506
- console.log(chalk7.dim(" Goodbye.\n"));
6372
+ console.log(chalk8.dim(" Goodbye.\n"));
5507
6373
  rl.close();
5508
6374
  process.exit(0);
5509
6375
  break;
@@ -5547,20 +6413,20 @@ function printTable(rows) {
5547
6413
  const maxData = rows.reduce((max, row) => Math.max(max, formatCell3(row[col]).length), 0);
5548
6414
  return Math.max(col.length, maxData);
5549
6415
  });
5550
- const header = columns.map((col, i) => chalk7.hex("#06b6d4").bold(col.padEnd(widths[i]))).join(" ");
5551
- const separator = widths.map((w) => chalk7.dim("\u2500".repeat(w))).join(chalk7.dim("\u2500\u2500"));
6416
+ const header = columns.map((col, i) => chalk8.hex("#06b6d4").bold(col.padEnd(widths[i]))).join(" ");
6417
+ const separator = widths.map((w) => chalk8.dim("\u2500".repeat(w))).join(chalk8.dim("\u2500\u2500"));
5552
6418
  console.log();
5553
6419
  console.log(` ${header}`);
5554
6420
  console.log(` ${separator}`);
5555
6421
  for (const row of rows) {
5556
6422
  const line = columns.map((col, i) => {
5557
6423
  const val = formatCell3(row[col]);
5558
- return typeof row[col] === "number" ? chalk7.hex("#f59e0b")(val.padStart(widths[i])) : val.padEnd(widths[i]);
6424
+ return typeof row[col] === "number" ? chalk8.hex("#f59e0b")(val.padStart(widths[i])) : val.padEnd(widths[i]);
5559
6425
  }).join(" ");
5560
6426
  console.log(` ${line}`);
5561
6427
  }
5562
6428
  console.log();
5563
- console.log(chalk7.dim(` ${rows.length} row(s)`));
6429
+ console.log(chalk8.dim(` ${rows.length} row(s)`));
5564
6430
  console.log();
5565
6431
  }
5566
6432
  function formatCell3(value) {
@@ -5578,12 +6444,13 @@ async function fetchAndPrint(serverUrl, path, key) {
5578
6444
  }
5579
6445
 
5580
6446
  // src/index.ts
6447
+ var collectRepeated = (value, acc) => [...acc, value];
5581
6448
  var program = new Command();
5582
6449
  program.name("pq").description("PipeQuery CLI \u2014 data sources, aggregations, APIs, and terminal dashboards").version("0.1.0").addHelpText("beforeAll", () => {
5583
6450
  printBanner();
5584
6451
  return "";
5585
6452
  }).configureHelp({
5586
- subcommandTerm: (cmd) => chalk8.hex("#06b6d4")(cmd.name()) + (cmd.usage() ? " " + chalk8.dim(cmd.usage()) : "")
6453
+ subcommandTerm: (cmd) => chalk9.hex("#06b6d4")(cmd.name()) + (cmd.usage() ? " " + chalk9.dim(cmd.usage()) : "")
5587
6454
  });
5588
6455
  program.command("init").description("Create a pipequery.yaml config file").action(initCommand);
5589
6456
  program.command("serve").description("Start the PipeQuery server").option("-p, --port <port>", "Server port", parseInt).option("-H, --host <host>", "Server host").option("-d, --daemon", "Run as a background daemon").option("--stop", "Stop a running daemon").action(serveCommand);
@@ -5606,6 +6473,12 @@ remote.command("status").description("Check remote server health").action(remote
5606
6473
  var mcp = program.command("mcp").description("Model Context Protocol server for AI clients");
5607
6474
  mcp.command("serve").description("Start an MCP server exposing pipequery as tools (for Claude, Cursor, Copilot, etc.)").option("--http", "Serve over HTTP/SSE instead of stdio").option("-p, --port <port>", "HTTP port (when --http is set)", parseInt).option("-H, --host <host>", "HTTP host (when --http is set)").option("--attach <url>", "Attach to a running `pq serve` instance at the given URL instead of loading pipequery.yaml locally").option("--auth-token <token>", "Require Authorization: Bearer <token> on HTTP requests. Falls back to $PIPEQUERY_MCP_TOKEN. Without either, the endpoint is unauthenticated.").action(mcpServeCommand);
5608
6475
  mcp.command("inspect").description("Print the MCP tool schemas as JSON (useful for directory submission / debugging)").option("--attach <url>", "Attach to a running `pq serve` instance at the given URL instead of loading pipequery.yaml locally").action(mcpInspectCommand);
6476
+ var telegram = program.command("telegram").description("Telegram bot transport for pipequery");
6477
+ telegram.command("serve").description("Run a Telegram bot that exposes pipequery commands (/query, /sources, /describe, /endpoints, /call) plus natural-language queries when --anthropic-key is set").option("-t, --bot-token <token>", "Telegram bot token (or set $PIPEQUERY_TG_BOT_TOKEN)").option("--attach <url>", "Attach to a running `pq serve` instance instead of loading pipequery.yaml locally").option("--allow-user <handle>", "Allowlist a user (numeric id or @username); repeatable. With no allowlist, anyone with the bot username can query.", collectRepeated, []).option("--anthropic-key <key>", "Anthropic API key (or set $ANTHROPIC_API_KEY) \u2014 enables natural-language \u2192 pipequery translation on plain-text messages via claude-haiku-4-5.").action(telegramServeCommand);
6478
+ var watch2 = program.command("watch").description("Manage alert watches (query \u2192 notification when condition fires)");
6479
+ watch2.command("list").description("List configured watches").action(watchListCommand);
6480
+ watch2.command("add <name>").description("Add a watch that fires a notification when its query result triggers the configured condition").requiredOption("-q, --query <expression>", "PipeQuery expression to evaluate on each tick").option("-i, --interval <duration>", "Poll interval (e.g., 30s, 5m). Default 60s.").option("-f, --fire-when <mode>", "Fire condition: when_non_empty (default), when_empty, on_change").option("--telegram-chat-id <id>", "Telegram chat / channel / group ID to notify").option("--telegram-bot-token <token>", "Bot token override (defaults to $PIPEQUERY_TG_BOT_TOKEN)").option("--telegram-message <template>", "Optional message template; supports {{ .field }} from the first row, plus {{ .count }}, {{ .watchName }}, {{ .reason }}").action(watchAddCommand);
6481
+ watch2.command("remove <name>").description("Remove a watch").action(watchRemoveCommand);
5609
6482
  program.command("stop").description("Stop the running PipeQuery server (daemon or foreground)").option("-f, --force", "Force kill (SIGKILL instead of SIGTERM)").action(stopCommand);
5610
6483
  program.command("completion").description("Generate shell completion script").option("-s, --shell <shell>", "Shell type: bash, zsh, fish").action(completionCommand);
5611
6484
  if (process.argv.length <= 2) {