kiro-telegram-bot 1.5.1
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/.env.example +104 -0
- package/LICENSE +21 -0
- package/README.md +517 -0
- package/bin/kiro-tg.mjs +21 -0
- package/docs/INSTALL.md +143 -0
- package/docs/ops/RELEASE_CHECKLIST.md +39 -0
- package/package.json +70 -0
- package/scripts/mq.ts +25 -0
- package/scripts/setup.mjs +78 -0
- package/src/acp/client.ts +456 -0
- package/src/acp/server-handlers.ts +85 -0
- package/src/acp/transport.ts +50 -0
- package/src/acp/types.ts +136 -0
- package/src/agents/catalog.ts +44 -0
- package/src/app/json-store.ts +54 -0
- package/src/app/reasoning.ts +30 -0
- package/src/app/settings-store.ts +31 -0
- package/src/app/stt.ts +53 -0
- package/src/app/types.ts +48 -0
- package/src/app/usage.ts +32 -0
- package/src/bot/auth.ts +27 -0
- package/src/bot/bot.ts +154 -0
- package/src/bot/chat-controller.ts +251 -0
- package/src/bot/commands.ts +48 -0
- package/src/bot/deps.ts +47 -0
- package/src/bot/handlers/control.ts +94 -0
- package/src/bot/handlers/history.ts +58 -0
- package/src/bot/handlers/kill.ts +69 -0
- package/src/bot/handlers/mcp.ts +205 -0
- package/src/bot/handlers/menu.ts +204 -0
- package/src/bot/handlers/message.ts +93 -0
- package/src/bot/handlers/photo.ts +108 -0
- package/src/bot/handlers/projects.ts +83 -0
- package/src/bot/handlers/running.ts +104 -0
- package/src/bot/handlers/session-card.ts +65 -0
- package/src/bot/handlers/sessions.ts +131 -0
- package/src/bot/handlers/system.ts +51 -0
- package/src/bot/handlers/tasks.ts +223 -0
- package/src/bot/handlers/usage.ts +33 -0
- package/src/bot/handlers/voice.ts +53 -0
- package/src/bot/image-return.ts +69 -0
- package/src/bot/menu/keyboard.ts +47 -0
- package/src/bot/menu/refresh.ts +13 -0
- package/src/bot/menu/status-panel.ts +78 -0
- package/src/bot/permission-service.ts +149 -0
- package/src/bot/prompt-content.ts +49 -0
- package/src/bot/prompt-retry.ts +70 -0
- package/src/bot/registry.ts +178 -0
- package/src/bot/session-runtime.ts +670 -0
- package/src/bot/telegram-io.ts +109 -0
- package/src/bot/typing.ts +35 -0
- package/src/bot/wizard/task-wizard.ts +214 -0
- package/src/cli.ts +125 -0
- package/src/config.ts +190 -0
- package/src/index.ts +74 -0
- package/src/logger.ts +78 -0
- package/src/mcp/config.ts +103 -0
- package/src/mcp/probe.ts +218 -0
- package/src/mcp/types.ts +68 -0
- package/src/projects/manager.ts +88 -0
- package/src/render/chunk.ts +57 -0
- package/src/render/diff.ts +48 -0
- package/src/render/escape.ts +22 -0
- package/src/render/markdown.ts +126 -0
- package/src/render/subagent.ts +75 -0
- package/src/render/tool-call.ts +102 -0
- package/src/service/index.ts +24 -0
- package/src/service/linux.ts +83 -0
- package/src/service/macos.ts +91 -0
- package/src/service/platform.ts +59 -0
- package/src/service/types.ts +34 -0
- package/src/service/windows.ts +103 -0
- package/src/sessions/history.ts +181 -0
- package/src/sessions/store.ts +133 -0
- package/src/sessions/tail.ts +86 -0
- package/src/sessions/types.ts +26 -0
- package/src/stream/streamer.ts +167 -0
- package/src/tasks/runner.ts +82 -0
- package/src/tasks/schedule.ts +142 -0
- package/src/tasks/scheduler.ts +53 -0
- package/src/tasks/store.ts +80 -0
- package/src/tasks/types.ts +33 -0
- package/tsconfig.json +19 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kiro Telegram Bot — entry point.
|
|
3
|
+
* Spawns the Kiro ACP agent, starts the Telegram bot, and wires graceful
|
|
4
|
+
* shutdown between them.
|
|
5
|
+
*/
|
|
6
|
+
import { AcpClient } from "./acp/client.js";
|
|
7
|
+
import { createBot } from "./bot/bot.js";
|
|
8
|
+
import { loadConfig } from "./config.js";
|
|
9
|
+
import { createLogger, enableFileLogging, setLogLevel } from "./logger.js";
|
|
10
|
+
|
|
11
|
+
async function main(): Promise<void> {
|
|
12
|
+
// Immediate feedback before any async work, so `npm start` shows life at once.
|
|
13
|
+
process.stdout.write("\u{1F916} Kiro Telegram Bot — starting…\n");
|
|
14
|
+
|
|
15
|
+
const cfg = loadConfig();
|
|
16
|
+
setLogLevel(cfg.logLevel);
|
|
17
|
+
enableFileLogging(cfg.logFile);
|
|
18
|
+
const log = createLogger("main");
|
|
19
|
+
|
|
20
|
+
log.info("starting Kiro Telegram Bot");
|
|
21
|
+
log.info(`workspace: ${cfg.workspace}`);
|
|
22
|
+
log.info(`kiro-cli: ${cfg.kiroCliPath}`);
|
|
23
|
+
log.info(`log file: ${cfg.logFile}`);
|
|
24
|
+
|
|
25
|
+
const acp = new AcpClient({
|
|
26
|
+
kiroCliPath: cfg.kiroCliPath,
|
|
27
|
+
workspace: cfg.workspace,
|
|
28
|
+
trustAllTools: cfg.trustAllTools,
|
|
29
|
+
agent: cfg.agent,
|
|
30
|
+
autoRestart: cfg.acpAutoRestart,
|
|
31
|
+
promptIdleTimeoutMs: cfg.promptIdleMs,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
await acp.start();
|
|
35
|
+
const { bot, registry, scheduler } = await createBot(cfg, acp);
|
|
36
|
+
scheduler.start();
|
|
37
|
+
|
|
38
|
+
let shuttingDown = false;
|
|
39
|
+
const shutdown = (code: number): void => {
|
|
40
|
+
if (shuttingDown) return;
|
|
41
|
+
shuttingDown = true;
|
|
42
|
+
log.info("shutting down…");
|
|
43
|
+
scheduler.stop();
|
|
44
|
+
registry.disposeAll();
|
|
45
|
+
void bot.stop().catch(() => {});
|
|
46
|
+
acp.stop();
|
|
47
|
+
setTimeout(() => process.exit(code), 500);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
acp.on("exit", () => {
|
|
51
|
+
if (!cfg.acpAutoRestart) {
|
|
52
|
+
log.error("kiro-cli ACP exited and auto-restart is off — stopping bot.");
|
|
53
|
+
shutdown(1);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
acp.on("restarted", () => log.info("ACP agent restarted; sessions will re-bind on next message."));
|
|
57
|
+
|
|
58
|
+
process.on("SIGINT", () => shutdown(0));
|
|
59
|
+
process.on("SIGTERM", () => shutdown(0));
|
|
60
|
+
process.on("uncaughtException", (err) => log.error("uncaughtException:", err));
|
|
61
|
+
process.on("unhandledRejection", (err) => log.error("unhandledRejection:", err));
|
|
62
|
+
|
|
63
|
+
await bot.start({
|
|
64
|
+
onStart: (info) => {
|
|
65
|
+
log.info(`bot online as @${info.username}`);
|
|
66
|
+
process.stdout.write(`\u2705 Online as @${info.username}. Send it a message on Telegram.\n`);
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
main().catch((err) => {
|
|
72
|
+
console.error("Fatal:", err instanceof Error ? err.message : err);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
});
|
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,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP config store — reads and edits Kiro's `mcp.json` files.
|
|
3
|
+
*
|
|
4
|
+
* Sources, in precedence order for display (a workspace entry shadows a global
|
|
5
|
+
* one with the same name, mirroring how Kiro merges them):
|
|
6
|
+
* • global → `~/.kiro/settings/mcp.json`
|
|
7
|
+
* • workspace → `<cwd>/.kiro/settings/mcp.json`
|
|
8
|
+
*
|
|
9
|
+
* Edits are surgical: we parse the file, flip a single `disabled` flag, and
|
|
10
|
+
* write it back with 2-space indentation, preserving every other field.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { createLogger } from "../logger.js";
|
|
16
|
+
import { detailOf, type McpScope, type McpServer, type McpServerConfig, transportOf } from "./types.js";
|
|
17
|
+
|
|
18
|
+
const log = createLogger("mcp:config");
|
|
19
|
+
|
|
20
|
+
interface McpFile {
|
|
21
|
+
mcpServers?: Record<string, McpServerConfig>;
|
|
22
|
+
[k: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Absolute path of the global mcp.json. */
|
|
26
|
+
export function globalMcpPath(): string {
|
|
27
|
+
return join(homedir(), ".kiro", "settings", "mcp.json");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Absolute path of a workspace mcp.json for a given project directory. */
|
|
31
|
+
export function workspaceMcpPath(cwd: string): string {
|
|
32
|
+
return join(cwd, ".kiro", "settings", "mcp.json");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readFile(path: string): McpFile | undefined {
|
|
36
|
+
if (!existsSync(path)) return undefined;
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(readFileSync(path, "utf-8")) as McpFile;
|
|
39
|
+
} catch (e) {
|
|
40
|
+
log.warn(`cannot parse ${path}: ${(e as Error).message}`);
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function serversFrom(path: string, scope: McpScope): McpServer[] {
|
|
46
|
+
const file = readFile(path);
|
|
47
|
+
const map = file?.mcpServers;
|
|
48
|
+
if (!map || typeof map !== "object") return [];
|
|
49
|
+
return Object.entries(map).map(([name, config]) => ({
|
|
50
|
+
name,
|
|
51
|
+
scope,
|
|
52
|
+
configPath: path,
|
|
53
|
+
disabled: config?.disabled === true,
|
|
54
|
+
transport: transportOf(config ?? {}),
|
|
55
|
+
detail: detailOf(config ?? {}),
|
|
56
|
+
config: config ?? {},
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* List all configured MCP servers. Workspace entries shadow global ones with
|
|
62
|
+
* the same name. Returns them sorted by name (case-insensitive).
|
|
63
|
+
*/
|
|
64
|
+
export function listMcpServers(cwd?: string): McpServer[] {
|
|
65
|
+
const byName = new Map<string, McpServer>();
|
|
66
|
+
for (const s of serversFrom(globalMcpPath(), "global")) byName.set(s.name, s);
|
|
67
|
+
if (cwd) {
|
|
68
|
+
for (const s of serversFrom(workspaceMcpPath(cwd), "workspace")) byName.set(s.name, s);
|
|
69
|
+
}
|
|
70
|
+
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Locate a single server by name (workspace shadows global). */
|
|
74
|
+
export function findMcpServer(name: string, cwd?: string): McpServer | undefined {
|
|
75
|
+
return listMcpServers(cwd).find((s) => s.name === name);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ToggleResult {
|
|
79
|
+
ok: boolean;
|
|
80
|
+
/** New disabled state on success. */
|
|
81
|
+
disabled?: boolean;
|
|
82
|
+
error?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Set the `disabled` flag for a server in its own config file. The change takes
|
|
87
|
+
* effect when the agent next (re)loads servers (after `/restart` / new session).
|
|
88
|
+
*/
|
|
89
|
+
export function setMcpDisabled(server: McpServer, disabled: boolean): ToggleResult {
|
|
90
|
+
const file = readFile(server.configPath);
|
|
91
|
+
if (!file || !file.mcpServers || !file.mcpServers[server.name]) {
|
|
92
|
+
return { ok: false, error: `server "${server.name}" not found in ${server.configPath}` };
|
|
93
|
+
}
|
|
94
|
+
const entry = file.mcpServers[server.name]!;
|
|
95
|
+
if (disabled) entry.disabled = true;
|
|
96
|
+
else delete entry.disabled; // absence === enabled; keeps the file clean
|
|
97
|
+
try {
|
|
98
|
+
writeFileSync(server.configPath, JSON.stringify(file, null, 2) + "\n", "utf-8");
|
|
99
|
+
return { ok: true, disabled };
|
|
100
|
+
} catch (e) {
|
|
101
|
+
return { ok: false, error: (e as Error).message };
|
|
102
|
+
}
|
|
103
|
+
}
|
package/src/mcp/probe.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP health probe — performs a real MCP `initialize` JSON-RPC handshake against
|
|
3
|
+
* a configured server to determine whether it actually connects, and why not.
|
|
4
|
+
*
|
|
5
|
+
* • stdio servers → spawn the command (args/env), write `initialize` to stdin,
|
|
6
|
+
* await a matching JSON-RPC response on stdout, then kill the process.
|
|
7
|
+
* • http servers → POST `initialize` to the URL (with headers); accept either
|
|
8
|
+
* a JSON body or an SSE `data:` line (Streamable HTTP transport).
|
|
9
|
+
*
|
|
10
|
+
* This mirrors exactly what an MCP client does on connect, so a success means
|
|
11
|
+
* the server is reachable and speaks MCP; a failure carries the real reason
|
|
12
|
+
* (command not found, timeout, HTTP status, transport error, …).
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { createLogger } from "../logger.js";
|
|
16
|
+
import type { McpProbeResult, McpServer } from "./types.js";
|
|
17
|
+
|
|
18
|
+
const log = createLogger("mcp:probe");
|
|
19
|
+
|
|
20
|
+
const INIT_REQUEST = {
|
|
21
|
+
jsonrpc: "2.0",
|
|
22
|
+
id: 1,
|
|
23
|
+
method: "initialize",
|
|
24
|
+
params: {
|
|
25
|
+
protocolVersion: "2024-11-05",
|
|
26
|
+
capabilities: {},
|
|
27
|
+
clientInfo: { name: "kiro-telegram-bot", version: "1.0.0" },
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export interface ProbeOptions {
|
|
32
|
+
timeoutMs: number;
|
|
33
|
+
concurrency: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Probe a single server. Never throws — failures are returned as results. */
|
|
37
|
+
export async function probeServer(server: McpServer, timeoutMs: number): Promise<McpProbeResult> {
|
|
38
|
+
if (server.disabled) return { name: server.name, ok: false, skipped: true, error: "disabled" };
|
|
39
|
+
const start = Date.now();
|
|
40
|
+
try {
|
|
41
|
+
const info = server.transport === "http" ? await probeHttp(server, timeoutMs) : await probeStdio(server, timeoutMs);
|
|
42
|
+
return { name: server.name, ok: true, ms: Date.now() - start, serverName: info.name, serverVersion: info.version };
|
|
43
|
+
} catch (e) {
|
|
44
|
+
return { name: server.name, ok: false, ms: Date.now() - start, error: (e as Error).message };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Probe many servers with bounded concurrency. Disabled servers are skipped. */
|
|
49
|
+
export async function probeAll(
|
|
50
|
+
servers: McpServer[],
|
|
51
|
+
opts: ProbeOptions,
|
|
52
|
+
onResult?: (r: McpProbeResult, done: number, total: number) => void,
|
|
53
|
+
): Promise<McpProbeResult[]> {
|
|
54
|
+
const results: McpProbeResult[] = new Array(servers.length);
|
|
55
|
+
let next = 0;
|
|
56
|
+
let done = 0;
|
|
57
|
+
const total = servers.length;
|
|
58
|
+
const worker = async (): Promise<void> => {
|
|
59
|
+
for (;;) {
|
|
60
|
+
const i = next++;
|
|
61
|
+
if (i >= servers.length) return;
|
|
62
|
+
const r = await probeServer(servers[i]!, opts.timeoutMs);
|
|
63
|
+
results[i] = r;
|
|
64
|
+
done++;
|
|
65
|
+
try {
|
|
66
|
+
onResult?.(r, done, total);
|
|
67
|
+
} catch {
|
|
68
|
+
/* non-fatal */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const workers = Array.from({ length: Math.max(1, Math.min(opts.concurrency, servers.length)) }, () => worker());
|
|
73
|
+
await Promise.all(workers);
|
|
74
|
+
return results;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface ServerIdent {
|
|
78
|
+
name?: string;
|
|
79
|
+
version?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function identFrom(result: unknown): ServerIdent {
|
|
83
|
+
const r = result as { serverInfo?: { name?: string; version?: string } };
|
|
84
|
+
return { name: r?.serverInfo?.name, version: r?.serverInfo?.version };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** stdio handshake: spawn, send initialize, await a JSON-RPC response. */
|
|
88
|
+
function probeStdio(server: McpServer, timeoutMs: number): Promise<ServerIdent> {
|
|
89
|
+
return new Promise<ServerIdent>((resolve, reject) => {
|
|
90
|
+
const cmd = server.config.command;
|
|
91
|
+
if (!cmd) return reject(new Error("no command configured"));
|
|
92
|
+
const args = Array.isArray(server.config.args) ? server.config.args.map(String) : [];
|
|
93
|
+
let settled = false;
|
|
94
|
+
let proc: ReturnType<typeof spawn>;
|
|
95
|
+
try {
|
|
96
|
+
proc = spawn(cmd, args, {
|
|
97
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
98
|
+
env: { ...process.env, ...(server.config.env ?? {}) },
|
|
99
|
+
windowsHide: true,
|
|
100
|
+
});
|
|
101
|
+
} catch (e) {
|
|
102
|
+
return reject(new Error(`spawn failed: ${(e as Error).message}`));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const finish = (err?: Error, ident?: ServerIdent): void => {
|
|
106
|
+
if (settled) return;
|
|
107
|
+
settled = true;
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
try {
|
|
110
|
+
proc.kill();
|
|
111
|
+
} catch {
|
|
112
|
+
/* ignore */
|
|
113
|
+
}
|
|
114
|
+
if (err) reject(err);
|
|
115
|
+
else resolve(ident ?? {});
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const timer = setTimeout(() => finish(new Error(`timeout after ${timeoutMs}ms (no response)`)), timeoutMs);
|
|
119
|
+
|
|
120
|
+
let stderrTail = "";
|
|
121
|
+
let buf = "";
|
|
122
|
+
proc.stdout?.setEncoding("utf-8");
|
|
123
|
+
proc.stdout?.on("data", (chunk: string) => {
|
|
124
|
+
buf += chunk;
|
|
125
|
+
let i: number;
|
|
126
|
+
while ((i = buf.indexOf("\n")) !== -1) {
|
|
127
|
+
const line = buf.slice(0, i).trim();
|
|
128
|
+
buf = buf.slice(i + 1);
|
|
129
|
+
if (!line) continue;
|
|
130
|
+
try {
|
|
131
|
+
const m = JSON.parse(line) as { id?: unknown; result?: unknown; error?: { message?: string } };
|
|
132
|
+
if (m && m.id === 1) {
|
|
133
|
+
if (m.error) finish(new Error(`server error: ${m.error.message ?? "unknown"}`));
|
|
134
|
+
else finish(undefined, identFrom(m.result));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
} catch {
|
|
138
|
+
/* partial / non-JSON banner line — keep reading */
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
proc.stderr?.setEncoding("utf-8");
|
|
143
|
+
proc.stderr?.on("data", (c: string) => {
|
|
144
|
+
stderrTail = (stderrTail + c).slice(-300);
|
|
145
|
+
});
|
|
146
|
+
proc.on("error", (e) => finish(new Error(`spawn failed: ${e.message}`)));
|
|
147
|
+
proc.on("exit", (code) => {
|
|
148
|
+
if (!settled) {
|
|
149
|
+
const tail = stderrTail.trim() ? ` — ${stderrTail.trim().split("\n").pop()}` : "";
|
|
150
|
+
finish(new Error(`process exited (code ${code})${tail}`));
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
proc.stdin?.write(JSON.stringify(INIT_REQUEST) + "\n");
|
|
156
|
+
} catch (e) {
|
|
157
|
+
finish(new Error(`write failed: ${(e as Error).message}`));
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** HTTP handshake: POST initialize; parse JSON or an SSE `data:` payload. */
|
|
163
|
+
async function probeHttp(server: McpServer, timeoutMs: number): Promise<ServerIdent> {
|
|
164
|
+
const url = server.config.url!;
|
|
165
|
+
const controller = new AbortController();
|
|
166
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
167
|
+
try {
|
|
168
|
+
const res = await fetch(url, {
|
|
169
|
+
method: "POST",
|
|
170
|
+
headers: {
|
|
171
|
+
"Content-Type": "application/json",
|
|
172
|
+
Accept: "application/json, text/event-stream",
|
|
173
|
+
...(server.config.headers ?? {}),
|
|
174
|
+
},
|
|
175
|
+
body: JSON.stringify(INIT_REQUEST),
|
|
176
|
+
signal: controller.signal,
|
|
177
|
+
});
|
|
178
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`.trim());
|
|
179
|
+
const text = await res.text();
|
|
180
|
+
const parsed = parseJsonOrSse(text);
|
|
181
|
+
if (!parsed) throw new Error("no JSON-RPC result in response");
|
|
182
|
+
if (parsed.error) throw new Error(`server error: ${parsed.error.message ?? "unknown"}`);
|
|
183
|
+
return identFrom(parsed.result);
|
|
184
|
+
} catch (e) {
|
|
185
|
+
const msg = (e as Error).name === "AbortError" ? `timeout after ${timeoutMs}ms` : (e as Error).message;
|
|
186
|
+
throw new Error(msg);
|
|
187
|
+
} finally {
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
interface RpcEnvelope {
|
|
193
|
+
result?: unknown;
|
|
194
|
+
error?: { message?: string };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Accept a plain JSON body or SSE frames (`event: …\n data: {json}`). */
|
|
198
|
+
function parseJsonOrSse(text: string): RpcEnvelope | undefined {
|
|
199
|
+
const trimmed = text.trim();
|
|
200
|
+
if (!trimmed) return undefined;
|
|
201
|
+
try {
|
|
202
|
+
return JSON.parse(trimmed) as RpcEnvelope;
|
|
203
|
+
} catch {
|
|
204
|
+
/* maybe SSE */
|
|
205
|
+
}
|
|
206
|
+
for (const line of trimmed.split("\n")) {
|
|
207
|
+
const m = /^data:\s*(.+)$/.exec(line.trim());
|
|
208
|
+
if (m) {
|
|
209
|
+
try {
|
|
210
|
+
return JSON.parse(m[1]!) as RpcEnvelope;
|
|
211
|
+
} catch {
|
|
212
|
+
/* keep scanning */
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
log.debug("unparseable MCP HTTP response:", trimmed.slice(0, 120));
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
package/src/mcp/types.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for MCP (Model Context Protocol) server inspection & control.
|
|
3
|
+
*
|
|
4
|
+
* Kiro CLI loads MCP servers from JSON config files: a global one at
|
|
5
|
+
* `~/.kiro/settings/mcp.json` (the "default" scope used by the default agent)
|
|
6
|
+
* and an optional per-workspace `<cwd>/.kiro/settings/mcp.json`. Each server may
|
|
7
|
+
* carry a `disabled` flag; toggling it enables/disables the server (applied the
|
|
8
|
+
* next time the agent (re)loads — i.e. after `/restart` or a new session).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type McpScope = "global" | "workspace";
|
|
12
|
+
export type McpTransport = "http" | "stdio" | "unknown";
|
|
13
|
+
|
|
14
|
+
/** Raw server definition as stored in an mcp.json `mcpServers` entry. */
|
|
15
|
+
export interface McpServerConfig {
|
|
16
|
+
command?: string;
|
|
17
|
+
args?: string[];
|
|
18
|
+
url?: string;
|
|
19
|
+
headers?: Record<string, string>;
|
|
20
|
+
env?: Record<string, string>;
|
|
21
|
+
timeout?: number;
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
autoApprove?: string[];
|
|
24
|
+
[k: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A configured server resolved from a specific config file. */
|
|
28
|
+
export interface McpServer {
|
|
29
|
+
name: string;
|
|
30
|
+
scope: McpScope;
|
|
31
|
+
/** Absolute path of the config file this server is defined in. */
|
|
32
|
+
configPath: string;
|
|
33
|
+
disabled: boolean;
|
|
34
|
+
transport: McpTransport;
|
|
35
|
+
/** Short transport descriptor for display (command or url, trimmed). */
|
|
36
|
+
detail: string;
|
|
37
|
+
config: McpServerConfig;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Result of a live connection probe (MCP `initialize` handshake). */
|
|
41
|
+
export interface McpProbeResult {
|
|
42
|
+
name: string;
|
|
43
|
+
ok: boolean;
|
|
44
|
+
/** Round-trip time in ms when ok. */
|
|
45
|
+
ms?: number;
|
|
46
|
+
/** Server-reported name/version when ok. */
|
|
47
|
+
serverName?: string;
|
|
48
|
+
serverVersion?: string;
|
|
49
|
+
/** Human-readable failure reason when not ok. */
|
|
50
|
+
error?: string;
|
|
51
|
+
/** True when the server was skipped because it is disabled. */
|
|
52
|
+
skipped?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function transportOf(c: McpServerConfig): McpTransport {
|
|
56
|
+
if (typeof c.url === "string" && c.url.trim()) return "http";
|
|
57
|
+
if (typeof c.command === "string" && c.command.trim()) return "stdio";
|
|
58
|
+
return "unknown";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function detailOf(c: McpServerConfig): string {
|
|
62
|
+
if (typeof c.url === "string" && c.url.trim()) return c.url.trim();
|
|
63
|
+
if (typeof c.command === "string" && c.command.trim()) {
|
|
64
|
+
const args = Array.isArray(c.args) && c.args.length ? " " + c.args.join(" ") : "";
|
|
65
|
+
return (c.command + args).trim();
|
|
66
|
+
}
|
|
67
|
+
return "(no command/url)";
|
|
68
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project manager — discovers candidate project directories under the
|
|
3
|
+
* configured roots, de-duplicated by name, with search and create helpers.
|
|
4
|
+
*/
|
|
5
|
+
import { mkdirSync, readdirSync, statSync } from "node:fs";
|
|
6
|
+
import { basename, join } from "node:path";
|
|
7
|
+
import { createLogger } from "../logger.js";
|
|
8
|
+
|
|
9
|
+
const log = createLogger("projects");
|
|
10
|
+
|
|
11
|
+
const IGNORE = new Set([
|
|
12
|
+
"node_modules",
|
|
13
|
+
".git",
|
|
14
|
+
".history",
|
|
15
|
+
"dist",
|
|
16
|
+
"build",
|
|
17
|
+
"out",
|
|
18
|
+
".cache",
|
|
19
|
+
"target",
|
|
20
|
+
".venv",
|
|
21
|
+
"__pycache__",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
export interface ProjectEntry {
|
|
25
|
+
name: string;
|
|
26
|
+
path: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class ProjectManager {
|
|
30
|
+
constructor(private readonly roots: string[]) {}
|
|
31
|
+
|
|
32
|
+
/** List projects, de-duplicated by (case-insensitive) name. */
|
|
33
|
+
list(limit = 100): ProjectEntry[] {
|
|
34
|
+
const byName = new Map<string, ProjectEntry>();
|
|
35
|
+
|
|
36
|
+
for (const root of this.roots) {
|
|
37
|
+
let children: string[];
|
|
38
|
+
try {
|
|
39
|
+
children = readdirSync(root);
|
|
40
|
+
} catch (e) {
|
|
41
|
+
log.debug(`cannot read root ${root}:`, (e as Error).message);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
for (const child of children) {
|
|
45
|
+
if (IGNORE.has(child) || child.startsWith(".")) continue;
|
|
46
|
+
const full = join(root, child);
|
|
47
|
+
try {
|
|
48
|
+
if (!statSync(full).isDirectory()) continue;
|
|
49
|
+
} catch {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const key = child.toLowerCase();
|
|
53
|
+
if (!byName.has(key)) byName.set(key, { name: child, path: full });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const out = [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
58
|
+
return out.slice(0, limit);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Projects whose name contains the query (case-insensitive). */
|
|
62
|
+
search(query: string, limit = 100): ProjectEntry[] {
|
|
63
|
+
const q = query.trim().toLowerCase();
|
|
64
|
+
if (!q) return this.list(limit);
|
|
65
|
+
return this.list(1000)
|
|
66
|
+
.filter((p) => p.name.toLowerCase().includes(q))
|
|
67
|
+
.slice(0, limit);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Create a new project folder under the first root and return it. */
|
|
71
|
+
create(name: string): ProjectEntry {
|
|
72
|
+
const clean = name.trim().replace(/[<>:"/\\|?*]/g, "_");
|
|
73
|
+
if (!clean) throw new Error("Invalid project name.");
|
|
74
|
+
const root = this.roots[0];
|
|
75
|
+
if (!root) throw new Error("No project root configured (set PROJECT_ROOTS).");
|
|
76
|
+
const full = join(root, clean);
|
|
77
|
+
mkdirSync(full, { recursive: true });
|
|
78
|
+
return { name: clean, path: full };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
isDirectory(path: string): boolean {
|
|
82
|
+
try {
|
|
83
|
+
return statSync(path).isDirectory();
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Split a MarkdownV2 string into Telegram-sized chunks (<= 4096 chars) without
|
|
3
|
+
* breaking code fences. If a split happens inside a ``` block, the block is
|
|
4
|
+
* closed before the boundary and reopened in the next chunk.
|
|
5
|
+
*/
|
|
6
|
+
const LIMIT = 4000; // headroom under Telegram's 4096 hard limit
|
|
7
|
+
|
|
8
|
+
export function chunkMarkdown(text: string, limit = LIMIT): string[] {
|
|
9
|
+
if (text.length <= limit) return text.length ? [text] : [];
|
|
10
|
+
|
|
11
|
+
const lines = text.split("\n");
|
|
12
|
+
const chunks: string[] = [];
|
|
13
|
+
let current: string[] = [];
|
|
14
|
+
let size = 0;
|
|
15
|
+
let fenceLang: string | null = null; // non-null => currently inside a fence
|
|
16
|
+
|
|
17
|
+
const flush = (): void => {
|
|
18
|
+
if (current.length === 0) return;
|
|
19
|
+
let body = current.join("\n");
|
|
20
|
+
if (fenceLang !== null) body += "\n```"; // close dangling fence
|
|
21
|
+
chunks.push(body);
|
|
22
|
+
current = [];
|
|
23
|
+
size = 0;
|
|
24
|
+
if (fenceLang !== null) {
|
|
25
|
+
// Reopen the fence at the top of the next chunk.
|
|
26
|
+
const reopen = "```" + fenceLang;
|
|
27
|
+
current.push(reopen);
|
|
28
|
+
size = reopen.length + 1;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
for (const rawLine of lines) {
|
|
33
|
+
const line = rawLine;
|
|
34
|
+
const fenceMatch = /^```(.*)$/.exec(line);
|
|
35
|
+
|
|
36
|
+
// Hard-split a single oversized line.
|
|
37
|
+
if (line.length + 1 > limit && fenceMatch === null) {
|
|
38
|
+
flush();
|
|
39
|
+
for (let i = 0; i < line.length; i += limit) {
|
|
40
|
+
chunks.push(line.slice(i, i + limit));
|
|
41
|
+
}
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (size + line.length + 1 > limit) flush();
|
|
46
|
+
|
|
47
|
+
current.push(line);
|
|
48
|
+
size += line.length + 1;
|
|
49
|
+
|
|
50
|
+
if (fenceMatch) {
|
|
51
|
+
fenceLang = fenceLang === null ? (fenceMatch[1] ?? "").trim() : null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
flush();
|
|
56
|
+
return chunks.filter((c) => c.trim().length > 0);
|
|
57
|
+
}
|