grok-telegram-bot 2.0.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.
Files changed (106) hide show
  1. package/.env.example +135 -0
  2. package/CHANGELOG.md +598 -0
  3. package/LICENSE +21 -0
  4. package/README.md +644 -0
  5. package/bin/grok-tg.mjs +21 -0
  6. package/docs/INSTALL.md +153 -0
  7. package/docs/UPGRADE.md +253 -0
  8. package/docs/ops/RELEASE_CHECKLIST.md +39 -0
  9. package/package.json +74 -0
  10. package/scripts/setup.mjs +116 -0
  11. package/src/agents/catalog.ts +58 -0
  12. package/src/app/accounts.ts +162 -0
  13. package/src/app/auth-service.ts +136 -0
  14. package/src/app/grok-credentials.ts +103 -0
  15. package/src/app/instance-lock.ts +139 -0
  16. package/src/app/json-store.ts +54 -0
  17. package/src/app/reasoning.ts +30 -0
  18. package/src/app/settings-store.ts +38 -0
  19. package/src/app/stt.ts +53 -0
  20. package/src/app/types.ts +56 -0
  21. package/src/app/updater.ts +234 -0
  22. package/src/app/usage.ts +38 -0
  23. package/src/app/version.ts +41 -0
  24. package/src/bot/account-rotator.ts +52 -0
  25. package/src/bot/auth.ts +38 -0
  26. package/src/bot/bot.ts +225 -0
  27. package/src/bot/chat-controller.ts +317 -0
  28. package/src/bot/commands.ts +52 -0
  29. package/src/bot/deps.ts +67 -0
  30. package/src/bot/file-ingest.ts +190 -0
  31. package/src/bot/handlers/accounts.ts +220 -0
  32. package/src/bot/handlers/auth.ts +64 -0
  33. package/src/bot/handlers/control.ts +103 -0
  34. package/src/bot/handlers/document.ts +112 -0
  35. package/src/bot/handlers/history.ts +63 -0
  36. package/src/bot/handlers/kill.ts +54 -0
  37. package/src/bot/handlers/mcp.ts +206 -0
  38. package/src/bot/handlers/menu.ts +220 -0
  39. package/src/bot/handlers/message.ts +103 -0
  40. package/src/bot/handlers/photo.ts +123 -0
  41. package/src/bot/handlers/projects.ts +183 -0
  42. package/src/bot/handlers/running.ts +181 -0
  43. package/src/bot/handlers/session-card.ts +81 -0
  44. package/src/bot/handlers/session-kill.ts +95 -0
  45. package/src/bot/handlers/sessions.ts +148 -0
  46. package/src/bot/handlers/system.ts +51 -0
  47. package/src/bot/handlers/tasks.ts +224 -0
  48. package/src/bot/handlers/usage.ts +38 -0
  49. package/src/bot/handlers/voice.ts +55 -0
  50. package/src/bot/image-return.ts +69 -0
  51. package/src/bot/menu/ephemeral.ts +117 -0
  52. package/src/bot/menu/keyboard.ts +49 -0
  53. package/src/bot/menu/refresh.ts +13 -0
  54. package/src/bot/menu/status-panel.ts +173 -0
  55. package/src/bot/permission-service.ts +149 -0
  56. package/src/bot/prompt-content.ts +64 -0
  57. package/src/bot/prompt-retry.ts +70 -0
  58. package/src/bot/reauth-controller.ts +297 -0
  59. package/src/bot/registry.ts +186 -0
  60. package/src/bot/reply-context.ts +77 -0
  61. package/src/bot/session-fork.ts +35 -0
  62. package/src/bot/session-runtime.ts +1048 -0
  63. package/src/bot/telegram-io.ts +109 -0
  64. package/src/bot/typing.ts +35 -0
  65. package/src/bot/wizard/task-wizard.ts +214 -0
  66. package/src/cli.ts +126 -0
  67. package/src/config.ts +248 -0
  68. package/src/grok/client.ts +617 -0
  69. package/src/grok/models.ts +50 -0
  70. package/src/grok/session-log.ts +148 -0
  71. package/src/grok/transport.ts +51 -0
  72. package/src/grok/types.ts +136 -0
  73. package/src/index.ts +84 -0
  74. package/src/logger.ts +78 -0
  75. package/src/mcp/config.ts +120 -0
  76. package/src/mcp/probe.ts +218 -0
  77. package/src/mcp/types.ts +68 -0
  78. package/src/projects/manager.ts +99 -0
  79. package/src/render/chunk.ts +57 -0
  80. package/src/render/diff.ts +48 -0
  81. package/src/render/escape.ts +22 -0
  82. package/src/render/file-summary.ts +111 -0
  83. package/src/render/hashtags.ts +34 -0
  84. package/src/render/markdown.ts +130 -0
  85. package/src/render/progress-estimate.ts +63 -0
  86. package/src/render/progress.ts +80 -0
  87. package/src/render/subagent.ts +75 -0
  88. package/src/render/tool-call.ts +196 -0
  89. package/src/service/index.ts +24 -0
  90. package/src/service/linux.ts +85 -0
  91. package/src/service/macos.ts +101 -0
  92. package/src/service/platform.ts +64 -0
  93. package/src/service/types.ts +36 -0
  94. package/src/service/windows.ts +198 -0
  95. package/src/sessions/history.ts +225 -0
  96. package/src/sessions/process.ts +30 -0
  97. package/src/sessions/store.ts +133 -0
  98. package/src/sessions/tail.ts +86 -0
  99. package/src/sessions/types.ts +26 -0
  100. package/src/stream/streamer.ts +261 -0
  101. package/src/tasks/runner.ts +82 -0
  102. package/src/tasks/schedule.ts +142 -0
  103. package/src/tasks/scheduler.ts +53 -0
  104. package/src/tasks/store.ts +80 -0
  105. package/src/tasks/types.ts +33 -0
  106. package/tsconfig.json +19 -0
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Bot-owned session log. Grok CLI keeps its own sessions in an internal SQLite
3
+ * store, but this bridge is the source of truth for the sessions IT drives: for
4
+ * each one it writes the SAME on-disk layout the original ACP bridge used, under
5
+ * the bot's data dir, so the session store, history parser and live-tail watcher
6
+ * all keep working without change:
7
+ *
8
+ * <sessionsDir>/<id>.json metadata (session_id, cwd, title, timestamps, …)
9
+ * <sessionsDir>/<id>.jsonl event log (Prompt / AssistantMessage / ToolUse)
10
+ * <sessionsDir>/<id>.lock { pid } while a turn is running (drives "active")
11
+ */
12
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { createLogger } from "../logger.js";
15
+
16
+ const log = createLogger("grok:session-log");
17
+
18
+ interface SessionFile {
19
+ session_id: string;
20
+ cwd: string;
21
+ title: string;
22
+ created_at: string;
23
+ updated_at: string;
24
+ session_created_reason?: string;
25
+ /** The id the underlying `grok --session` uses (may differ from ours). */
26
+ grok_session_id?: string;
27
+ /** Model last used for this session (applied via `grok --model`). */
28
+ model?: string;
29
+ }
30
+
31
+ export class SessionLog {
32
+ constructor(private readonly dir: string) {}
33
+
34
+ private path(id: string, ext: string): string {
35
+ return join(this.dir, `${id}.${ext}`);
36
+ }
37
+
38
+ private ensureDir(): void {
39
+ try {
40
+ mkdirSync(this.dir, { recursive: true });
41
+ } catch {
42
+ /* best-effort */
43
+ }
44
+ }
45
+
46
+ /** Create the metadata file for a new session if it doesn't exist yet. */
47
+ create(id: string, cwd: string, reason = "user"): void {
48
+ this.ensureDir();
49
+ if (existsSync(this.path(id, "json"))) return;
50
+ const now = new Date().toISOString();
51
+ const meta: SessionFile = {
52
+ session_id: id,
53
+ cwd,
54
+ title: "(untitled)",
55
+ created_at: now,
56
+ updated_at: now,
57
+ session_created_reason: reason,
58
+ };
59
+ this.writeMeta(id, meta);
60
+ }
61
+
62
+ read(id: string): SessionFile | undefined {
63
+ try {
64
+ return JSON.parse(readFileSync(this.path(id, "json"), "utf-8")) as SessionFile;
65
+ } catch {
66
+ return undefined;
67
+ }
68
+ }
69
+
70
+ private writeMeta(id: string, meta: SessionFile): void {
71
+ try {
72
+ this.ensureDir();
73
+ writeFileSync(this.path(id, "json"), JSON.stringify(meta, null, 2), "utf-8");
74
+ } catch (e) {
75
+ log.debug("writeMeta failed:", (e as Error).message);
76
+ }
77
+ }
78
+
79
+ /** Merge a partial update into the metadata file (touches updated_at). */
80
+ update(id: string, patch: Partial<SessionFile>): void {
81
+ const cur = this.read(id) ?? {
82
+ session_id: id,
83
+ cwd: patch.cwd ?? "",
84
+ title: "(untitled)",
85
+ created_at: new Date().toISOString(),
86
+ updated_at: new Date().toISOString(),
87
+ };
88
+ const next: SessionFile = { ...cur, ...patch, session_id: id, updated_at: new Date().toISOString() };
89
+ this.writeMeta(id, next);
90
+ }
91
+
92
+ grokIdFor(id: string): string | undefined {
93
+ return this.read(id)?.grok_session_id;
94
+ }
95
+
96
+ modelFor(id: string): string | undefined {
97
+ return this.read(id)?.model;
98
+ }
99
+
100
+ cwdFor(id: string): string | undefined {
101
+ return this.read(id)?.cwd;
102
+ }
103
+
104
+ // ── event log (history-compatible jsonl) ──────────────────────────────────
105
+
106
+ private append(id: string, kind: string, data: Record<string, unknown>): void {
107
+ try {
108
+ this.ensureDir();
109
+ const line = JSON.stringify({ kind, data: { ...data, meta: { timestamp: Date.now() } } });
110
+ appendFileSync(this.path(id, "jsonl"), line + "\n", "utf-8");
111
+ } catch (e) {
112
+ log.debug("append failed:", (e as Error).message);
113
+ }
114
+ }
115
+
116
+ logUser(id: string, text: string): void {
117
+ if (!text.trim()) return;
118
+ this.append(id, "Prompt", { content: [{ kind: "text", data: text }] });
119
+ }
120
+
121
+ logAssistant(id: string, text: string): void {
122
+ if (!text.trim()) return;
123
+ this.append(id, "AssistantMessage", { content: [{ kind: "text", data: text }] });
124
+ }
125
+
126
+ logTool(id: string, name: string, summary: string): void {
127
+ this.append(id, "ToolUse", { tool_name: name, content: [{ kind: "text", data: summary }] });
128
+ }
129
+
130
+ // ── lock (active detection) ───────────────────────────────────────────────
131
+
132
+ lock(id: string, pid: number): void {
133
+ try {
134
+ this.ensureDir();
135
+ writeFileSync(this.path(id, "lock"), JSON.stringify({ pid, started_at: new Date().toISOString() }), "utf-8");
136
+ } catch {
137
+ /* best-effort */
138
+ }
139
+ }
140
+
141
+ unlock(id: string): void {
142
+ try {
143
+ rmSync(this.path(id, "lock"), { force: true });
144
+ } catch {
145
+ /* best-effort */
146
+ }
147
+ }
148
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Newline-delimited JSON-RPC framing over the ACP agent's stdio.
3
+ * Parses incoming lines and emits typed messages; writes outgoing messages.
4
+ * Used by the persistent `grok agent stdio` process.
5
+ */
6
+ import type { ChildProcessWithoutNullStreams } from "node:child_process";
7
+ import { EventEmitter } from "node:events";
8
+ import type { JsonRpcMessage } from "./types.js";
9
+ import { createLogger } from "../logger.js";
10
+
11
+ const log = createLogger("grok:transport");
12
+
13
+ export class JsonRpcTransport extends EventEmitter {
14
+ private buffer = "";
15
+
16
+ constructor(private readonly proc: ChildProcessWithoutNullStreams) {
17
+ super();
18
+ proc.stdout.setEncoding("utf-8");
19
+ proc.stdout.on("data", (chunk: string) => this.onData(chunk));
20
+ proc.stderr.setEncoding("utf-8");
21
+ proc.stderr.on("data", (chunk: string) => {
22
+ const msg = chunk.trim();
23
+ if (msg) log.debug("[grok stderr]", msg.slice(0, 500));
24
+ });
25
+ }
26
+
27
+ private onData(chunk: string): void {
28
+ this.buffer += chunk;
29
+ let idx: number;
30
+ while ((idx = this.buffer.indexOf("\n")) !== -1) {
31
+ const line = this.buffer.slice(0, idx).trim();
32
+ this.buffer = this.buffer.slice(idx + 1);
33
+ if (!line) continue;
34
+ let parsed: JsonRpcMessage;
35
+ try {
36
+ parsed = JSON.parse(line) as JsonRpcMessage;
37
+ } catch {
38
+ log.debug("non-JSON line ignored:", line.slice(0, 200));
39
+ continue;
40
+ }
41
+ this.emit("message", parsed);
42
+ }
43
+ }
44
+
45
+ send(msg: object): void {
46
+ if (!this.proc.stdin.writable) {
47
+ throw new Error("ACP process stdin is not writable");
48
+ }
49
+ this.proc.stdin.write(JSON.stringify(msg) + "\n");
50
+ }
51
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Agent Client Protocol (ACP) type definitions for the Grok Build CLI agent
3
+ * (`grok agent stdio`). Wire format: newline-delimited JSON-RPC 2.0 over stdio.
4
+ * @see https://agentclientprotocol.com @see https://docs.x.ai/build/cli/headless-scripting
5
+ */
6
+
7
+ export interface JsonRpcRequest {
8
+ jsonrpc: "2.0";
9
+ id: number | string;
10
+ method: string;
11
+ params?: unknown;
12
+ }
13
+
14
+ export interface JsonRpcResponse {
15
+ jsonrpc: "2.0";
16
+ id: number | string;
17
+ result?: unknown;
18
+ error?: { code: number; message: string; data?: unknown };
19
+ }
20
+
21
+ export interface JsonRpcNotification {
22
+ jsonrpc: "2.0";
23
+ method: string;
24
+ params?: unknown;
25
+ }
26
+
27
+ export type JsonRpcMessage = JsonRpcResponse & JsonRpcNotification & { method?: string };
28
+
29
+ /** A content block in a prompt or message. */
30
+ export interface ContentBlock {
31
+ type: "text" | "image" | "resource";
32
+ text?: string;
33
+ data?: string;
34
+ mimeType?: string;
35
+ [k: string]: unknown;
36
+ }
37
+
38
+ /** One authentication method advertised by the agent in `initialize`. */
39
+ export interface AuthMethod {
40
+ id: string; // e.g. "cached_token" | "xai.api_key"
41
+ name?: string;
42
+ description?: string;
43
+ }
44
+
45
+ export interface InitializeResult {
46
+ protocolVersion: number;
47
+ authMethods?: AuthMethod[];
48
+ agentCapabilities?: {
49
+ loadSession?: boolean;
50
+ promptCapabilities?: { image?: boolean };
51
+ };
52
+ agentInfo?: { name?: string; version?: string };
53
+ }
54
+
55
+ export interface NewSessionResult {
56
+ sessionId: string;
57
+ }
58
+
59
+ export interface PromptResult {
60
+ stopReason?: string; // e.g. "end_turn", "cancelled", "max_tokens"
61
+ }
62
+
63
+ /** session/update notification payload. */
64
+ export interface SessionUpdate {
65
+ sessionUpdate:
66
+ | "agent_message_chunk"
67
+ | "agent_thought_chunk"
68
+ | "tool_call"
69
+ | "tool_call_update"
70
+ | "plan"
71
+ | "user_message_chunk"
72
+ | string;
73
+ content?: ContentBlock;
74
+ toolCallId?: string;
75
+ title?: string;
76
+ kind?: string; // "read" | "edit" | "execute" | "search" | ...
77
+ status?: "pending" | "in_progress" | "completed" | "failed" | string;
78
+ rawInput?: Record<string, unknown>;
79
+ content_blocks?: ToolCallContent[];
80
+ [k: string]: unknown;
81
+ }
82
+
83
+ /** A piece of tool-call content (text, diff, etc.). */
84
+ export interface ToolCallContent {
85
+ type: "content" | "diff" | string;
86
+ path?: string;
87
+ oldText?: string | null;
88
+ newText?: string;
89
+ content?: ContentBlock;
90
+ [k: string]: unknown;
91
+ }
92
+
93
+ export interface SessionNotificationParams {
94
+ sessionId: string;
95
+ update: SessionUpdate;
96
+ }
97
+
98
+ /** Permission request from the agent (server -> client) — ACP "ask" mode. */
99
+ export interface RequestPermissionParams {
100
+ sessionId: string;
101
+ toolCall?: { toolCallId?: string; title?: string; kind?: string; rawInput?: Record<string, unknown> };
102
+ options: Array<{ optionId: string; name: string; kind?: string }>;
103
+ }
104
+
105
+ export type PermissionOutcome =
106
+ | { outcome: { outcome: "selected"; optionId: string } }
107
+ | { outcome: { outcome: "cancelled" } };
108
+
109
+ /** One subagent ("crew" member) as reported by the agent, if it emits them. */
110
+ export interface SubagentInfo {
111
+ sessionId: string;
112
+ sessionName?: string;
113
+ agentName?: string;
114
+ role?: string;
115
+ initialQuery?: string;
116
+ status?: { type?: string; message?: string };
117
+ group?: string;
118
+ dependsOn?: string[];
119
+ hasLoop?: boolean;
120
+ loopIteration?: number;
121
+ loopMaxIterations?: number;
122
+ createdAtMs?: number;
123
+ }
124
+
125
+ export interface PendingStage {
126
+ name?: string;
127
+ role?: string;
128
+ agentName?: string;
129
+ dependsOn?: string[];
130
+ [k: string]: unknown;
131
+ }
132
+
133
+ export interface SubagentListUpdate {
134
+ subagents?: SubagentInfo[];
135
+ pendingStages?: PendingStage[];
136
+ }
package/src/index.ts ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Grok Telegram Bot — entry point.
3
+ * Starts the Grok ACP bridge (`grok agent stdio`), the Telegram bot, and wires
4
+ * graceful shutdown between them.
5
+ */
6
+ import { GrokClient } from "./grok/client.js";
7
+ import { createBot } from "./bot/bot.js";
8
+ import { CANONICAL_DIR, loadConfig } from "./config.js";
9
+ import { InstanceLock } from "./app/instance-lock.js";
10
+ import { join } from "node:path";
11
+ import { createLogger, enableFileLogging, setLogLevel } from "./logger.js";
12
+
13
+ async function main(): Promise<void> {
14
+ process.stdout.write("\u{1F916} Grok Telegram Bot — starting…\n");
15
+
16
+ const cfg = loadConfig();
17
+ setLogLevel(cfg.logLevel);
18
+ enableFileLogging(cfg.logFile);
19
+ const log = createLogger("main");
20
+
21
+ // Single-instance guard: kill any ghost/duplicate already polling this token.
22
+ const lock = new InstanceLock(cfg.token, join(CANONICAL_DIR, "locks"), process.env.GROK_TG_SUPERVISED === "1");
23
+ if (cfg.singleInstance && !(await lock.acquire())) {
24
+ process.stdout.write(
25
+ "\u26D4 Another Grok Telegram Bot is already running for this token (a background service). Use `grok-tg restart`, or `grok-tg stop` first.\n",
26
+ );
27
+ process.exit(0);
28
+ }
29
+
30
+ log.info("starting Grok Telegram Bot");
31
+ log.info(`workspace: ${cfg.workspace}`);
32
+ log.info(`grok: ${cfg.grokCliPath}`);
33
+ log.info(`sessions: ${cfg.sessionsDir}`);
34
+ log.info(`log file: ${cfg.logFile}`);
35
+
36
+ const grok = new GrokClient({
37
+ grokCliPath: cfg.grokCliPath,
38
+ workspace: cfg.workspace,
39
+ sessionsDir: cfg.sessionsDir,
40
+ trustAllTools: cfg.trustAllTools,
41
+ apiKey: cfg.grokApiKey,
42
+ model: cfg.grokModel,
43
+ autoRestart: cfg.grokAutoRestart,
44
+ promptIdleTimeoutMs: cfg.promptIdleMs,
45
+ });
46
+
47
+ await grok.start();
48
+ const { bot, registry, scheduler, updater } = await createBot(cfg, grok);
49
+ scheduler.start();
50
+ await updater.start();
51
+
52
+ let shuttingDown = false;
53
+ const shutdown = (code: number): void => {
54
+ if (shuttingDown) return;
55
+ shuttingDown = true;
56
+ log.info("shutting down…");
57
+ scheduler.stop();
58
+ updater.stop();
59
+ registry.disposeAll();
60
+ void bot.stop().catch(() => {});
61
+ grok.stop();
62
+ lock.release();
63
+ setTimeout(() => process.exit(code), 500);
64
+ };
65
+
66
+ grok.on("restarted", () => log.info("Grok bridge re-bound; sessions continue on next message."));
67
+
68
+ process.on("SIGINT", () => shutdown(0));
69
+ process.on("SIGTERM", () => shutdown(0));
70
+ process.on("uncaughtException", (err) => log.error("uncaughtException:", err));
71
+ process.on("unhandledRejection", (err) => log.error("unhandledRejection:", err));
72
+
73
+ await bot.start({
74
+ onStart: (info) => {
75
+ log.info(`bot online as @${info.username}`);
76
+ process.stdout.write(`\u2705 Online as @${info.username}. Send it a message on Telegram.\n`);
77
+ },
78
+ });
79
+ }
80
+
81
+ main().catch((err) => {
82
+ console.error("Fatal:", err instanceof Error ? err.message : err);
83
+ process.exit(1);
84
+ });
package/src/logger.ts ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Tiny leveled logger with timestamps and optional file output (for daemon
3
+ * mode, where stdout may not be captured).
4
+ */
5
+ import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
6
+ import { dirname } from "node:path";
7
+
8
+ const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 } as const;
9
+ export type LogLevel = keyof typeof LEVELS;
10
+
11
+ let threshold: number = LEVELS.info;
12
+ let filePath: string | undefined;
13
+
14
+ const MAX_LOG_BYTES = 5 * 1024 * 1024; // rotate when the log file exceeds 5 MB
15
+
16
+ export function setLogLevel(level: string | undefined): void {
17
+ const lvl = (level || "info").toLowerCase() as LogLevel;
18
+ threshold = LEVELS[lvl] ?? LEVELS.info;
19
+ }
20
+
21
+ /** Mirror all log output to a file (rotated once at startup if too large). */
22
+ export function enableFileLogging(path: string): void {
23
+ try {
24
+ mkdirSync(dirname(path), { recursive: true });
25
+ try {
26
+ if (statSync(path).size > MAX_LOG_BYTES) renameSync(path, `${path}.old`);
27
+ } catch {
28
+ /* no existing file */
29
+ }
30
+ filePath = path;
31
+ } catch {
32
+ filePath = undefined;
33
+ }
34
+ }
35
+
36
+ function ts(): string {
37
+ return new Date().toISOString().replace("T", " ").replace("Z", "");
38
+ }
39
+
40
+ function emit(level: LogLevel, scope: string, args: unknown[]): void {
41
+ if (LEVELS[level] < threshold) return;
42
+ const tag = `${ts()} ${level.toUpperCase().padEnd(5)} [${scope}]`;
43
+ const fn = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
44
+ fn(tag, ...args);
45
+ if (filePath) {
46
+ try {
47
+ appendFileSync(filePath, `${tag} ${args.map(stringify).join(" ")}\n`);
48
+ } catch {
49
+ /* non-fatal */
50
+ }
51
+ }
52
+ }
53
+
54
+ function stringify(v: unknown): string {
55
+ if (typeof v === "string") return v;
56
+ if (v instanceof Error) return v.stack || v.message;
57
+ try {
58
+ return JSON.stringify(v);
59
+ } catch {
60
+ return String(v);
61
+ }
62
+ }
63
+
64
+ export interface Logger {
65
+ debug: (...a: unknown[]) => void;
66
+ info: (...a: unknown[]) => void;
67
+ warn: (...a: unknown[]) => void;
68
+ error: (...a: unknown[]) => void;
69
+ }
70
+
71
+ export function createLogger(scope: string): Logger {
72
+ return {
73
+ debug: (...a) => emit("debug", scope, a),
74
+ info: (...a) => emit("info", scope, a),
75
+ warn: (...a) => emit("warn", scope, a),
76
+ error: (...a) => emit("error", scope, a),
77
+ };
78
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * MCP config store — reads and edits Grok CLI's MCP server definitions.
3
+ *
4
+ * Grok keeps them in two places:
5
+ * • global → `~/.grok/user-settings.json` under `mcp.servers` (array), and
6
+ * • workspace → `<cwd>/.grok/settings.json` under `mcpServers` (object map).
7
+ *
8
+ * We normalize both shapes into a flat list. Edits are surgical: parse, flip a
9
+ * single `disabled` flag on one entry, write back with 2-space indentation.
10
+ */
11
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { createLogger } from "../logger.js";
15
+ import { detailOf, type McpScope, type McpServer, type McpServerConfig, transportOf } from "./types.js";
16
+
17
+ const log = createLogger("mcp:config");
18
+
19
+ /** Absolute path of the global settings file (Grok user settings). */
20
+ export function globalMcpPath(): string {
21
+ return join(homedir(), ".grok", "user-settings.json");
22
+ }
23
+
24
+ /** Absolute path of a workspace settings file for a given project directory. */
25
+ export function workspaceMcpPath(cwd: string): string {
26
+ return join(cwd, ".grok", "settings.json");
27
+ }
28
+
29
+ interface RawFile {
30
+ mcpServers?: Record<string, McpServerConfig>;
31
+ mcp?: { servers?: Array<McpServerConfig & { name?: string }> };
32
+ servers?: Array<McpServerConfig & { name?: string }>;
33
+ [k: string]: unknown;
34
+ }
35
+
36
+ function readJson(path: string): RawFile | undefined {
37
+ if (!existsSync(path)) return undefined;
38
+ try {
39
+ return JSON.parse(readFileSync(path, "utf-8")) as RawFile;
40
+ } catch (e) {
41
+ log.warn(`cannot parse ${path}: ${(e as Error).message}`);
42
+ return undefined;
43
+ }
44
+ }
45
+
46
+ /** Extract normalized servers from a file (supports object-map and array). */
47
+ function serversFrom(path: string, scope: McpScope): McpServer[] {
48
+ const file = readJson(path);
49
+ if (!file) return [];
50
+ const out: McpServer[] = [];
51
+ const push = (name: string, config: McpServerConfig): void => {
52
+ out.push({
53
+ name,
54
+ scope,
55
+ configPath: path,
56
+ disabled: config?.disabled === true,
57
+ transport: transportOf(config ?? {}),
58
+ detail: detailOf(config ?? {}),
59
+ config: config ?? {},
60
+ });
61
+ };
62
+ if (file.mcpServers && typeof file.mcpServers === "object") {
63
+ for (const [name, config] of Object.entries(file.mcpServers)) push(name, config);
64
+ }
65
+ const arr = file.mcp?.servers ?? file.servers;
66
+ if (Array.isArray(arr)) {
67
+ for (const entry of arr) if (entry?.name) push(entry.name, entry);
68
+ }
69
+ return out;
70
+ }
71
+
72
+ /** List all configured MCP servers (workspace entries shadow global). */
73
+ export function listMcpServers(cwd?: string): McpServer[] {
74
+ const byName = new Map<string, McpServer>();
75
+ for (const s of serversFrom(globalMcpPath(), "global")) byName.set(s.name, s);
76
+ if (cwd) {
77
+ for (const s of serversFrom(workspaceMcpPath(cwd), "workspace")) byName.set(s.name, s);
78
+ }
79
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
80
+ }
81
+
82
+ export function findMcpServer(name: string, cwd?: string): McpServer | undefined {
83
+ return listMcpServers(cwd).find((s) => s.name === name);
84
+ }
85
+
86
+ export interface ToggleResult {
87
+ ok: boolean;
88
+ disabled?: boolean;
89
+ error?: string;
90
+ }
91
+
92
+ /** Set the `disabled` flag for a server in its own config file. */
93
+ export function setMcpDisabled(server: McpServer, disabled: boolean): ToggleResult {
94
+ const file = readJson(server.configPath);
95
+ if (!file) return { ok: false, error: `cannot read ${server.configPath}` };
96
+
97
+ const applyMap = (map?: Record<string, McpServerConfig>): boolean => {
98
+ if (!map || !map[server.name]) return false;
99
+ const entry = map[server.name]!;
100
+ if (disabled) entry.disabled = true;
101
+ else delete entry.disabled;
102
+ return true;
103
+ };
104
+ const applyArr = (arr?: Array<McpServerConfig & { name?: string }>): boolean => {
105
+ const entry = arr?.find((e) => e.name === server.name);
106
+ if (!entry) return false;
107
+ if (disabled) entry.disabled = true;
108
+ else delete entry.disabled;
109
+ return true;
110
+ };
111
+
112
+ const changed = applyMap(file.mcpServers) || applyArr(file.mcp?.servers) || applyArr(file.servers);
113
+ if (!changed) return { ok: false, error: `server "${server.name}" not found in ${server.configPath}` };
114
+ try {
115
+ writeFileSync(server.configPath, JSON.stringify(file, null, 2) + "\n", "utf-8");
116
+ return { ok: true, disabled };
117
+ } catch (e) {
118
+ return { ok: false, error: (e as Error).message };
119
+ }
120
+ }