kritya 0.8.2-beta

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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +434 -0
  3. package/dist/agent/budget.js +25 -0
  4. package/dist/agent/compactor.js +78 -0
  5. package/dist/agent/contextWarning.js +6 -0
  6. package/dist/agent/killSwitch.js +111 -0
  7. package/dist/agent/loop.js +543 -0
  8. package/dist/agent/memory.js +145 -0
  9. package/dist/agent/plugins.js +408 -0
  10. package/dist/agent/skills.js +210 -0
  11. package/dist/agent/skillsCli.js +51 -0
  12. package/dist/agent/systemPrompt.js +122 -0
  13. package/dist/agent/tokens.js +55 -0
  14. package/dist/agent/toolExecutor.js +331 -0
  15. package/dist/agent/workflow.js +496 -0
  16. package/dist/agent/worktree.js +116 -0
  17. package/dist/atomicWrite.js +109 -0
  18. package/dist/audit/audit.js +230 -0
  19. package/dist/audit/cli.js +135 -0
  20. package/dist/commands/custom.js +69 -0
  21. package/dist/commands/mcpCommand.js +433 -0
  22. package/dist/commands/pluginsCommand.js +204 -0
  23. package/dist/commands/registry.js +739 -0
  24. package/dist/config/config.js +214 -0
  25. package/dist/config/debug.js +19 -0
  26. package/dist/config/models.js +82 -0
  27. package/dist/config/retention.js +24 -0
  28. package/dist/config/winAcl.js +38 -0
  29. package/dist/crash.js +102 -0
  30. package/dist/engine.js +113 -0
  31. package/dist/git/git.js +46 -0
  32. package/dist/headless.js +274 -0
  33. package/dist/hooks/hooks.js +122 -0
  34. package/dist/index.js +670 -0
  35. package/dist/lsp/client.js +413 -0
  36. package/dist/lsp/manager.js +0 -0
  37. package/dist/lsp/registry.js +62 -0
  38. package/dist/mcp/callback.js +141 -0
  39. package/dist/mcp/client.js +945 -0
  40. package/dist/mcp/login.js +117 -0
  41. package/dist/mcp/oauth.js +345 -0
  42. package/dist/mcp/servers.js +114 -0
  43. package/dist/mcp/spawnWin.js +86 -0
  44. package/dist/mcp/tokens.js +97 -0
  45. package/dist/mcp/transport.js +295 -0
  46. package/dist/net/urlSafety.js +158 -0
  47. package/dist/permissions/danger.js +96 -0
  48. package/dist/permissions/permissions.js +62 -0
  49. package/dist/permissions/rules.js +69 -0
  50. package/dist/plugins/discover.js +108 -0
  51. package/dist/plugins/mcp.js +80 -0
  52. package/dist/provider/client.js +460 -0
  53. package/dist/provider/switchyardClient.js +46 -0
  54. package/dist/provider/switchyardSidecar.js +245 -0
  55. package/dist/provider/textToolCalls.js +140 -0
  56. package/dist/repomap/repoMap.js +168 -0
  57. package/dist/repomap/symbols.js +190 -0
  58. package/dist/session/store.js +328 -0
  59. package/dist/shell/background.js +131 -0
  60. package/dist/shell/sandbox.js +396 -0
  61. package/dist/telemetry/metrics.js +172 -0
  62. package/dist/telemetry/otlp.js +128 -0
  63. package/dist/telemetry/tracer.js +240 -0
  64. package/dist/tools/askUser.js +64 -0
  65. package/dist/tools/bg.js +59 -0
  66. package/dist/tools/common.js +158 -0
  67. package/dist/tools/deepResearch.js +126 -0
  68. package/dist/tools/diff.js +70 -0
  69. package/dist/tools/document/docx.js +44 -0
  70. package/dist/tools/document/pdf.js +190 -0
  71. package/dist/tools/document/pptx.js +148 -0
  72. package/dist/tools/document/types.js +26 -0
  73. package/dist/tools/document/xlsx.js +87 -0
  74. package/dist/tools/document.js +389 -0
  75. package/dist/tools/edit.js +56 -0
  76. package/dist/tools/fetchUrl.js +218 -0
  77. package/dist/tools/fuzzyMatch.js +74 -0
  78. package/dist/tools/glob.js +34 -0
  79. package/dist/tools/grep.js +85 -0
  80. package/dist/tools/ignore.js +28 -0
  81. package/dist/tools/index.js +66 -0
  82. package/dist/tools/ls.js +27 -0
  83. package/dist/tools/lsp.js +239 -0
  84. package/dist/tools/notebook.js +215 -0
  85. package/dist/tools/read.js +32 -0
  86. package/dist/tools/repoMap.js +26 -0
  87. package/dist/tools/secretScan.js +143 -0
  88. package/dist/tools/shell.js +134 -0
  89. package/dist/tools/skills.js +69 -0
  90. package/dist/tools/subagent.js +57 -0
  91. package/dist/tools/tasks.js +43 -0
  92. package/dist/tools/webSearch.js +97 -0
  93. package/dist/tools/write.js +47 -0
  94. package/dist/tools/writeAgent.js +72 -0
  95. package/dist/trust/aiDisclosure.js +45 -0
  96. package/dist/trust/mcpTrust.js +111 -0
  97. package/dist/trust/trust.js +231 -0
  98. package/dist/types.js +1 -0
  99. package/dist/ui/AiDisclosurePrompt.js +23 -0
  100. package/dist/ui/App.js +449 -0
  101. package/dist/ui/Banner.js +71 -0
  102. package/dist/ui/ElicitationPrompt.js +46 -0
  103. package/dist/ui/Markdown.js +148 -0
  104. package/dist/ui/McpTrustPrompt.js +42 -0
  105. package/dist/ui/ModelPicker.js +19 -0
  106. package/dist/ui/PermissionPrompt.js +27 -0
  107. package/dist/ui/SelectList.js +24 -0
  108. package/dist/ui/Spinner.js +12 -0
  109. package/dist/ui/StatusLine.js +11 -0
  110. package/dist/ui/TranscriptItem.js +13 -0
  111. package/dist/ui/TrustPrompt.js +12 -0
  112. package/dist/ui/highlight.js +137 -0
  113. package/dist/ui/inline.js +241 -0
  114. package/dist/ui/mermaid.js +98 -0
  115. package/dist/ui/table.js +148 -0
  116. package/dist/ui/toolOutputPreview.js +34 -0
  117. package/dist/ui/useAgent.js +534 -0
  118. package/dist/ui/useKillSwitch.js +65 -0
  119. package/dist/ui/useSessionResume.js +39 -0
  120. package/dist/ui/useUsageBudget.js +149 -0
  121. package/dist/ui/viewport.js +71 -0
  122. package/dist/undo/undo.js +293 -0
  123. package/dist/version.js +4 -0
  124. package/package.json +122 -0
