planche 0.7.20 → 0.8.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 (82) hide show
  1. package/app/assets/{AboutDialog-dKk703-Z.js → AboutDialog-CC7jb8_P.js} +1 -1
  2. package/app/assets/AiSettingsDialog-1t0_9610.js +1 -0
  3. package/app/assets/ConnectDialog-C8novVo3.js +1 -0
  4. package/app/assets/{CopyDatabaseDialog-B7w3Ek74.js → CopyDatabaseDialog-BZaC4BRf.js} +1 -1
  5. package/app/assets/CopyTableDialog-mjYPJxx8.js +1 -0
  6. package/app/assets/{CreateDatabaseDialog-Dnulabm5.js → CreateDatabaseDialog-K9LU6SFa.js} +1 -1
  7. package/app/assets/{CreateSchemaDialog-Lvff6Tdj.js → CreateSchemaDialog-IMU6kUwh.js} +1 -1
  8. package/app/assets/{DataDiffDialog-DNTu-7-T.js → DataDiffDialog-DcPx4VLE.js} +1 -1
  9. package/app/assets/{DownloadToCSVDialog-jxCkzB3J.js → DownloadToCSVDialog-cWxA73M8.js} +2 -2
  10. package/app/assets/EditSchemaDialog-UuUlGY4B.js +2 -0
  11. package/app/assets/{FindReplaceDialog-CyNlKq00.js → FindReplaceDialog-BphY4fYD.js} +1 -1
  12. package/app/assets/{FlushDialog-Cp7W6Ck1.js → FlushDialog-C1ahiGEa.js} +1 -1
  13. package/app/assets/GeneralSettingsDialog-CSoV6bfv.js +1 -0
  14. package/app/assets/{GroupByDialog-CHwsURs9.js → GroupByDialog-CO8in38J.js} +1 -1
  15. package/app/assets/{LicenseDialog-RY0SOUb1.js → LicenseDialog-d17TldUe.js} +1 -1
  16. package/app/assets/LinkColumnDialog-Dtz38QJy.js +1 -0
  17. package/app/assets/{LocalHistoryDialog-LnF86KJl.js → LocalHistoryDialog-B8WdxxF7.js} +1 -1
  18. package/app/assets/{ProcessListDialog-Jt-1b88E.js → ProcessListDialog-D0hVqOEu.js} +1 -1
  19. package/app/assets/QuerySettingsDialog-C-chLD8U.js +1 -0
  20. package/app/assets/QuickCommandPalette-BhTxG9s-.js +1 -0
  21. package/app/assets/RowFormDialog-MZivb9nJ.js +2 -0
  22. package/app/assets/SafetySettingsDialog-D4p0vAK7.js +1 -0
  23. package/app/assets/SchemaDiffDialog-BIxIvBGm.js +7 -0
  24. package/app/assets/{SchemaToHTMLDialog-Da8c-rAe.js → SchemaToHTMLDialog-CalQNcBO.js} +1 -1
  25. package/app/assets/{StatusDialog-mibIDpmA.js → StatusDialog-C49Nlkmj.js} +1 -1
  26. package/app/assets/{SyncDialog-BIDs_FZr.js → SyncDialog-DgjppEJu.js} +1 -1
  27. package/app/assets/{TermsDialog-CWw3XSw0.js → TermsDialog-CMGa972R.js} +1 -1
  28. package/app/assets/{UserAddDialog-D-TQNCsa.js → UserAddDialog-D_Ntb9Oc.js} +1 -1
  29. package/app/assets/{UserManagerDialog-DTxhu0T8.js → UserManagerDialog-BTOhBvd9.js} +1 -1
  30. package/app/assets/{VariablesDialog-CCzXtQQV.js → VariablesDialog-BTVi_JEh.js} +1 -1
  31. package/app/assets/connection-store-BnO3WqEO.js +1 -0
  32. package/app/assets/contract-W8-jEnEI.js +1 -0
  33. package/app/assets/database-BvWJHB7q.js +1 -0
  34. package/app/assets/de-DPmJYeTi.js +2 -0
  35. package/app/assets/{engine-DtZy_Zk0.js → engine-s5WfEr4n.js} +1 -1
  36. package/app/assets/error-hints-AQZemZKj.js +8 -0
  37. package/app/assets/fr-BLVbrMir.js +2 -0
  38. package/app/assets/i18n-D0mAAGED.js +2 -0
  39. package/app/assets/ja-BKUEaPbL.js +2 -0
  40. package/app/assets/ko-XOd5LtL3.js +2 -0
  41. package/app/assets/license-store-D_XwbbCs.js +1 -0
  42. package/app/assets/{main-BPskUSLM.js → main-C-Gt0g0g.js} +206 -189
  43. package/app/assets/main-C3ej0FPT.css +2 -0
  44. package/app/assets/{pro-gate-IRkx6bbE.js → pro-gate-DZATEYdR.js} +1 -1
  45. package/app/assets/{pro-upsell-lFmxmise.js → pro-upsell-akqEMS8_.js} +1 -1
  46. package/app/assets/pro-usage-C661VD02.js +1 -0
  47. package/app/assets/query-execution-CZvQYEUq.js +2 -0
  48. package/app/assets/{query-store-BR6Saeed.js → query-store-dYiA8oM5.js} +1 -1
  49. package/app/assets/{select-C3A-RS4G.js → select-DwgCtoqv.js} +1 -1
  50. package/app/assets/settings-controls-DrW8_eiX.js +1 -0
  51. package/app/assets/settings-store-BBVuUVjA.js +1 -0
  52. package/app/assets/table-copy-LtbDgIEc.js +1 -0
  53. package/app/assets/tunneling-BPoIRxJA.js +1 -0
  54. package/app/assets/zh-DNy8t_lA.js +2 -0
  55. package/app/index.html +17 -17
  56. package/dist/index.js +799 -45
  57. package/package.json +3 -1
  58. package/app/assets/ConnectDialog-DdXULzdj.js +0 -1
  59. package/app/assets/CopyTableDialog-B0kK-0Bl.js +0 -1
  60. package/app/assets/EditSchemaDialog-CFrD-8z9.js +0 -2
  61. package/app/assets/LinkColumnDialog-BD9rpsyg.js +0 -1
  62. package/app/assets/QuickCommandPalette-DEFuPaG7.js +0 -1
  63. package/app/assets/RowFormDialog-mM6FFZQr.js +0 -2
  64. package/app/assets/SafetySettingsDialog-B3MafT1S.js +0 -1
  65. package/app/assets/SchemaDiffDialog-BvNzQo5m.js +0 -7
  66. package/app/assets/connection-store-Ds9Gjt3K.js +0 -1
  67. package/app/assets/contract-D2InX341.js +0 -1
  68. package/app/assets/database-eJWHFM-2.js +0 -1
  69. package/app/assets/de-D9wNOTjH.js +0 -2
  70. package/app/assets/fr-CMT9T7_Z.js +0 -2
  71. package/app/assets/i18n-BAv7QdV3.js +0 -2
  72. package/app/assets/ja-C5Z0_8jZ.js +0 -2
  73. package/app/assets/ko-BF4ZzLce.js +0 -2
  74. package/app/assets/license-store-BfuSb6KZ.js +0 -1
  75. package/app/assets/main-DmICiyzI.css +0 -2
  76. package/app/assets/pro-usage-WSD_KOk4.js +0 -1
  77. package/app/assets/query-execution-C5QxvBZj.js +0 -7
  78. package/app/assets/settings-store-CSMhaLcw.js +0 -1
  79. package/app/assets/table-copy-BJBmJ9OO.js +0 -1
  80. package/app/assets/tunneling-rujGqkfQ.js +0 -1
  81. package/app/assets/use-engine-CxcEyIko.js +0 -1
  82. package/app/assets/zh-BpFgSEeR.js +0 -2
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // src/index.ts
4
4
  import { randomUUID as randomUUID3 } from "crypto";
