@dikacode/telegram-mcp 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -150,7 +150,7 @@ telegram-mcp
150
150
  }
151
151
  ```
152
152
 
153
- Pin a version if you want reproducible installs: `npx -y @dikacode/telegram-mcp@1.0.1`.
153
+ Pin a version if you want reproducible installs: `npx -y @dikacode/telegram-mcp@1.0.2`.
154
154
 
155
155
  ### 2. Clone and link
156
156
 
@@ -170,7 +170,7 @@ npm link # optional: makes `telegram-mcp` available on PATH
170
170
  Download the tarball attached to the latest release and install it globally:
171
171
 
172
172
  ```bash
173
- npm install -g https://github.com/dikaglorya/telegram-mcp/releases/download/v1.0.1/dikacode-telegram-mcp-1.0.1.tgz
173
+ npm install -g https://github.com/dikaglorya/telegram-mcp/releases/download/v1.0.2/dikacode-telegram-mcp-1.0.2.tgz
174
174
  telegram-mcp
175
175
  ```
176
176
 
@@ -188,13 +188,33 @@ npm fetches the repository, installs dependencies, runs the `prepare` build, and
188
188
 
189
189
  ## Updating
190
190
 
191
- How you installed it decides whether you have to do anything.
191
+ One command, whichever way it was installed:
192
+
193
+ ```bash
194
+ telegram-mcp update
195
+ ```
196
+
197
+ It works out how *this* copy got here and runs the right thing — `npm install
198
+ -g @latest` for a global install (reusing a custom `--prefix`), `git pull
199
+ --ff-only` plus a rebuild for a checkout, and a plain restart instruction for an
200
+ npx run, where nothing on disk needs changing. `--check` reports and installs
201
+ nothing.
202
+
203
+ ```text
204
+ telegram-mcp 1.0.2 · update
205
+
206
+ installed 1.0.1 /home/you/.local/lib/node_modules/@dikacode/telegram-mcp
207
+ latest 1.0.2
208
+ install global npm package
209
+
210
+ ▲ 1.0.1 -> 1.0.2 available
211
+ ```
192
212
 
193
213
  | Installed with | Behaviour |
194
214
  | --- | --- |
195
215
  | `npx -y @dikacode/telegram-mcp` (unpinned) | **Automatic.** npm resolves `latest` on every start, so the next client restart picks up a new release. |
196
- | `npx -y @dikacode/telegram-mcp@1.0.1` (pinned) | **Never.** That is the point of pinning. |
197
- | `npm install -g @dikacode/telegram-mcp` | **Manual**, one command: `npm update -g @dikacode/telegram-mcp` |
216
+ | `npx -y @dikacode/telegram-mcp@1.0.2` (pinned) | **Never.** That is the point of pinning. |
217
+ | `npm install -g @dikacode/telegram-mcp` | **`telegram-mcp update`**, or `npm update -g @dikacode/telegram-mcp`. |
198
218
  | Release tarball | **Manual.** Reinstall from the newer tarball. |
199
219
  | `npx -y github:dikaglorya/telegram-mcp` | **Not automatic.** npm caches the fetched repo; clear the cache or pin a ref (`#main`) to move forward. |
200
220
 
@@ -202,7 +222,8 @@ Check what you are running:
202
222
 
203
223
  ```bash
204
224
  telegram-mcp --version
205
- npm outdated -g @dikacode/telegram-mcp # global installs only
225
+ telegram-mcp update --check # newer release available? installs nothing
226
+ npm outdated -g @dikacode/telegram-mcp # global installs only
206
227
  ```
207
228
 
208
229
  The server also tells you. When a newer release exists it prints one line to
@@ -528,7 +549,8 @@ All settings are environment variables. The server also loads a `.env` next to t
528
549
  | `Cannot find module .../dist/index.js` | The package was installed without building. Run `npm run build`, or use the release tarball. |
529
550
  | Login works, then the next run asks again | `TELEGRAM_SESSION_PATH` changed between runs. Keep it stable, or leave it unset. |
530
551
  | Window/terminal closes when started by hand | Expected: the server speaks MCP on stdio. Start it through a client, or use `npm run login` for the standalone CLI. |
531
- | `update available: …` on every start | A newer release is on npm. Run the upgrade command it prints, or set `TELEGRAM_UPDATE_CHECK=0` if you pin versions on purpose. |
552
+ | `update available: …` on every start | A newer release is on npm. Run `telegram-mcp update`, or set `TELEGRAM_UPDATE_CHECK=0` if you pin versions on purpose. |
553
+ | `telegram-mcp update` finds no upgrade command | The copy lives in a `node_modules` tree npm does not manage as a global prefix (npx cache, or a project dependency). Restart it as `npx -y @dikacode/telegram-mcp@latest`, or upgrade the project dependency. |
532
554
  | `telegram-mcp --version` prints nothing | You are on `1.0.0`, which predates the flag. Upgrade to `1.0.1` or newer. |
533
555
 
534
556
  ## Development
@@ -565,7 +587,7 @@ The harness also supports `--json`, `--only=tool_a,tool_b`, `--cleanup`, `--recl
565
587
  ```
566
588
  telegram-mcp/
567
589
  ├── bin/telegram-mcp.js portable launcher (finds Node, then dist/index.js)
568
- ├── cli/ standalone login and doctor commands
590
+ ├── cli/ standalone login, doctor and update commands
569
591
  ├── assets/ animated README art (SVG, no scripts)
570
592
  ├── harness/ unit checks + end-to-end MCP harness
571
593
  ├── index.ts stdio entrypoint
@@ -592,7 +614,7 @@ The TypeScript is built with `tsc` to `dist/`. There is no bundler; the publishe
592
614
 
593
615
  ## Release
594
616
 