@@ -0,0 +1,413 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import { pathToFileURL } from "node:url";
4
+ import { languageIdForFile } from "./registry.js";
5
+ const REQUEST_TIMEOUT_MS = 15_000;
6
+ /**
7
+ * Minimal LSP client over stdio. Implements only what the lsp_* tools need:
8
+ * initialize, didOpen/didChange full-text sync, definition, references, and
9
+ * collecting publishDiagnostics pushes. Deliberately dependency-free — the
10
+ * framing and dispatch below is the whole protocol surface we use.
11
+ */
12
+ export class LspClient {
13
+ config;
14
+ workspace;
15
+ proc = null;
16
+ nextId = 1;
17
+ pending = new Map();
18
+ buffer = Buffer.alloc(0);
19
+ initPromise = null;
20
+ /** version + last-synced text per open document, keyed by absolute path. */
21
+ openDocs = new Map();
22
+ /** Latest diagnostics push per document URI, with a receipt counter for waiting. */
23
+ diagnostics = new Map();
24
+ diagStamp = 0;
25
+ diagWaiters = new Set();
26
+ /** Progress tokens with an active begin — nonempty means the server is still indexing. */
27
+ activeProgress = new Set();
28
+ progressWaiters = new Set();
29
+ /** Set when the server process exits or errors; the manager respawns on next use. */
30
+ dead = false;
31
+ deathReason = "";
32
+ constructor(config, workspace) {
33
+ this.config = config;
34
+ this.workspace = workspace;
35
+ }
36
+ /** Spawn the server and run the initialize handshake. Rejects if the binary is missing. */
37
+ start() {
38
+ if (!this.initPromise)
39
+ this.initPromise = this.doStart();
40
+ return this.initPromise;
41
+ }
42
+ doStart() {
43
+ return new Promise((resolve, reject) => {
44
+ const proc = spawn(this.config.command, this.config.args, {
45
+ cwd: this.workspace,
46
+ stdio: ["pipe", "pipe", "pipe"],
47
+ windowsHide: true,
48
+ });
49
+ this.proc = proc;
50
+ proc.on("error", (err) => {
51
+ this.markDead(err.code === "ENOENT"
52
+ ? `'${this.config.command}' is not installed (install: ${this.config.installHint})`
53
+ : `failed to start '${this.config.command}': ${err.message}`);
54
+ reject(new Error(this.deathReason));
55
+ });
56
+ proc.on("exit", (code) => {
57
+ this.markDead(`language server '${this.config.command}' exited (code ${code})`);
58
+ });
59
+ proc.stdout.on("data", (chunk) => this.onData(chunk));
60
+ // stderr is ignored: language servers log progress there and it is not
61
+ // useful to the model; a crash surfaces via the exit handler instead.
62
+ proc.stderr.resume();
63
+ this.request("initialize", {
64
+ processId: process.pid,
65
+ rootUri: pathToFileURL(this.workspace).href,
66
+ workspaceFolders: [{ uri: pathToFileURL(this.workspace).href, name: "workspace" }],
67
+ capabilities: {
68
+ textDocument: {
69
+ synchronization: { didSave: false },
70
+ definition: { linkSupport: true },
71
+ references: {},
72
+ hover: { contentFormat: ["markdown", "plaintext"] },
73
+ rename: { prepareSupport: false },
74
+ publishDiagnostics: {},
75
+ },
76
+ workspace: { configuration: true, workspaceFolders: true },
77
+ // Lets the server report indexing/project-load progress, which
78
+ // waitForIndexing uses to avoid answering queries from a
79
+ // half-loaded project (tsserver, gopls, and rust-analyzer all
80
+ // return incomplete references until their initial scan finishes).
81
+ window: { workDoneProgress: true },
82
+ },
83
+ })
84
+ .then(() => {
85
+ this.notify("initialized", {});
86
+ resolve();
87
+ })
88
+ .catch(reject);
89
+ });
90
+ }
91
+ markDead(reason) {
92
+ if (this.dead)
93
+ return;
94
+ this.dead = true;
95
+ this.deathReason = reason;
96
+ for (const [, p] of this.pending) {
97
+ clearTimeout(p.timer);
98
+ p.reject(new Error(reason));
99
+ }
100
+ this.pending.clear();
101
+ this.wakeDiagWaiters();
102
+ this.wakeProgressWaiters();
103
+ }
104
+ dispose() {
105
+ if (this.proc && !this.dead) {
106
+ // Fire-and-forget shutdown; kill regardless so we never hang on exit.
107
+ this.notify("exit", undefined);
108
+ this.proc.kill();
109
+ }
110
+ this.markDead("client disposed");
111
+ }
112
+ // ---- JSON-RPC transport -------------------------------------------------
113
+ send(msg) {
114
+ if (!this.proc || this.dead)
115
+ return;
116
+ const body = Buffer.from(JSON.stringify({ jsonrpc: "2.0", ...msg }), "utf8");
117
+ this.proc.stdin.write(`Content-Length: ${body.length}\r\n\r\n`);
118
+ this.proc.stdin.write(body);
119
+ }
120
+ request(method, params, timeoutMs = REQUEST_TIMEOUT_MS) {
121
+ if (this.dead)
122
+ return Promise.reject(new Error(this.deathReason));
123
+ const id = this.nextId++;
124
+ return new Promise((resolve, reject) => {
125
+ const timer = setTimeout(() => {
126
+ this.pending.delete(id);
127
+ reject(new Error(`LSP request '${method}' timed out after ${timeoutMs / 1000}s`));
128
+ }, timeoutMs);
129
+ this.pending.set(id, { resolve, reject, timer });
130
+ this.send({ id, method, params });
131
+ });
132
+ }
133
+ notify(method, params) {
134
+ this.send({ method, params });
135
+ }
136
+ onData(chunk) {
137
+ this.buffer = Buffer.concat([this.buffer, chunk]);
138
+ for (;;) {
139
+ const headerEnd = this.buffer.indexOf("\r\n\r\n");
140
+ if (headerEnd === -1)
141
+ return;
142
+ const header = this.buffer.subarray(0, headerEnd).toString("ascii");
143
+ const match = /Content-Length:\s*(\d+)/i.exec(header);
144
+ if (!match) {
145
+ // Malformed frame — drop the header and resync rather than spinning.
146
+ this.buffer = this.buffer.subarray(headerEnd + 4);
147
+ continue;
148
+ }
149
+ const length = Number(match[1]);
150
+ const bodyStart = headerEnd + 4;
151
+ if (this.buffer.length < bodyStart + length)
152
+ return;
153
+ const body = this.buffer.subarray(bodyStart, bodyStart + length).toString("utf8");
154
+ this.buffer = this.buffer.subarray(bodyStart + length);
155
+ try {
156
+ this.onMessage(JSON.parse(body));
157
+ }
158
+ catch {
159
+ // Unparseable frame from the server; skip it.
160
+ }
161
+ }
162
+ }
163
+ onMessage(msg) {
164
+ if (msg.id !== undefined && msg.method) {
165
+ this.onServerRequest(msg);
166
+ }
167
+ else if (msg.id !== undefined) {
168
+ const p = this.pending.get(Number(msg.id));
169
+ if (!p)
170
+ return;
171
+ this.pending.delete(Number(msg.id));
172
+ clearTimeout(p.timer);
173
+ if (msg.error)
174
+ p.reject(new Error(`${msg.method ?? "LSP"}: ${msg.error.message}`));
175
+ else
176
+ p.resolve(msg.result);
177
+ }
178
+ else if (msg.method === "textDocument/publishDiagnostics") {
179
+ const params = msg.params;
180
+ this.diagnostics.set(params.uri, { items: params.diagnostics, stamp: ++this.diagStamp });
181
+ this.wakeDiagWaiters();
182
+ }
183
+ else if (msg.method === "$/progress") {
184
+ const params = msg.params;
185
+ if (params.value?.kind === "begin")
186
+ this.activeProgress.add(params.token);
187
+ else if (params.value?.kind === "end")
188
+ this.activeProgress.delete(params.token);
189
+ this.wakeProgressWaiters();
190
+ }
191
+ // Other notifications (logMessage, progress, ...) are irrelevant here.
192
+ }
193
+ /** Servers block waiting for answers to their own requests — respond to the common ones. */
194
+ onServerRequest(msg) {
195
+ switch (msg.method) {
196
+ case "workspace/configuration": {
197
+ const items = msg.params?.items ?? [];
198
+ this.send({ id: msg.id, result: items.map(() => null) });
199
+ break;
200
+ }
201
+ case "client/registerCapability":
202
+ case "client/unregisterCapability":
203
+ case "window/workDoneProgress/create":
204
+ this.send({ id: msg.id, result: null });
205
+ break;
206
+ case "workspace/applyEdit":
207
+ this.send({ id: msg.id, result: { applied: false } });
208
+ break;
209
+ default:
210
+ this.send({ id: msg.id, error: { code: -32601, message: "method not supported" } });
211
+ }
212
+ }
213
+ // ---- document sync ------------------------------------------------------
214
+ /**
215
+ * Ensure the server has the current on-disk content of `absPath`.
216
+ * Returns the document URI and whether a (re)sync was actually sent —
217
+ * callers use that to know if fresh diagnostics are on the way.
218
+ */
219
+ async syncDocument(absPath) {
220
+ const uri = pathToFileURL(absPath).href;
221
+ const text = await fs.readFile(absPath, "utf8");
222
+ const open = this.openDocs.get(absPath);
223
+ if (!open) {
224
+ this.openDocs.set(absPath, { version: 1, text });
225
+ this.notify("textDocument/didOpen", {
226
+ textDocument: {
227
+ uri,
228
+ languageId: languageIdForFile(this.config, absPath),
229
+ version: 1,
230
+ text,
231
+ },
232
+ });
233
+ return { uri, changed: true };
234
+ }
235
+ if (open.text !== text) {
236
+ open.version++;
237
+ open.text = text;
238
+ this.notify("textDocument/didChange", {
239
+ textDocument: { uri, version: open.version },
240
+ contentChanges: [{ text }],
241
+ });
242
+ return { uri, changed: true };
243
+ }
244
+ return { uri, changed: false };
245
+ }
246
+ /**
247
+ * Wait for the server's active indexing/project-load progress to finish
248
+ * (bounded). Definition/references answered mid-load silently miss
249
+ * cross-file results, which is worse than a short delay. `justSynced`
250
+ * grants a grace period for the "begin" notification to arrive at all —
251
+ * project loading only starts after the first didOpen.
252
+ */
253
+ async waitForIndexing(justSynced, timeoutMs = 15_000) {
254
+ const deadline = Date.now() + timeoutMs;
255
+ if (justSynced && this.activeProgress.size === 0) {
256
+ const grace = Date.now() + 700;
257
+ while (Date.now() < grace && this.activeProgress.size === 0 && !this.dead) {
258
+ await this.waitForProgressChange(grace - Date.now());
259
+ }
260
+ }
261
+ while (this.activeProgress.size > 0 && Date.now() < deadline && !this.dead) {
262
+ await this.waitForProgressChange(deadline - Date.now());
263
+ }
264
+ }
265
+ waitForProgressChange(maxMs) {
266
+ return new Promise((resolve) => {
267
+ const done = () => {
268
+ clearTimeout(timer);
269
+ this.progressWaiters.delete(done);
270
+ resolve();
271
+ };
272
+ const timer = setTimeout(done, Math.max(50, Math.min(maxMs, 500)));
273
+ this.progressWaiters.add(done);
274
+ });
275
+ }
276
+ wakeProgressWaiters() {
277
+ const waiters = [...this.progressWaiters];
278
+ this.progressWaiters.clear();
279
+ for (const w of waiters)
280
+ w();
281
+ }
282
+ // ---- feature requests ---------------------------------------------------
283
+ async definition(absPath, position) {
284
+ const { uri, changed } = await this.syncDocument(absPath);
285
+ await this.waitForIndexing(changed);
286
+ const result = await this.request("textDocument/definition", {
287
+ textDocument: { uri },
288
+ position,
289
+ });
290
+ return normalizeLocations(result);
291
+ }
292
+ async references(absPath, position) {
293
+ const { uri, changed } = await this.syncDocument(absPath);
294
+ await this.waitForIndexing(changed);
295
+ const result = await this.request("textDocument/references", {
296
+ textDocument: { uri },
297
+ position,
298
+ context: { includeDeclaration: true },
299
+ });
300
+ return normalizeLocations(result);
301
+ }
302
+ /**
303
+ * Resolved type/signature/doc for the symbol at a position, as rendered by
304
+ * the server (markdown or plain text). Returns null when the server has
305
+ * nothing to show there.
306
+ */
307
+ async hover(absPath, position) {
308
+ const { uri, changed } = await this.syncDocument(absPath);
309
+ await this.waitForIndexing(changed);
310
+ const result = await this.request("textDocument/hover", {
311
+ textDocument: { uri },
312
+ position,
313
+ });
314
+ return extractHover(result);
315
+ }
316
+ /**
317
+ * Ask the server for a project-wide rename of the symbol at a position.
318
+ * Returns the edits grouped per file — the server does NOT touch disk, the
319
+ * caller applies them. An empty array means the symbol can't be renamed
320
+ * there (keyword, literal, or a server that refused).
321
+ */
322
+ async rename(absPath, position, newName) {
323
+ const { uri, changed } = await this.syncDocument(absPath);
324
+ await this.waitForIndexing(changed);
325
+ const result = await this.request("textDocument/rename", {
326
+ textDocument: { uri },
327
+ position,
328
+ newName,
329
+ });
330
+ return normalizeWorkspaceEdit(result);
331
+ }
332
+ /**
333
+ * Sync the file and return its diagnostics. Diagnostics are pushed by the
334
+ * server asynchronously, so after a (re)sync we wait for the next push for
335
+ * this URI, up to `timeoutMs` — servers analyzing a large project for the
336
+ * first time may deliver later, in which case the last-known set is returned.
337
+ */
338
+ async diagnosticsFor(absPath, timeoutMs = 8_000) {
339
+ const { uri, changed } = await this.syncDocument(absPath);
340
+ const before = this.diagnostics.get(uri)?.stamp ?? 0;
341
+ if (!changed && before > 0)
342
+ return this.diagnostics.get(uri).items;
343
+ const deadline = Date.now() + timeoutMs;
344
+ while (Date.now() < deadline && !this.dead) {
345
+ const current = this.diagnostics.get(uri);
346
+ if (current && current.stamp > before)
347
+ return current.items;
348
+ await this.waitForDiagPush(deadline - Date.now());
349
+ }
350
+ return this.diagnostics.get(uri)?.items ?? [];
351
+ }
352
+ waitForDiagPush(maxMs) {
353
+ return new Promise((resolve) => {
354
+ const timer = setTimeout(done, Math.max(50, Math.min(maxMs, 1000)));
355
+ const waiter = done;
356
+ this.diagWaiters.add(waiter);
357
+ function done() {
358
+ clearTimeout(timer);
359
+ resolve();
360
+ }
361
+ });
362
+ }
363
+ wakeDiagWaiters() {
364
+ const waiters = [...this.diagWaiters];
365
+ this.diagWaiters.clear();
366
+ for (const w of waiters)
367
+ w();
368
+ }
369
+ }
370
+ /** Flatten a WorkspaceEdit (either `changes` map or `documentChanges` array) to per-file edits. */
371
+ function normalizeWorkspaceEdit(result) {
372
+ if (!result || typeof result !== "object")
373
+ return [];
374
+ const edit = result;
375
+ if (edit.documentChanges) {
376
+ // documentChanges may also carry create/rename/delete ops (no textDocument); skip those.
377
+ return edit.documentChanges
378
+ .filter((c) => c.textDocument && Array.isArray(c.edits))
379
+ .map((c) => ({ uri: c.textDocument.uri, edits: c.edits }));
380
+ }
381
+ if (edit.changes) {
382
+ return Object.entries(edit.changes).map(([uri, edits]) => ({ uri, edits }));
383
+ }
384
+ return [];
385
+ }
386
+ /** Hover contents can be MarkupContent, a MarkedString, or an array of them; flatten to text. */
387
+ function extractHover(result) {
388
+ const contents = result?.contents;
389
+ if (contents === undefined || contents === null)
390
+ return null;
391
+ const parts = [];
392
+ const push = (v) => {
393
+ if (typeof v === "string")
394
+ parts.push(v);
395
+ else if (v && typeof v === "object" && "value" in v)
396
+ parts.push(String(v.value));
397
+ };
398
+ if (Array.isArray(contents))
399
+ contents.forEach(push);
400
+ else
401
+ push(contents);
402
+ const text = parts.join("\n\n").trim();
403
+ return text.length > 0 ? text : null;
404
+ }
405
+ /** Servers return Location | Location[] | LocationLink[] | null; flatten to Location[]. */
406
+ function normalizeLocations(result) {
407
+ if (!result)
408
+ return [];
409
+ const items = Array.isArray(result) ? result : [result];
410
+ return items.map((item) => "targetUri" in item
411
+ ? { uri: item.targetUri, range: item.targetSelectionRange }
412
+ : { uri: item.uri, range: item.range });
413
+ }
Binary file
@@ -0,0 +1,62 @@
1
+ import path from "node:path";
2
+ const SERVERS = [
3
+ {
4
+ id: "typescript",
5
+ command: "typescript-language-server",
6
+ args: ["--stdio"],
7
+ installHint: "npm install -g typescript-language-server typescript",
8
+ languageIds: {
9
+ ts: "typescript",
10
+ mts: "typescript",
11
+ cts: "typescript",
12
+ tsx: "typescriptreact",
13
+ js: "javascript",
14
+ mjs: "javascript",
15
+ cjs: "javascript",
16
+ jsx: "javascriptreact",
17
+ },
18
+ },
19
+ {
20
+ id: "python",
21
+ command: "pyright-langserver",
22
+ args: ["--stdio"],
23
+ installHint: "npm install -g pyright",
24
+ languageIds: { py: "python", pyi: "python" },
25
+ },
26
+ {
27
+ id: "go",
28
+ command: "gopls",
29
+ args: [],
30
+ installHint: "go install golang.org/x/tools/gopls@latest",
31
+ languageIds: { go: "go" },
32
+ },
33
+ {
34
+ id: "rust",
35
+ command: "rust-analyzer",
36
+ args: [],
37
+ installHint: "rustup component add rust-analyzer",
38
+ languageIds: { rs: "rust" },
39
+ },
40
+ {
41
+ id: "clangd",
42
+ command: "clangd",
43
+ args: [],
44
+ installHint: "install clangd via your OS package manager (apt/brew/winget)",
45
+ languageIds: { c: "c", h: "c", cpp: "cpp", hpp: "cpp", cc: "cpp", cxx: "cpp", hh: "cpp" },
46
+ },
47
+ ];
48
+ /** Find the language server responsible for a file, or null if none is known. */
49
+ export function serverForFile(filePath) {
50
+ const ext = path.extname(filePath).slice(1).toLowerCase();
51
+ if (!ext)
52
+ return null;
53
+ return SERVERS.find((s) => ext in s.languageIds) ?? null;
54
+ }
55
+ export function languageIdForFile(config, filePath) {
56
+ const ext = path.extname(filePath).slice(1).toLowerCase();
57
+ return config.languageIds[ext] ?? "plaintext";
58
+ }
59
+ /** Extensions kritya can serve at all — used for the tool description. */
60
+ export function supportedExtensions() {
61
+ return SERVERS.flatMap((s) => Object.keys(s.languageIds));
62
+ }
@@ -0,0 +1,141 @@
1
+ import { spawn } from "node:child_process";
2
+ import http from "node:http";
3
+ import os from "node:os";
4
+ import { debugLog } from "../config/debug.js";
5
+ /**
6
+ * The loopback half of the OAuth flow (RFC 8252): kritya runs in a terminal and
7
+ * must never see the user's password, so the browser does the login and hands
8
+ * the authorization code back over a one-shot HTTP server on 127.0.0.1.
9
+ *
10
+ * The listener binds an OS-assigned port on the loopback interface only, is
11
+ * created *before* dynamic client registration (the exact redirect_uri has to
12
+ * be registered, so the port must already be known), and shuts down the moment
13
+ * it has an answer. `state` is checked here rather than by the caller: a
14
+ * mismatched callback is an attempted CSRF, not a user-visible error case.
15
+ */
16
+ const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000;
17
+ function page(title, detail) {
18
+ // Deliberately dependency-free and inline-styled: this renders in the user's
19
+ // browser, and a login callback should not fetch anything from the network.
20
+ return `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title></head>
21
+ <body style="font-family:system-ui,sans-serif;max-width:32rem;margin:6rem auto;padding:0 1.5rem;line-height:1.6">
22
+ <h1 style="font-size:1.25rem;margin:0 0 .5rem">${title}</h1>
23
+ <p style="color:#555;margin:0">${detail}</p>
24
+ </body></html>`;
25
+ }
26
+ export async function startCallbackServer(state) {
27
+ let settle;
28
+ let outcome;
29
+ const finish = (result) => {
30
+ if (outcome)
31
+ return;
32
+ outcome = result;
33
+ if (!settle)
34
+ return;
35
+ if (result.code)
36
+ settle.resolve(result.code);
37
+ else
38
+ settle.reject(result.error ?? new Error("authorization failed"));
39
+ };
40
+ const server = http.createServer((req, res) => {
41
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
42
+ if (url.pathname !== "/callback") {
43
+ res.writeHead(404).end();
44
+ return;
45
+ }
46
+ const error = url.searchParams.get("error");
47
+ const code = url.searchParams.get("code");
48
+ const gotState = url.searchParams.get("state");
49
+ if (error) {
50
+ const description = url.searchParams.get("error_description") ?? error;
51
+ res.writeHead(400, { "content-type": "text/html; charset=utf-8" });
52
+ res.end(page("Login failed", description));
53
+ finish({ error: new Error(`authorization denied: ${description}`) });
54
+ return;
55
+ }
56
+ if (gotState !== state) {
57
+ // Someone else's callback, or a forged one. Say nothing useful to it.
58
+ res.writeHead(400, { "content-type": "text/html; charset=utf-8" });
59
+ res.end(page("Login failed", "State mismatch — this callback was ignored."));
60
+ finish({ error: new Error("state mismatch on OAuth callback") });
61
+ return;
62
+ }
63
+ if (!code) {
64
+ res.writeHead(400, { "content-type": "text/html; charset=utf-8" });
65
+ res.end(page("Login failed", "No authorization code was returned."));
66
+ finish({ error: new Error("no authorization code in callback") });
67
+ return;
68
+ }
69
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
70
+ res.end(page("kritya is connected", "You can close this tab and return to your terminal."));
71
+ finish({ code });
72
+ });
73
+ await new Promise((resolve, reject) => {
74
+ server.once("error", reject);
75
+ // Loopback only — never 0.0.0.0. Port 0 lets the OS pick a free one.
76
+ server.listen(0, "127.0.0.1", () => resolve());
77
+ });
78
+ const address = server.address();
79
+ const port = typeof address === "object" && address ? address.port : 0;
80
+ if (!port) {
81
+ server.close();
82
+ throw new Error("could not bind a loopback port for the OAuth callback");
83
+ }
84
+ const close = () => {
85
+ server.close();
86
+ server.closeAllConnections?.();
87
+ };
88
+ return {
89
+ redirectUri: `http://127.0.0.1:${port}/callback`,
90
+ waitForCode() {
91
+ return new Promise((resolve, reject) => {
92
+ settle = { resolve, reject };
93
+ // A callback that already arrived (fast browser, slow caller) must not
94
+ // be lost between listen() and waitForCode().
95
+ if (outcome) {
96
+ if (outcome.code)
97
+ resolve(outcome.code);
98
+ else
99
+ reject(outcome.error ?? new Error("authorization failed"));
100
+ return;
101
+ }
102
+ const timer = setTimeout(() => {
103
+ finish({ error: new Error("timed out waiting for the browser callback (5 minutes)") });
104
+ }, CALLBACK_TIMEOUT_MS);
105
+ timer.unref?.();
106
+ }).finally(close);
107
+ },
108
+ close,
109
+ };
110
+ }
111
+ /**
112
+ * Best-effort browser launch. Returns false when there is nothing to open —
113
+ * over SSH, in a container, or on a headless box — so the caller can fall back
114
+ * to printing the URL for the user to open somewhere else.
115
+ */
116
+ export function openBrowser(url) {
117
+ if (process.env.KRITYA_NO_BROWSER)
118
+ return false;
119
+ // No DISPLAY/WAYLAND_DISPLAY on Linux means no browser to hand this to.
120
+ if (os.platform() === "linux" &&
121
+ !process.env.DISPLAY &&
122
+ !process.env.WAYLAND_DISPLAY &&
123
+ !process.env.WSL_DISTRO_NAME) {
124
+ return false;
125
+ }
126
+ const [cmd, args] = os.platform() === "win32"
127
+ ? ["cmd", ["/c", "start", "", url]]
128
+ : os.platform() === "darwin"
129
+ ? ["open", [url]]
130
+ : ["xdg-open", [url]];
131
+ try {
132
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true, windowsHide: true });
133
+ child.on("error", (err) => debugLog(`openBrowser(${cmd})`, err));
134
+ child.unref();
135
+ return true;
136
+ }
137
+ catch (err) {
138
+ debugLog(`openBrowser(${cmd})`, err);
139
+ return false;
140
+ }
141
+ }