5
5
  import { createRequire as createRequire2 } from "module";
6
- import { spawn } from "child_process";
6
+ import { spawn as spawn2 } from "child_process";
7
7
  import { parseArgs } from "util";
8
8
 
9
9
  // src/gateway.ts
@@ -234,8 +234,8 @@ function toClientConfig(config) {
234
234
  var SEARCH_PATH_STATEMENT = /^\s*SET\s+(SESSION\s+|LOCAL\s+)?search_path\b/i;
235
235
  var quoteIdent = (name) => `"${name.replaceAll('"', '""')}"`;
236
236
  var PostgresDriver = class {
237
- constructor(client, config) {
238
- this.client = client;
237
+ constructor(client2, config) {
238
+ this.client = client2;
239
239
  this.config = config;
240
240
  this.label = `${config.user}@${config.host}:${config.port}/${config.database}`;
241
241
  }
@@ -346,17 +346,17 @@ var PostgresDriver = class {
346
346
  };
347
347
  var postgresDriverFactory = {
348
348
  async open(config) {
349
- const client = new pg.Client(toClientConfig(config));
350
- await client.connect();
351
- client.on("error", (err) => console.error("[postgres] Connection error:", err.message));
352
- const driver = new PostgresDriver(client, config);
349
+ const client2 = new pg.Client(toClientConfig(config));
350
+ await client2.connect();
351
+ client2.on("error", (err) => console.error("[postgres] Connection error:", err.message));
352
+ const driver = new PostgresDriver(client2, config);
353
353
  await driver.loadTypeNames();
354
354
  return driver;
355
355
  },
356
356
  async test(config) {
357
- const client = new pg.Client(toClientConfig(config));
358
- await client.connect();
359
- await client.end();
357
+ const client2 = new pg.Client(toClientConfig(config));
358
+ await client2.connect();
359
+ await client2.end();
360
360
  },
361
361
  parseConfig(body) {
362
362
  const host = typeof body.host === "string" ? body.host.trim() : "";
@@ -653,8 +653,8 @@ var MssqlDriver = class {
653
653
  * decide whether it is editable and address rows by primary key.
654
654
  */
655
655
  async describe(text) {
656
- const cached2 = this.sources.get(text);
657
- if (cached2 !== void 0) return cached2;
656
+ const cached4 = this.sources.get(text);
657
+ if (cached4 !== void 0) return cached4;
658
658
  let result = null;
659
659
  try {
660
660
  const req = this.pool.request();
@@ -811,8 +811,8 @@ var OracleDriver = class {
811
811
  }
812
812
  async tableColumns(schema, table) {
813
813
  const key = `${schema}.${table}`;
814
- const cached2 = this.columns.get(key);
815
- if (cached2 !== void 0) return cached2;
814
+ const cached4 = this.columns.get(key);
815
+ if (cached4 !== void 0) return cached4;
816
816
  let out = null;
817
817
  try {
818
818
  const res = await this.conn.execute(
@@ -1065,8 +1065,8 @@ var D1Driver = class {
1065
1065
  return result;
1066
1066
  }
1067
1067
  async tableColumns(table) {
1068
- const cached2 = this.columns.get(table);
1069
- if (cached2 !== void 0) return cached2;
1068
+ const cached4 = this.columns.get(table);
1069
+ if (cached4 !== void 0) return cached4;
1070
1070
  let out = null;
1071
1071
  try {
1072
1072
  const info = await this.raw(`PRAGMA table_info("${table.replaceAll('"', '""')}")`);
@@ -1504,8 +1504,8 @@ var DoDriver = class {
1504
1504
  * like one and as a name otherwise.
1505
1505
  */
1506
1506
  targetFor(database) {
1507
- const cached2 = this.targets.get(database);
1508
- if (cached2) return cached2;
1507
+ const cached4 = this.targets.get(database);
1508
+ if (cached4) return cached4;
1509
1509
  const slash = database.indexOf("/");
1510
1510
  if (slash <= 0 || slash === database.length - 1) {
1511
1511
  throw new DoError(
@@ -1519,8 +1519,8 @@ var DoDriver = class {
1519
1519
  }
1520
1520
  async tableColumns(database, target, table) {
1521
1521
  const key = `${database} ${table}`;
1522
- const cached2 = this.columns.get(key);
1523
- if (cached2 !== void 0) return cached2;
1522
+ const cached4 = this.columns.get(key);
1523
+ if (cached4 !== void 0) return cached4;
1524
1524
  let out = null;
1525
1525
  try {
1526
1526
  const info = await this.transport.run(target, `PRAGMA table_info("${table.replaceAll('"', '""')}")`);
@@ -1975,7 +1975,7 @@ function describeSshError(err, ssh) {
1975
1975
  }
1976
1976
  async function openSshTunnel(requested, target) {
1977
1977
  const ssh = await resolveConfig(requested);
1978
- const client = new Client();
1978
+ const client2 = new Client();
1979
1979
  const connectConfig = toConnectConfig(ssh);
1980
1980
  let verifyError;
1981
1981
  connectConfig.hostVerifier = (key, verified) => {
@@ -2005,22 +2005,22 @@ async function openSshTunnel(requested, target) {
2005
2005
  });
2006
2006
  };
2007
2007
  await new Promise((resolve5, reject) => {
2008
- client.once("ready", resolve5);
2009
- client.once("error", (err) => reject(verifyError ?? describeSshError(err, ssh)));
2008
+ client2.once("ready", resolve5);
2009
+ client2.once("error", (err) => reject(verifyError ?? describeSshError(err, ssh)));
2010
2010
  try {
2011
- client.connect(connectConfig);
2011
+ client2.connect(connectConfig);
2012
2012
  } catch (err) {
2013
2013
  reject(describeSshError(err, ssh));
2014
2014
  }
2015
2015
  });
2016
- client.on("error", (err) => console.error(`[ssh] ${ssh.host}:${ssh.port}: ${err.message}`));
2016
+ client2.on("error", (err) => console.error(`[ssh] ${ssh.host}:${ssh.port}: ${err.message}`));
2017
2017
  const sockets = /* @__PURE__ */ new Set();
2018
2018
  let forwardError;
2019
2019
  const server = createServer((socket) => {
2020
2020
  sockets.add(socket);
2021
2021
  socket.once("close", () => sockets.delete(socket));
2022
2022
  socket.on("error", () => socket.destroy());
2023
- client.forwardOut(socket.localAddress ?? "127.0.0.1", socket.localPort ?? 0, target.host, target.port, (err, stream) => {
2023
+ client2.forwardOut(socket.localAddress ?? "127.0.0.1", socket.localPort ?? 0, target.host, target.port, (err, stream) => {
2024
2024
  if (err) {
2025
2025
  const local = /^(localhost|127\.0\.0\.1|::1)$/i.test(target.host);
2026
2026
  const hint = local ? `"${target.host}" is resolved on the jump host itself; use the database's real address (e.g. the RDS endpoint) as the database host` : "is the database reachable from the jump host, and does it allow TCP forwarding?";
@@ -2039,7 +2039,7 @@ async function openSshTunnel(requested, target) {
2039
2039
  server.once("error", reject);
2040
2040
  server.listen(0, "127.0.0.1", () => resolve5());
2041
2041
  }).catch((err) => {
2042
- client.end();
2042
+ client2.end();
2043
2043
  throw new TunnelError(`Could not open local tunnel port: ${err.message}`, 400);
2044
2044
  });
2045
2045
  const { port } = server.address();
@@ -2049,11 +2049,11 @@ async function openSshTunnel(requested, target) {
2049
2049
  closed = true;
2050
2050
  for (const s of sockets) s.destroy();
2051
2051
  server.close(() => {
2052
- client.end();
2052
+ client2.end();
2053
2053
  resolve5();
2054
2054
  });
2055
2055
  });
2056
- client.once("close", () => void close());
2056
+ client2.once("close", () => void close());
2057
2057
  return {
2058
2058
  host: "127.0.0.1",
2059
2059
  port,
@@ -2076,11 +2076,11 @@ var PRO_FEATURES = [
2076
2076
  "compare_schemas",
2077
2077
  "compare_data",
2078
2078
  "copy_table",
2079
- "dry_run",
2080
2079
  "federated",
2081
2080
  "scratchpad",
2082
2081
  "column_link",
2083
- "sync"
2082
+ "sync",
2083
+ "ai_explain"
2084
2084
  ];
2085
2085
 
2086
2086
  // src/usage.ts
@@ -2203,12 +2203,12 @@ function parseDriverConfig(body) {
2203
2203
  const ssh = parseSshConfig(body.ssh);
2204
2204
  return ssh ? { ...config, ssh } : config;
2205
2205
  }
2206
- async function withTunnel(config, run) {
2207
- if (isDirectDriver(config) || !config.ssh) return run(config, void 0);
2206
+ async function withTunnel(config, run2) {
2207
+ if (isDirectDriver(config) || !config.ssh) return run2(config, void 0);
2208
2208
  const tunnel = await openSshTunnel(config.ssh, { host: config.host, port: config.port });
2209
2209
  recordUsage("ssh_tunnel");
2210
2210
  try {
2211
- return await run({ ...config, host: tunnel.host, port: tunnel.port, ssh: void 0 }, tunnel);
2211
+ return await run2({ ...config, host: tunnel.host, port: tunnel.port, ssh: void 0 }, tunnel);
2212
2212
  } catch (err) {
2213
2213
  await tunnel.close();
2214
2214
  throw tunnel.forwardError ? new TunnelError(tunnel.forwardError, 400) : err;
@@ -2330,7 +2330,7 @@ function describeProfile(entry) {
2330
2330
 
2331
2331
  // src/index.ts
2332
2332
  import { existsSync as existsSync4 } from "fs";
2333
- import { join as join7, resolve as resolve4 } from "path";
2333
+ import { join as join11, resolve as resolve4 } from "path";
2334
2334
  import { fileURLToPath as fileURLToPath2 } from "url";
2335
2335
 
2336
2336
  // src/install.ts
@@ -2550,10 +2550,610 @@ async function updateNotice(current) {
2550
2550
  return isNewer(latest, current) ? `planche ${latest} is available (you have ${current}) \u2014 update with: planche upgrade` : null;
2551
2551
  }
2552
2552
 
2553
+ // src/ai-cli.ts
2554
+ import { execFile as execFileCb2, spawn } from "child_process";
2555
+ import { mkdtemp, readFile as readFile5, rm, writeFile as writeFile3 } from "fs/promises";
2556
+ import { tmpdir } from "os";
2557
+ import { join as join8 } from "path";
2558
+ import { promisify as promisify2 } from "util";
2559
+
2560
+ // src/ai-config.ts
2561
+ import { chmod, mkdir as mkdir3, readFile as readFile4, rename as rename2, unlink, writeFile as writeFile2 } from "fs/promises";
2562
+ import { homedir as homedir7 } from "os";
2563
+ import { join as join7 } from "path";
2564
+
2565
+ // ../shared/ai.ts
2566
+ var AI_DEFAULT_MODEL = {
2567
+ // Anthropic's most capable general model. Explaining a failure is the paid
2568
+ // half of the product, so the default is not chosen to be cheap.
2569
+ anthropic: "claude-opus-5",
2570
+ // Same API, same models; only where the credential comes from differs.
2571
+ "anthropic-profile": "claude-opus-5",
2572
+ openai: "gpt-4o",
2573
+ gemini: "gemini-2.5-flash",
2574
+ // Not a model but the command to run: the CLI decides the model itself.
2575
+ cli: "claude"
2576
+ };
2577
+
2578
+ // src/ai-anthropic.ts
2579
+ import { execFile as execFileCb } from "child_process";
2580
+ import { access } from "fs/promises";
2581
+ import { homedir as homedir6 } from "os";
2582
+ import { join as join6 } from "path";
2583
+ import { promisify } from "util";
2584
+ import Anthropic from "@anthropic-ai/sdk";
2585
+ function toMessages(turns) {
2586
+ const out = [];
2587
+ for (const turn of turns) {
2588
+ if (turn.role === "user") {
2589
+ out.push({ role: "user", content: turn.content });
2590
+ continue;
2591
+ }
2592
+ if (turn.role === "assistant") {
2593
+ const blocks = [];
2594
+ if (turn.content.trim()) blocks.push({ type: "text", text: turn.content });
2595
+ for (const call of turn.toolCalls ?? []) {
2596
+ blocks.push({ type: "tool_use", id: call.id, name: call.name, input: call.input });
2597
+ }
2598
+ if (blocks.length > 0) out.push({ role: "assistant", content: blocks });
2599
+ continue;
2600
+ }
2601
+ const result = {
2602
+ type: "tool_result",
2603
+ tool_use_id: turn.toolCallId,
2604
+ content: turn.content,
2605
+ ...turn.isError ? { is_error: true } : {}
2606
+ };
2607
+ const last = out[out.length - 1];
2608
+ if (last && last.role === "user" && Array.isArray(last.content)) {
2609
+ last.content.push(result);
2610
+ } else {
2611
+ out.push({ role: "user", content: [result] });
2612
+ }
2613
+ }
2614
+ return out;
2615
+ }
2616
+ var execFile = promisify(execFileCb);
2617
+ async function anthropicProfileAvailable() {
2618
+ try {
2619
+ await execFile("ant", ["auth", "status"], { timeout: 1e4 });
2620
+ return true;
2621
+ } catch {
2622
+ try {
2623
+ await access(join6(homedir6(), ".config", "anthropic"));
2624
+ return true;
2625
+ } catch {
2626
+ return false;
2627
+ }
2628
+ }
2629
+ }
2630
+ async function runAnthropic(config, req) {
2631
+ const client2 = new Anthropic({
2632
+ ...config.key ? { apiKey: config.key } : {},
2633
+ ...config.baseUrl ? { baseURL: config.baseUrl } : {}
2634
+ });
2635
+ const tools2 = (req.tools ?? []).map((tool) => ({
2636
+ name: tool.name,
2637
+ description: tool.description,
2638
+ input_schema: tool.inputSchema
2639
+ }));
2640
+ try {
2641
+ const response = await client2.messages.create({
2642
+ model: config.model,
2643
+ max_tokens: MAX_TOKENS,
2644
+ system: req.system,
2645
+ messages: toMessages(turnsOf(req)),
2646
+ ...tools2.length > 0 ? { tools: tools2 } : {}
2647
+ });
2648
+ const text = response.content.filter((block) => block.type === "text").map((block) => block.text).join("");
2649
+ const toolCalls = response.content.filter((block) => block.type === "tool_use").map((block) => ({ id: block.id, name: block.name, input: block.input ?? {} }));
2650
+ if (!text && toolCalls.length === 0) throw new AiError(502, "The model returned nothing.");
2651
+ return {
2652
+ text,
2653
+ toolCalls,
2654
+ stop: response.stop_reason === "tool_use" ? "tool_use" : response.stop_reason === "max_tokens" ? "length" : "end"
2655
+ };
2656
+ } catch (err) {
2657
+ if (err instanceof AiError) throw err;
2658
+ const noCredential = 'This machine has no Anthropic credential the SDK could use. Run "ant auth login", or set a key instead.';
2659
+ if (err instanceof Anthropic.AuthenticationError) {
2660
+ throw new AiError(401, config.key ? "The AI key was rejected." : noCredential);
2661
+ }
2662
+ if (!config.key && err instanceof Error && /resolve authentication/i.test(err.message)) {
2663
+ throw new AiError(401, noCredential);
2664
+ }
2665
+ if (err instanceof Anthropic.RateLimitError) throw new AiError(429, "The provider is rate limiting this key.");
2666
+ if (err instanceof Anthropic.BadRequestError) throw new AiError(400, err.message);
2667
+ if (err instanceof Anthropic.APIError) throw new AiError(502, `The provider answered ${err.status}: ${err.message}`);
2668
+ throw new AiError(502, err instanceof Error ? err.message : String(err));
2669
+ }
2670
+ }
2671
+
2672
+ // src/ai-config.ts
2673
+ var PLANCHE_DIR4 = join7(homedir7(), ".planche");
2674
+ var AI_FILE = join7(PLANCHE_DIR4, "ai.json");
2675
+ function envConfig() {
2676
+ const key = process.env.PLANCHE_AI_KEY?.trim() || process.env.ANTHROPIC_API_KEY?.trim() || process.env.OPENAI_API_KEY?.trim() || process.env.GEMINI_API_KEY?.trim();
2677
+ if (!key) return null;
2678
+ const named = process.env.PLANCHE_AI_PROVIDER?.trim();
2679
+ const provider = named === "openai" || named === "anthropic" || named === "gemini" || named === "cli" || named === "anthropic-profile" ? named : process.env.GEMINI_API_KEY?.trim() ? "gemini" : process.env.OPENAI_API_KEY?.trim() && !process.env.ANTHROPIC_API_KEY?.trim() ? "openai" : "anthropic";
2680
+ return {
2681
+ provider,
2682
+ model: process.env.PLANCHE_AI_MODEL?.trim() || AI_DEFAULT_MODEL[provider],
2683
+ baseUrl: process.env.PLANCHE_AI_BASE_URL?.trim() || void 0,
2684
+ key
2685
+ };
2686
+ }
2687
+ var session = null;
2688
+ var cached2;
2689
+ async function fileConfig() {
2690
+ if (cached2 !== void 0) return cached2;
2691
+ try {
2692
+ const raw = JSON.parse(await readFile4(AI_FILE, "utf8"));
2693
+ const provider = raw.provider === "openai" || raw.provider === "gemini" ? raw.provider : "anthropic";
2694
+ cached2 = typeof raw.key === "string" && raw.key ? {
2695
+ provider,
2696
+ model: typeof raw.model === "string" && raw.model ? raw.model : AI_DEFAULT_MODEL[provider],
2697
+ baseUrl: typeof raw.baseUrl === "string" && raw.baseUrl ? raw.baseUrl : void 0,
2698
+ key: raw.key
2699
+ } : null;
2700
+ } catch {
2701
+ cached2 = null;
2702
+ }
2703
+ return cached2;
2704
+ }
2705
+ async function aiConfig() {
2706
+ return envConfig() ?? session ?? await fileConfig();
2707
+ }
2708
+ async function aiConfigView() {
2709
+ const env = envConfig();
2710
+ const file = env ? null : await fileConfig();
2711
+ const config = env ?? session ?? file;
2712
+ const storage = env ? "environment" : session ? "session" : file ? "file" : "none";
2713
+ return {
2714
+ configured: config !== null,
2715
+ provider: config?.provider ?? "anthropic",
2716
+ model: config?.model ?? AI_DEFAULT_MODEL.anthropic,
2717
+ baseUrl: config?.baseUrl,
2718
+ fromEnvironment: env !== null,
2719
+ storage,
2720
+ path: AI_FILE,
2721
+ clis: await installedClis(),
2722
+ anthropicProfile: await anthropicProfileAvailable()
2723
+ };
2724
+ }
2725
+ function defaultModelFor(provider) {
2726
+ return AI_DEFAULT_MODEL[provider];
2727
+ }
2728
+ async function saveAiConfig(config, remember) {
2729
+ if (!remember) {
2730
+ session = config;
2731
+ await forgetStoredConfig();
2732
+ return false;
2733
+ }
2734
+ session = null;
2735
+ await mkdir3(PLANCHE_DIR4, { recursive: true });
2736
+ const temp = `${AI_FILE}.${process.pid}.tmp`;
2737
+ await writeFile2(temp, JSON.stringify(config, null, 2), { mode: 384 });
2738
+ await chmod(temp, 384);
2739
+ await rename2(temp, AI_FILE);
2740
+ cached2 = config;
2741
+ return true;
2742
+ }
2743
+ async function forgetStoredConfig() {
2744
+ cached2 = null;
2745
+ try {
2746
+ await unlink(AI_FILE);
2747
+ } catch {
2748
+ }
2749
+ }
2750
+ async function forgetAiConfig() {
2751
+ session = null;
2752
+ await forgetStoredConfig();
2753
+ }
2754
+ var aiConfigPath = AI_FILE;
2755
+
2756
+ // src/ai-gemini.ts
2757
+ var DEFAULT_BASE = "https://generativelanguage.googleapis.com/v1beta";
2758
+ function toContents(turns) {
2759
+ const out = [];
2760
+ for (const turn of turns) {
2761
+ if (turn.role === "user") {
2762
+ out.push({ role: "user", parts: [{ text: turn.content }] });
2763
+ } else if (turn.role === "assistant") {
2764
+ const parts = [];
2765
+ if (turn.content.trim()) parts.push({ text: turn.content });
2766
+ for (const call of turn.toolCalls ?? []) parts.push({ functionCall: { name: call.name, args: call.input } });
2767
+ if (parts.length > 0) out.push({ role: "model", parts });
2768
+ } else {
2769
+ const part = {
2770
+ functionResponse: { name: turn.toolCallId, response: { result: turn.content, ...turn.isError ? { error: true } : {} } }
2771
+ };
2772
+ const last = out[out.length - 1];
2773
+ if (last && last.role === "user" && last.parts.every((p) => p.functionResponse)) last.parts.push(part);
2774
+ else out.push({ role: "user", parts: [part] });
2775
+ }
2776
+ }
2777
+ return out;
2778
+ }
2779
+ async function runGemini(config, req) {
2780
+ const base = (config.baseUrl || DEFAULT_BASE).replace(/\/+$/, "");
2781
+ const url = `${base}/models/${encodeURIComponent(config.model)}:generateContent?key=${encodeURIComponent(config.key)}`;
2782
+ const tools2 = req.tools ?? [];
2783
+ let response;
2784
+ try {
2785
+ response = await fetch(url, {
2786
+ method: "POST",
2787
+ headers: { "content-type": "application/json" },
2788
+ body: JSON.stringify({
2789
+ systemInstruction: { parts: [{ text: req.system }] },
2790
+ contents: toContents(turnsOf(req)),
2791
+ ...tools2.length > 0 ? {
2792
+ tools: [
2793
+ {
2794
+ functionDeclarations: tools2.map((tool) => ({
2795
+ name: tool.name,
2796
+ description: tool.description,
2797
+ parameters: tool.inputSchema
2798
+ }))
2799
+ }
2800
+ ]
2801
+ } : {},
2802
+ generationConfig: {
2803
+ // A JSON mime type and function calling cannot both be asked for.
2804
+ ...tools2.length > 0 ? {} : { responseMimeType: "application/json" },
2805
+ maxOutputTokens: MAX_TOKENS
2806
+ }
2807
+ })
2808
+ });
2809
+ } catch (err) {
2810
+ throw new AiError(502, err instanceof Error ? err.message : String(err));
2811
+ }
2812
+ const data = await response.json().catch(() => null);
2813
+ if (!response.ok) {
2814
+ const message = data?.error?.message ?? `${response.status} ${response.statusText}`;
2815
+ if (response.status === 400 || response.status === 401 || response.status === 403) {
2816
+ throw new AiError(401, `The AI key was rejected: ${message}`);
2817
+ }
2818
+ if (response.status === 429) throw new AiError(429, "The provider is rate limiting this key.");
2819
+ throw new AiError(502, `The provider answered ${response.status}: ${message}`);
2820
+ }
2821
+ const candidate = data?.candidates?.[0];
2822
+ const parts = candidate?.content?.parts ?? [];
2823
+ const text = parts.map((p) => p.text ?? "").join("");
2824
+ const toolCalls = parts.filter((p) => typeof p.functionCall?.name === "string").map((p) => ({ id: p.functionCall.name, name: p.functionCall.name, input: p.functionCall.args ?? {} }));
2825
+ if (!text && toolCalls.length === 0) throw new AiError(502, "The model returned nothing.");
2826
+ return {
2827
+ text,
2828
+ toolCalls,
2829
+ stop: toolCalls.length > 0 ? "tool_use" : candidate?.finishReason === "MAX_TOKENS" ? "length" : "end"
2830
+ };
2831
+ }
2832
+
2833
+ // src/ai-openai.ts
2834
+ import OpenAI from "openai";
2835
+ function toMessages2(system, turns) {
2836
+ const out = [{ role: "system", content: system }];
2837
+ for (const turn of turns) {
2838
+ if (turn.role === "user") {
2839
+ out.push({ role: "user", content: turn.content });
2840
+ } else if (turn.role === "assistant") {
2841
+ const calls = turn.toolCalls ?? [];
2842
+ out.push({
2843
+ role: "assistant",
2844
+ content: turn.content || null,
2845
+ ...calls.length > 0 ? {
2846
+ tool_calls: calls.map((call) => ({
2847
+ id: call.id,
2848
+ type: "function",
2849
+ function: { name: call.name, arguments: JSON.stringify(call.input) }
2850
+ }))
2851
+ } : {}
2852
+ });
2853
+ } else {
2854
+ out.push({ role: "tool", tool_call_id: turn.toolCallId, content: turn.content });
2855
+ }
2856
+ }
2857
+ return out;
2858
+ }
2859
+ function parseArguments(raw) {
2860
+ try {
2861
+ const parsed = JSON.parse(raw || "{}");
2862
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
2863
+ } catch {
2864
+ return {};
2865
+ }
2866
+ }
2867
+ async function runOpenAI(config, req) {
2868
+ const client2 = new OpenAI({ apiKey: config.key, ...config.baseUrl ? { baseURL: config.baseUrl } : {} });
2869
+ const tools2 = (req.tools ?? []).map((tool) => ({
2870
+ type: "function",
2871
+ function: { name: tool.name, description: tool.description, parameters: tool.inputSchema }
2872
+ }));
2873
+ try {
2874
+ const response = await client2.chat.completions.create({
2875
+ model: config.model,
2876
+ max_completion_tokens: MAX_TOKENS,
2877
+ // JSON mode and tools are mutually exclusive here: forcing a JSON object
2878
+ // leaves the model no way to answer with a call. The one-shot prompts
2879
+ // ask for JSON, a tool run does not.
2880
+ ...tools2.length > 0 ? { tools: tools2 } : { response_format: { type: "json_object" } },
2881
+ messages: toMessages2(req.system, turnsOf(req))
2882
+ });
2883
+ const choice = response.choices[0];
2884
+ const text = choice?.message?.content ?? "";
2885
+ const toolCalls = (choice?.message?.tool_calls ?? []).filter((call) => call.type === "function").map((call) => ({ id: call.id, name: call.function.name, input: parseArguments(call.function.arguments) }));
2886
+ if (!text && toolCalls.length === 0) throw new AiError(502, "The model returned nothing.");
2887
+ return {
2888
+ text,
2889
+ toolCalls,
2890
+ stop: choice?.finish_reason === "tool_calls" ? "tool_use" : choice?.finish_reason === "length" ? "length" : "end"
2891
+ };
2892
+ } catch (err) {
2893
+ if (err instanceof AiError) throw err;
2894
+ if (err instanceof OpenAI.APIError) {
2895
+ const status = err.status === 401 || err.status === 429 || err.status === 400 ? err.status : 502;
2896
+ throw new AiError(status, err.status === 401 ? "The AI key was rejected." : err.message);
2897
+ }
2898
+ throw new AiError(502, err instanceof Error ? err.message : String(err));
2899
+ }
2900
+ }
2901
+
2902
+ // src/ai-run.ts
2903
+ var AiError = class extends Error {
2904
+ constructor(status, message) {
2905
+ super(message);
2906
+ this.status = status;
2907
+ this.name = "AiError";
2908
+ }
2909
+ status;
2910
+ };
2911
+ var MAX_TOKENS = 8e3;
2912
+ function turnsOf(req) {
2913
+ if (req.messages && req.messages.length > 0) return req.messages;
2914
+ return [{ role: "user", content: req.user ?? "" }];
2915
+ }
2916
+ async function runPrompt(req) {
2917
+ const config = await aiConfig();
2918
+ if (!config) throw new AiError(501, "No AI key is configured on this agent.");
2919
+ const model = req.model?.trim() || config.model;
2920
+ const run2 = config.provider === "cli" ? runCli : config.provider === "openai" ? runOpenAI : config.provider === "gemini" ? runGemini : runAnthropic;
2921
+ const result = await run2({ ...config, model }, req);
2922
+ return {
2923
+ success: true,
2924
+ text: result.text,
2925
+ model,
2926
+ ...result.toolCalls.length > 0 ? { toolCalls: result.toolCalls } : {},
2927
+ stop: result.stop
2928
+ };
2929
+ }
2930
+
2931
+ // src/ai-cli.ts
2932
+ var execFile2 = promisify2(execFileCb2);
2933
+ var CLI_COMMANDS = ["claude", "codex"];
2934
+ var TIMEOUT_MS = 18e4;
2935
+ var mcp = null;
2936
+ function configureCliBridge(url, token) {
2937
+ mcp = { url, token };
2938
+ }
2939
+ function mcpUrl() {
2940
+ return mcp?.url ?? null;
2941
+ }
2942
+ function isCliCommand(name) {
2943
+ return CLI_COMMANDS.includes(name);
2944
+ }
2945
+ async function installedClis() {
2946
+ const found = [];
2947
+ for (const name of CLI_COMMANDS) {
2948
+ try {
2949
+ await execFile2(name, ["--version"], { timeout: 1e4 });
2950
+ found.push(name);
2951
+ } catch {
2952
+ }
2953
+ }
2954
+ return found;
2955
+ }
2956
+ function run(command, args, prompt) {
2957
+ return new Promise((resolve5, reject) => {
2958
+ const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] });
2959
+ let out = "";
2960
+ let err = "";
2961
+ const timer2 = setTimeout(() => {
2962
+ child.kill("SIGKILL");
2963
+ reject(new AiError(504, `${command} did not answer within ${TIMEOUT_MS / 1e3}s.`));
2964
+ }, TIMEOUT_MS);
2965
+ child.stdout.on("data", (d) => out += d);
2966
+ child.stderr.on("data", (d) => err += d);
2967
+ child.on("error", (e) => {
2968
+ clearTimeout(timer2);
2969
+ reject(new AiError(501, `${command} could not be started: ${e.message}`));
2970
+ });
2971
+ child.on("close", (code) => {
2972
+ clearTimeout(timer2);
2973
+ if (code === 0) resolve5(out);
2974
+ else reject(new AiError(502, `${command} exited ${code}: ${err.trim().slice(0, 400) || "no output"}`));
2975
+ });
2976
+ child.stdin.end(prompt);
2977
+ });
2978
+ }
2979
+ function unwrap(text) {
2980
+ const fenced = text.trim().match(/^```(?:json)?\s*\n([\s\S]*?)\n```$/);
2981
+ return (fenced ? fenced[1] : text).trim();
2982
+ }
2983
+ async function runCli(config, req) {
2984
+ const command = config.model.trim();
2985
+ const wantsTools = (req.tools?.length ?? 0) > 0;
2986
+ if (wantsTools && command !== "claude") {
2987
+ throw new AiError(400, `${command} cannot be given Planche's tools. Use the claude CLI for the AI panel, or a provider with an API key.`);
2988
+ }
2989
+ if (wantsTools && !mcp) {
2990
+ throw new AiError(501, "This agent cannot offer its tools to a CLI. Restart it, or use a provider with an API key.");
2991
+ }
2992
+ if (!isCliCommand(command)) {
2993
+ throw new AiError(400, `Unknown CLI "${command}". Use one of: ${CLI_COMMANDS.join(", ")}.`);
2994
+ }
2995
+ const turns = turnsOf(req);
2996
+ const prompt = [req.system, "", turns.map((t) => "content" in t ? t.content : "").join("\n\n")].join("\n");
2997
+ let text;
2998
+ if (command === "claude") {
2999
+ if (!wantsTools) {
3000
+ text = await run("claude", ["-p"], prompt);
3001
+ } else {
3002
+ const dir = await mkdtemp(join8(tmpdir(), "planche-mcp-"));
3003
+ const file = join8(dir, "mcp.json");
3004
+ try {
3005
+ await writeFile3(
3006
+ file,
3007
+ JSON.stringify({
3008
+ mcpServers: {
3009
+ planche: { type: "http", url: mcp.url, headers: { Authorization: `Bearer ${mcp.token}` } }
3010
+ }
3011
+ }),
3012
+ { mode: 384 }
3013
+ );
3014
+ text = await run("claude", ["-p", "--strict-mcp-config", "--mcp-config", file], prompt);
3015
+ } finally {
3016
+ await rm(dir, { recursive: true, force: true });
3017
+ }
3018
+ }
3019
+ } else {
3020
+ const dir = await mkdtemp(join8(tmpdir(), "planche-cli-"));
3021
+ const file = join8(dir, "answer.txt");
3022
+ try {
3023
+ await run("codex", ["exec", "--skip-git-repo-check", "-o", file, "-"], prompt);
3024
+ text = await readFile5(file, "utf8");
3025
+ } finally {
3026
+ await rm(dir, { recursive: true, force: true });
3027
+ }
3028
+ }
3029
+ const answer = unwrap(text);
3030
+ if (!answer) throw new AiError(502, `${command} returned nothing.`);
3031
+ return { text: answer, toolCalls: [], stop: "end" };
3032
+ }
3033
+
3034
+ // src/mcp-token.ts
3035
+ import { chmod as chmod2, mkdir as mkdir4, readFile as readFile6, rm as rm2, writeFile as writeFile4 } from "fs/promises";
3036
+ import { randomBytes } from "crypto";
3037
+ import { homedir as homedir8 } from "os";
3038
+ import { join as join9 } from "path";
3039
+ var DIR = join9(homedir8(), ".planche");
3040
+ var FILE = join9(DIR, "mcp-token");
3041
+ var mcpTokenPath = FILE;
3042
+ var cached3 = null;
3043
+ async function readMcpToken() {
3044
+ if (cached3) return cached3;
3045
+ try {
3046
+ const text = (await readFile6(FILE, "utf8")).trim();
3047
+ cached3 = text || null;
3048
+ } catch {
3049
+ cached3 = null;
3050
+ }
3051
+ return cached3;
3052
+ }
3053
+ async function ensureMcpToken() {
3054
+ const existing = await readMcpToken();
3055
+ if (existing) return existing;
3056
+ const token = randomBytes(32).toString("base64url");
3057
+ await mkdir4(DIR, { recursive: true, mode: 448 });
3058
+ await writeFile4(FILE, `${token}
3059
+ `, { mode: 384 });
3060
+ await chmod2(FILE, 384);
3061
+ cached3 = token;
3062
+ return token;
3063
+ }
3064
+ async function forgetMcpToken() {
3065
+ cached3 = null;
3066
+ await rm2(FILE, { force: true });
3067
+ }
3068
+
2553
3069
  // src/server.ts
2554
3070
  import express from "express";
2555
3071
  import { createHash as createHash2, randomUUID as randomUUID2, timingSafeEqual as timingSafeEqual2 } from "crypto";
2556
3072
 
3073
+ // src/bridge.ts
3074
+ var CALL_TIMEOUT_MS = 12e4;
3075
+ var client = null;
3076
+ var tools = [];
3077
+ var pending = /* @__PURE__ */ new Map();
3078
+ var nextId = 0;
3079
+ function bridgeTools() {
3080
+ return tools;
3081
+ }
3082
+ function attachBridge(res) {
3083
+ client?.end();
3084
+ client = res;
3085
+ res.on("close", () => {
3086
+ if (client !== res) return;
3087
+ client = null;
3088
+ tools = [];
3089
+ });
3090
+ }
3091
+ function setBridgeTools(next) {
3092
+ tools = next;
3093
+ }
3094
+ function callThroughBridge(name, input) {
3095
+ const target = client;
3096
+ if (!target) {
3097
+ return Promise.resolve({ content: "Planche is not open in a browser, so its tools cannot run.", isError: true });
3098
+ }
3099
+ const id = `call-${++nextId}`;
3100
+ return new Promise((resolve5) => {
3101
+ const timer2 = setTimeout(() => {
3102
+ pending.delete(id);
3103
+ resolve5({ content: `The app did not answer "${name}" within ${CALL_TIMEOUT_MS / 1e3}s.`, isError: true });
3104
+ }, CALL_TIMEOUT_MS);
3105
+ pending.set(id, { resolve: resolve5, timer: timer2 });
3106
+ target.write(`data: ${JSON.stringify({ id, name, input })}
3107
+
3108
+ `);
3109
+ });
3110
+ }
3111
+ function resolveBridgeCall(id, content, isError) {
3112
+ const waiting = pending.get(id);
3113
+ if (!waiting) return;
3114
+ pending.delete(id);
3115
+ clearTimeout(waiting.timer);
3116
+ waiting.resolve({ content, isError });
3117
+ }
3118
+
3119
+ // src/mcp.ts
3120
+ var FALLBACK_PROTOCOL = "2025-06-18";
3121
+ async function handleMcp(req, res) {
3122
+ const msg = req.body ?? {};
3123
+ const reply = (result) => res.json({ jsonrpc: "2.0", id: msg.id, result });
3124
+ switch (msg.method) {
3125
+ case "initialize":
3126
+ reply({
3127
+ protocolVersion: typeof msg.params?.protocolVersion === "string" ? msg.params.protocolVersion : FALLBACK_PROTOCOL,
3128
+ capabilities: { tools: {} },
3129
+ serverInfo: { name: "planche", version: "1" }
3130
+ });
3131
+ return;
3132
+ case "tools/list":
3133
+ reply({
3134
+ tools: bridgeTools().map((tool) => ({
3135
+ name: tool.name,
3136
+ description: tool.description,
3137
+ inputSchema: tool.inputSchema
3138
+ }))
3139
+ });
3140
+ return;
3141
+ case "tools/call": {
3142
+ const name = typeof msg.params?.name === "string" ? msg.params.name : "";
3143
+ const args = msg.params?.arguments ?? {};
3144
+ const { content, isError } = await callThroughBridge(name, args);
3145
+ reply({ content: [{ type: "text", text: content }], isError });
3146
+ return;
3147
+ }
3148
+ default:
3149
+ if (msg.id === void 0) {
3150
+ res.status(202).end();
3151
+ return;
3152
+ }
3153
+ reply({});
3154
+ }
3155
+ }
3156
+
2557
3157
  // src/tunneling.ts
2558
3158
  import { randomUUID } from "crypto";
2559
3159
 
@@ -2844,7 +3444,7 @@ var ConnectionManager = class {
2844
3444
 
2845
3445
  // src/server.ts
2846
3446
  import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
2847
- import { join as join6 } from "path";
3447
+ import { join as join10 } from "path";
2848
3448
  function errorStatus(err) {
2849
3449
  const status = err?.status;
2850
3450
  return typeof status === "number" && status >= 400 && status < 600 ? status : 400;
@@ -2910,7 +3510,7 @@ function accessControl(allowed) {
2910
3510
  }
2911
3511
  res.setHeader("Access-Control-Allow-Origin", origin);
2912
3512
  res.setHeader("Vary", "Origin");
2913
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
3513
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
2914
3514
  res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
2915
3515
  res.setHeader("Access-Control-Max-Age", "600");
2916
3516
  if (req.headers["access-control-request-private-network"] === "true") {
@@ -2982,7 +3582,7 @@ function createAgent(options) {
2982
3582
  options.onUnpaired(pairing, origin);
2983
3583
  return true;
2984
3584
  }
2985
- app.get("/health", (req, res) => {
3585
+ app.get("/health", async (req, res) => {
2986
3586
  const body = {
2987
3587
  success: true,
2988
3588
  name: "planche",
@@ -2991,6 +3591,8 @@ function createAgent(options) {
2991
3591
  activeConnections: manager.activeConnections,
2992
3592
  uptime: process.uptime()
2993
3593
  };
3594
+ const ai = await aiConfigView();
3595
+ body.ai = { configured: ai.configured, model: ai.model, tools: true };
2994
3596
  if (options.install) body.install = options.install;
2995
3597
  if (options.gateway) body.gateway = { name: options.gateway.name, profiles: options.gateway.profiles.length };
2996
3598
  if (req.headers.authorization !== void 0) {
@@ -3015,16 +3617,29 @@ function createAgent(options) {
3015
3617
  const body = { success: true, token: options.authToken };
3016
3618
  res.json(body);
3017
3619
  });
3620
+ async function mcpAuthorized(req) {
3621
+ if (req.path !== "/mcp") return false;
3622
+ const given = bearerOf(req.headers.authorization);
3623
+ const expected = await readMcpToken();
3624
+ if (!given || !expected) return false;
3625
+ return secretEquals(given, expected);
3626
+ }
3018
3627
  app.use((req, res, next) => {
3019
3628
  if (throttle.locked(peerOf(req))) {
3020
3629
  fail(res, 429, "Too many failed authentication attempts; try again shortly");
3021
3630
  return;
3022
3631
  }
3023
- if (!authorized(req)) {
3024
- fail(res, 401, "Unauthorized: invalid or missing auth token");
3632
+ if (authorized(req)) {
3633
+ next();
3025
3634
  return;
3026
3635
  }
3027
- next();
3636
+ void mcpAuthorized(req).then((ok) => {
3637
+ if (ok) {
3638
+ next();
3639
+ return;
3640
+ }
3641
+ fail(res, 401, "Unauthorized: invalid or missing auth token");
3642
+ });
3028
3643
  });
3029
3644
  app.use(express.json({ limit: options.bodyLimit }));
3030
3645
  app.get("/profiles", (_req, res) => {
@@ -3098,6 +3713,114 @@ function createAgent(options) {
3098
3713
  await flushUsage();
3099
3714
  res.json(await usageSnapshot());
3100
3715
  });
3716
+ app.get("/ai/config", async (_req, res) => {
3717
+ res.json({ success: true, ...await aiConfigView() });
3718
+ });
3719
+ app.put("/ai/config", async (req, res) => {
3720
+ const current = await aiConfigView();
3721
+ if (current.fromEnvironment) {
3722
+ fail(res, 409, "This agent's AI settings come from its environment and cannot be changed from the app.");
3723
+ return;
3724
+ }
3725
+ const body = req.body ?? {};
3726
+ const provider = body.provider === "openai" || body.provider === "gemini" || body.provider === "cli" || body.provider === "anthropic-profile" ? body.provider : "anthropic";
3727
+ const keyless = provider === "cli" || provider === "anthropic-profile";
3728
+ const key = typeof body.key === "string" ? body.key.trim() : "";
3729
+ if (!key && !keyless) {
3730
+ fail(res, 400, "key is required");
3731
+ return;
3732
+ }
3733
+ const model = typeof body.model === "string" && body.model.trim() ? body.model.trim() : defaultModelFor(provider);
3734
+ if (provider === "cli" && !isCliCommand(model)) {
3735
+ fail(res, 400, `model must name an installed CLI: ${CLI_COMMANDS.join(", ")}`);
3736
+ return;
3737
+ }
3738
+ const baseUrl = typeof body.baseUrl === "string" ? body.baseUrl.trim() : "";
3739
+ if (baseUrl && !/^https?:\/\//i.test(baseUrl)) {
3740
+ fail(res, 400, "baseUrl must be an http(s) URL");
3741
+ return;
3742
+ }
3743
+ const written = await saveAiConfig({
3744
+ provider,
3745
+ model,
3746
+ baseUrl: baseUrl || void 0,
3747
+ key
3748
+ }, body.remember === true);
3749
+ console.log(
3750
+ provider === "cli" ? `[agent] AI answers will come from the local "${model}" CLI; no key is held.` : provider === "anthropic-profile" ? "[agent] AI answers will use this machine's own Anthropic credential; no key is held." : written ? `[agent] AI key saved to ${aiConfigPath} (owner-only). Remove it with "Forget key" in Settings, or by deleting that file.` : "[agent] AI key accepted for this session only; it is not written to disk."
3751
+ );
3752
+ res.json({ success: true, ...await aiConfigView() });
3753
+ });
3754
+ app.delete("/ai/config", async (_req, res) => {
3755
+ await forgetAiConfig();
3756
+ res.json({ success: true, ...await aiConfigView() });
3757
+ });
3758
+ app.post("/ai/run", async (req, res) => {
3759
+ const body = req.body ?? {};
3760
+ const hasTurns = Array.isArray(body.messages) && body.messages.length > 0;
3761
+ const hasUser = typeof body.user === "string" && body.user.trim() !== "";
3762
+ if (typeof body.system !== "string" || !hasTurns && !hasUser) {
3763
+ fail(res, 400, "system and one of user/messages are required");
3764
+ return;
3765
+ }
3766
+ try {
3767
+ res.json(await runPrompt({
3768
+ system: body.system,
3769
+ ...hasUser ? { user: body.user } : {},
3770
+ ...hasTurns ? { messages: body.messages } : {},
3771
+ ...Array.isArray(body.tools) && body.tools.length > 0 ? { tools: body.tools } : {},
3772
+ model: typeof body.model === "string" ? body.model : void 0
3773
+ }));
3774
+ } catch (err) {
3775
+ const e = err instanceof AiError ? err : new AiError(502, String(err));
3776
+ fail(res, e.status, e.message);
3777
+ }
3778
+ });
3779
+ app.post("/mcp", async (req, res) => {
3780
+ await handleMcp(req, res);
3781
+ });
3782
+ app.get("/bridge/events", (req, res) => {
3783
+ res.writeHead(200, {
3784
+ "content-type": "text/event-stream",
3785
+ "cache-control": "no-store",
3786
+ connection: "keep-alive"
3787
+ });
3788
+ res.write(": open\n\n");
3789
+ attachBridge(res);
3790
+ });
3791
+ app.post("/bridge/tools", (req, res) => {
3792
+ const tools2 = req.body?.tools;
3793
+ if (!Array.isArray(tools2)) {
3794
+ fail(res, 400, "tools must be an array");
3795
+ return;
3796
+ }
3797
+ setBridgeTools(tools2);
3798
+ res.json({ success: true });
3799
+ });
3800
+ app.post("/bridge/result", (req, res) => {
3801
+ const { id, content, isError } = req.body ?? {};
3802
+ if (typeof id !== "string") {
3803
+ fail(res, 400, "id is required");
3804
+ return;
3805
+ }
3806
+ resolveBridgeCall(id, typeof content === "string" ? content : "", isError === true);
3807
+ res.json({ success: true });
3808
+ });
3809
+ app.post("/mcp/setup", async (_req, res) => {
3810
+ const url = mcpUrl();
3811
+ if (!url) {
3812
+ fail(res, 503, "The agent is not listening yet.");
3813
+ return;
3814
+ }
3815
+ const body = { success: true, url, token: await ensureMcpToken(), path: mcpTokenPath };
3816
+ console.log(`[agent] MCP token handed to the app; stored at ${mcpTokenPath} (owner-only).`);
3817
+ res.json(body);
3818
+ });
3819
+ app.delete("/mcp/setup", async (_req, res) => {
3820
+ await forgetMcpToken();
3821
+ console.log(`[agent] MCP token revoked; ${mcpTokenPath} removed.`);
3822
+ res.json({ success: true });
3823
+ });
3101
3824
  app.post("/cancel", async (req, res) => {
3102
3825
  try {
3103
3826
  const connectionId = req.body?.connectionId;
@@ -3171,7 +3894,7 @@ function serveApp(app, appDir) {
3171
3894
  "form-action 'none'",
3172
3895
  "frame-ancestors 'none'"
3173
3896
  ].join("; ");
3174
- const indexFile = join6(appDir, "index.html");
3897
+ const indexFile = join10(appDir, "index.html");
3175
3898
  const indexHtml = existsSync3(indexFile) ? readFileSync3(indexFile, "utf8").replace("<head>", '<head>\n <meta name="planche-agent" content="same-origin" />') : null;
3176
3899
  const headers = (res) => {
3177
3900
  res.setHeader("Content-Security-Policy", csp);
@@ -3207,7 +3930,7 @@ var DEV_ORIGINS = [
3207
3930
  ];
3208
3931
  function bundledAppDir() {
3209
3932
  const dir = fileURLToPath2(new URL("../app/", import.meta.url));
3210
- return existsSync4(join7(dir, "index.html")) ? dir : null;
3933
+ return existsSync4(join11(dir, "index.html")) ? dir : null;
3211
3934
  }
3212
3935
  function isLoopbackUrl(url) {
3213
3936
  try {
@@ -3230,7 +3953,7 @@ async function appIsReachable(url, timeoutMs = 2500) {
3230
3953
  }
3231
3954
  function resolveAppDir(explicit) {
3232
3955
  const dir = explicit ? resolve4(explicit) : fileURLToPath2(new URL("../app/", import.meta.url));
3233
- if (!existsSync4(join7(dir, "index.html"))) {
3956
+ if (!existsSync4(join11(dir, "index.html"))) {
3234
3957
  console.error(explicit ? `--app-dir has no index.html: ${dir}` : `This build does not include the web app (${dir}). Install the published package, or pass --app-dir <dir>.`);
3235
3958
  process.exit(2);
3236
3959
  }
@@ -3242,6 +3965,7 @@ function usage() {
3242
3965
  Usage: planche [options]
3243
3966
  planche upgrade [version] Update a standalone install in place
3244
3967
  planche uninstall [--yes] Remove it again (settings in ~/.planche are kept)
3968
+ planche mcp [setup|forget] Print the MCP client configuration, or revoke its token
3245
3969
 
3246
3970
  Options:
3247
3971
  -p, --port <n> Port to listen on (default: 8888, env PLANCHE_PORT)
@@ -3284,10 +4008,36 @@ function openInBrowser(url) {
3284
4008
  try {
3285
4009
  const env = { ...process.env };
3286
4010
  delete env.PLANCHE_AUTH_TOKEN;
3287
- spawn(cmd, args, { stdio: "ignore", detached: true, env }).on("error", () => void 0).unref();
4011
+ spawn2(cmd, args, { stdio: "ignore", detached: true, env }).on("error", () => void 0).unref();
3288
4012
  } catch {
3289
4013
  }
3290
4014
  }
4015
+ async function runMcpCommand(action, port) {
4016
+ if (action === "forget") {
4017
+ await forgetMcpToken();
4018
+ console.log(`Removed ${mcpTokenPath}. Any client configured with it now gets 401.`);
4019
+ return 0;
4020
+ }
4021
+ if (action !== void 0 && action !== "setup") {
4022
+ console.error("Usage: planche mcp [setup|forget]");
4023
+ return 1;
4024
+ }
4025
+ const token = await ensureMcpToken();
4026
+ const url = `http://127.0.0.1:${port}/mcp`;
4027
+ console.log("Add Planche to an agent that speaks MCP over HTTP:\n");
4028
+ console.log(` claude mcp add --transport http planche ${url} \\
4029
+ -H "Authorization: Bearer ${token}"
4030
+ `);
4031
+ console.log("Or as JSON:\n");
4032
+ console.log(` ${JSON.stringify({ mcpServers: { planche: { type: "http", url, headers: { Authorization: `Bearer ${token}` } } } })}
4033
+ `);
4034
+ console.log(`The token is stored at ${mcpTokenPath} (owner-only) and survives restarts, unlike the`);
4035
+ console.log("agent's own token. It reaches this one route and nothing else: calls run inside the");
4036
+ console.log("open browser tab, under its guard rails, and every write stops there for approval.");
4037
+ console.log("Planche must be open with the AI panel showing, or there are no tools to call.");
4038
+ console.log(`Revoke it with "planche mcp forget".`);
4039
+ return 0;
4040
+ }
3291
4041
  async function main() {
3292
4042
  const [subcommand] = process.argv.slice(2);
3293
4043
  if (subcommand === "upgrade") {
@@ -3296,6 +4046,9 @@ async function main() {
3296
4046
  if (subcommand === "uninstall") {
3297
4047
  process.exit(await runUninstall(process.argv.includes("--yes") || process.argv.includes("-y")));
3298
4048
  }
4049
+ if (subcommand === "mcp") {
4050
+ process.exit(await runMcpCommand(process.argv[3], Number(process.env.PLANCHE_PORT ?? 8888)));
4051
+ }
3299
4052
  const major = Number(process.versions.node.split(".")[0]);
3300
4053
  if (major < 22) {
3301
4054
  console.error(`planche needs Node.js 22 or newer (you are running ${process.version}).`);
@@ -3432,6 +4185,7 @@ async function main() {
3432
4185
  console.log(`${DEFAULT_APP_URL === hostedAppUrl ? "planche.dev" : hostedAppUrl} is unreachable \u2014 serving the app bundled with this agent (v${VERSION}) instead.`);
3433
4186
  }
3434
4187
  const server = app.listen(port, listenAddr, () => {
4188
+ configureCliBridge(`http://127.0.0.1:${port}/mcp`, authToken);
3435
4189
  console.log(divider);
3436
4190
  console.log(`Planche agent ${VERSION} listening on ${agentUrl}`);
3437
4191
  console.log(divider);