# @tg-search/cli `tg-search` is a local-first Telegram CLI designed for humans to authenticate once and for AI Agents to compose explicit retrieval commands afterward. ## Install and build Install the released CLI with Homebrew on macOS or Linux: Homebrew installs Node.js as a runtime dependency. You do not need to install Node.js manually or build this repository. This package provides the local CLI and daemon; see the [root README](../../README.md) for web application deployment. ```bash brew tap groupultra/telegram-search https://github.com/groupultra/telegram-search brew install groupultra/telegram-search/tg-search tg-search profile configure --apiId YOUR_API_ID --apiHash YOUR_API_HASH tg-search auth login brew services start groupultra/telegram-search/tg-search tg-search daemon status ``` `brew services` manages the `default` profile as your user and starts it again at login. Its data stays in the normal CLI data directory, including across Homebrew upgrades. Logs are `daemon.stdout.log` and `daemon.stderr.log` beside the default profile's data, with the daemon's existing rotation and retention. Configure credentials in the profile; a login service does not inherit your terminal's Telegram environment variables. Run the commands without `sudo`. ```bash brew services stop groupultra/telegram-search/tg-search brew upgrade groupultra/telegram-search/tg-search brew services restart groupultra/telegram-search/tg-search ``` For other profiles, use `tg-search --profile NAME daemon run` with your own supervisor configuration. From this monorepo: ```bash pnpm install pnpm run build:packages pnpm -F @tg-search/cli build pnpm cli --help ``` The package exposes the `tg-search` executable when installed from a registry. ### Release packaging After building packages, run `node packages/cli/scripts/package-release.mjs out/homebrew` to produce a standalone npm tarball. Bundled workspace code and exact runtime dependency versions replace workspace/catalog protocols; `npm-shrinkwrap.json` pins the dependency tree, including optional native binaries for the installation host. No workspace packages need to be published to npm. The Release CLI workflow builds the tagged source, verifies installation and a local database query, and attaches the tarball and updated Formula to the GitHub Release. Copy the released `tg-search.rb` into `Formula/tg-search.rb` in a PR and merge it to make the new version available through the tap. Release uploads intentionally reject existing assets instead of replacing a checksummed installation source. ## Profiles and login ### Remote read mode Connect to a hosted TGS server without downloading its database or Telegram session: ```bash tg-search --profile cloud profile remote https://tgs.example.com tg-search --profile cloud search "project progress" tg-search --remote https://tgs.example.com messages query --limit 50 ``` Provide the server's access token through `TG_SEARCH_REMOTE_TOKEN` using your secret manager or shell environment. Tokens are not accepted in URLs or command arguments and are not saved in profile configuration. `--remote` overrides `TG_SEARCH_REMOTE_URL`, which overrides the selected profile's saved URL. HTTPS is required except on loopback for development. The server base URL may include a path prefix. Supported commands: `chats list`, `messages list`, `messages query`, `search`, `context`, and `stats`. Results use `meta.source: "remote"` and preserve pagination and application errors. No local database or Telegram client is opened, including when the request fails. Direct `--remote`/environment use does not create a local profile. `sync` and `export` are not exposed remotely in this version; run them on the host owning the data. `auth` and `daemon` are local management commands and reject remote profiles. Use a separate local profile for local operation. There is no automatic fallback when the server is unreachable. See [server setup](../../apps/server/README.md) for access-token and account configuration. Saved-index queries work without an active Telegram connection; live chat/message reads require the account to be signed in on the server. Profiles isolate Telegram credentials, the StringSession, PGlite data, and exports under the operating system's application data directory. Config and session files use mode `0600`, and `TG_SEARCH_HOME` can override the data root. - macOS: `~/Library/Application Support/telegram-search/profiles//` - Windows: `%LOCALAPPDATA%/telegram-search/Data/profiles//` - Linux: `$XDG_DATA_HOME/telegram-search/profiles//`, falling back to `~/.local/share/telegram-search/profiles//` ```bash tg-search --profile work profile create work tg-search --profile work profile configure --apiId 123456 --apiHash abcdef tg-search --profile work auth login --phone +6512345678 ``` ## Local daemon (Linux and macOS) The daemon keeps one Telegram connection and one local database writer for a profile. Start it before logging in when you want new messages to arrive without repeatedly running remote reads: ```bash tg-search --profile work daemon run # In a second terminal: tg-search --profile work auth login tg-search --profile work daemon status ``` `daemon run` is intentionally foreground-only in this first version. Run it under Docker, a `systemd --user` unit on Linux, or a `launchd` LaunchAgent on macOS when it should survive terminal closure. The daemon listens on a profile-scoped Unix socket under a user-only temporary directory; it holds an exclusive profile lock, so other CLI commands automatically use the daemon instead of opening a second Telegram/PGlite runtime. As with the server, realtime persistence receives all new, edited, and deleted messages after login. Bulk history remains a separate `sync --takeout` action and still requires explicit Takeout approval. The daemon writes structured operational records at `info` level to its supervisor's stdout/stderr. For a `launchd` or `systemd` setup, direct these streams to `/daemon.stdout.log` and `/daemon.stderr.log`. The daemon checks them every six hours, rotates the prior day's file, and deletes archives older than 14 days; set `TG_SEARCH_DAEMON_LOG_RETENTION_DAYS` to override the retention period. After a Telegram transport disconnect, `daemon status` reports `reconnecting` and records transport, authorization, and historical catch-up independently. Once the transport is restored and the account is ready, the daemon may report `ready` while `health.catchUp` is still running or has failed. Recovered messages, edits, and deletions are persisted before their `pts` checkpoints advance. If Telegram reports a gap that is too large for either difference API, the daemon preserves the old checkpoint and records a catch-up failure; run an explicit full sync before restarting catch-up. The daemon adds no reconnect scheduler beyond GramJS retries; use explicit `daemon recover` when those retries are exhausted. You may use `TELEGRAM_API_ID` and `TELEGRAM_API_HASH` instead of storing API credentials in profile config. Login prompts and progress are written to stderr. ## Agent commands Commands use a human-readable text format by default. Pass `--json` for automation and Agent calls; successful JSON envelopes contain `ok`, `data`, top-level `next_cursor` when pagination applies, and `meta.profile` / `meta.source`. Failed JSON envelopes contain `ok: false` and a structured `error`; the process also exits non-zero. Diagnostics, prompts, GramJS logs, migration logs, and streaming progress go to stderr. Read commands accept `--format text|json|compact|jsonl`; `--json` is equivalent to `--format json`. Compact output keeps every field, including reply IDs, media, context and coverage, but places each top-level array item on one line. JSONL uses a `result` header, one `item` record per array item (`key`, `index`, `data`), and a terminal `completed` record. The header retains all non-array data fields, metadata and cursor, and lists each array's key and count so the original result can be reconstructed. A missing terminal record means output was interrupted. Serialization completion applies to one result; inspect its cursor and coverage to determine which history or references remain unread. Use `--output-file ./private/page.jsonl` on a read command to avoid large terminal output. This writes JSONL by default (or your explicitly selected format) and prints only a receipt with its path, byte count, format and `status: completed`. It writes this single command result; it does not follow pagination or download attachments. New directories use `0700` and result files use `0600`; existing directory permissions are preserved. Output and `.partial` paths are exclusive: existing files are never overwritten. A failed write reports its `.partial` path and `status: partial`; choose a fresh path to rerun. The final filename is published only after every chunk has been written and flushed. ```bash tg-search messages list --chat 123456 --format compact tg-search messages query --limit 100 --output-file ./private/page.jsonl tg-search context --chat 123456 --message 42 --format jsonl ``` Use `--` before a search query that starts with a dash, for example `tg-search --json search -- "--output-file=literal-query"`. Arguments after the terminator remain literal query text; they cannot change the profile, endpoint, output format, output file, or help behavior. Unknown commands fail even with `--help` and report available subcommands. Failures from command parsing use `INVALID_ARGUMENT`; remote setup and transport failures distinguish `REMOTE_CREDENTIALS_MISSING`, `REMOTE_AUTHENTICATION_FAILED`, `REMOTE_PERMISSION_DENIED`, `REQUEST_DEADLINE_EXCEEDED` and `REMOTE_UNREACHABLE`. Structured application errors returned by the server retain their original code. JSON failures are emitted once on stdout without an additional plain-text error. A valid machine format also applies to argument errors; an invalid format still honors `--json` for failure output. ```bash # Discover chats remotely. No messages are persisted. tg-search --profile work chats list --limit 200 --json # Read one chat remotely. No messages are persisted. tg-search --profile work messages list --chat 123456 --from 2026-01-01 --to 2026-12-31 --json # Search one chat on Telegram, without syncing it first. tg-search --profile work messages list --chat 123456 --query "项目进展" --from 2026-01-01 --json # After the user explicitly approves Telegram Takeout, persist selected chats. # At least --chat or --all is required. Never add --takeout without that approval. tg-search --profile work sync --takeout --chat 123456,789012 --from 2026-01-01 --to 2026-12-31 --json # Query and search only the local PGlite database. These commands do not connect to Telegram. tg-search --profile work messages query --from 2026-01-01 --to 2026-12-31 --json tg-search --profile work search "项目进展" --chat 123456 --json tg-search --profile work context --chat 123456 --message 42 --before 20 --after 20 --json tg-search --profile work stats --group-by month --timezone Asia/Singapore --from 2026-01-01 --to 2026-12-31 --json ``` If Telegram returns `TAKEOUT_INIT_DELAY_*`, the CLI emits `TAKEOUT_AUTHORIZATION_REQUIRED` with `details.action: "authorize_takeout_in_telegram"`. An Agent must stop, ask the user to review and authorize the pending data export request on one of their Telegram devices, and rerun `sync --takeout` only after the user confirms and Telegram allows it. This is distinct from the user's initial approval for the CLI to add `--takeout`; it is a Telegram-side security confirmation and is never retried automatically. The current CLI `search` command uses local jieba text retrieval. It does not generate query embeddings, so vector retrieval is not enabled by this command. `messages list` reads newest first. Its `next_cursor` is the ID of the last message scanned, so paging stays correct while the chat receives new messages; pass it back as `--cursor`, or pass any known message ID to read strictly older messages. A page may contain fewer than `--limit` items, even none, because service messages are skipped and one call scans a bounded amount of history. Only `next_cursor: null` means the requested range has been read completely. Remote pages may include Telegram's raw `total`, but Telegram does not guarantee that it reflects the CLI's sender and date filters. Treat it as informational rather than as an exact filtered count. Use Takeout plus local queries when exact filtered counts are required. ```bash tg-search --profile work messages list --chat 123456 --sender me --to 2026-01-31 --limit 1 ``` ## Annual export ```bash tg-search --profile work export \ --from 2026-01-01 \ --to 2026-12-31 \ --timezone Asia/Singapore \ --format jsonl \ --output ./telegram-2026 ``` The export contains deterministic monthly JSONL files plus `manifest.json` with the selected IANA time zone and per-file SHA-256 checksums. `--timezone` defaults to `UTC`; set it explicitly when local calendar months matter. The export includes text and structured forward/media/link metadata, but not media binaries, Telegram sessions, embeddings, or credentials. Reply records keep `replyToId` and also embed a one-level `replyTo` message snapshot so an Agent can read the referenced sender, timestamp, text, forward, media, and link metadata without joining the archive. The snapshot is resolved from the local database even when the target falls outside the selected `--from`/`--to` range. Reply hydration stays within the selected `--chat` values; without a chat selection it uses the active account scope. It is `null` when the target is unavailable locally or outside the selected chat scope, and reply chains are not recursively expanded. This reply-aware output is manifest schema version `2`. The CLI performs no AI analysis. An Agent can read the JSONL files and produce a monthly or annual summary separately. ## Privacy boundary - Remote `chats list` and `messages list` read Telegram without persisting message domain data. - Bulk history is persisted through `sync --takeout`, and the Agent may add `--takeout` only after explicit user approval. A running, authenticated daemon also persists new messages, edits, and deletions in realtime. - Declined consent or Takeout initialization failure stops the sync; it never falls back to ordinary `GetHistory` bulk reads. - Takeout requests only the selected chat category and does not request contacts or files for text sync. - Local query, search, context, stats, and export do not create a Telegram connection. - Media references and metadata may be stored; media binaries are not downloaded by these CLI commands. ### Read deadlines and cancellation Read commands accept `--timeout-ms` (default `60000`). One absolute deadline is carried through IPC and remote HTTP to the application; transport adapters do not retry a cancelled read. SIGINT/SIGTERM cancels the active request without rebuilding a daemon's shared Telegram connection. Application pagination stops before another page is sent and listeners/timers are removed. GramJS 2.26 does not expose cancellation for an individual in-flight RPC. A cancelled Telegram read reports `sdkCancellation: unsupported` and `inFlightMayContinue: true` when that RPC is still active. Its late result is observed and discarded; cancellation does not claim to have removed it from the SDK queue. Runtime recovery belongs to the daemon owner. ### Message point reads ```sh tg-search messages get --chat 42 --ids 10,11 --source local --json tg-search messages get --chat 42 --ids 10,11 --source telegram --json ``` The source is required. Up to 100 chat-scoped IDs are returned with `missingIds`; this operation does not persist Telegram messages or request Takeout. Relationship facts are preserved in JSON output. ### Reply context and window coverage ```sh tg-search messages replies --chat 42 --ids 10,11 --source telegram --depth 1 --nodes 100 --json tg-search messages read --chat 42 --source telegram --from 1700000000 --to 1700007200 --max-pages 10 --max-messages 1000 --json ``` `replies` follows reply relationships, while `context --chat ... --message ...` returns indexed time neighbors. Reply coverage reports unresolved, unrequested and budget-stopped links separately. The node budget counts roots and missing lookups. Cross-chat references require their own explicit point read. `read` follows the source cursor until exhaustion or a page/message budget. Resume with the returned `nextCursor` and the same source, chat and window. `exhausted` only means that window's pagination ended; it never implies that reply chains or attachment contents were inspected. `cursor_stalled` reports a nonadvancing backend rather than retrying forever. ### Runtime diagnostics and recovery - `profile inspect --json` reports credential presence and configuration source. - `profile doctor --json` adds observed local daemon health. These commands do not authenticate or probe Telegram, and never print credential or endpoint values. - `daemon status --json` separates transport, authorization, historical catch-up, and the last observed local/live read result, including transition/success times. A connected socket alone does not establish read availability. - `daemon reload --json` reloads changed saved authentication state. - `daemon recover --json` checks saved credentials/session, rebuilds the runtime through its owner, and returns before/after status. Recovery can interrupt active requests, so it is an explicit command rather than a per-request fallback. Output metadata identifies CLI execution mode and artifact version, source path, SHA256 digest and Node version. Daemon reads also identify the daemon artifact, so a newly installed CLI can be distinguished from a long-lived daemon. An older incompatible daemon status contract returns `DAEMON_PROTOCOL_MISMATCH`; restart that process using the current CLI build. ### Explicit attachment reads `media read` retrieves one bounded byte range from a selected message attachment. Choose `--source local` for stored content or `--source telegram` for a live read; the command never changes sources automatically. ```bash tg-search media read --chat --message --source telegram --length 65536 --max-bytes 10485760 --json ``` The result contains base64 `data`, attachment metadata, and `nextOffset`. Continue with `--offset` until `nextOffset` is null. The default attachment limit is 10 MiB, and one response contains at most 256 KiB of content so it fits the IPC frame budget. A null continuation means the returned range reached EOF; it does not prove earlier ranges were read. `contentStatus: complete` is returned only when this range covers the entire file, and does not mean the caller interpreted its content. Unsupported or unavailable local content produces an explicit error. Remote reads use the server's existing account credential. ### Temporary attachment URLs `media url` asks a remote server to issue a download URL for one attachment. The link expires after five minutes and can be fetched without an Authorization header. It returns `url`, a server-relative `path`, `expiresAt` in Unix milliseconds, and `media` metadata (`type`, `size`, optional `mimeType` and `fileName`). The default whole-file limit is 10 MiB. ```bash tg-search media url --remote https://tgs.example.invalid --chat CHAT_ID --message MESSAGE_ID --source local --index 0 --max-bytes 10485760 --json ``` Use `--source local` for bytes already stored **on that server**. Use `--source telegram` to authorize an explicit Telegram download by that server. The command never switches sources. Telegram documents include files, audio, video and stickers; URLs return their original bytes. Animated stickers are not transcoded into images. The endpoint can also come from `TG_SEARCH_REMOTE_URL` or the selected profile. The command accepts the existing profile, timeout and output options. Without a remote endpoint it returns `REMOTE_OPERATION_UNSUPPORTED` before starting a local runtime or creating a profile. For PNG/JPEG images, pass `data.url` to the model provider's **image input field**. A URL pasted into prompt text does not load the image. Use a supported file input for documents. The endpoint must be reachable by the model provider: a localhost or private-network URL is not reachable by a cloud model. The CLI does not publish your computer or make a private endpoint public. Treat the URL as a temporary credential. Give it only to the intended authorized consumer; do not put it in logs, reports, screenshots or shared chat messages.