planche 0.7.19 → 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 (124) hide show
  1. package/THIRD-PARTY-NOTICES.md +269 -1
  2. package/app/assets/AboutDialog-CC7jb8_P.js +1 -0
  3. package/app/assets/AiSettingsDialog-1t0_9610.js +1 -0
  4. package/app/assets/BigInteger-BlV0QR66.js +1 -0
  5. package/app/assets/ConnectDialog-C8novVo3.js +1 -0
  6. package/app/assets/CopyDatabaseDialog-BZaC4BRf.js +1 -0
  7. package/app/assets/CopyTableDialog-mjYPJxx8.js +1 -0
  8. package/app/assets/CreateDatabaseDialog-K9LU6SFa.js +1 -0
  9. package/app/assets/CreateSchemaDialog-IMU6kUwh.js +1 -0
  10. package/app/assets/DataDiffDialog-DcPx4VLE.js +1 -0
  11. package/app/assets/{DownloadToCSVDialog-BC6Sq8Ce.js → DownloadToCSVDialog-cWxA73M8.js} +2 -2
  12. package/app/assets/EditSchemaDialog-UuUlGY4B.js +2 -0
  13. package/app/assets/FindReplaceDialog-BphY4fYD.js +1 -0
  14. package/app/assets/{FlushDialog-BX0F3dJQ.js → FlushDialog-C1ahiGEa.js} +1 -1
  15. package/app/assets/GeneralSettingsDialog-CSoV6bfv.js +1 -0
  16. package/app/assets/GroupByDialog-CO8in38J.js +1 -0
  17. package/app/assets/LicenseDialog-d17TldUe.js +1 -0
  18. package/app/assets/LinkColumnDialog-Dtz38QJy.js +1 -0
  19. package/app/assets/LocalHistoryDialog-B8WdxxF7.js +1 -0
  20. package/app/assets/ProcessListDialog-D0hVqOEu.js +1 -0
  21. package/app/assets/QuerySettingsDialog-C-chLD8U.js +1 -0
  22. package/app/assets/QuickCommandPalette-BhTxG9s-.js +1 -0
  23. package/app/assets/RowFormDialog-MZivb9nJ.js +2 -0
  24. package/app/assets/SafetySettingsDialog-D4p0vAK7.js +1 -0
  25. package/app/assets/SchemaDiffDialog-BIxIvBGm.js +7 -0
  26. package/app/assets/{SchemaToHTMLDialog-DjZQwEbM.js → SchemaToHTMLDialog-CalQNcBO.js} +2 -2
  27. package/app/assets/StatusDialog-C49Nlkmj.js +1 -0
  28. package/app/assets/SyncDialog-DgjppEJu.js +1 -0
  29. package/app/assets/{TermsDialog-B53To0oi.js → TermsDialog-CMGa972R.js} +1 -1
  30. package/app/assets/UserAddDialog-D_Ntb9Oc.js +1 -0
  31. package/app/assets/UserManagerDialog-BTOhBvd9.js +1 -0
  32. package/app/assets/VariablesDialog-BTVi_JEh.js +1 -0
  33. package/app/assets/app-storage-C_1RLCyI.js +1 -0
  34. package/app/assets/button-B2EsmMJs.js +1 -0
  35. package/app/assets/chevron-up-CBnVjNE1.js +1 -0
  36. package/app/assets/connection-store-BnO3WqEO.js +1 -0
  37. package/app/assets/contract-W8-jEnEI.js +1 -0
  38. package/app/assets/database-BvWJHB7q.js +1 -0
  39. package/app/assets/de-DPmJYeTi.js +2 -0
  40. package/app/assets/dialog-FBpgH0QJ.js +1 -0
  41. package/app/assets/dist-BUXw8brT.js +1 -0
  42. package/app/assets/dist-BYhFWvmC.js +41 -0
  43. package/app/assets/dist-BrzYmjQV.js +1 -0
  44. package/app/assets/dist-C2vlTGNz.js +1 -0
  45. package/app/assets/editor-registry-ukUyoekH.js +1 -0
  46. package/app/assets/{engine-CpwlM_Ww.js → engine-s5WfEr4n.js} +1 -1
  47. package/app/assets/error-hints-AQZemZKj.js +8 -0
  48. package/app/assets/fr-BLVbrMir.js +2 -0
  49. package/app/assets/hmr-singleton-Bit0Kfog.js +1 -0
  50. package/app/assets/i18n-D0mAAGED.js +2 -0
  51. package/app/assets/ja-BKUEaPbL.js +2 -0
  52. package/app/assets/ko-XOd5LtL3.js +2 -0
  53. package/app/assets/license-store-D_XwbbCs.js +1 -0
  54. package/app/assets/{main-BLydvBDF.js → main-C-Gt0g0g.js} +214 -196
  55. package/app/assets/main-C3ej0FPT.css +2 -0
  56. package/app/assets/mariadb-BT1adDMU.js +17 -0
  57. package/app/assets/mysql-BEg8tI_S.js +17 -0
  58. package/app/assets/postgresql-erqhE-5Q.js +19 -0
  59. package/app/assets/pro-gate-DZATEYdR.js +1 -0
  60. package/app/assets/pro-upsell-akqEMS8_.js +1 -0
  61. package/app/assets/pro-usage-C661VD02.js +1 -0
  62. package/app/assets/query-execution-CZvQYEUq.js +2 -0
  63. package/app/assets/query-store-dYiA8oM5.js +1 -0
  64. package/app/assets/rolldown-runtime-hePW80VL.js +1 -0
  65. package/app/assets/select-DwgCtoqv.js +1 -0
  66. package/app/assets/settings-controls-DrW8_eiX.js +1 -0
  67. package/app/assets/settings-store-BBVuUVjA.js +1 -0
  68. package/app/assets/table-copy-LtbDgIEc.js +1 -0
  69. package/app/assets/transactsql-BVCsopZt.js +16 -0
  70. package/app/assets/tunneling-BPoIRxJA.js +1 -0
  71. package/app/assets/ui-store-5IssrgmG.js +1 -0
  72. package/app/assets/{user-store-l9ZslFwK.js → user-store-DOy2VMsT.js} +1 -1
  73. package/app/assets/zh-DNy8t_lA.js +2 -0
  74. package/app/index.html +29 -20
  75. package/app/landing.css +96 -0
  76. package/app/third-party-notices.txt +269 -1
  77. package/dist/index.js +866 -54
  78. package/package.json +3 -1
  79. package/app/assets/AboutDialog-BaQAZjtT.js +0 -1
  80. package/app/assets/ConnectDialog-BmJvDi1P.js +0 -1
  81. package/app/assets/CopyDatabaseDialog-BEM-56Og.js +0 -1
  82. package/app/assets/CopyTableDialog-C2r7l2Ck.js +0 -1
  83. package/app/assets/CreateDatabaseDialog-CZhIpgiO.js +0 -1
  84. package/app/assets/CreateSchemaDialog-CNq4nZum.js +0 -1
  85. package/app/assets/DataDiffDialog-mUg2rIWn.js +0 -1
  86. package/app/assets/EditSchemaDialog-B0dCI4Lb.js +0 -2
  87. package/app/assets/FindReplaceDialog-DXJp2TN6.js +0 -1
  88. package/app/assets/GroupByDialog-BQPTetJF.js +0 -1
  89. package/app/assets/LinkColumnDialog-BEuhj8o8.js +0 -1
  90. package/app/assets/LocalHistoryDialog-BGF91HKS.js +0 -1
  91. package/app/assets/ProcessListDialog-VezdyrHo.js +0 -1
  92. package/app/assets/QuickCommandPalette-BseiwcpU.js +0 -1
  93. package/app/assets/RowFormDialog-XRuG3rMt.js +0 -2
  94. package/app/assets/SafetySettingsDialog-Zh_a4nml.js +0 -1
  95. package/app/assets/SchemaDiffDialog-BrnruiVP.js +0 -7
  96. package/app/assets/StatusDialog-a7NhoFvt.js +0 -1
  97. package/app/assets/UserAddDialog-BBYoKeBv.js +0 -1
  98. package/app/assets/UserManagerDialog-BPC_IoO6.js +0 -1
  99. package/app/assets/VariablesDialog-CzHDGRpy.js +0 -1
  100. package/app/assets/button--s6nUzD8.js +0 -1
  101. package/app/assets/chevron-up-QmvRX9Fw.js +0 -1
  102. package/app/assets/connection-store-N-ZwIkVg.js +0 -1
  103. package/app/assets/database-CpQt6YGv.js +0 -1
  104. package/app/assets/de-Ci_btQN-.js +0 -2
  105. package/app/assets/dialog-CJoWJzRo.js +0 -1
  106. package/app/assets/dist-BAFbxkKh.js +0 -1
  107. package/app/assets/dist-CBYDYlja.js +0 -41
  108. package/app/assets/dist-DgJHEXPr.js +0 -1
  109. package/app/assets/dist-pKt0y9E-.js +0 -1
  110. package/app/assets/editor-registry-DhF4Rf_I.js +0 -1
  111. package/app/assets/fr-rLx93rAO.js +0 -2
  112. package/app/assets/hmr-singleton-DWC1xbJ4.js +0 -1
  113. package/app/assets/i18n-57fo5jb0.js +0 -2
  114. package/app/assets/ja-DkiQpTGb.js +0 -2
  115. package/app/assets/ko-DHI7mstP.js +0 -2
  116. package/app/assets/main-DX7Tr5cg.css +0 -2
  117. package/app/assets/pro-usage-VxPxjJqt.js +0 -1
  118. package/app/assets/query-execution-4-JHu9gp.js +0 -7
  119. package/app/assets/select-ibmUj7AE.js +0 -1
  120. package/app/assets/settings-store-WUA0-UcP.js +0 -1
  121. package/app/assets/table-copy-C1ivtmj8.js +0 -1
  122. package/app/assets/ui-store-DfspmPOn.js +0 -1
  123. package/app/assets/use-engine-B9GBHTL_.js +0 -1
  124. package/app/assets/zh-mgKu2Zwj.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(
@@ -915,6 +915,23 @@ var oracleDriverFactory = {
915
915
 
916
916
  // src/drivers/d1.ts
917
917
  var API = "https://api.cloudflare.com/client/v4";
918
+ var LOCAL_API = "/cdn-cgi/local/explorer/api";
919
+ var SELECT_STAR = /^\s*SELECT\s+(?:DISTINCT\s+)?\*/i;
920
+ var localRawUrl = (origin, databaseId) => `${origin}${LOCAL_API}/d1/database/${encodeURIComponent(databaseId)}/raw`;
921
+ var LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1", "0.0.0.0"]);
922
+ function parseDevUrl(raw) {
923
+ let url;
924
+ try {
925
+ url = new URL(raw);
926
+ } catch {
927
+ throw new Error(`Not a valid URL: ${raw}`);
928
+ }
929
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("devUrl must be an http(s) URL");
930
+ if (!LOOPBACK.has(url.hostname.toLowerCase())) {
931
+ throw new Error("devUrl must point at a wrangler dev server on this machine (localhost).");
932
+ }
933
+ return url.origin;
934
+ }
918
935
  var D1Error = class extends Error {
919
936
  constructor(message, status) {
920
937
  super(message);
@@ -958,6 +975,7 @@ function firstNonNull2(records, index) {
958
975
  var D1Driver = class {
959
976
  type = "d1";
960
977
  label;
978
+ /** Not readonly: a local database resolves its id lazily (see `resolveUrl`). */
961
979
  url;
962
980
  headers;
963
981
  /** Declared column types per table, for result-source resolution. */
@@ -972,18 +990,53 @@ var D1Driver = class {
972
990
  async cancel() {
973
991
  this.inFlight?.abort();
974
992
  }
993
+ /** Local databases resolve their id on first use; the remote ones know it up front. */
994
+ local;
995
+ resolving = null;
975
996
  constructor(config) {
976
- this.label = `d1:${config.databaseId}`;
977
- this.url = `${API}/accounts/${encodeURIComponent(config.accountId)}/d1/database/${encodeURIComponent(config.databaseId)}/raw`;
978
- this.headers = { Authorization: `Bearer ${config.apiToken}`, "Content-Type": "application/json" };
997
+ this.local = config.devUrl ?? null;
998
+ this.label = `d1:${config.databaseId || this.local || ""}`;
999
+ if (this.local) {
1000
+ this.headers = { "Content-Type": "application/json" };
1001
+ this.url = config.databaseId ? localRawUrl(this.local, config.databaseId) : "";
1002
+ } else {
1003
+ this.headers = { Authorization: `Bearer ${config.apiToken ?? ""}`, "Content-Type": "application/json" };
1004
+ this.url = `${API}/accounts/${encodeURIComponent(config.accountId ?? "")}/d1/database/${encodeURIComponent(config.databaseId ?? "")}/raw`;
1005
+ }
1006
+ }
1007
+ /**
1008
+ * A local dev server usually binds one database, so the connection form does
1009
+ * not have to ask for a uuid it would only copy from wrangler's config.
1010
+ */
1011
+ async resolveUrl() {
1012
+ if (this.url) return this.url;
1013
+ this.resolving ??= (async () => {
1014
+ const listed = await fetch(`${this.local}${LOCAL_API}/d1/database`, { signal: AbortSignal.timeout(1e4) });
1015
+ if (!listed.ok) throw new D1Error(`No wrangler dev server answering at ${this.local}`, 400);
1016
+ const body = await listed.json();
1017
+ const bound = body.result ?? [];
1018
+ if (bound.length === 0) throw new D1Error("That wrangler dev server has no D1 database bound.", 400);
1019
+ const databases = [...new Map(bound.map((d) => [d.uuid ?? "", d])).values()];
1020
+ if (databases.length > 1) {
1021
+ const choices = databases.map((d) => `${d.name ?? "?"} (${d.uuid ?? "?"})`).join(", ");
1022
+ throw new D1Error(
1023
+ `That dev server binds ${databases.length} databases; put one of these ids in the Database ID field: ${choices}`,
1024
+ 400
1025
+ );
1026
+ }
1027
+ this.url = localRawUrl(this.local, databases[0].uuid ?? "");
1028
+ return this.url;
1029
+ })();
1030
+ return this.resolving;
979
1031
  }
980
1032
  async raw(sql2) {
1033
+ const url = this.url || await this.resolveUrl();
981
1034
  let res;
982
1035
  try {
983
1036
  const controller = new AbortController();
984
1037
  this.inFlight = controller;
985
1038
  try {
986
- res = await fetch(this.url, {
1039
+ res = await fetch(url, {
987
1040
  method: "POST",
988
1041
  headers: this.headers,
989
1042
  body: JSON.stringify({ sql: sql2 }),
@@ -1012,8 +1065,8 @@ var D1Driver = class {
1012
1065
  return result;
1013
1066
  }
1014
1067
  async tableColumns(table) {
1015
- const cached2 = this.columns.get(table);
1016
- if (cached2 !== void 0) return cached2;
1068
+ const cached4 = this.columns.get(table);
1069
+ if (cached4 !== void 0) return cached4;
1017
1070
  let out = null;
1018
1071
  try {
1019
1072
  const info = await this.raw(`PRAGMA table_info("${table.replaceAll('"', '""')}")`);
@@ -1064,7 +1117,8 @@ var D1Driver = class {
1064
1117
  }
1065
1118
  const source = RESULT_KIND.test(text) ? singleSourceTable2(text) : null;
1066
1119
  const known = source ? await this.tableColumns(source) : null;
1067
- const fields = columns.map((name, index) => {
1120
+ const names = columns.length > 0 ? columns : known && SELECT_STAR.test(text) ? [...known.keys()] : [];
1121
+ const fields = names.map((name, index) => {
1068
1122
  const fromTable = known?.has(name) ?? false;
1069
1123
  const type = (fromTable ? known?.get(name) : null) ?? inferTypeFromValue2(firstNonNull2(records, index));
1070
1124
  return {
@@ -1099,8 +1153,10 @@ var d1DriverFactory = {
1099
1153
  const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
1100
1154
  const databaseId = typeof body.databaseId === "string" ? body.databaseId.trim() : "";
1101
1155
  const apiToken = typeof body.apiToken === "string" ? body.apiToken.trim() : "";
1156
+ const devUrl = typeof body.devUrl === "string" ? body.devUrl.trim() : "";
1157
+ if (devUrl) return { driver: "d1", devUrl: parseDevUrl(devUrl), databaseId: databaseId || void 0 };
1102
1158
  if (!accountId || !databaseId || !apiToken) {
1103
- throw new Error("accountId, databaseId and apiToken are required");
1159
+ throw new Error("accountId, databaseId and apiToken are required (or devUrl for a local wrangler dev database)");
1104
1160
  }
1105
1161
  return { driver: "d1", accountId, databaseId, apiToken };
1106
1162
  }
@@ -1448,8 +1504,8 @@ var DoDriver = class {
1448
1504
  * like one and as a name otherwise.
1449
1505
  */
1450
1506
  targetFor(database) {
1451
- const cached2 = this.targets.get(database);
1452
- if (cached2) return cached2;
1507
+ const cached4 = this.targets.get(database);
1508
+ if (cached4) return cached4;
1453
1509
  const slash = database.indexOf("/");
1454
1510
  if (slash <= 0 || slash === database.length - 1) {
1455
1511
  throw new DoError(
@@ -1463,8 +1519,8 @@ var DoDriver = class {
1463
1519
  }
1464
1520
  async tableColumns(database, target, table) {
1465
1521
  const key = `${database} ${table}`;
1466
- const cached2 = this.columns.get(key);
1467
- if (cached2 !== void 0) return cached2;
1522
+ const cached4 = this.columns.get(key);
1523
+ if (cached4 !== void 0) return cached4;
1468
1524
  let out = null;
1469
1525
  try {
1470
1526
  const info = await this.transport.run(target, `PRAGMA table_info("${table.replaceAll('"', '""')}")`);
@@ -1551,8 +1607,8 @@ var DoDriver = class {
1551
1607
  this.columns.clear();
1552
1608
  }
1553
1609
  };
1554
- var LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1", "0.0.0.0"]);
1555
- function parseDevUrl(raw) {
1610
+ var LOOPBACK2 = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1", "0.0.0.0"]);
1611
+ function parseDevUrl2(raw) {
1556
1612
  let url;
1557
1613
  try {
1558
1614
  url = new URL(raw);
@@ -1562,7 +1618,7 @@ function parseDevUrl(raw) {
1562
1618
  if (url.protocol !== "http:" && url.protocol !== "https:") {
1563
1619
  throw new Error("devUrl must be an http(s) URL");
1564
1620
  }
1565
- if (!LOOPBACK.has(url.hostname.toLowerCase())) {
1621
+ if (!LOOPBACK2.has(url.hostname.toLowerCase())) {
1566
1622
  throw new Error(
1567
1623
  "devUrl must point at a wrangler dev server on this machine (localhost). Use the deployed Durable Objects connection for objects that are not local."
1568
1624
  );
@@ -1581,7 +1637,7 @@ var doDriverFactory = {
1581
1637
  parseConfig(body) {
1582
1638
  const devUrl = typeof body.devUrl === "string" ? body.devUrl.trim() : "";
1583
1639
  if (!devUrl) throw new Error("devUrl is required");
1584
- return { driver: "do", devUrl: parseDevUrl(devUrl) };
1640
+ return { driver: "do", devUrl: parseDevUrl2(devUrl) };
1585
1641
  }
1586
1642
  };
1587
1643
  var doRemoteDriverFactory = {
@@ -1919,7 +1975,7 @@ function describeSshError(err, ssh) {
1919
1975
  }
1920
1976
  async function openSshTunnel(requested, target) {
1921
1977
  const ssh = await resolveConfig(requested);
1922
- const client = new Client();
1978
+ const client2 = new Client();
1923
1979
  const connectConfig = toConnectConfig(ssh);
1924
1980
  let verifyError;
1925
1981
  connectConfig.hostVerifier = (key, verified) => {
@@ -1949,22 +2005,22 @@ async function openSshTunnel(requested, target) {
1949
2005
  });
1950
2006
  };
1951
2007
  await new Promise((resolve5, reject) => {
1952
- client.once("ready", resolve5);
1953
- client.once("error", (err) => reject(verifyError ?? describeSshError(err, ssh)));
2008
+ client2.once("ready", resolve5);
2009
+ client2.once("error", (err) => reject(verifyError ?? describeSshError(err, ssh)));
1954
2010
  try {
1955
- client.connect(connectConfig);
2011
+ client2.connect(connectConfig);
1956
2012
  } catch (err) {
1957
2013
  reject(describeSshError(err, ssh));
1958
2014
  }
1959
2015
  });
1960
- 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}`));
1961
2017
  const sockets = /* @__PURE__ */ new Set();
1962
2018
  let forwardError;
1963
2019
  const server = createServer((socket) => {
1964
2020
  sockets.add(socket);
1965
2021
  socket.once("close", () => sockets.delete(socket));
1966
2022
  socket.on("error", () => socket.destroy());
1967
- 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) => {
1968
2024
  if (err) {
1969
2025
  const local = /^(localhost|127\.0\.0\.1|::1)$/i.test(target.host);
1970
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?";
@@ -1983,7 +2039,7 @@ async function openSshTunnel(requested, target) {
1983
2039
  server.once("error", reject);
1984
2040
  server.listen(0, "127.0.0.1", () => resolve5());
1985
2041
  }).catch((err) => {
1986
- client.end();
2042
+ client2.end();
1987
2043
  throw new TunnelError(`Could not open local tunnel port: ${err.message}`, 400);
1988
2044
  });
1989
2045
  const { port } = server.address();
@@ -1993,11 +2049,11 @@ async function openSshTunnel(requested, target) {
1993
2049
  closed = true;
1994
2050
  for (const s of sockets) s.destroy();
1995
2051
  server.close(() => {
1996
- client.end();
2052
+ client2.end();
1997
2053
  resolve5();
1998
2054
  });
1999
2055
  });
2000
- client.once("close", () => void close());
2056
+ client2.once("close", () => void close());
2001
2057
  return {
2002
2058
  host: "127.0.0.1",
2003
2059
  port,
@@ -2022,7 +2078,9 @@ var PRO_FEATURES = [
2022
2078
  "copy_table",
2023
2079
  "federated",
2024
2080
  "scratchpad",
2025
- "column_link"
2081
+ "column_link",
2082
+ "sync",
2083
+ "ai_explain"
2026
2084
  ];
2027
2085
 
2028
2086
  // src/usage.ts
@@ -2145,12 +2203,12 @@ function parseDriverConfig(body) {
2145
2203
  const ssh = parseSshConfig(body.ssh);
2146
2204
  return ssh ? { ...config, ssh } : config;
2147
2205
  }
2148
- async function withTunnel(config, run) {
2149
- 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);
2150
2208
  const tunnel = await openSshTunnel(config.ssh, { host: config.host, port: config.port });
2151
2209
  recordUsage("ssh_tunnel");
2152
2210
  try {
2153
- 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);
2154
2212
  } catch (err) {
2155
2213
  await tunnel.close();
2156
2214
  throw tunnel.forwardError ? new TunnelError(tunnel.forwardError, 400) : err;
@@ -2272,7 +2330,7 @@ function describeProfile(entry) {
2272
2330
 
2273
2331
  // src/index.ts
2274
2332
  import { existsSync as existsSync4 } from "fs";
2275
- import { join as join7, resolve as resolve4 } from "path";
2333
+ import { join as join11, resolve as resolve4 } from "path";
2276
2334
  import { fileURLToPath as fileURLToPath2 } from "url";
2277
2335
 
2278
2336
  // src/install.ts
@@ -2492,10 +2550,610 @@ async function updateNotice(current) {
2492
2550
  return isNewer(latest, current) ? `planche ${latest} is available (you have ${current}) \u2014 update with: planche upgrade` : null;
2493
2551
  }
2494
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
+
2495
3069
  // src/server.ts
2496
3070
  import express from "express";
2497
3071
  import { createHash as createHash2, randomUUID as randomUUID2, timingSafeEqual as timingSafeEqual2 } from "crypto";
2498
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
+
2499
3157
  // src/tunneling.ts
2500
3158
  import { randomUUID } from "crypto";
2501
3159
 
@@ -2786,7 +3444,7 @@ var ConnectionManager = class {
2786
3444
 
2787
3445
  // src/server.ts
2788
3446
  import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
2789
- import { join as join6 } from "path";
3447
+ import { join as join10 } from "path";
2790
3448
  function errorStatus(err) {
2791
3449
  const status = err?.status;
2792
3450
  return typeof status === "number" && status >= 400 && status < 600 ? status : 400;
@@ -2852,7 +3510,7 @@ function accessControl(allowed) {
2852
3510
  }
2853
3511
  res.setHeader("Access-Control-Allow-Origin", origin);
2854
3512
  res.setHeader("Vary", "Origin");
2855
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
3513
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
2856
3514
  res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
2857
3515
  res.setHeader("Access-Control-Max-Age", "600");
2858
3516
  if (req.headers["access-control-request-private-network"] === "true") {
@@ -2924,7 +3582,7 @@ function createAgent(options) {
2924
3582
  options.onUnpaired(pairing, origin);
2925
3583
  return true;
2926
3584
  }
2927
- app.get("/health", (req, res) => {
3585
+ app.get("/health", async (req, res) => {
2928
3586
  const body = {
2929
3587
  success: true,
2930
3588
  name: "planche",
@@ -2933,6 +3591,8 @@ function createAgent(options) {
2933
3591
  activeConnections: manager.activeConnections,
2934
3592
  uptime: process.uptime()
2935
3593
  };
3594
+ const ai = await aiConfigView();
3595
+ body.ai = { configured: ai.configured, model: ai.model, tools: true };
2936
3596
  if (options.install) body.install = options.install;
2937
3597
  if (options.gateway) body.gateway = { name: options.gateway.name, profiles: options.gateway.profiles.length };
2938
3598
  if (req.headers.authorization !== void 0) {
@@ -2957,16 +3617,29 @@ function createAgent(options) {
2957
3617
  const body = { success: true, token: options.authToken };
2958
3618
  res.json(body);
2959
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
+ }
2960
3627
  app.use((req, res, next) => {
2961
3628
  if (throttle.locked(peerOf(req))) {
2962
3629
  fail(res, 429, "Too many failed authentication attempts; try again shortly");
2963
3630
  return;
2964
3631
  }
2965
- if (!authorized(req)) {
2966
- fail(res, 401, "Unauthorized: invalid or missing auth token");
3632
+ if (authorized(req)) {
3633
+ next();
2967
3634
  return;
2968
3635
  }
2969
- 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
+ });
2970
3643
  });
2971
3644
  app.use(express.json({ limit: options.bodyLimit }));
2972
3645
  app.get("/profiles", (_req, res) => {
@@ -3040,6 +3713,114 @@ function createAgent(options) {
3040
3713
  await flushUsage();
3041
3714
  res.json(await usageSnapshot());
3042
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
+ });
3043
3824
  app.post("/cancel", async (req, res) => {
3044
3825
  try {
3045
3826
  const connectionId = req.body?.connectionId;
@@ -3113,7 +3894,7 @@ function serveApp(app, appDir) {
3113
3894
  "form-action 'none'",
3114
3895
  "frame-ancestors 'none'"
3115
3896
  ].join("; ");
3116
- const indexFile = join6(appDir, "index.html");
3897
+ const indexFile = join10(appDir, "index.html");
3117
3898
  const indexHtml = existsSync3(indexFile) ? readFileSync3(indexFile, "utf8").replace("<head>", '<head>\n <meta name="planche-agent" content="same-origin" />') : null;
3118
3899
  const headers = (res) => {
3119
3900
  res.setHeader("Content-Security-Policy", csp);
@@ -3149,7 +3930,7 @@ var DEV_ORIGINS = [
3149
3930
  ];
3150
3931
  function bundledAppDir() {
3151
3932
  const dir = fileURLToPath2(new URL("../app/", import.meta.url));
3152
- return existsSync4(join7(dir, "index.html")) ? dir : null;
3933
+ return existsSync4(join11(dir, "index.html")) ? dir : null;
3153
3934
  }
3154
3935
  function isLoopbackUrl(url) {
3155
3936
  try {
@@ -3172,7 +3953,7 @@ async function appIsReachable(url, timeoutMs = 2500) {
3172
3953
  }
3173
3954
  function resolveAppDir(explicit) {
3174
3955
  const dir = explicit ? resolve4(explicit) : fileURLToPath2(new URL("../app/", import.meta.url));
3175
- if (!existsSync4(join7(dir, "index.html"))) {
3956
+ if (!existsSync4(join11(dir, "index.html"))) {
3176
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>.`);
3177
3958
  process.exit(2);
3178
3959
  }
@@ -3184,6 +3965,7 @@ function usage() {
3184
3965
  Usage: planche [options]
3185
3966
  planche upgrade [version] Update a standalone install in place
3186
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
3187
3969
 
3188
3970
  Options:
3189
3971
  -p, --port <n> Port to listen on (default: 8888, env PLANCHE_PORT)
@@ -3226,10 +4008,36 @@ function openInBrowser(url) {
3226
4008
  try {
3227
4009
  const env = { ...process.env };
3228
4010
  delete env.PLANCHE_AUTH_TOKEN;
3229
- 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();
3230
4012
  } catch {
3231
4013
  }
3232
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
+ }
3233
4041
  async function main() {
3234
4042
  const [subcommand] = process.argv.slice(2);
3235
4043
  if (subcommand === "upgrade") {
@@ -3238,6 +4046,9 @@ async function main() {
3238
4046
  if (subcommand === "uninstall") {
3239
4047
  process.exit(await runUninstall(process.argv.includes("--yes") || process.argv.includes("-y")));
3240
4048
  }
4049
+ if (subcommand === "mcp") {
4050
+ process.exit(await runMcpCommand(process.argv[3], Number(process.env.PLANCHE_PORT ?? 8888)));
4051
+ }
3241
4052
  const major = Number(process.versions.node.split(".")[0]);
3242
4053
  if (major < 22) {
3243
4054
  console.error(`planche needs Node.js 22 or newer (you are running ${process.version}).`);
@@ -3374,6 +4185,7 @@ async function main() {
3374
4185
  console.log(`${DEFAULT_APP_URL === hostedAppUrl ? "planche.dev" : hostedAppUrl} is unreachable \u2014 serving the app bundled with this agent (v${VERSION}) instead.`);
3375
4186
  }
3376
4187
  const server = app.listen(port, listenAddr, () => {
4188
+ configureCliBridge(`http://127.0.0.1:${port}/mcp`, authToken);
3377
4189
  console.log(divider);
3378
4190
  console.log(`Planche agent ${VERSION} listening on ${agentUrl}`);
3379
4191
  console.log(divider);