595
- **v1.0.1** is the current release, published to npm as
617
+ **v1.0.2** is the current release, published to npm as
596
618
  [`@dikacode/telegram-mcp`](https://www.npmjs.com/package/@dikacode/telegram-mcp).
597
619
 
598
620
  - 69 MCP tools, all registered with strict input schemas.
@@ -602,6 +624,8 @@ The TypeScript is built with `tsc` to `dist/`. There is no bundler; the publishe
602
624
  - Optional allowlist, audited calls, redacted secrets.
603
625
  - Cross-platform launcher; no hardcoded paths.
604
626
  - `--version` / `--help`, and a startup notice when a newer release exists.
627
+ - `telegram-mcp update` upgrades whichever layout is installed; `--check`
628
+ reports without touching anything.
605
629
  - End-to-end harness with paging, policy probes and artifact cleanup.
606
630
 
607
631
  See [CHANGELOG.md](https://github.com/dikaglorya/telegram-mcp/blob/main/CHANGELOG.md) for the full release notes. Release artifacts are on the [releases page](https://github.com/dikaglorya/telegram-mcp/releases).
@@ -1,7 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * Doctor: validate config, session presence, permissions, live connectivity.
4
- * Prints pass/fail. Never prints secrets.
4
+ * Never prints secrets.
5
+ *
6
+ * Output is a framed report on stdout when run as a CLI — this is a human
7
+ * command, not an MCP server, so stdout is free. Under `--quiet` (or when
8
+ * piped) it prints one line per check with no colour, which is what CI wants.
5
9
  */
6
10
  import "../env.js";
7
11
  import fs from "node:fs";
@@ -9,8 +13,14 @@ import path from "node:path";
9
13
  import { ensureDirs, getConfig, hasSession, readSessionString, redact } from "../config.js";
10
14
  import { lockFile } from "../perms.js";
11
15
  import { getClient, disconnect } from "../telegram.js";
12
- function line(okFlag, label, detail = "") {
13
- process.stdout.write(`[${okFlag ? "PASS" : "FAIL"}] ${label}${detail ? ` — ${detail}` : ""}\n`);
16
+ import { banner, colorEnabled, gap, paint, sym, theme } from "../ui.js";
17
+ const out = (text) => void process.stdout.write(text);
18
+ const rows = [];
19
+ function record(pass, label, detail = "") {
20
+ rows.push({ pass, label, detail });
21
+ }
22
+ function icon(pass) {
23
+ return pass ? theme.success(sym.ok) : theme.fail(sym.fail);
14
24
  }
15
25
  async function main() {
16
26
  let failures = 0;
@@ -19,49 +29,55 @@ async function main() {
19
29
  config = getConfig();
20
30
  }
21
31
  catch (err) {
22
- line(false, "config", err instanceof Error ? err.message : String(err));
32
+ record(false, "config", err instanceof Error ? err.message : String(err));
33
+ report();
23
34
  process.exit(1);
24
35
  }
25
36
  ensureDirs(config);
26
- line(true, "config", `api_id=${config.apiId}, allowAll=${config.allowAllChats}, allowlist=${config.allowlist.length}`);
27
- line(fs.existsSync(path.dirname(config.sessionPath)), "state dir", path.dirname(config.sessionPath));
37
+ record(true, "config", `api_id=${config.apiId}, allowAll=${config.allowAllChats}, allowlist=${config.allowlist.length}`);
38
+ record(fs.existsSync(path.dirname(config.sessionPath)), "state dir", path.dirname(config.sessionPath));
28
39
  const has = hasSession(config);
29
- line(has, "session present", config.sessionPath);
40
+ record(has, "session present", config.sessionPath);
30
41
  if (!has)
31
42
  failures++;
32
43
  if (has) {
33
44
  if (process.platform === "win32") {
34
- line(true, "session permissions", "skipped on Windows (inherits user profile ACLs)");
45
+ record(true, "session permissions", "skipped on Windows (inherits user profile ACLs)");
35
46
  }
36
47
  else {
37
48
  try {
38
49
  const mode = fs.statSync(config.sessionPath).mode & 0o777;
39
50
  const good = (mode & 0o077) === 0;
40
- line(good, "session permissions", `mode=${mode.toString(8)}`);
41
- if (!good) {
51
+ if (good) {
52
+ record(true, "session permissions", `mode=${mode.toString(8)}`);
53
+ }
54
+ else {
42
55
  lockFile(config.sessionPath);
43
- line(true, "session permissions", "repaired to 600");
56
+ const fixed = (fs.statSync(config.sessionPath).mode & 0o777 & 0o077) === 0;
57
+ record(fixed, "session permissions", fixed ? "repaired to 600" : `mode=${mode.toString(8)}`);
58
+ if (!fixed)
59
+ failures++;
44
60
  }
45
61
  }
46
62
  catch (err) {
47
- line(false, "session permissions", err instanceof Error ? err.message : String(err));
63
+ record(false, "session permissions", err instanceof Error ? err.message : String(err));
48
64
  failures++;
49
65
  }
50
66
  }
51
67
  try {
52
- line(readSessionString(config).length > 0, "session readable");
68
+ record(readSessionString(config).length > 0, "session readable");
53
69
  }
54
70
  catch (err) {
55
- line(false, "session readable", err instanceof Error ? err.message : String(err));
71
+ record(false, "session readable", err instanceof Error ? err.message : String(err));
56
72
  failures++;
57
73
  }
58
74
  try {
59
75
  const client = await getClient();
60
76
  const authorized = await client.isUserAuthorized();
61
- line(authorized, "telegram authorized");
77
+ record(authorized, "telegram authorized");
62
78
  if (authorized) {
63
79
  const me = (await client.getMe());
64
- line(true, "account", `id=${String(me.id ?? "")} username=${me.username ?? "none"}`);
80
+ record(true, "account", `id=${String(me.id ?? "")} username=${me.username ?? "none"}`);
65
81
  }
66
82
  else {
67
83
  failures++;
@@ -69,14 +85,48 @@ async function main() {
69
85
  await disconnect();
70
86
  }
71
87
  catch (err) {
72
- line(false, "telegram connectivity", redact(err instanceof Error ? err.message : String(err)));
88
+ record(false, "telegram connectivity", redact(err instanceof Error ? err.message : String(err)));
73
89
  failures++;
74
90
  }
75
91
  }
76
- process.stdout.write(failures === 0 ? "\nall checks passed.\n" : `\n${failures} check(s) failed.\n`);
92
+ report(failures);
77
93
  process.exit(failures === 0 ? 0 : 1);
78
94
  }
95
+ function report(failures) {
96
+ const plain = !colorEnabled() || process.argv.includes("--quiet");
97
+ if (!plain) {
98
+ banner({ subtitle: "health check", compact: true });
99
+ gap();
100
+ }
101
+ const width = Math.max(...rows.map((r) => r.label.length));
102
+ for (const row of rows) {
103
+ if (plain) {
104
+ out(`[${row.pass ? "PASS" : "FAIL"}] ${row.label}${row.detail ? ` - ${row.detail}` : ""}\n`);
105
+ }
106
+ else {
107
+ const detail = row.detail ? ` ${theme.muted(row.detail)}` : "";
108
+ // padEnd measures code units; the icon is one glyph, so this aligns.
109
+ out(` ${icon(row.pass)} ${theme.label(row.label.padEnd(width))}${detail}\n`);
110
+ }
111
+ }
112
+ if (failures === undefined)
113
+ return;
114
+ if (plain) {
115
+ out(failures === 0 ? "\nall checks passed.\n" : `\n${failures} check(s) failed.\n`);
116
+ return;
117
+ }
118
+ gap();
119
+ out(failures === 0
120
+ ? ` ${theme.success(`${sym.ok} ${paint.bold("all checks passed")}`)}\n`
121
+ : ` ${theme.fail(`${sym.fail} ${failures} check(s) failed`)}\n`);
122
+ if (failures !== 0 && hasHint()) {
123
+ out(` ${theme.muted(`${sym.bullet} run ${paint.bold("telegram-mcp login")} to issue a fresh session`)}\n`);
124
+ }
125
+ }
126
+ function hasHint() {
127
+ return rows.some((r) => !r.pass && /session|authorized/i.test(r.label));
128
+ }
79
129
  main().catch((err) => {
80
- process.stderr.write(`[doctor] fatal: ${redact(err instanceof Error ? err.message : String(err))}\n`);
130
+ process.stderr.write(`${theme.fail(`[doctor] fatal: ${redact(err instanceof Error ? err.message : String(err))}`)}\n`);
81
131
  process.exit(1);
82
132
  });
package/dist/cli/login.js CHANGED
@@ -15,6 +15,7 @@ import { TelegramClient } from "telegram";
15
15
  import { StringSession } from "telegram/sessions/index.js";
16
16
  import { ensureDirs, getConfig, hasSession, readSessionString, writeSessionString, redact, } from "../config.js";
17
17
  import { createLogger } from "../logger.js";
18
+ import { banner, gap, hint, nextSteps, paint, spinner, step, success, sym, theme, warn } from "../ui.js";
18
19
  function ask(question) {
19
20
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
20
21
  return new Promise((resolve) => rl.question(question, (answer) => {
@@ -22,6 +23,9 @@ function ask(question) {
22
23
  resolve(answer.trim());
23
24
  }));
24
25
  }
26
+ function askPlain(question) {
27
+ return ask(`${theme.accent(`${sym.arrow} `)}${theme.label(question)} `);
28
+ }
25
29
  /**
26
30
  * Ask a question without echoing the answer. readline's `terminal` mode masks
27
31
  * the prompt on POSIX and Windows alike; it falls back to a plain read when
@@ -57,6 +61,9 @@ function askSecret(question) {
57
61
  async function main() {
58
62
  const config = getConfig();
59
63
  ensureDirs(config);
64
+ banner({ subtitle: "interactive login", compact: true });
65
+ gap();
66
+ step(1, 3, "Checking the existing session");
60
67
  // Re-running login silently mints a NEW auth key for the same account. Telegram
61
68
  // then treats the previous key as AUTH_KEY_DUPLICATED and every client holding
62
69
  // it stops working. Refuse to overwrite a session that still works.
@@ -65,25 +72,35 @@ async function main() {
65
72
  connectionRetries: 2,
66
73
  baseLogger: createLogger(),
67
74
  });
75
+ const check = spinner("verifying the saved session");
68
76
  try {
69
77
  await probe.connect();
70
78
  const ok = await probe.isUserAuthorized();
71
79
  if (ok) {
72
80
  const me = (await probe.getMe());
73
- process.stderr.write(`[login] already authorized as ${me.username ?? String(me.id ?? "")} — nothing to do.\n` +
74
- `[login] session: ${config.sessionPath}\n` +
75
- `[login] pass --force to overwrite it with a fresh auth key.\n`);
81
+ check.stop(`${theme.success(sym.ok)} already authorized as ${theme.label(String(me.username ?? me.id ?? ""))}`);
82
+ gap();
83
+ hint(`session kept at ${config.sessionPath}`);
84
+ hint(`pass ${theme.label("--force")} to overwrite it with a fresh auth key`);
76
85
  await probe.disconnect().catch(() => undefined);
77
86
  return;
78
87
  }
88
+ check.stop(`${theme.warn(`${sym.warn} saved session is no longer authorized`)}`);
79
89
  }
80
90
  catch {
91
+ check.stop(`${theme.warn(`${sym.warn} saved session is unusable — issuing a fresh one`)}`);
81
92
  /* dead or duplicated session — fall through and replace it */
82
93
  }
83
94
  await probe.disconnect().catch(() => undefined);
84
- process.stderr.write(`[login] existing session is unusable, issuing a fresh one\n`);
85
95
  }
86
- process.stderr.write(`[login] session will be stored at ${config.sessionPath}\n`);
96
+ else {
97
+ hint(hasSession(config) ? `overwriting the session because of ${theme.label("--force")}` : "no session on disk yet");
98
+ }
99
+ gap();
100
+ step(2, 3, "Signing in to Telegram");
101
+ hint(`the session will be stored at ${config.sessionPath}`);
102
+ if (!process.stdin.isTTY)
103
+ hint(`stdin is not a terminal — prompts may not echo correctly`);
87
104
  const existing = hasSession(config) ? readSessionString(config) : "";
88
105
  const session = new StringSession(existing);
89
106
  const client = new TelegramClient(session, config.apiId, config.apiHash, {
@@ -97,28 +114,40 @@ async function main() {
97
114
  phoneNumber: async () => {
98
115
  const stored = process.env.TELEGRAM_PHONE?.trim();
99
116
  if (stored) {
100
- process.stderr.write(`[login] using phone from .env\n`);
117
+ hint(`using ${theme.label("TELEGRAM_PHONE")} from .env`);
101
118
  return stored;
102
119
  }
103
- return ask("Phone number (international, e.g. +15551234567): ");
120
+ return askPlain("Phone number (international, e.g. +15551234567)");
121
+ },
122
+ phoneCode: async () => {
123
+ hint("Telegram is sending a code to your other devices");
124
+ return askPlain("Login code");
125
+ },
126
+ password: async () => {
127
+ hint("two-step verification is on for this account");
128
+ return askSecret("2FA password (leave blank if none)");
104
129
  },
105
- phoneCode: async () => ask("Login code from Telegram: "),
106
- password: async () => askSecret("2FA password (leave blank if none)"),
107
130
  onError: (err) => {
108
131
  // The update loop keeps pinging after we disconnect; those late
109
132
  // rejections are noise, not auth failures.
110
133
  const msg = err instanceof Error ? err.message : String(err);
111
134
  if (msg === "TIMEOUT" || msg.includes("TIMEOUT"))
112
135
  return;
113
- process.stderr.write(`[login] auth error: ${redact(msg)}\n`);
136
+ warn(`auth error: ${redact(msg)}`);
114
137
  },
115
138
  });
116
139
  const me = (await client.getMe());
117
140
  const serialized = client.session.save();
118
141
  writeSessionString(config, serialized);
119
- process.stderr.write(`[login] authorized as id=${String(me.id ?? "")} username=${me.username ?? "none"}\n`);
120
- process.stderr.write(`[login] session saved to ${config.sessionPath}\n`);
121
- process.stderr.write("[login] you can close this and start the MCP server.\n");
142
+ gap();
143
+ step(3, 3, "Saving");
144
+ success(`authorized as ${paint.bold(String(me.username ?? me.id ?? ""))}`);
145
+ hint(`session saved to ${config.sessionPath} ${theme.muted("(0600)")}`);
146
+ nextSteps([
147
+ `start your MCP client — it spawns ${theme.label("telegram-mcp")} from the config you already added`,
148
+ `or run ${theme.label("telegram-mcp doctor")} to confirm everything is healthy`,
149
+ ]);
150
+ gap();
122
151
  }
123
152
  finally {
124
153
  // gramJS fires the update-ping loop the moment we connect. Disconnecting
@@ -136,6 +165,9 @@ async function main() {
136
165
  }
137
166
  main().catch((err) => {
138
167
  const msg = err instanceof Error ? err.message : String(err);
139
- process.stderr.write(`[login] failed: ${redact(msg)}\n`);
168
+ process.stderr.write(`${theme.fail(`${sym.fail} login failed: ${redact(msg)}`)}\n`);
169
+ if (/not set|invalid/i.test(msg)) {
170
+ process.stderr.write(` ${theme.muted(`${sym.bullet} get api_id/api_hash from `)}${theme.underline("https://my.telegram.org/apps")}\n`);
171
+ }
140
172
  process.exit(1);
141
173
  });
@@ -0,0 +1,293 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `telegram-mcp update` — bring the installed copy up to date.
4
+ *
5
+ * The hard part is not the upgrade, it is knowing *how* this copy was
6
+ * installed. A global npm install is upgraded with `npm i -g`, an npx run is
7
+ * replaced by asking npx for @latest, and a git checkout is updated with
8
+ * `git pull` plus a rebuild. Guessing wrong either does nothing (npm refuses
9
+ * to touch a package it does not own) or silently reinstalls the same version.
10
+ *
11
+ * So detection is made from the filesystem, not from a flag the user has to
12
+ * remember:
13
+ * - the real path of this file lives under node_modules -> npm package
14
+ * - under a global prefix (`npm root -g`) -> global install
15
+ * - anywhere else (npx cache, local dep) -> npx
16
+ * - a sibling `.git` directory -> git checkout
17
+ *
18
+ * With `--check` nothing is installed; it only reports. That is what
19
+ * `telegram-mcp update --check` and the doctor use, and it never writes.
20
+ */
21
+ import "../env.js";
22
+ import { spawn, execFileSync } from "node:child_process";
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+ import { PACKAGE_NAME, SERVER_VERSION, compareVersions } from "../version.js";
27
+ import { getUpdateInfo } from "../update-check.js";
28
+ import { banner, gap, hint, info, sym, theme } from "../ui.js";
29
+ const out = (text) => void process.stdout.write(text);
30
+ /**
31
+ * Where npm puts globally installed packages. Asking npm is more reliable than
32
+ * reconstructing `process.execPath`, which differs between Homebrew, nvm,
33
+ * Volta and the Windows installer. `NPM_CONFIG_PREFIX` still applies, so a
34
+ * custom prefix is detected too.
35
+ */
36
+ function npmGlobalRoot() {
37
+ try {
38
+ const root = execFileSync("npm", ["root", "-g"], {
39
+ encoding: "utf8",
40
+ stdio: ["ignore", "pipe", "ignore"],
41
+ timeout: 10_000,
42
+ shell: process.platform === "win32",
43
+ }).trim();
44
+ return root || null;
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ /**
51
+ * Candidates for the global prefix. `npm root -g` is authoritative, but it is
52
+ * skipped when npm is not on PATH (clients do strip it), and an explicit
53
+ * `npm i -g --prefix <dir>` is easy to miss. The real path of this file gives
54
+ * the answer either way: it ends in
55
+ * <prefix>/lib/node_modules/<name>/dist/cli/update.js (POSIX)
56
+ * <prefix>/node_modules/<name>/dist/cli/update.js (Windows)
57
+ */
58
+ function globalRoots() {
59
+ const candidates = new Set();
60
+ const fromNpm = npmGlobalRoot();
61
+ if (fromNpm)
62
+ candidates.add(fromNpm);
63
+ const marker = process.platform === "win32" ? "/node_modules/" : "/lib/node_modules/";
64
+ const here = fileURLToPath(import.meta.url).replace(/\\/g, "/");
65
+ const at = here.lastIndexOf(marker);
66
+ if (at !== -1)
67
+ candidates.add(here.slice(0, at + marker.length - 1));
68
+ return [...candidates].map((dir) => path.resolve(dir)).filter((dir) => fs.existsSync(dir));
69
+ }
70
+ /** Walk up looking for a package root (a directory with package.json + .git or dist). */
71
+ function findPackageRoot(start) {
72
+ let dir = start;
73
+ for (let i = 0; i < 6; i++) {
74
+ if (fs.existsSync(path.join(dir, "package.json")))
75
+ return dir;
76
+ const parent = path.dirname(dir);
77
+ if (parent === dir)
78
+ return null;
79
+ dir = parent;
80
+ }
81
+ return null;
82
+ }
83
+ export function detectInstall() {
84
+ const here = path.dirname(fileURLToPath(import.meta.url));
85
+ const root = findPackageRoot(here) ?? here;
86
+ const normalized = root.replace(/\\/g, "/");
87
+ // A git checkout is the only layout that carries its own .git.
88
+ if (fs.existsSync(path.join(root, ".git"))) {
89
+ return { kind: "source", location: root };
90
+ }
91
+ if (/\/node_modules\//.test(`${normalized}/`)) {
92
+ const global = globalRoots().map((p) => p.replace(/\\/g, "/").replace(/\/+$/, ""));
93
+ if (global.some((g) => normalized === g || normalized.startsWith(`${g}/`))) {
94
+ return { kind: "global", location: root };
95
+ }
96
+ // Anything else under node_modules was put there by npx, or by a project
97
+ // that lists this package as a dependency, and both are refreshed by
98
+ // asking npm for @latest rather than by a -g install.
99
+ return { kind: "npx", location: root };
100
+ }
101
+ return { kind: "unknown", location: root };
102
+ }
103
+ export async function plan(allowNetwork = true) {
104
+ const { kind, location } = detectInstall();
105
+ const info = await getUpdateInfo({ allowNetwork });
106
+ const latest = info.source === "disabled" ? null : info.latest;
107
+ const outdated = latest !== null ? compareVersions(latest, SERVER_VERSION) > 0 : false;
108
+ const base = {
109
+ kind,
110
+ location,
111
+ current: SERVER_VERSION,
112
+ latest: latest ?? null,
113
+ outdated,
114
+ };
115
+ if (kind === "source") {
116
+ // A detached HEAD, a shallow clone or a checkout without an upstream all
117
+ // fail `pull --ff-only`. Ask git about the tracked upstream instead of
118
+ // assuming `origin/main` exists.
119
+ const upstream = git(location, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]);
120
+ if (!upstream) {
121
+ return {
122
+ ...base,
123
+ command: null,
124
+ note: "this checkout has no upstream branch; update it with `git pull` yourself, then npm install && npm run build",
125
+ };
126
+ }
127
+ return {
128
+ ...base,
129
+ command: ["git", "-C", location, "pull", "--ff-only"],
130
+ note: "then rebuild: npm install && npm run build",
131
+ };
132
+ }
133
+ if (kind === "global") {
134
+ // Respect a custom prefix: `npm i -g --prefix /opt/x` is a global install
135
+ // too, and upgrading it with a plain `npm i -g` would land in a different
136
+ // tree and leave the old copy in place.
137
+ const prefix = globalPrefix();
138
+ return {
139
+ ...base,
140
+ command: prefix
141
+ ? ["npm", "install", "-g", "--prefix", prefix, `${PACKAGE_NAME}@latest`]
142
+ : ["npm", "install", "-g", `${PACKAGE_NAME}@latest`],
143
+ };
144
+ }
145
+ if (kind === "npx") {
146
+ return {
147
+ ...base,
148
+ command: null,
149
+ note: `this copy was loaded from a node_modules folder; restart with \`npx -y ${PACKAGE_NAME}@latest\` and npx will fetch the newest release`,
150
+ };
151
+ }
152
+ return { ...base, command: null, note: "cannot tell how this copy was installed; use npm install -g @latest" };
153
+ }
154
+ /** Run git and return trimmed stdout, or null when it fails. Never throws. */
155
+ function git(cwd, args) {
156
+ try {
157
+ const result = execFileSync("git", ["-C", cwd, ...args], {
158
+ encoding: "utf8",
159
+ stdio: ["ignore", "pipe", "ignore"],
160
+ timeout: 5_000,
161
+ }).trim();
162
+ return result || null;
163
+ }
164
+ catch {
165
+ return null;
166
+ }
167
+ }
168
+ /**
169
+ * The prefix a global install lives under, or null when it is the default one.
170
+ * Read from the resolved global root (`<prefix>/lib/node_modules` on POSIX,
171
+ * `<prefix>/node_modules` on Windows) so no extra npm call is needed.
172
+ */
173
+ function globalPrefix() {
174
+ // Applies to the global root that contains the running copy: a custom
175
+ // prefix must be reused, or the upgrade would install next to it instead of
176
+ // over it.
177
+ const here = path.dirname(fileURLToPath(import.meta.url));
178
+ const normalized = here.replace(/\\/g, "/");
179
+ for (const root of globalRoots()) {
180
+ const g = root.replace(/\\/g, "/").replace(/\/+$/, "");
181
+ if (normalized.startsWith(`${g}/`)) {
182
+ return process.platform === "win32" ? path.dirname(root) : path.dirname(path.dirname(root));
183
+ }
184
+ }
185
+ return null;
186
+ }
187
+ function run(command) {
188
+ return new Promise((resolve) => {
189
+ const child = spawn(command[0], command.slice(1), {
190
+ stdio: "inherit",
191
+ shell: process.platform === "win32",
192
+ });
193
+ child.on("error", () => resolve(1));
194
+ child.on("exit", (code) => resolve(code ?? 1));
195
+ });
196
+ }
197
+ function printPlan(p, checkOnly) {
198
+ const label = p.kind === "global"
199
+ ? "global npm package"
200
+ : p.kind === "npx"
201
+ ? "npx or project dependency"
202
+ : p.kind === "source"
203
+ ? "git checkout"
204
+ : "unknown";
205
+ out(`\n ${theme.label("installed")} ${theme.bold(p.current)} ${theme.muted(p.location)}\n`);
206
+ out(` ${theme.label("latest")} ${p.latest ? theme.bold(p.latest) : theme.muted("unknown")}\n`);
207
+ out(` ${theme.label("install")} ${label}\n`);
208
+ if (p.latest === null) {
209
+ hint("could not reach the npm registry; check the network or TELEGRAM_UPDATE_CHECK");
210
+ return;
211
+ }
212
+ if (!p.outdated) {
213
+ out(`\n ${theme.success(`${sym.ok} already up to date`)}\n\n`);
214
+ return;
215
+ }
216
+ out(`\n ${theme.warn(`${sym.warn} ${theme.bold(`${p.current} -> ${p.latest}`)} available`)}\n`);
217
+ if (p.note)
218
+ hint(p.note);
219
+ if (checkOnly) {
220
+ if (p.command)
221
+ hint(`to upgrade, run ${theme.label(p.command.join(" "))}`);
222
+ hint("run without --check to apply it");
223
+ out("\n");
224
+ return;
225
+ }
226
+ }
227
+ async function main() {
228
+ const argv = process.argv.slice(2);
229
+ const checkOnly = argv.includes("--check") || argv.includes("--dry-run");
230
+ if (argv.includes("--help") || argv.includes("-h")) {
231
+ out([
232
+ "",
233
+ ` ${theme.bold("telegram-mcp update")} upgrade this install to the latest release`,
234
+ ` ${theme.bold("telegram-mcp update --check")} report only; install nothing`,
235
+ "",
236
+ " The command is chosen from how this copy was installed:",
237
+ ` ${theme.accent("global install")} npm install -g ${PACKAGE_NAME}@latest`,
238
+ ` ${theme.accent("npx run")} restart with npx -y ${PACKAGE_NAME}@latest`,
239
+ ` ${theme.accent("git checkout")} git pull --ff-only, then npm install && npm run build`,
240
+ "",
241
+ ].join("\n"));
242
+ return;
243
+ }
244
+ banner({ subtitle: "update", compact: true });
245
+ const p = await plan(true);
246
+ printPlan(p, checkOnly);
247
+ if (checkOnly || !p.outdated || !p.command) {
248
+ if (!checkOnly && p.outdated && !p.command && p.note) {
249
+ gap();
250
+ hint("nothing was installed; this copy is refreshed by the command above");
251
+ out("\n");
252
+ }
253
+ return;
254
+ }
255
+ info(`${theme.label("running")} ${p.command.join(" ")}`);
256
+ const code = await run(p.command);
257
+ if (code !== 0) {
258
+ out(`\n ${theme.fail(`${sym.fail} update command exited with ${code}`)}\n\n`);
259
+ process.exitCode = code;
260
+ return;
261
+ }
262
+ if (p.kind === "source") {
263
+ info(`${theme.label("rebuilding")} npm install && npm run build`);
264
+ const install = await run(["npm", "install"]);
265
+ if (install === 0)
266
+ await run(["npm", "run", "build"]);
267
+ }
268
+ gap();
269
+ out(` ${theme.success(`${sym.ok} updated to ${p.latest}`)}\n`);
270
+ hint("restart your MCP client so it spawns the new copy");
271
+ out("\n");
272
+ }
273
+ /**
274
+ * Only run when this file is the entrypoint. `detectInstall` is imported by the
275
+ * unit checks, and importing a module must not print a report or spawn npm.
276
+ */
277
+ function invokedDirectly() {
278
+ const entry = process.argv[1];
279
+ if (!entry)
280
+ return false;
281
+ try {
282
+ return path.resolve(entry) === fileURLToPath(import.meta.url);
283
+ }
284
+ catch {
285
+ return false;
286
+ }
287
+ }
288
+ if (invokedDirectly()) {
289
+ void main().catch((err) => {
290
+ process.stderr.write(`${theme.fail(`${sym.fail} update failed: ${err instanceof Error ? err.message : String(err)}`)}\n`);
291
+ process.exit(1);
292
+ });
293
+ }
package/dist/index.js CHANGED
@@ -12,65 +12,123 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
12
12
  import { buildServer } from "./server.js";
13
13
  import { ensureDirs, getConfig, redact } from "./config.js";
14
14
  import { shutdownLogin } from "./login-tools.js";
15
- import { PACKAGE_NAME, SERVER_NAME, SERVER_VERSION } from "./version.js";
15
+ import { PACKAGE_NAME, SERVER_VERSION } from "./version.js";
16
16
  import { warnIfOutdated } from "./update-check.js";
17
- const HELP = `${SERVER_NAME} ${SERVER_VERSION}
18
-
19
- An MCP server for a personal Telegram account, spoken over stdio.
20
-
21
- Usage:
22
- telegram-mcp serve MCP on stdio (what an MCP client runs)
23
- telegram-mcp --version, -v print the version and exit
24
- telegram-mcp --help, -h print this help and exit
25
-
26
- Setup:
27
- 1. Get api_id and api_hash from https://my.telegram.org/apps
28
- 2. Provide them as TELEGRAM_API_ID / TELEGRAM_API_HASH, or let the agent call
29
- telegram_setup
30
- 3. Log in with telegram_login_start (the agent can do this for you)
31
-
32
- Updating:
33
- npx -y ${PACKAGE_NAME}@latest # npx, always current
34
- npm install -g ${PACKAGE_NAME}@latest # global install
35
-
36
- Docs: https://github.com/dikaglorya/telegram-mcp
37
- `;
17
+ import { banner, gap, hint, info, muted, sym, theme } from "./ui.js";
18
+ function help() {
19
+ const rows = [
20
+ ["telegram-mcp", "serve MCP on stdio (what an MCP client runs)"],
21
+ ["telegram-mcp --version", "print the version and exit"],
22
+ ["telegram-mcp --help", "print this help and exit"],
23
+ ["telegram-mcp login", "log in from this terminal instead of chat"],
24
+ ["telegram-mcp doctor", "check config, session and connectivity"],
25
+ ["telegram-mcp update", "upgrade this install to the latest release"],
26
+ ["telegram-mcp update --check", "report the available version, install nothing"],
27
+ ];
28
+ const width = Math.max(...rows.map(([cmd]) => cmd.length));
29
+ const usage = rows
30
+ .map(([cmd, desc]) => ` ${theme.accent(cmd.padEnd(width))} ${theme.muted(desc)}`)
31
+ .join("\n");
32
+ return [
33
+ `${theme.title("▀█▀ █▀▀ █░░ █▀▀ █▀▀ █▀█ ▄▀█ █▀▄▀█")}`,
34
+ `${theme.title("░█░ █▀▀ █░░ ██▄ █▄█ █▀▄ █▀█ █░▀░█")} ${theme.muted(SERVER_VERSION)}`,
35
+ "",
36
+ theme.label("An MCP server for a personal Telegram account, over MTProto."),
37
+ "",
38
+ theme.bold("Usage"),
39
+ usage,
40
+ "",
41
+ theme.bold("Setup"),
42
+ ` ${theme.accent("1.")} Get api_id and api_hash from ${theme.underline("https://my.telegram.org/apps")}`,
43
+ ` ${theme.accent("2.")} Provide them as ${theme.label("TELEGRAM_API_ID")} / ${theme.label("TELEGRAM_API_HASH")},`,
44
+ ` or let the agent call ${theme.label("telegram_setup")}`,
45
+ ` ${theme.accent("3.")} Log in with ${theme.label("telegram_login_start")} (the agent can do it),`,
46
+ ` or run ${theme.label("telegram-mcp login")} in a terminal`,
47
+ "",
48
+ theme.bold("Updating"),
49
+ ` ${theme.accent("telegram-mcp update".padEnd(42))} ${theme.muted("detects how it was installed")}`,
50
+ ` ${theme.accent("telegram-mcp update --check".padEnd(42))} ${theme.muted("report only")}`,
51
+ ` ${theme.accent(`npx -y ${PACKAGE_NAME}@latest`.padEnd(42))} ${theme.muted("npx, always current")}`,
52
+ ` ${theme.accent(`npm install -g ${PACKAGE_NAME}@latest`.padEnd(42))} ${theme.muted("global install")}`,
53
+ "",
54
+ `${theme.muted("Docs")} ${theme.underline("https://github.com/dikaglorya/telegram-mcp")}`,
55
+ "",
56
+ ].join("\n");
57
+ }
58
+ /** Subcommands that act like a CLI instead of serving stdio. */
59
+ function dispatchSubcommand(argv) {
60
+ const command = argv[0];
61
+ if (command !== "login" && command !== "doctor" && command !== "update")
62
+ return false;
63
+ // Each module runs its own main() on import; they keep the same behaviour as
64
+ // `npm run login` and `npm run doctor`, just reachable from one binary.
65
+ const targets = {
66
+ login: "./cli/login.js",
67
+ doctor: "./cli/doctor.js",
68
+ update: "./cli/update.js",
69
+ };
70
+ const target = targets[command];
71
+ void import(target).catch((err) => {
72
+ process.stderr.write(`${theme.fail(`${sym.fail} cannot start ${command}: ${err instanceof Error ? err.message : String(err)}`)}\n`);
73
+ process.exit(1);
74
+ });
75
+ return true;
76
+ }
77
+ /** Handled before the transport is created, so stdout stays parseable. */
38
78
  function handleCliFlags(argv) {
39
- const flags = new Set(argv.slice(2));
40
- if (flags.has("--version") || flags.has("-v")) {
79
+ // Claude Desktop (and a few other clients) launch the server with
80
+ // `mcp-server-<name>` as an argument. That is not a subcommand, so anything
81
+ // unrecognised must fall through to serving rather than be interpreted.
82
+ const args = argv.slice(2).filter((a) => !/^mcp-server-/.test(a));
83
+ if (args.includes("--version") || args.includes("-v")) {
41
84
  process.stdout.write(`${SERVER_VERSION}\n`);
42
85
  return true;
43
86
  }
44
- if (flags.has("--help") || flags.has("-h")) {
45
- process.stdout.write(HELP);
87
+ if (args.includes("--help") || args.includes("-h") || args[0] === "help") {
88
+ process.stdout.write(help());
46
89
  return true;
47
90
  }
48
- return false;
91
+ return dispatchSubcommand(args);
92
+ }
93
+ function startupBanner(configured, allowAll, allowlist) {
94
+ if (configured) {
95
+ banner({ subtitle: "your Telegram account, as an MCP server", compact: true });
96
+ info(`${theme.success(sym.ok)} serving over stdio ${theme.muted(`(allowAll=${allowAll}, allowlist=${allowlist})`)}`);
97
+ return;
98
+ }
99
+ banner({ subtitle: "not configured yet", compact: true });
100
+ info(`${theme.warn(`${sym.warn} ${theme.bold("not configured")} — no api_id/api_hash yet`)}`);
101
+ hint(`get them from ${theme.underline("https://my.telegram.org/apps")}`);
102
+ hint(`then call ${theme.label("telegram_setup")} from chat, or run ${theme.label("telegram-mcp login")}`);
103
+ gap();
104
+ muted(`serving anyway — waiting for the agent`);
49
105
  }
50
106
  async function main() {
51
107
  // A missing .env must NOT be fatal: the user needs telegram_setup (and the
52
108
  // doctor) to be reachable so they can fix it from chat. Only a genuinely
53
109
  // broken server would exit here.
54
110
  let config = null;
111
+ let reason = "";
55
112
  try {
56
113
  config = getConfig();
57
114
  ensureDirs(config);
58
115
  }
59
116
  catch (err) {
60
- process.stderr.write(`[telegram-mcp] not configured yet: ${err instanceof Error ? err.message : String(err)}\n` +
61
- `[telegram-mcp] serving anyway — call telegram_setup to create .env, then telegram_login_start.\n`);
117
+ reason = redact(err instanceof Error ? err.message : String(err));
62
118
  }
63
119
  const server = buildServer();
64
120
  const transport = new StdioServerTransport();
65
121
  await server.connect(transport);
66
- process.stderr.write(config
67
- ? `[telegram-mcp] serving over stdio (allowAll=${config.allowAllChats}, allowlist=${config.allowlist.length})\n`
68
- : `[telegram-mcp] serving over stdio (unconfigured — telegram_setup required)\n`);
122
+ startupBanner(Boolean(config), config?.allowAllChats ?? false, config?.allowlist.length ?? 0);
123
+ if (!config && reason) {
124
+ hint(theme.muted(reason.split(".")[0]));
125
+ hint(theme.muted("this server keeps running so the agent can call telegram_setup"));
126
+ }
69
127
  // After the transport is live, so a slow or unreachable registry can never
70
128
  // delay the first tool call.
71
129
  warnIfOutdated();
72
130
  const shutdown = async (signal) => {
73
- process.stderr.write(`[telegram-mcp] ${signal}, shutting down\n`);
131
+ muted(`${signal}, shutting down`);
74
132
  try {
75
133
  // Drops any half-finished login socket too, not just the main client.
76
134
  await shutdownLogin();
@@ -85,7 +143,7 @@ async function main() {
85
143
  }
86
144
  if (!handleCliFlags(process.argv)) {
87
145
  main().catch((err) => {
88
- process.stderr.write(`[telegram-mcp] fatal: ${redact(err instanceof Error ? err.stack ?? err.message : String(err))}\n`);
146
+ process.stderr.write(`${theme.fail(`${sym.fail} fatal: ${redact(err instanceof Error ? err.stack ?? err.message : String(err))}`)}\n`);
89
147
  process.exit(1);
90
148
  });
91
149
  }
package/dist/ui.js ADDED
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Terminal presentation: colour, symbols, a banner and small animations.
3
+ *
4
+ * Rules this module enforces, in order of importance:
5
+ *
6
+ * 1. **stdout is never touched.** Only stderr goes through here. stdout is the
7
+ * MCP JSON-RPC channel, and a single ANSI escape sequence written there
8
+ * corrupts the stream and gets the server dropped by the client.
9
+ * 2. **Degrade silently.** Colour needs a TTY, a terminal that is not dumb,
10
+ * and no NO_COLOR. Piped output, CI logs and `npx ... > file` get plain
11
+ * text with no escape codes at all.
12
+ * 3. **Animation is optional and cannot hang.** Every animation has a hard
13
+ * time cap and is skipped when `TERM=dumb`, when stderr is not a TTY, when
14
+ * the terminal is narrow, or when TELEGRAM_NO_ANIMATION is set.
15
+ *
16
+ * The exported surface is small on purpose: the rest of the codebase should not
17
+ * be threading `isTTY` checks through every message.
18
+ */
19
+ import { SERVER_NAME, SERVER_VERSION } from "./version.js";
20
+ /* ------------------------------------------------------------------ */
21
+ /* Capability detection */
22
+ /* ------------------------------------------------------------------ */
23
+ const isWindows = process.platform === "win32";
24
+ function stderrIsTty() {
25
+ return Boolean(process.stderr.isTTY);
26
+ }
27
+ /**
28
+ * Colour support. `NO_COLOR` is the de-facto standard opt-out and takes
29
+ * priority over everything (https://no-color.org). `FORCE_COLOR` exists for the
30
+ * case where output is piped into a colour-aware pager.
31
+ */
32
+ export function colorEnabled() {
33
+ if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== "")
34
+ return false;
35
+ const forced = process.env.FORCE_COLOR;
36
+ if (forced !== undefined && forced !== "" && forced !== "0")
37
+ return true;
38
+ if (forced === "0")
39
+ return false;
40
+ if (!stderrIsTty())
41
+ return false;
42
+ if (process.env.TERM === "dumb")
43
+ return false;
44
+ // Windows Terminal and VS Code advertise themselves; plain conhost does not
45
+ // reliably handle SGR, so it stays plain unless FORCE_COLOR says otherwise.
46
+ if (isWindows && !process.env.WT_SESSION && !process.env.TERM_PROGRAM)
47
+ return false;
48
+ return true;
49
+ }
50
+ /** Whether the fancy stuff (banner frame, spinner) should run at all. */
51
+ export function animationEnabled() {
52
+ if (process.env.TELEGRAM_NO_ANIMATION && process.env.TELEGRAM_NO_ANIMATION !== "0")
53
+ return false;
54
+ if (!stderrIsTty())
55
+ return false;
56
+ if (process.env.TERM === "dumb")
57
+ return false;
58
+ const columns = process.stderr.columns ?? 0;
59
+ if (columns > 0 && columns < 60)
60
+ return false;
61
+ return colorEnabled();
62
+ }
63
+ function sgr(open, close = 0) {
64
+ return (text) => (colorEnabled() ? `\u001b[${open}m${text}\u001b[${close}m` : text);
65
+ }
66
+ /** 256-colour foreground, falling back to cyan when colour is off. */
67
+ function c256(code, fallback) {
68
+ return (text) => colorEnabled() ? `\u001b[38;5;${code}m${text}\u001b[0m` : `\u001b[${fallback}m${text}\u001b[0m`;
69
+ }
70
+ const basic = !process.env.TELEGRAM_256_COLORS;
71
+ /**
72
+ * Low-level painters, for the rare call that needs one word emphasised inside a
73
+ * sentence. Prefer `theme` in new code; these are aliases kept small on purpose.
74
+ */
75
+ export const paint = {
76
+ bold: sgr(1, 22),
77
+ dim: sgr(2, 22),
78
+ underline: sgr(4, 24),
79
+ };
80
+ /**
81
+ * The palette: cool cyan/teal for structure, warm accents for emphasis.
82
+ * Names are semantic (`success`, `fail`) so call sites read as intent.
83
+ */
84
+ export const theme = {
85
+ bold: paint.bold,
86
+ dim: paint.dim,
87
+ underline: paint.underline,
88
+ border: basic ? sgr(90) : c256(244, 90),
89
+ title: basic ? sgr(96) : c256(51, 96),
90
+ label: basic ? sgr(97) : c256(252, 97),
91
+ accent: basic ? sgr(96) : c256(45, 96),
92
+ success: basic ? sgr(92) : c256(48, 92),
93
+ fail: basic ? sgr(91) : c256(203, 91),
94
+ warn: basic ? sgr(93) : c256(214, 93),
95
+ muted: basic ? sgr(90) : c256(242, 90),
96
+ };
97
+ /* ------------------------------------------------------------------ */
98
+ /* Symbols */
99
+ /* ------------------------------------------------------------------ */
100
+ // Unicode box drawing and symbols are fine on every modern terminal, but
101
+ // Windows conhost with a legacy code page mangles them, so ASCII is used there.
102
+ const unicodeSymbols = !isWindows || Boolean(process.env.WT_SESSION);
103
+ export const sym = {
104
+ ok: unicodeSymbols ? "✔" : "v",
105
+ fail: unicodeSymbols ? "✖" : "x",
106
+ warn: unicodeSymbols ? "▲" : "!",
107
+ arrow: unicodeSymbols ? "→" : "->",
108
+ bullet: unicodeSymbols ? "·" : "-",
109
+ bar: unicodeSymbols ? "│" : "|",
110
+ tl: unicodeSymbols ? "╭" : "+",
111
+ tr: unicodeSymbols ? "╮" : "+",
112
+ bl: unicodeSymbols ? "╰" : "+",
113
+ br: unicodeSymbols ? "╯" : "+",
114
+ h: unicodeSymbols ? "─" : "-",
115
+ };
116
+ /* ------------------------------------------------------------------ */
117
+ /* Message helpers — stderr only */
118
+ /* ------------------------------------------------------------------ */
119
+ function write(text) {
120
+ process.stderr.write(text);
121
+ }
122
+ /** The canonical diagnostic line. `[telegram-mcp] ...` stays greppable. */
123
+ export function info(message) {
124
+ write(`${theme.title(`[${SERVER_NAME}]`)} ${message}\n`);
125
+ }
126
+ export function muted(message) {
127
+ write(`${theme.muted(`[${SERVER_NAME}]`)} ${message}\n`);
128
+ }
129
+ export function success(message) {
130
+ write(`${theme.success(sym.ok)} ${message}\n`);
131
+ }
132
+ export function warn(message) {
133
+ write(`${theme.warn(`${sym.warn} ${message}`)}\n`);
134
+ }
135
+ export function error(message) {
136
+ write(`${theme.fail(`${sym.fail} ${message}`)}\n`);
137
+ }
138
+ export function hint(message) {
139
+ write(` ${theme.muted(`${sym.bullet} ${message}`)}\n`);
140
+ }
141
+ /** A blank line, but only when the terminal can afford it. */
142
+ export function gap() {
143
+ if (animationEnabled())
144
+ write("\n");
145
+ }
146
+ /* ------------------------------------------------------------------ */
147
+ /* Banner */
148
+ /* ------------------------------------------------------------------ */
149
+ /** Two-row block wordmark: readable at small widths, unlike a huge figlet. */
150
+ const WORDMARK = [
151
+ "▀█▀ █▀▀ █░░ █▀▀ █▀▀ █▀█ ▄▀█ █▀▄▀█",
152
+ "░█░ █▀▀ █░░ ██▄ █▄█ █▀▄ █▀█ █░▀░█",
153
+ ];
154
+ /**
155
+ * Print the banner. Deliberately quiet on repeated runs: the full frame is for
156
+ * CLI commands (login, doctor, --help), while a server start uses the compact
157
+ * form so an agent's log does not fill up with art.
158
+ */
159
+ export function banner(opts = {}) {
160
+ const version = opts.version ?? SERVER_VERSION;
161
+ const subtitle = opts.subtitle ?? "your Telegram account, as an MCP server";
162
+ if (opts.compact || !animationEnabled()) {
163
+ write(`${theme.title(SERVER_NAME)} ${theme.muted(version)} ${theme.muted(sym.bullet)} ${theme.label(subtitle)}\n`);
164
+ return;
165
+ }
166
+ const width = Math.max(...WORDMARK.map((row) => [...row].length)) + 4;
167
+ write(`${theme.border(`${sym.tl}${sym.h.repeat(width)}${sym.tr}`)}\n`);
168
+ for (const row of WORDMARK)
169
+ write(`${theme.border(sym.bar)} ${theme.title(row)}\n`);
170
+ write(`${theme.border(sym.bar)} ${theme.muted(version)} ${theme.muted(sym.bullet)} ${theme.label(subtitle)}\n`);
171
+ write(`${theme.border(`${sym.bl}${sym.h.repeat(width)}${sym.br}`)}\n`);
172
+ }
173
+ /* ------------------------------------------------------------------ */
174
+ /* Spinner */
175
+ /* ------------------------------------------------------------------ */
176
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
177
+ /** Redraw the current line in place. Only valid when animation is on. */
178
+ function redraw(text) {
179
+ write(`\u001b[2K\r${text}`);
180
+ }
181
+ function clearLine() {
182
+ write("\u001b[2K\r");
183
+ }
184
+ /**
185
+ * A one-line spinner with a hard time cap, so a stuck network call cannot leave
186
+ * something spinning in a log forever. `stop()` erases the line and optionally
187
+ * replaces it with a final message.
188
+ */
189
+ export function spinner(text, opts = {}) {
190
+ const maxMs = opts.maxMs ?? 6000;
191
+ if (!animationEnabled()) {
192
+ write(`${theme.muted(`${sym.bullet} ${text}`)}\n`);
193
+ return { stop: () => undefined };
194
+ }
195
+ let frame = 0;
196
+ const started = Date.now();
197
+ let stopped = false;
198
+ const stop = (final) => {
199
+ if (stopped)
200
+ return;
201
+ stopped = true;
202
+ clearInterval(timer);
203
+ clearLine();
204
+ if (final)
205
+ write(`${final}\n`);
206
+ };
207
+ const draw = () => redraw(`${theme.accent(FRAMES[frame++ % FRAMES.length])} ${text}`);
208
+ draw();
209
+ const timer = setInterval(() => {
210
+ if (Date.now() - started > maxMs) {
211
+ // Give up quietly: the caller still prints its own outcome line.
212
+ stop();
213
+ return;
214
+ }
215
+ draw();
216
+ }, 90);
217
+ // Never let the animation be the reason the process stays alive.
218
+ timer.unref?.();
219
+ return { stop };
220
+ }
221
+ /** A numbered step header, e.g. `[1/3] Connecting to Telegram`. */
222
+ export function step(index, total, text) {
223
+ write(`${theme.accent(`[${theme.muted(`${index}/${total}`)}]`)} ${theme.bold(text)}\n`);
224
+ }
225
+ /**
226
+ * The three-step onboarding footer shown once credentials are written. It is
227
+ * the answer to "ok, and now what?" — the most common question after setup.
228
+ */
229
+ export function nextSteps(steps) {
230
+ if (!steps.length)
231
+ return;
232
+ gap();
233
+ write(`${theme.label(theme.bold("Next"))}\n`);
234
+ steps.forEach((text, i) => {
235
+ write(` ${theme.accent(String(i + 1))}${theme.muted(".")} ${text}\n`);
236
+ });
237
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dikacode/telegram-mcp",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Local MCP server that connects CLI agents (OpenCode, Codex CLI, Claude Code, ...) to a personal Telegram account over MTProto.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,6 +18,7 @@
18
18
  "start": "node dist/index.js",
19
19
  "login": "tsx cli/login.ts",
20
20
  "doctor": "tsx cli/doctor.ts",
21
+ "preview": "node harness/preview.mjs",
21
22
  "typecheck": "tsc -p tsconfig.json --noEmit",
22
23
  "login:force": "tsx cli/login.ts --force",
23
24
  "test": "npm run build && node harness/unit.mjs",