@coopcli/specsketch 0.1.2 → 5.2.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 (162) hide show
  1. package/README.md +85 -4
  2. package/dist/cli/index.js +2203 -88
  3. package/dist/client/assets/abnfDiagram-VRR7QNED-CTNJVYti.js +1 -0
  4. package/dist/client/assets/{ar-SA-G6X2FPQ2-jwBzex-2.js → ar-SA-G6X2FPQ2-Dx5RkIpf.js} +1 -1
  5. package/dist/client/assets/architecture-TIHT7OUA-b9_tu6da.js +1 -0
  6. package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-DdYSrIl0.js → architectureDiagram-ZJ3FMSHR-C7zIp5ev.js} +1 -1
  7. package/dist/client/assets/{az-AZ-76LH7QW2-Dis0vDnD.js → az-AZ-76LH7QW2-lKEr0lki.js} +1 -1
  8. package/dist/client/assets/{bg-BG-XCXSNQG7-BCcYYSpX.js → bg-BG-XCXSNQG7-BrueLcBf.js} +1 -1
  9. package/dist/client/assets/{blockDiagram-677ZJIJ3-IHRgXLG2.js → blockDiagram-677ZJIJ3-B3Y6utF7.js} +1 -1
  10. package/dist/client/assets/{bn-BD-2XOGV67Q-Cs38e4dA.js → bn-BD-2XOGV67Q-BVnHj_Zk.js} +1 -1
  11. package/dist/client/assets/{c4Diagram-LMCZKHZV-DuitrGTA.js → c4Diagram-LMCZKHZV-Dt_cruyV.js} +1 -1
  12. package/dist/client/assets/{ca-ES-6MX7JW3Y-J6JluMn1.js → ca-ES-6MX7JW3Y-BM6SppAL.js} +1 -1
  13. package/dist/client/assets/channel-Dz1KOO7f.js +1 -0
  14. package/dist/client/assets/{chunk-32BRIVSS-FVqbFiM9.js → chunk-32BRIVSS-Ca80Axke.js} +1 -1
  15. package/dist/client/assets/{chunk-52WLFC77-B5fSTF98.js → chunk-52WLFC77-CtSrQbPt.js} +1 -1
  16. package/dist/client/assets/{chunk-C7G6YPKG-CUlznrHX.js → chunk-C7G6YPKG-B2LFv6gm.js} +1 -1
  17. package/dist/client/assets/{chunk-EIO257PC-B_XNiJ0A.js → chunk-EIO257PC-BAdMCi3M.js} +1 -1
  18. package/dist/client/assets/{chunk-EX3LRPZG-D7H3g3lm.js → chunk-EX3LRPZG-BJ4MtZ6_.js} +1 -1
  19. package/dist/client/assets/{chunk-FWX5IMBZ-DlSTzkRW.js → chunk-FWX5IMBZ-Oeex84SP.js} +2 -2
  20. package/dist/client/assets/{chunk-HOUHSVGY-BB87Tq5U.js → chunk-HOUHSVGY-CwXgFjr1.js} +1 -1
  21. package/dist/client/assets/{chunk-ICXQ74PX-DHmdnexU.js → chunk-ICXQ74PX-CNjPZw9f.js} +1 -1
  22. package/dist/client/assets/{chunk-MOJQB5TN-Cd67Wk3-.js → chunk-MOJQB5TN-CH9qxz0s.js} +1 -1
  23. package/dist/client/assets/{chunk-OGEWGWER-G3kRomY-.js → chunk-OGEWGWER-CXz-hiqH.js} +1 -1
  24. package/dist/client/assets/{chunk-PUDLZKDR-DJw3XJhs.js → chunk-PUDLZKDR-DeKKZ45n.js} +1 -1
  25. package/dist/client/assets/{chunk-Q4XR5HBZ-BV5Bw0_D.js → chunk-Q4XR5HBZ-BfA-corw.js} +1 -1
  26. package/dist/client/assets/{chunk-V7JOEXUC-C2vxCA10.js → chunk-V7JOEXUC-DBArf2HI.js} +1 -1
  27. package/dist/client/assets/{chunk-VAUOI2AC-B2lAp2X6.js → chunk-VAUOI2AC-Ce5MjSpg.js} +1 -1
  28. package/dist/client/assets/{chunk-VR4S4FIN-Dk58Ba0X.js → chunk-VR4S4FIN-DUkeWBHv.js} +1 -1
  29. package/dist/client/assets/{chunk-WYO6CB5R-rhSewh5r.js → chunk-WYO6CB5R-5IIfqwS1.js} +1 -1
  30. package/dist/client/assets/{chunk-ZGVPDNZ5-C8pQspwU.js → chunk-ZGVPDNZ5-BVFUMmyX.js} +1 -1
  31. package/dist/client/assets/classDiagram-OUVF2IWQ-5dxJ_ejB.js +1 -0
  32. package/dist/client/assets/classDiagram-v2-EOCWNBFH-5dxJ_ejB.js +1 -0
  33. package/dist/client/assets/{cs-CZ-2BRQDIVT-CMTTkdlc.js → cs-CZ-2BRQDIVT-BeWSN4Lg.js} +1 -1
  34. package/dist/client/assets/{railroad-3IZDKUUU-Cyefv0S0.js → cynefin-VYW2F7L2-BPoEGt5P.js} +1 -1
  35. package/dist/client/assets/{cynefinDiagram-TSTJHNR4-BlF8b9qP.js → cynefinDiagram-TSTJHNR4-D-HGa5eC.js} +1 -1
  36. package/dist/client/assets/{da-DK-5WZEPLOC-BMtFOXTa.js → da-DK-5WZEPLOC-C7Kj8cSr.js} +1 -1
  37. package/dist/client/assets/{dagre-VKFMJZFB-DytIg2RQ.js → dagre-VKFMJZFB-DYJas6XO.js} +1 -1
  38. package/dist/client/assets/{de-DE-XR44H4JA-C2G_UWRS.js → de-DE-XR44H4JA-CGONhJum.js} +1 -1
  39. package/dist/client/assets/{diagram-FQU43EPY-BOqWKyO9.js → diagram-FQU43EPY-CovScBBf.js} +2 -2
  40. package/dist/client/assets/{diagram-G47NLZAW-1dP1jpC7.js → diagram-G47NLZAW-6_UOQ1JD.js} +1 -1
  41. package/dist/client/assets/{diagram-NH7WQ7WH-VyXFGKGb.js → diagram-NH7WQ7WH-DfM8Ux2D.js} +1 -1
  42. package/dist/client/assets/{diagram-OA4YK3LP-BP7GwNyd.js → diagram-OA4YK3LP-BFJDUTm_.js} +2 -2
  43. package/dist/client/assets/{diagram-WEI45ONY-DyHZtrRF.js → diagram-WEI45ONY-8OKtOSaq.js} +1 -1
  44. package/dist/client/assets/{dist-C_FPOqHa.js → dist-CBoHd3nF.js} +4 -4
  45. package/dist/client/assets/ebnfDiagram-CCIWWBDH-TBneTBs-.js +1 -0
  46. package/dist/client/assets/{el-GR-BZB4AONW-BTK70kle.js → el-GR-BZB4AONW-C9Q6-3JL.js} +1 -1
  47. package/dist/client/assets/{en-B4ZKOASM-DQDUvTjb.js → en-B4ZKOASM-DooiP2-J.js} +1 -1
  48. package/dist/client/assets/{erDiagram-Q63AITRT-CI8TMXqA.js → erDiagram-Q63AITRT-Dy5QBJCT.js} +1 -1
  49. package/dist/client/assets/{es-ES-U4NZUMDT-6TeZK11C.js → es-ES-U4NZUMDT-DKxT2wWX.js} +1 -1
  50. package/dist/client/assets/{eu-ES-A7QVB2H4-Cb0oNkmv.js → eu-ES-A7QVB2H4-DrKnZ3gY.js} +1 -1
  51. package/dist/client/assets/eventmodeling-45OFAUF4-DP9O5S3e.js +1 -0
  52. package/dist/client/assets/{fa-IR-HGAKTJCU-JYkLqShQ.js → fa-IR-HGAKTJCU-DjA2Px-T.js} +1 -1
  53. package/dist/client/assets/{fi-FI-Z5N7JZ37-u_gehdrx.js → fi-FI-Z5N7JZ37-NUghXbpa.js} +1 -1
  54. package/dist/client/assets/flowDiagram-23GEKE2U-CUBp7I9C.js +1 -0
  55. package/dist/client/assets/{fr-FR-RHASNOE6-CmE3ELbC.js → fr-FR-RHASNOE6-9Vmv2nhB.js} +1 -1
  56. package/dist/client/assets/{ganttDiagram-NO4QXBWP-CH13wSU6.js → ganttDiagram-NO4QXBWP-DNoDTMaW.js} +1 -1
  57. package/dist/client/assets/gitGraph-TEB2WS4Q-BdzEwYG8.js +1 -0
  58. package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-IpKqkdqX.js → gitGraphDiagram-IHSO6WYX-Coi-sbuV.js} +1 -1
  59. package/dist/client/assets/{gl-ES-HMX3MZ6V-Fy4K9BOx.js → gl-ES-HMX3MZ6V-CrTqlGTr.js} +1 -1
  60. package/dist/client/assets/{he-IL-6SHJWFNN-BtuHPbrL.js → he-IL-6SHJWFNN-BDoFv8vR.js} +1 -1
  61. package/dist/client/assets/{hi-IN-IWLTKZ5I-TEGNFf4N.js → hi-IN-IWLTKZ5I-DwqsXYOg.js} +1 -1
  62. package/dist/client/assets/{hu-HU-A5ZG7DT2-BAaPCJl5.js → hu-HU-A5ZG7DT2-Bfb8Cu1b.js} +1 -1
  63. package/dist/client/assets/{id-ID-SAP4L64H-DyuFpf3d.js → id-ID-SAP4L64H-DOW7LO7-.js} +1 -1
  64. package/dist/client/assets/image-GAAHSSAO-BGgn-PVN.js +1 -0
  65. package/dist/client/assets/index-oPO4_QxD.js +270 -0
  66. package/dist/client/assets/{index--u8Mf6T6.css → index-pqEtQj4K.css} +1 -1
  67. package/dist/client/assets/info-DKCQHKI2-DrYXVDvR.js +1 -0
  68. package/dist/client/assets/{infoDiagram-FWYZ7A6U-DbwYrIVA.js → infoDiagram-FWYZ7A6U-lZtFhAXP.js} +1 -1
  69. package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-DUKB4IOX.js → ishikawaDiagram-FXEZZL3T-BREj5_gD.js} +1 -1
  70. package/dist/client/assets/{it-IT-JPQ66NNP-DI0hWELN.js → it-IT-JPQ66NNP-Bgr9Cu8C.js} +1 -1
  71. package/dist/client/assets/{ja-JP-DBVTYXUO-BVDm3NlP.js → ja-JP-DBVTYXUO-BGNdWvdH.js} +1 -1
  72. package/dist/client/assets/{journeyDiagram-5HDEW3XC-CBla9cDa.js → journeyDiagram-5HDEW3XC-CMBikQPO.js} +1 -1
  73. package/dist/client/assets/{kaa-6HZHGXH3-BPmENbr7.js → kaa-6HZHGXH3-CRDlnQ9T.js} +1 -1
  74. package/dist/client/assets/{kab-KAB-ZGHBKWFO-SJCSLUAj.js → kab-KAB-ZGHBKWFO-C4Lqev4p.js} +1 -1
  75. package/dist/client/assets/{kanban-definition-HUTT4EX6-DF40bon8.js → kanban-definition-HUTT4EX6-BWBx3j_A.js} +1 -1
  76. package/dist/client/assets/{kk-KZ-P5N5QNE5-iO6ccQA0.js → kk-KZ-P5N5QNE5-DgFr17Od.js} +1 -1
  77. package/dist/client/assets/{km-KH-HSX4SM5Z-DaeRmR92.js → km-KH-HSX4SM5Z-BDvXuwGA.js} +1 -1
  78. package/dist/client/assets/{ko-KR-MTYHY66A-BbIUlDG8.js → ko-KR-MTYHY66A-BNhHcL3I.js} +1 -1
  79. package/dist/client/assets/{ku-TR-6OUDTVRD-mV8MrK_d.js → ku-TR-6OUDTVRD-DNktxnYd.js} +1 -1
  80. package/dist/client/assets/{line-Cyy-k4TY.js → line-Dt0OF9ws.js} +1 -1
  81. package/dist/client/assets/{lt-LT-XHIRWOB4-NGxzPcvJ.js → lt-LT-XHIRWOB4-aRviZ0MH.js} +1 -1
  82. package/dist/client/assets/{lv-LV-5QDEKY6T-BSIwo-WO.js → lv-LV-5QDEKY6T-CoVYcDKN.js} +1 -1
  83. package/dist/client/assets/mermaid-parser.core-PdKOsWiX.js +7 -0
  84. package/dist/client/assets/{mindmap-definition-LN4V7U3C-BcAHqIrw.js → mindmap-definition-LN4V7U3C-CQ5v-EBF.js} +1 -1
  85. package/dist/client/assets/{mr-IN-CRQNXWMA-DMqRrESd.js → mr-IN-CRQNXWMA-BxQJIhtJ.js} +1 -1
  86. package/dist/client/assets/{my-MM-5M5IBNSE-BU9rr6eQ.js → my-MM-5M5IBNSE-FhM1FFVw.js} +1 -1
  87. package/dist/client/assets/{nb-NO-T6EIAALU-CfCFCnvr.js → nb-NO-T6EIAALU-CU26LQ4M.js} +1 -1
  88. package/dist/client/assets/{nl-NL-IS3SIHDZ-BDDDlrXW.js → nl-NL-IS3SIHDZ-DEbkQpIZ.js} +1 -1
  89. package/dist/client/assets/{nn-NO-6E72VCQL-DxXyaKak.js → nn-NO-6E72VCQL-DTXzh0og.js} +1 -1
  90. package/dist/client/assets/{oc-FR-POXYY2M6-DhEINSYs.js → oc-FR-POXYY2M6-BMWu-GuX.js} +1 -1
  91. package/dist/client/assets/{pa-IN-N4M65BXN-CK7mHZv5.js → pa-IN-N4M65BXN-BvvaU4wr.js} +1 -1
  92. package/dist/client/assets/{treemap-6X3UGDF4-BDCwJXtu.js → packet-7NZHBO7P-D3npuWOU.js} +1 -1
  93. package/dist/client/assets/pegDiagram-2B236MQR-BRo6DMPk.js +1 -0
  94. package/dist/client/assets/percentages-BXMCSKIN-EXL0wbvf.js +1 -0
  95. package/dist/client/assets/{wardley-OPB4EBWU-DyOUx8-a.js → pie-RZYD4A2V-CNkV5sKG.js} +1 -1
  96. package/dist/client/assets/{pieDiagram-ENE6RG2P-BLDvuItJ.js → pieDiagram-ENE6RG2P-DjyEljFW.js} +1 -1
  97. package/dist/client/assets/{pl-PL-T2D74RX3-CqscU5Ri.js → pl-PL-T2D74RX3-Q4-sfWh2.js} +1 -1
  98. package/dist/client/assets/{pt-BR-5N22H2LF-DmaMKyvD.js → pt-BR-5N22H2LF-D3I0MlSt.js} +1 -1
  99. package/dist/client/assets/{pt-PT-UZXXM6DQ-DF4Iue14.js → pt-PT-UZXXM6DQ-CjvJgWuJ.js} +1 -1
  100. package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-G7yFHj4A.js → quadrantDiagram-ABIIQ3AL-DwPjmUqM.js} +1 -1
  101. package/dist/client/assets/{cynefin-VYW2F7L2-D4wuR1xh.js → radar-I7S5WNFK-BUDPffFb.js} +1 -1
  102. package/dist/client/assets/{gitGraph-TEB2WS4Q-g2g2G5VS.js → railroad-3IZDKUUU-Cdo3kV8g.js} +1 -1
  103. package/dist/client/assets/railroad-abnf-AHOZXSZD-DhwVR0O6.js +1 -0
  104. package/dist/client/assets/railroad-ebnf-EBAXGLYW-BH-sR6KG.js +1 -0
  105. package/dist/client/assets/railroad-peg-LSFZ7HO6-DNVw0v5I.js +1 -0
  106. package/dist/client/assets/railroadDiagram-RFXS5EU6-DM4LPeTp.js +1 -0
  107. package/dist/client/assets/{requirementDiagram-TGXJPOKE-C9A-HrEu.js → requirementDiagram-TGXJPOKE-BgufYhbt.js} +1 -1
  108. package/dist/client/assets/{ro-RO-JPDTUUEW-fRWAU3yw.js → ro-RO-JPDTUUEW-DTqaPOB6.js} +1 -1
  109. package/dist/client/assets/{ru-RU-B4JR7IUQ-CKPt5MqY.js → ru-RU-B4JR7IUQ-DGLVQl1a.js} +1 -1
  110. package/dist/client/assets/{sankeyDiagram-HTMAVEWB-exIXTcGE.js → sankeyDiagram-HTMAVEWB-B1Sjq8RS.js} +1 -1
  111. package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-aDjop-R8.js → sequenceDiagram-DBY2YBRQ-D5SV4V4W.js} +1 -1
  112. package/dist/client/assets/{si-LK-N5RQ5JYF-MDsRqHY9.js → si-LK-N5RQ5JYF-C3gSdqxC.js} +1 -1
  113. package/dist/client/assets/{sk-SK-C5VTKIMK-CRZETqXz.js → sk-SK-C5VTKIMK-Bg1sgW2y.js} +1 -1
  114. package/dist/client/assets/{sl-SI-NN7IZMDC-CXfLqNoN.js → sl-SI-NN7IZMDC-5tRgzcvI.js} +1 -1
  115. package/dist/client/assets/{stateDiagram-2N3HPSRC-y0Ml_ZQw.js → stateDiagram-2N3HPSRC-BDd2Qyke.js} +1 -1
  116. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-Dc9mjKnL.js +1 -0
  117. package/dist/client/assets/subset-shared.chunk-CBqF3lLT.js +1 -0
  118. package/dist/client/assets/{subset-worker.chunk-DbYO8wJu.js → subset-worker.chunk-kFoGWzk5.js} +1 -1
  119. package/dist/client/assets/{sv-SE-XGPEYMSR-BC6B5BtV.js → sv-SE-XGPEYMSR-BOzuSEwa.js} +1 -1
  120. package/dist/client/assets/{swimlanes-5IMT3BWC-CYWdivj3.js → swimlanes-5IMT3BWC-DjFw-CEo.js} +1 -1
  121. package/dist/client/assets/swimlanesDiagram-G3AALYLV-DxdVR5MZ.js +8 -0
  122. package/dist/client/assets/{ta-IN-2NMHFXQM-DilUfcfF.js → ta-IN-2NMHFXQM-C7WRODt_.js} +1 -1
  123. package/dist/client/assets/{th-TH-HPSO5L25-BaInGJjE.js → th-TH-HPSO5L25-WFuWcN58.js} +1 -1
  124. package/dist/client/assets/{timeline-definition-FHXFAJF6-zAb82GBx.js → timeline-definition-FHXFAJF6-BItaRl8g.js} +1 -1
  125. package/dist/client/assets/{tr-TR-DEFEU3FU-oygIet1p.js → tr-TR-DEFEU3FU-DtSAkhUR.js} +1 -1
  126. package/dist/client/assets/{treeView-QDETBFTQ-sU-avi1O.js → treeView-QDETBFTQ-BsqpkMak.js} +1 -1
  127. package/dist/client/assets/treemap-6X3UGDF4-CF2ZChjy.js +1 -0
  128. package/dist/client/assets/{uk-UA-QMV73CPH-QqCQ4UX-.js → uk-UA-QMV73CPH-BLNYCDf-.js} +1 -1
  129. package/dist/client/assets/{vennDiagram-L72KCM5P-BXrsZk41.js → vennDiagram-L72KCM5P-DkVOQsDM.js} +1 -1
  130. package/dist/client/assets/{vi-VN-M7AON7JQ-yJDC_vY8.js → vi-VN-M7AON7JQ-CC-oykOI.js} +1 -1
  131. package/dist/client/assets/wardley-OPB4EBWU-BfqiNa2m.js +1 -0
  132. package/dist/client/assets/{wardleyDiagram-EHGQE667-DlOqjfHt.js → wardleyDiagram-EHGQE667-C7jTfbZa.js} +1 -1
  133. package/dist/client/assets/{xychartDiagram-FW5EYKEG-BYm9TaM8.js → xychartDiagram-FW5EYKEG-KJVcpo2q.js} +1 -1
  134. package/dist/client/assets/{zh-CN-LNUGB5OW-BNjW-fP_.js → zh-CN-LNUGB5OW-BpxSN9jo.js} +1 -1
  135. package/dist/client/assets/{zh-HK-E62DVLB3-apDiW2t-.js → zh-HK-E62DVLB3-BVqdbOUX.js} +1 -1
  136. package/dist/client/assets/{zh-TW-RAJ6MFWO-qAMfxdUZ.js → zh-TW-RAJ6MFWO-c__UZRJ7.js} +1 -1
  137. package/dist/client/index.html +2 -2
  138. package/package.json +74 -69
  139. package/dist/client/assets/abnfDiagram-VRR7QNED-D5qnskik.js +0 -1
  140. package/dist/client/assets/architecture-TIHT7OUA-Bj5GS0Ct.js +0 -1
  141. package/dist/client/assets/channel-B0WxnXxi.js +0 -1
  142. package/dist/client/assets/classDiagram-OUVF2IWQ-Dnwqjkgm.js +0 -1
  143. package/dist/client/assets/classDiagram-v2-EOCWNBFH-Dnwqjkgm.js +0 -1
  144. package/dist/client/assets/ebnfDiagram-CCIWWBDH-Bitxc5e_.js +0 -1
  145. package/dist/client/assets/eventmodeling-45OFAUF4-CTwqsmD_.js +0 -1
  146. package/dist/client/assets/flowDiagram-23GEKE2U-BZYDBy9C.js +0 -1
  147. package/dist/client/assets/image-GAAHSSAO-tSmyz8fT.js +0 -1
  148. package/dist/client/assets/index-BkkP6_3-.js +0 -270
  149. package/dist/client/assets/info-DKCQHKI2-DjHPtgDy.js +0 -1
  150. package/dist/client/assets/mermaid-parser.core-BC7y6sOJ.js +0 -7
  151. package/dist/client/assets/packet-7NZHBO7P-BxQG27fi.js +0 -1
  152. package/dist/client/assets/pegDiagram-2B236MQR-6m6GI5bN.js +0 -1
  153. package/dist/client/assets/percentages-BXMCSKIN-BuLIaCEd.js +0 -1
  154. package/dist/client/assets/pie-RZYD4A2V-DrlUa6Jm.js +0 -1
  155. package/dist/client/assets/radar-I7S5WNFK-DB1qIDmZ.js +0 -1
  156. package/dist/client/assets/railroad-abnf-AHOZXSZD-BkviW5Tj.js +0 -1
  157. package/dist/client/assets/railroad-ebnf-EBAXGLYW-CXg6HJd6.js +0 -1
  158. package/dist/client/assets/railroad-peg-LSFZ7HO6-BgTAGb1l.js +0 -1
  159. package/dist/client/assets/railroadDiagram-RFXS5EU6-BvUGw21O.js +0 -1
  160. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-D8dyI3My.js +0 -1
  161. package/dist/client/assets/subset-shared.chunk-BTjBJrZm.js +0 -1
  162. package/dist/client/assets/swimlanesDiagram-G3AALYLV-CdLCcze0.js +0 -8
package/dist/cli/index.js CHANGED
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // server/cli.ts
4
- import { existsSync as existsSync2, readFileSync as readFileSync2, readdirSync as readdirSync2 } from "node:fs";
5
- import { homedir } from "node:os";
6
- import { dirname as dirname2, extname, join as join2, normalize, resolve as resolve2 } from "node:path";
4
+ import { existsSync as existsSync3, readFileSync as readFileSync3, readdirSync as readdirSync3 } from "node:fs";
5
+ import { homedir as homedir3 } from "node:os";
6
+ import { dirname as dirname3, extname, join as join5, normalize as normalize2, resolve as resolve3 } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
- import { parseArgs } from "node:util";
8
+ import { parseArgs as parseArgs2 } from "node:util";
9
9
  import { serve } from "@hono/node-server";
10
10
  import Anthropic from "@anthropic-ai/sdk";
11
+ import { WebSocketServer } from "ws";
11
12
 
12
13
  // server/app.ts
13
14
  import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
@@ -41,10 +42,29 @@ var GENERATION_MODELS = [
41
42
  ];
42
43
  var DEFAULT_MODEL = "claude-opus-4-8";
43
44
  var GenerationModel = z.enum(GENERATION_MODELS);
45
+ var ChatRole = z.enum(["user", "assistant", "summary"]);
46
+ var ChatMessage = z.object({
47
+ role: ChatRole,
48
+ text: z.string(),
49
+ /** ISO-8601 creation time. */
50
+ at: z.string()
51
+ });
44
52
  var SpecRequest = z.object({
45
53
  title: z.string().optional(),
46
54
  model: GenerationModel.optional(),
47
- graph: DiagramGraph
55
+ graph: DiagramGraph,
56
+ /**
57
+ * The spec-review conversation so far. Regenerate sends it; the server
58
+ * folds it into the generation prompt as steering context and resets the
59
+ * chat with a seed summary afterwards.
60
+ */
61
+ conversation: z.array(ChatMessage).optional(),
62
+ /**
63
+ * User-edited generation system prompt (System Prompt side-pane option).
64
+ * Absent → the server's effective prompt (workspace override or built-in
65
+ * default). `@` references inside it resolve like any other input.
66
+ */
67
+ systemPrompt: z.string().optional()
48
68
  });
49
69
  var CapabilitySpec = z.object({
50
70
  capability: z.string(),
@@ -64,6 +84,38 @@ var SpecBundle = z.object({
64
84
  /** One entry per capability the architecture introduces. */
65
85
  specs: z.array(CapabilitySpec)
66
86
  });
87
+ var ChatRequest = z.object({
88
+ /** The user's new message (may contain `@` references). */
89
+ message: z.string().min(1),
90
+ /** Conversation history preceding `message`, oldest first. */
91
+ conversation: z.array(ChatMessage).default([]),
92
+ model: GenerationModel.optional(),
93
+ /** URL mode has no workspace — the client supplies the bundle to ground in. */
94
+ bundle: SpecBundle.optional()
95
+ });
96
+ var UnresolvedRefInfo = z.object({ ref: z.string(), reason: z.string() });
97
+ var ChatReply = z.object({
98
+ reply: ChatMessage,
99
+ /** Workspace-relative paths whose contents joined the answer's context. */
100
+ resolvedRefs: z.array(z.string()),
101
+ /** References that soft-failed, with the reason shown to the user. */
102
+ unresolvedRefs: z.array(UnresolvedRefInfo)
103
+ });
104
+ var SpecResponse = z.object({
105
+ bundle: SpecBundle,
106
+ seed: z.array(ChatMessage)
107
+ });
108
+ var PromptSave = z.object({
109
+ system: z.string().min(1).nullable()
110
+ });
111
+ var PromptInfo = z.object({
112
+ /** The prompt the next generation will use (custom override or default). */
113
+ system: z.string(),
114
+ /** The built-in default, for the editor's Reset action. */
115
+ default: z.string(),
116
+ /** True when a custom override is active. */
117
+ custom: z.boolean()
118
+ });
67
119
  var ExcalidrawScene = z.object({
68
120
  elements: z.array(z.unknown()),
69
121
  appState: z.record(z.string(), z.unknown()).optional(),
@@ -79,9 +131,242 @@ var SessionSnapshot = z.object({
79
131
  title: z.string().optional(),
80
132
  model: GenerationModel.optional(),
81
133
  scene: ExcalidrawScene.nullable(),
82
- bundle: SpecBundle.nullable()
134
+ bundle: SpecBundle.nullable(),
135
+ /** Persisted spec-review chat (seed + exchanges), oldest first. */
136
+ chat: z.array(ChatMessage).optional()
83
137
  });
84
138
 
139
+ // server/references.ts
140
+ import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
141
+ import { basename, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
142
+ var REF_FILE_BYTE_LIMIT = 64 * 1024;
143
+ var REF_TOTAL_BYTE_LIMIT = 256 * 1024;
144
+ var REF_DIR_FILE_LIMIT = 50;
145
+ var SEARCH_IGNORE = /* @__PURE__ */ new Set([".git", "node_modules", "dist", ".wrangler", ".DS_Store"]);
146
+ function parseReferences(text) {
147
+ const tokens = [];
148
+ const pattern = /(^|[\s([{'"`])@([^\s@]+)/g;
149
+ for (const match of text.matchAll(pattern)) {
150
+ const token = (match[2] ?? "").replace(/[.,;:!?)\]}'"`>]+$/, "");
151
+ if (!token || token.startsWith("<")) continue;
152
+ if (!tokens.includes(token)) tokens.push(token);
153
+ }
154
+ return tokens;
155
+ }
156
+ function insideRoot(rootReal, target) {
157
+ return target.startsWith(rootReal + sep);
158
+ }
159
+ var WALK_ENTRY_LIMIT = 2e4;
160
+ function collectEntries(root) {
161
+ const files = [];
162
+ const dirs = [];
163
+ const walk = (dir) => {
164
+ if (files.length + dirs.length >= WALK_ENTRY_LIMIT) return;
165
+ let entries;
166
+ try {
167
+ entries = readdirSync(dir, { withFileTypes: true });
168
+ } catch {
169
+ return;
170
+ }
171
+ for (const entry of entries) {
172
+ if (files.length + dirs.length >= WALK_ENTRY_LIMIT) return;
173
+ if (SEARCH_IGNORE.has(entry.name) || entry.isSymbolicLink()) continue;
174
+ const full = join(dir, entry.name);
175
+ if (entry.isDirectory()) {
176
+ dirs.push(relative(root, full));
177
+ walk(full);
178
+ } else if (entry.isFile()) files.push(relative(root, full));
179
+ }
180
+ };
181
+ walk(root);
182
+ return { files, dirs };
183
+ }
184
+ var collectFiles = (root) => collectEntries(root).files;
185
+ function findByName(root, name) {
186
+ return collectFiles(root).filter((p) => basename(p) === name).sort((a, b) => {
187
+ const depth = a.split(sep).length - b.split(sep).length;
188
+ return depth !== 0 ? depth : a.localeCompare(b);
189
+ });
190
+ }
191
+ var FILE_SEARCH_LIMIT = 20;
192
+ function searchFiles(root, query, limit = FILE_SEARCH_LIMIT) {
193
+ if (root === void 0) return [];
194
+ let rootReal;
195
+ try {
196
+ rootReal = realpathSync(resolve(root));
197
+ } catch {
198
+ return [];
199
+ }
200
+ const q = query.trim().toLowerCase();
201
+ const { files, dirs } = collectEntries(rootReal);
202
+ const all = [...dirs.map((d) => `${d}/`), ...files];
203
+ const matches = q ? all.filter((p) => p.toLowerCase().includes(q)) : all;
204
+ const rank = (p) => {
205
+ const lower = p.toLowerCase();
206
+ return [
207
+ q && basename(lower.replace(/\/$/, "")).includes(q) ? 0 : 1,
208
+ q ? lower.indexOf(q) : 0,
209
+ p.length
210
+ ];
211
+ };
212
+ return matches.map((p) => ({ p, r: rank(p) })).sort(
213
+ (a, b) => a.r[0] - b.r[0] || a.r[1] - b.r[1] || a.r[2] - b.r[2] || a.p.localeCompare(b.p)
214
+ ).slice(0, limit).map(({ p }) => p);
215
+ }
216
+ function readClamped(target) {
217
+ const raw = readFileSync(target);
218
+ if (raw.byteLength <= REF_FILE_BYTE_LIMIT) {
219
+ return { content: raw.toString("utf8"), truncated: false };
220
+ }
221
+ return {
222
+ content: `${raw.subarray(0, REF_FILE_BYTE_LIMIT).toString("utf8")}
223
+ \u2026 [truncated at ${REF_FILE_BYTE_LIMIT} bytes]`,
224
+ truncated: true
225
+ };
226
+ }
227
+ function expandReferences(text, root) {
228
+ const resolved = [];
229
+ const unresolved = [];
230
+ const refs = parseReferences(text);
231
+ if (refs.length === 0) return { resolved, unresolved };
232
+ let rootReal = null;
233
+ if (root !== void 0) {
234
+ try {
235
+ rootReal = realpathSync(resolve(root));
236
+ } catch {
237
+ rootReal = null;
238
+ }
239
+ }
240
+ let totalBytes = 0;
241
+ for (const ref of refs) {
242
+ if (rootReal === null) {
243
+ unresolved.push({ ref, reason: "file references are unavailable here" });
244
+ continue;
245
+ }
246
+ if (totalBytes >= REF_TOTAL_BYTE_LIMIT) {
247
+ unresolved.push({ ref, reason: "reference budget exceeded \u2014 too much file content already included" });
248
+ continue;
249
+ }
250
+ const outcome = resolveOne(rootReal, ref);
251
+ if ("reason" in outcome) {
252
+ unresolved.push({ ref, ...outcome });
253
+ continue;
254
+ }
255
+ if ("dirFiles" in outcome) {
256
+ let included = 0;
257
+ for (const relPath of outcome.dirFiles) {
258
+ if (included >= REF_DIR_FILE_LIMIT || totalBytes >= REF_TOTAL_BYTE_LIMIT) break;
259
+ let body;
260
+ try {
261
+ body = readClamped(join(rootReal, relPath));
262
+ } catch {
263
+ continue;
264
+ }
265
+ totalBytes += Buffer.byteLength(body.content, "utf8");
266
+ resolved.push({ ref, path: relPath, ...body });
267
+ included++;
268
+ }
269
+ const omitted = outcome.dirFiles.length - included;
270
+ if (included === 0) {
271
+ unresolved.push({
272
+ ref,
273
+ reason: "reference budget exceeded \u2014 too much file content already included"
274
+ });
275
+ } else if (omitted > 0) {
276
+ unresolved.push({
277
+ ref,
278
+ kind: "partial",
279
+ reason: `directory partially included \u2014 ${omitted} of ${outcome.dirFiles.length} files omitted by the size budget`
280
+ });
281
+ }
282
+ continue;
283
+ }
284
+ totalBytes += Buffer.byteLength(outcome.content, "utf8");
285
+ resolved.push({ ref, ...outcome });
286
+ }
287
+ return { resolved, unresolved };
288
+ }
289
+ function resolveOne(rootReal, ref) {
290
+ if (isAbsolute(ref)) return { reason: "absolute paths are not allowed" };
291
+ const rel = normalize(ref);
292
+ if (rel.split(sep).includes("..")) return { reason: "path escapes the working directory" };
293
+ const direct = resolve(rootReal, rel);
294
+ if (direct !== rootReal && !insideRoot(rootReal, direct)) {
295
+ return { reason: "path escapes the working directory" };
296
+ }
297
+ const stats = statOrNull(direct);
298
+ if (stats?.isDirectory()) {
299
+ let real;
300
+ try {
301
+ real = realpathSync(direct);
302
+ } catch {
303
+ return { reason: "not readable" };
304
+ }
305
+ if (real !== rootReal && !insideRoot(rootReal, real)) {
306
+ return { reason: "resolves outside the working directory" };
307
+ }
308
+ const dirFiles = collectFiles(direct).map((p) => relative(rootReal, join(direct, p))).sort((a, b) => {
309
+ const depth = a.split(sep).length - b.split(sep).length;
310
+ return depth !== 0 ? depth : a.localeCompare(b);
311
+ });
312
+ if (dirFiles.length === 0) return { reason: "directory contains no files" };
313
+ return { dirFiles };
314
+ }
315
+ if (stats?.isFile()) {
316
+ let real;
317
+ try {
318
+ real = realpathSync(direct);
319
+ } catch {
320
+ return { reason: "not readable" };
321
+ }
322
+ if (!insideRoot(rootReal, real)) return { reason: "resolves outside the working directory" };
323
+ try {
324
+ return { path: relative(rootReal, direct), ...readClamped(direct) };
325
+ } catch {
326
+ return { reason: "not readable" };
327
+ }
328
+ }
329
+ if (rel.includes(sep)) return { reason: "not found" };
330
+ const matches = findByName(rootReal, rel);
331
+ if (matches.length === 0) return { reason: "not found" };
332
+ const best = matches[0];
333
+ try {
334
+ const body = readClamped(join(rootReal, best));
335
+ return {
336
+ path: best,
337
+ ...body,
338
+ ...matches.length > 1 ? {
339
+ note: `ambiguous \u2014 ${matches.length} files named "${rel}"; using ${best}, others: ${matches.slice(1, 5).join(", ")}${matches.length > 5 ? ", \u2026" : ""}`
340
+ } : {}
341
+ };
342
+ } catch {
343
+ return { reason: "not readable" };
344
+ }
345
+ }
346
+ function statOrNull(path) {
347
+ try {
348
+ return statSync(path);
349
+ } catch {
350
+ return null;
351
+ }
352
+ }
353
+ function renderReferenceContext(expansion) {
354
+ const parts = [];
355
+ for (const r of expansion.resolved) {
356
+ const note = r.note ? `(${r.note})
357
+ ` : "";
358
+ parts.push(`=== @${r.path} ===
359
+ ${note}${r.content}
360
+ === end @${r.path} ===`);
361
+ }
362
+ for (const u of expansion.unresolved) {
363
+ parts.push(
364
+ u.kind === "partial" ? `@${u.ref}: ${u.reason}.` : `@${u.ref}: could not be resolved (${u.reason}) \u2014 do not invent its contents.`
365
+ );
366
+ }
367
+ return parts.join("\n\n");
368
+ }
369
+
85
370
  // server/app.ts
86
371
  var SYSTEM = `You are a principal software architect and specification writer.
87
372
  Given an architecture diagram as a graph (nodes = components/services/datastores/actors,
@@ -95,8 +380,26 @@ Follow OpenSpec conventions:
95
380
  - Each capability the architecture introduces gets its own spec: a Purpose paragraph,
96
381
  "The system SHALL ..." requirements, and a "WHEN / THEN" scenario per requirement.
97
382
  - changeName and capability ids are kebab-case.
98
- Ground every requirement in what the diagram shows; record ambiguity as an assumption in design
99
- rather than inventing unrelated features. Prefer 2-5 focused capabilities.`;
383
+
384
+ Referenced files: the input may contain "@<path | filename>" references that were resolved
385
+ to real file contents, delimited as "=== @<path> === \u2026 === end @<path> ===" blocks. That
386
+ content is authoritative ground truth about the existing system \u2014 prefer it over inference
387
+ from the diagram or your own assumptions. A note like "@foo.ts: could not be resolved" marks
388
+ a reference that failed to resolve; acknowledge the gap where relevant, never invent the
389
+ file's contents.
390
+
391
+ Conversation context: the input may include a "Conversation so far" section \u2014 a review chat
392
+ about the previous generation. Treat user statements there as requirements and corrections
393
+ that MUST steer this regeneration; treat assistant statements as clarifying context. Apply
394
+ them even where they override what the diagram alone would suggest, and say so in design.
395
+
396
+ When a baseline (existing artifacts and/or a prior conversation) is present, change only
397
+ what the diagram, referenced files, and conversation implicate \u2014 keep every unrelated part
398
+ of the previous spec stable, verbatim where possible.
399
+
400
+ Ground every requirement in what the diagram, referenced files, and conversation actually
401
+ show; record ambiguity as an assumption in design rather than inventing unrelated features.
402
+ Prefer 2-5 focused capabilities.`;
100
403
  function renderBaseline(ws) {
101
404
  const a = ws.readArtifacts();
102
405
  const sections = [];
@@ -128,7 +431,121 @@ ${edges}
128
431
  Notes:
129
432
  ${notes}`;
130
433
  }
131
- function createApp(anthropic, workspace) {
434
+ var CHAT_SYSTEM = `You are SpecSketch's spec review assistant. The user generated an OpenSpec
435
+ change proposal from an architecture diagram and is reviewing it. Answer questions about the
436
+ OpenSpec below: explain requirements and scenarios, surface gaps and ambiguities, suggest
437
+ concrete wording. Ground every answer in the OpenSpec artifacts and any referenced files
438
+ provided \u2014 quote the relevant artifact when it helps.
439
+
440
+ "=== @<path> ===" blocks are real file contents resolved from @-references; they are
441
+ authoritative over your assumptions. A note like "@x: could not be resolved" means the file
442
+ was unavailable \u2014 say so rather than guessing its contents.
443
+
444
+ Answers render as plain text in a narrow side pane: be concise, use short paragraphs or
445
+ simple "- " lists, no headings or heavy markdown.`;
446
+ function renderBundleContext(b) {
447
+ const sections = [
448
+ `--- proposal.md ---
449
+ ${b.proposal}`,
450
+ `--- design.md ---
451
+ ${b.design}`,
452
+ `--- tasks.md ---
453
+ ${b.tasks}`,
454
+ ...b.specs.map((s) => `--- specs/${s.capability}/spec.md ---
455
+ ${s.spec}`)
456
+ ];
457
+ return `Current OpenSpec (change "${b.changeName}"):
458
+
459
+ ${sections.join("\n\n")}`;
460
+ }
461
+ function renderConversation(conversation) {
462
+ if (conversation.length === 0) return "";
463
+ const turns = conversation.map((m) => `[${m.role === "user" ? "user" : "assistant"}]
464
+ ${m.text}`).join("\n\n");
465
+ return `
466
+
467
+ Conversation so far (a review chat about the previous generation \u2014 user statements are requirements and corrections that MUST steer this regeneration):
468
+
469
+ ${turns}`;
470
+ }
471
+ var SUMMARY_SYSTEM = `You write the opening message of a fresh spec-review chat inside SpecSketch.
472
+ A generation of the OpenSpec just completed and the previous conversation (if any) was
473
+ cleared. Produce a short plain-text handoff:
474
+ - If a prior conversation is provided, first recap in at most ~150 words what the user
475
+ asked for, corrected, and decided there.
476
+ - Then list what changed in this generation as short "- " bullets, grounded in the
477
+ changed-file list. When no files changed, say the artifacts were already up to date.
478
+ No headings, no markdown beyond "- " bullets, no preamble \u2014 the text is shown directly
479
+ as the first chat message.`;
480
+ async function makeSeed(anthropic, model, conversation, changedFiles, bundle) {
481
+ const changes = changedFiles.length ? changedFiles.map((f) => `- ${f}`).join("\n") : "- (none \u2014 every artifact was already up to date)";
482
+ const prompt = (conversation.length > 0 ? `Prior conversation:
483
+
484
+ ${conversation.map((m) => `[${m.role === "user" ? "user" : "assistant"}]
485
+ ${m.text}`).join("\n\n")}
486
+
487
+ ` : "There is no prior conversation \u2014 summarize only the changes made in this generation.\n\n") + `Files changed by this generation:
488
+ ${changes}
489
+
490
+ Spec summary: ${bundle.summary}`;
491
+ let text = "";
492
+ try {
493
+ const msg = await createWithModelGate(anthropic, {
494
+ model,
495
+ max_tokens: 700,
496
+ system: SUMMARY_SYSTEM,
497
+ messages: [{ role: "user", content: prompt }]
498
+ });
499
+ if (msg.stop_reason !== "refusal") text = textOf(msg);
500
+ } catch {
501
+ }
502
+ if (!text) {
503
+ const files = changedFiles.length ? `Files updated: ${changedFiles.join(", ")}.` : "No files changed \u2014 everything was already up to date.";
504
+ text = conversation.length > 0 ? `Spec regenerated with the previous conversation as context. ${files}` : `Spec generated. ${files}`;
505
+ }
506
+ return [{ role: "summary", text, at: (/* @__PURE__ */ new Date()).toISOString() }];
507
+ }
508
+ function bundleFilePaths(b) {
509
+ return [
510
+ "proposal.md",
511
+ "design.md",
512
+ "tasks.md",
513
+ ...b.specs.map((s) => `specs/${s.capability}/spec.md`)
514
+ ];
515
+ }
516
+ var FABLE_BETAS = ["server-side-fallback-2026-06-01"];
517
+ var FABLE_FALLBACKS = [{ model: "claude-opus-4-8" }];
518
+ function parseWithModelGate(anthropic, request) {
519
+ return request.model === "claude-fable-5" ? anthropic.beta.messages.parse({ ...request, betas: FABLE_BETAS, fallbacks: FABLE_FALLBACKS }) : anthropic.messages.parse(request);
520
+ }
521
+ async function createWithModelGate(anthropic, request) {
522
+ const msg = request.model === "claude-fable-5" ? await anthropic.beta.messages.create({
523
+ ...request,
524
+ betas: FABLE_BETAS,
525
+ fallbacks: FABLE_FALLBACKS
526
+ }) : await anthropic.messages.create(request);
527
+ return msg;
528
+ }
529
+ function textOf(msg) {
530
+ return msg.content.filter((b) => b.type === "text").map((b) => b.text ?? "").join("");
531
+ }
532
+ function anthropicErrorResponse(c, err, kind) {
533
+ const message = err instanceof Error ? err.message : String(err);
534
+ const status = err.status;
535
+ if (status === 401 || /authentication|auth token|x-api-key/i.test(message)) {
536
+ return c.json(
537
+ {
538
+ error: "anthropic-auth",
539
+ hint: "No Anthropic credentials available to the server. Set ANTHROPIC_API_KEY in your environment, or install the Anthropic CLI and run `ant auth login` (macOS: brew install anthropics/tap/ant), then restart specsketch.",
540
+ detail: message
541
+ },
542
+ 401
543
+ );
544
+ }
545
+ return c.json({ error: kind, detail: message }, 502);
546
+ }
547
+ function createApp(anthropic, workspace, getShare, options) {
548
+ const refRoot = options === void 0 ? process.cwd() : options.refRoot;
132
549
  const app = new Hono();
133
550
  app.get("/api/health", (c) => c.json({ ok: true }));
134
551
  app.get(
@@ -136,7 +553,8 @@ function createApp(anthropic, workspace) {
136
553
  (c) => c.json({
137
554
  workspace: Boolean(workspace),
138
555
  models: GENERATION_MODELS,
139
- defaultModel: DEFAULT_MODEL
556
+ defaultModel: DEFAULT_MODEL,
557
+ share: getShare ? getShare() : { active: false }
140
558
  })
141
559
  );
142
560
  if (workspace) {
@@ -147,7 +565,8 @@ function createApp(anthropic, workspace) {
147
565
  title: sidecar.title,
148
566
  model: GENERATION_MODELS.find((m) => m === sidecar.model),
149
567
  scene: workspace.loadScene(),
150
- bundle: workspace.toBundle()
568
+ bundle: workspace.toBundle(),
569
+ chat: workspace.readChat()
151
570
  });
152
571
  });
153
572
  app.put("/api/session", async (c) => {
@@ -159,81 +578,1609 @@ function createApp(anthropic, workspace) {
159
578
  return c.json({ ok: true });
160
579
  });
161
580
  }
581
+ app.get(
582
+ "/api/files",
583
+ (c) => c.json({ files: searchFiles(refRoot, c.req.query("q") ?? "") })
584
+ );
585
+ app.get("/api/prompt", (c) => {
586
+ const custom = workspace?.readSystemPrompt();
587
+ return c.json({ system: custom ?? SYSTEM, default: SYSTEM, custom: custom !== void 0 });
588
+ });
589
+ app.put("/api/prompt", async (c) => {
590
+ const parsed = PromptSave.safeParse(await c.req.json().catch(() => null));
591
+ if (!parsed.success) {
592
+ return c.json({ error: "invalid prompt", detail: parsed.error.issues }, 400);
593
+ }
594
+ workspace?.writeSystemPrompt(parsed.data.system ?? void 0);
595
+ return c.json({ ok: true, persisted: Boolean(workspace) });
596
+ });
162
597
  app.post("/api/spec", async (c) => {
163
598
  const parsed = SpecRequest.safeParse(await c.req.json().catch(() => null));
164
599
  if (!parsed.success) {
165
600
  return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
166
601
  }
167
- const { title, graph, model = DEFAULT_MODEL } = parsed.data;
602
+ const { title, graph, model = DEFAULT_MODEL, conversation = [] } = parsed.data;
168
603
  if (graph.nodes.length === 0) return c.json({ error: "diagram has no nodes" }, 400);
604
+ const system = parsed.data.systemPrompt ?? workspace?.readSystemPrompt() ?? SYSTEM;
169
605
  const changeNameHint = workspace ? `
170
606
 
171
607
  Use exactly "${workspace.changeName}" as the changeName.` : "";
608
+ const refs = expandReferences(
609
+ [
610
+ ...system === SYSTEM ? [] : [system],
611
+ title ?? "",
612
+ renderGraph(graph),
613
+ ...conversation.map((m) => m.text)
614
+ ].join("\n"),
615
+ refRoot
616
+ );
617
+ const refContext = renderReferenceContext(refs);
172
618
  const prompt = `Architecture diagram: "${title ?? "Untitled architecture"}"
173
619
 
174
620
  ${renderGraph(graph)}
175
621
 
176
- Produce the OpenSpec change proposal for this architecture as structured output.` + changeNameHint + (workspace ? renderBaseline(workspace) : "");
622
+ Produce the OpenSpec change proposal for this architecture as structured output.` + changeNameHint + (workspace ? renderBaseline(workspace) : "") + renderConversation(conversation) + (refContext ? `
623
+
624
+ Referenced files (resolved from @-references in the input):
625
+
626
+ ${refContext}` : "");
177
627
  let msg;
178
628
  try {
179
- const request = {
629
+ msg = await parseWithModelGate(anthropic, {
180
630
  model,
181
631
  max_tokens: 16e3,
182
632
  // non-streaming stays under the SDK HTTP timeout
183
- system: SYSTEM,
633
+ system,
184
634
  messages: [{ role: "user", content: prompt }],
185
635
  output_config: { format: zodOutputFormat(SpecBundle) }
186
636
  // No temperature/top_p or thinking config — both 400 on modern models.
187
- };
188
- msg = model === "claude-fable-5" ? (
189
- // Fable: opt into server-side refusal fallbacks by default.
190
- await anthropic.beta.messages.parse({
191
- ...request,
192
- betas: ["server-side-fallback-2026-06-01"],
193
- fallbacks: [{ model: "claude-opus-4-8" }]
194
- })
195
- ) : await anthropic.messages.parse(request);
637
+ });
196
638
  } catch (err) {
197
- const message = err instanceof Error ? err.message : String(err);
198
- const status = err.status;
199
- if (status === 401 || /authentication|auth token|x-api-key/i.test(message)) {
200
- return c.json(
201
- {
202
- error: "anthropic-auth",
203
- hint: "No Anthropic credentials available to the server. Set ANTHROPIC_API_KEY in your environment, or install the Anthropic CLI and run `ant auth login` (macOS: brew install anthropics/tap/ant), then restart specsketch.",
204
- detail: message
205
- },
206
- 401
207
- );
208
- }
209
- return c.json({ error: "generation-failed", detail: message }, 502);
639
+ return anthropicErrorResponse(c, err, "generation-failed");
210
640
  }
211
641
  if (msg.stop_reason === "refusal") {
212
642
  return c.json({ error: "refusal", detail: msg.stop_details }, 422);
213
643
  }
214
644
  if (!msg.parsed_output) return c.json({ error: "model returned no structured output" }, 502);
215
645
  const output = SpecBundle.parse(msg.parsed_output);
216
- if (workspace) {
217
- const bundle = { ...output, changeName: workspace.changeName };
218
- workspace.writeBundle(bundle);
219
- return c.json(bundle);
646
+ const bundle = workspace ? { ...output, changeName: workspace.changeName } : output;
647
+ const changedFiles = workspace ? workspace.writeBundle(bundle) : bundleFilePaths(bundle);
648
+ const seed = await makeSeed(anthropic, model, conversation, changedFiles, bundle);
649
+ if (workspace) workspace.writeChat(seed);
650
+ return c.json({ bundle, seed });
651
+ });
652
+ app.post("/api/chat", async (c) => {
653
+ const parsed = ChatRequest.safeParse(await c.req.json().catch(() => null));
654
+ if (!parsed.success) {
655
+ return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
656
+ }
657
+ const { message, conversation, model = DEFAULT_MODEL } = parsed.data;
658
+ const bundle = parsed.data.bundle ?? workspace?.toBundle() ?? null;
659
+ if (!bundle) return c.json({ error: "no spec to chat about \u2014 generate one first" }, 400);
660
+ const refs = expandReferences(message, refRoot);
661
+ const refContext = renderReferenceContext(refs);
662
+ const history = conversation.map((m) => ({
663
+ role: m.role === "user" ? "user" : "assistant",
664
+ content: m.text
665
+ }));
666
+ let msg;
667
+ try {
668
+ msg = await createWithModelGate(anthropic, {
669
+ model,
670
+ max_tokens: 2e3,
671
+ system: `${CHAT_SYSTEM}
672
+
673
+ ${renderBundleContext(bundle)}`,
674
+ messages: [
675
+ ...history,
676
+ {
677
+ role: "user",
678
+ content: message + (refContext ? `
679
+
680
+ Referenced files (resolved from @-references in the message):
681
+
682
+ ${refContext}` : "")
683
+ }
684
+ ]
685
+ });
686
+ } catch (err) {
687
+ return anthropicErrorResponse(c, err, "chat-failed");
688
+ }
689
+ if (msg.stop_reason === "refusal") {
690
+ return c.json({ error: "refusal", detail: msg.stop_details }, 422);
220
691
  }
221
- return c.json(output);
692
+ const text = textOf(msg);
693
+ if (!text) return c.json({ error: "model returned no text" }, 502);
694
+ const reply = { role: "assistant", text, at: (/* @__PURE__ */ new Date()).toISOString() };
695
+ workspace?.writeChat([
696
+ ...conversation,
697
+ { role: "user", text: message, at: reply.at },
698
+ reply
699
+ ]);
700
+ return c.json({
701
+ reply,
702
+ resolvedRefs: refs.resolved.map((r) => r.path),
703
+ unresolvedRefs: refs.unresolved
704
+ });
705
+ });
706
+ app.delete("/api/chat", (c) => {
707
+ workspace?.writeChat([]);
708
+ return c.json({ ok: true });
222
709
  });
223
710
  return app;
224
711
  }
225
712
 
713
+ // server/coop-auth.ts
714
+ import { homedir as homedir2 } from "node:os";
715
+ import { join as join3 } from "node:path";
716
+
717
+ // ../shared/src/cli-auth.ts
718
+ import { exec } from "node:child_process";
719
+ import { randomBytes } from "node:crypto";
720
+ import { existsSync } from "node:fs";
721
+ import { createServer } from "node:http";
722
+ import { hostname as hostname2 } from "node:os";
723
+ import { URL as URL2 } from "node:url";
724
+
725
+ // ../shared/src/cli-config.ts
726
+ import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
727
+ import { homedir, hostname } from "node:os";
728
+ import { join as join2, dirname } from "node:path";
729
+
730
+ // ../shared/src/schemas.ts
731
+ import { z as z2 } from "zod";
732
+ var planTierSchema = z2.enum(["hobby", "squad", "org"]);
733
+ var jurisdictionSchema = z2.enum(["eu", "fedramp"]);
734
+ var locationHintSchema = z2.enum([
735
+ "wnam",
736
+ "enam",
737
+ "weur",
738
+ "eeur",
739
+ "apac",
740
+ "oc",
741
+ "afr",
742
+ "me",
743
+ "sam"
744
+ ]);
745
+ var sessionNameSchema = z2.string().min(1).max(63).regex(
746
+ /^[a-z0-9][a-z0-9-]*$/,
747
+ "Session name must be lowercase alphanumeric with hyphens, starting with a letter or digit"
748
+ );
749
+ var teamNameSchema = z2.string().min(1).max(63).regex(
750
+ /^[a-z0-9][a-z0-9-]*$/,
751
+ "Team name must be lowercase alphanumeric with hyphens"
752
+ );
753
+ var messageKindSchema = z2.enum(["text", "context", "request", "system"]);
754
+ var messageContentSchema = z2.object({
755
+ kind: messageKindSchema,
756
+ // No .max() here: the 10 000-char body limit is business logic owned by the
757
+ // SessionActor (MAX_BODY_BYTES), which rejects with 413 MESSAGE_TOO_LARGE on
758
+ // both REST and WS. A schema cap would pre-empt it as a generic 400.
759
+ body: z2.string().min(1),
760
+ // Two-arg z.record: identical semantics in zod 3, required form in zod 4
761
+ // (specsketch bundles this file against zod 4 — keep every construct dual-safe).
762
+ metadata: z2.record(z2.string(), z2.unknown()).optional()
763
+ });
764
+ var createTeamInputSchema = z2.object({
765
+ name: teamNameSchema
766
+ });
767
+ var createSessionInputSchema = z2.object({
768
+ name: sessionNameSchema,
769
+ teamId: z2.string().optional(),
770
+ jurisdiction: jurisdictionSchema.optional(),
771
+ locationHint: locationHintSchema.optional()
772
+ });
773
+ var sendMessageInputSchema = z2.object({
774
+ content: messageContentSchema
775
+ });
776
+ var inviteInputSchema = z2.object({
777
+ email: z2.string().email(),
778
+ role: z2.enum(["admin", "member"]).default("member")
779
+ });
780
+ var joinTeamInputSchema = z2.object({
781
+ inviteCode: z2.string().min(1)
782
+ });
783
+ var markReadInputSchema = z2.object({
784
+ notificationIds: z2.array(z2.string().min(1))
785
+ });
786
+ var pollQuerySchema = z2.object({
787
+ since: z2.string().optional(),
788
+ limit: z2.coerce.number().int().min(1).max(100).default(50),
789
+ session: z2.string().optional()
790
+ });
791
+ var messagesQuerySchema = z2.object({
792
+ cursor: z2.string().optional(),
793
+ limit: z2.coerce.number().int().min(1).max(100).default(50)
794
+ });
795
+ var teamTypeSchema = z2.enum(["personal", "shared"]);
796
+ var teamRoleSchema = z2.enum(["owner", "admin", "member"]);
797
+ var accessPermissionSchema = z2.enum(["read", "write", "admin"]);
798
+ var accessGrantInputSchema = z2.object({
799
+ userId: z2.string().min(1),
800
+ sessionName: sessionNameSchema.nullable().default(null),
801
+ // null = general
802
+ permission: accessPermissionSchema
803
+ });
804
+ var machineIdSchema = z2.string().min(1).max(63).regex(
805
+ /^[a-z0-9][a-z0-9-]*$/,
806
+ "Machine ID must be lowercase alphanumeric with hyphens, starting with a letter or digit"
807
+ );
808
+ var clerkProfileInputSchema = z2.object({
809
+ email: z2.string().email().nullable(),
810
+ displayName: z2.string().min(1).max(100),
811
+ avatarUrl: z2.string().url().nullable(),
812
+ provider: z2.string().min(1).max(50),
813
+ providerUserId: z2.string().min(1).max(100)
814
+ });
815
+ var sessionEnrollmentSchema = z2.object({
816
+ sessionId: z2.string(),
817
+ team: z2.string(),
818
+ teamName: z2.string().optional(),
819
+ key: z2.string().startsWith("api_"),
820
+ enrolledAt: z2.string().datetime(),
821
+ apiUrl: z2.string().url().optional(),
822
+ machineId: z2.string().optional()
823
+ });
824
+ var authConfigSchema = z2.object({
825
+ key: z2.string().startsWith("api_"),
826
+ userId: z2.string().min(1),
827
+ displayName: z2.string().nullable().optional().default(null),
828
+ email: z2.string().email().nullable(),
829
+ machineId: z2.string().nullable(),
830
+ authenticatedAt: z2.string().datetime()
831
+ });
832
+ var configBaseFields = {
833
+ apiUrl: z2.string().url(),
834
+ machineId: z2.string().nullable(),
835
+ sessions: z2.record(z2.string(), sessionEnrollmentSchema),
836
+ defaults: z2.object({
837
+ team: z2.string().nullable(),
838
+ session: z2.string().nullable()
839
+ }),
840
+ poll: z2.object({
841
+ lastPollAt: z2.string().datetime().nullable()
842
+ })
843
+ };
844
+ var coopConfigV1Schema = z2.object({
845
+ version: z2.literal(1),
846
+ ...configBaseFields
847
+ });
848
+ var coopConfigV2Schema = z2.object({
849
+ version: z2.literal(2),
850
+ ...configBaseFields,
851
+ auth: authConfigSchema.optional()
852
+ });
853
+ var profileNameSchema = z2.string().regex(
854
+ /^[a-z0-9][a-z0-9_-]{0,63}$/,
855
+ "Profile name must be lowercase alphanumeric with dashes/underscores, max 64 chars"
856
+ );
857
+ var coopProfileSchema = z2.object({
858
+ apiUrl: z2.string().url(),
859
+ auth: authConfigSchema.optional(),
860
+ sessions: z2.record(z2.string(), sessionEnrollmentSchema),
861
+ defaults: z2.object({
862
+ team: z2.string().nullable(),
863
+ session: z2.string().nullable()
864
+ }),
865
+ poll: z2.object({
866
+ lastPollAt: z2.string().datetime().nullable()
867
+ })
868
+ });
869
+ var coopConfigV3ObjectSchema = z2.object({
870
+ version: z2.literal(3),
871
+ machineId: z2.string().nullable(),
872
+ defaultProfile: z2.string(),
873
+ profiles: z2.record(profileNameSchema, coopProfileSchema)
874
+ });
875
+ function requireDefaultProfilePointer(config, ctx) {
876
+ if (!(config.defaultProfile in config.profiles)) {
877
+ ctx.addIssue({
878
+ code: "custom",
879
+ path: ["defaultProfile"],
880
+ message: `defaultProfile "${config.defaultProfile}" does not name an existing profile`
881
+ });
882
+ }
883
+ }
884
+ var coopConfigV3Schema = coopConfigV3ObjectSchema.superRefine(
885
+ requireDefaultProfilePointer
886
+ );
887
+ var coopConfigSchema = z2.discriminatedUnion("version", [
888
+ coopConfigV1Schema,
889
+ coopConfigV2Schema,
890
+ coopConfigV3ObjectSchema
891
+ ]).superRefine((config, ctx) => {
892
+ if (config.version === 3) {
893
+ requireDefaultProfilePointer(config, ctx);
894
+ }
895
+ });
896
+ var hookStdinSchema = z2.object({
897
+ session_id: z2.string().optional(),
898
+ cwd: z2.string().optional(),
899
+ hook_event_name: z2.string().optional()
900
+ }).passthrough();
901
+ var wsClientMessageSchema = z2.discriminatedUnion("type", [
902
+ z2.object({ type: z2.literal("message"), content: messageContentSchema }),
903
+ z2.object({ type: z2.literal("presence_request") }),
904
+ z2.object({ type: z2.literal("ping") })
905
+ ]);
906
+
907
+ // ../shared/src/cli-config.ts
908
+ var DEFAULT_CONFIG_DIR = join2(homedir(), ".coopcli");
909
+ var DEFAULT_CONFIG_PATH = join2(DEFAULT_CONFIG_DIR, "config.json");
910
+ var PROD_API_URL = "https://api.coopcli.com";
911
+ function emptyProfile(apiUrl) {
912
+ return {
913
+ apiUrl,
914
+ sessions: {},
915
+ defaults: { team: null, session: null },
916
+ poll: { lastPollAt: null }
917
+ };
918
+ }
919
+ var DEFAULT_CONFIG = {
920
+ version: 3,
921
+ machineId: hostname(),
922
+ defaultProfile: "default",
923
+ profiles: { default: emptyProfile(PROD_API_URL) }
924
+ };
925
+ function upgradeToV3(parsed) {
926
+ if (parsed.version === 3) return parsed;
927
+ const profile = {
928
+ apiUrl: parsed.apiUrl,
929
+ ...parsed.version === 2 && parsed.auth ? { auth: parsed.auth } : {},
930
+ sessions: parsed.sessions,
931
+ defaults: parsed.defaults,
932
+ poll: parsed.poll
933
+ };
934
+ return {
935
+ version: 3,
936
+ machineId: parsed.machineId,
937
+ defaultProfile: "default",
938
+ profiles: { default: profile }
939
+ };
940
+ }
941
+ function profileFromEnv(env, envVars) {
942
+ for (const name of envVars) {
943
+ const value = env[name];
944
+ if (value) return value;
945
+ }
946
+ return null;
947
+ }
948
+ var ConfigStore = class {
949
+ config = null;
950
+ selectedProfile = null;
951
+ configPath;
952
+ configDir;
953
+ constructor(configPath) {
954
+ this.configPath = configPath ?? DEFAULT_CONFIG_PATH;
955
+ this.configDir = dirname(this.configPath);
956
+ }
957
+ /**
958
+ * Select the profile this store operates on for the rest of the process
959
+ * (--profile flag / COOP_PROFILE env). Without a selection, the
960
+ * defaultProfile pointer applies.
961
+ */
962
+ selectProfile(name) {
963
+ this.selectedProfile = name;
964
+ }
965
+ activeProfileName(config) {
966
+ return this.selectedProfile ?? config.defaultProfile;
967
+ }
968
+ view(config) {
969
+ const profileName = this.activeProfileName(config);
970
+ const profile = config.profiles[profileName];
971
+ if (!profile) {
972
+ const available = Object.keys(config.profiles).join(", ");
973
+ throw new Error(`Unknown profile "${profileName}". Available profiles: ${available}`);
974
+ }
975
+ return { profileName, machineId: config.machineId, ...profile };
976
+ }
977
+ /** Returns the active-profile view of the previously loaded config. Throws if load() hasn't been called. */
978
+ cached() {
979
+ return this.view(this.cachedRaw());
980
+ }
981
+ /** Returns the full previously loaded config. Throws if load() hasn't been called. */
982
+ cachedRaw() {
983
+ if (this.config === null) {
984
+ throw new Error("Config not loaded yet. Call load() first.");
985
+ }
986
+ return this.config;
987
+ }
988
+ /** Load the config file and return the active profile's view. */
989
+ async load() {
990
+ return this.view(await this.loadRaw());
991
+ }
992
+ /** Load the config file, normalized to v3. Legacy v1/v2 files upgrade in memory (persisted on next save). */
993
+ async loadRaw() {
994
+ if (this.config !== null) {
995
+ return this.config;
996
+ }
997
+ try {
998
+ const raw = await readFile(this.configPath, "utf8");
999
+ const parsed = upgradeToV3(coopConfigSchema.parse(JSON.parse(raw)));
1000
+ if (!parsed.machineId) {
1001
+ parsed.machineId = hostname();
1002
+ }
1003
+ this.config = parsed;
1004
+ return parsed;
1005
+ } catch (err) {
1006
+ if (isNodeError(err) && err.code === "ENOENT") {
1007
+ this.config = structuredClone(DEFAULT_CONFIG);
1008
+ return this.config;
1009
+ }
1010
+ if (err instanceof SyntaxError || err?.name === "ZodError") {
1011
+ throw new Error(`Config file (${this.configPath}) is corrupt. Reset with: rm ${this.configPath}`);
1012
+ }
1013
+ throw err;
1014
+ }
1015
+ }
1016
+ async save(config) {
1017
+ await mkdir(this.configDir, { recursive: true, mode: 448 });
1018
+ const content = JSON.stringify(config, null, 2);
1019
+ const tmpPath = this.configPath + ".tmp";
1020
+ await writeFile(tmpPath, content, { mode: 384 });
1021
+ const { rename } = await import("node:fs/promises");
1022
+ await rename(tmpPath, this.configPath);
1023
+ try {
1024
+ await chmod(this.configPath, 384);
1025
+ } catch {
1026
+ }
1027
+ this.config = config;
1028
+ }
1029
+ /** Load, transform the active profile, and save — the single write path for profile-scoped state. */
1030
+ async saveProfile(mutate) {
1031
+ const config = await this.loadRaw();
1032
+ const name = this.activeProfileName(config);
1033
+ const profile = config.profiles[name];
1034
+ if (!profile) {
1035
+ throw new Error(`Profile "${name}" not found in config (${this.configPath})`);
1036
+ }
1037
+ await this.save({
1038
+ ...config,
1039
+ profiles: { ...config.profiles, [name]: mutate(profile) }
1040
+ });
1041
+ }
1042
+ /** Validate config for common misconfigurations. Returns warning strings. */
1043
+ validate() {
1044
+ if (!this.config) return [];
1045
+ let view;
1046
+ try {
1047
+ view = this.view(this.config);
1048
+ } catch {
1049
+ return [];
1050
+ }
1051
+ const warnings = [];
1052
+ try {
1053
+ const url = new URL(view.apiUrl);
1054
+ if (!["http:", "https:"].includes(url.protocol)) {
1055
+ warnings.push(`apiUrl protocol "${url.protocol}" is not http/https`);
1056
+ }
1057
+ } catch {
1058
+ warnings.push(`apiUrl "${view.apiUrl}" is not a valid URL`);
1059
+ }
1060
+ if (this.selectedProfile === null && (view.apiUrl.includes("localhost") || view.apiUrl.includes("127.0.0.1"))) {
1061
+ warnings.push(
1062
+ `profile "${view.profileName}" points to localhost \u2014 select it explicitly with --profile, or run coop login`
1063
+ );
1064
+ }
1065
+ return warnings;
1066
+ }
1067
+ async enrollSession(name, enrollment) {
1068
+ await this.saveProfile((profile) => ({
1069
+ ...profile,
1070
+ sessions: { ...profile.sessions, [name]: enrollment }
1071
+ }));
1072
+ }
1073
+ async unenrollSession(name) {
1074
+ await this.saveProfile((profile) => {
1075
+ const sessions = { ...profile.sessions };
1076
+ delete sessions[name];
1077
+ const defaults = { ...profile.defaults };
1078
+ if (defaults.session === name) {
1079
+ defaults.session = null;
1080
+ }
1081
+ return { ...profile, sessions, defaults };
1082
+ });
1083
+ }
1084
+ async setDefault(key, value) {
1085
+ await this.saveProfile((profile) => ({
1086
+ ...profile,
1087
+ defaults: { ...profile.defaults, [key]: value }
1088
+ }));
1089
+ }
1090
+ async getDefaultSession() {
1091
+ const view = await this.load();
1092
+ return view.defaults.session;
1093
+ }
1094
+ async getSessionKey(sessionName) {
1095
+ const view = await this.load();
1096
+ const enrollment = view.sessions[sessionName];
1097
+ if (!enrollment) {
1098
+ throw new Error(`Not enrolled in session "${sessionName}". Join it first.`);
1099
+ }
1100
+ return enrollment.key;
1101
+ }
1102
+ /** Resolve the API URL for a session, falling back to the active profile's apiUrl. */
1103
+ async getApiUrl(sessionName) {
1104
+ const view = await this.load();
1105
+ if (sessionName) {
1106
+ const enrollment = view.sessions[sessionName];
1107
+ if (enrollment?.apiUrl) return enrollment.apiUrl;
1108
+ }
1109
+ return view.apiUrl;
1110
+ }
1111
+ /** Resolve the machine ID for a session, falling back to the global machineId or the profile's auth. */
1112
+ async getMachineId(sessionName) {
1113
+ const view = await this.load();
1114
+ if (sessionName) {
1115
+ const enrollment = view.sessions[sessionName];
1116
+ if (enrollment?.machineId) return enrollment.machineId;
1117
+ }
1118
+ if (view.machineId) return view.machineId;
1119
+ if (view.auth?.machineId) return view.auth.machineId;
1120
+ return null;
1121
+ }
1122
+ async setMachineId(machineId) {
1123
+ const config = await this.loadRaw();
1124
+ await this.save({ ...config, machineId });
1125
+ }
1126
+ /** Set the active profile's API base URL. */
1127
+ async setApiUrl(apiUrl) {
1128
+ await this.saveProfile((profile) => ({ ...profile, apiUrl }));
1129
+ }
1130
+ /** Create the active profile (empty, at the given endpoint) if it does not exist yet. */
1131
+ async ensureActiveProfile(apiUrl) {
1132
+ const config = await this.loadRaw();
1133
+ const name = this.activeProfileName(config);
1134
+ if (config.profiles[name]) return;
1135
+ await this.save({
1136
+ ...config,
1137
+ profiles: { ...config.profiles, [name]: emptyProfile(apiUrl) }
1138
+ });
1139
+ }
1140
+ /** Re-point the defaultProfile fallback. The target must exist. */
1141
+ async setDefaultProfile(name) {
1142
+ const config = await this.loadRaw();
1143
+ if (!config.profiles[name]) {
1144
+ const available = Object.keys(config.profiles).join(", ");
1145
+ throw new Error(`Unknown profile "${name}". Available profiles: ${available}`);
1146
+ }
1147
+ await this.save({ ...config, defaultProfile: name });
1148
+ }
1149
+ /** True if any profile holds an auth block (used to detect the first login). */
1150
+ async hasAnyAuth() {
1151
+ const config = await this.loadRaw();
1152
+ return Object.values(config.profiles).some((profile) => profile.auth !== void 0);
1153
+ }
1154
+ /**
1155
+ * Remove a profile including its credentials and enrollments. The
1156
+ * defaultProfile pointer target cannot be deleted — re-point first.
1157
+ */
1158
+ async deleteProfile(name) {
1159
+ const config = await this.loadRaw();
1160
+ if (!config.profiles[name]) {
1161
+ const available = Object.keys(config.profiles).join(", ");
1162
+ throw new Error(`Unknown profile "${name}". Available profiles: ${available}`);
1163
+ }
1164
+ if (config.defaultProfile === name) {
1165
+ throw new Error(
1166
+ `Profile "${name}" is the default profile. Re-point it first: coop profile set-default <other>`
1167
+ );
1168
+ }
1169
+ const profiles = { ...config.profiles };
1170
+ delete profiles[name];
1171
+ await this.save({ ...config, profiles });
1172
+ }
1173
+ async updateLastPoll() {
1174
+ await this.saveProfile((profile) => ({
1175
+ ...profile,
1176
+ poll: { lastPollAt: (/* @__PURE__ */ new Date()).toISOString() }
1177
+ }));
1178
+ }
1179
+ /** Returns true if the active profile has an auth block. */
1180
+ isAuthenticated() {
1181
+ if (this.config === null) return false;
1182
+ return this.view(this.config).auth !== void 0;
1183
+ }
1184
+ /** Returns the user-scoped API key, or throws if not authenticated. */
1185
+ getAuthKey() {
1186
+ const auth = this.config === null ? void 0 : this.view(this.config).auth;
1187
+ if (!auth) {
1188
+ throw new Error("Not authenticated. Run `coop login` first.");
1189
+ }
1190
+ return auth.key;
1191
+ }
1192
+ /** Returns the user ID, or throws if not authenticated. */
1193
+ getUserId() {
1194
+ const auth = this.config === null ? void 0 : this.view(this.config).auth;
1195
+ if (!auth) {
1196
+ throw new Error("Not authenticated. Run `coop login` first.");
1197
+ }
1198
+ return auth.userId;
1199
+ }
1200
+ /**
1201
+ * Saves the auth block into the active profile. Legacy v1/v2 files are
1202
+ * normalized to v3 by load(), so the write always persists v3.
1203
+ */
1204
+ async setAuth(auth) {
1205
+ await this.saveProfile((profile) => ({ ...profile, auth }));
1206
+ }
1207
+ /**
1208
+ * Returns true if the user should be nudged to run `coop login`:
1209
+ * the active profile has no auth block.
1210
+ */
1211
+ needsLoginNudge() {
1212
+ if (this.config === null) return true;
1213
+ return this.view(this.config).auth === void 0;
1214
+ }
1215
+ /**
1216
+ * Removes the auth block from the active profile (for `coop logout`).
1217
+ * Sessions are preserved.
1218
+ */
1219
+ async clearAuth() {
1220
+ await this.saveProfile((profile) => {
1221
+ const { auth: _removed, ...rest } = profile;
1222
+ return rest;
1223
+ });
1224
+ }
1225
+ };
1226
+ function isNodeError(err) {
1227
+ return err instanceof Error && "code" in err;
1228
+ }
1229
+
1230
+ // ../shared/src/cli-auth.ts
1231
+ function resolveDisplayName(name, email, userId) {
1232
+ if (name && !name.startsWith("user_")) return name;
1233
+ if (email) return email.split("@")[0];
1234
+ if (userId) return userId.slice(0, 12);
1235
+ if (name) return name.slice(0, 12);
1236
+ return "unknown";
1237
+ }
1238
+ async function openBrowser(url) {
1239
+ const escaped = url.replace(/"/g, '\\"');
1240
+ const cmd = process.platform === "darwin" ? `open "${escaped}"` : process.platform === "win32" ? `start "" "${escaped}"` : `xdg-open "${escaped}"`;
1241
+ return new Promise((resolve4) => {
1242
+ exec(cmd, () => resolve4());
1243
+ });
1244
+ }
1245
+ var PAGE_STYLE = "body{background:#0f0f0f;color:#e8e8e8;font-family:system-ui;display:flex;justify-content:center;padding-top:4rem}";
1246
+ function htmlPage(title, body) {
1247
+ return `<!DOCTYPE html><html><head><title>${title}</title><style>${PAGE_STYLE}</style></head><body><div><h2>${title}</h2><p>${body}</p></div></body></html>`;
1248
+ }
1249
+ function startLocalhostServer(options) {
1250
+ const timeout = options.timeout ?? 12e4;
1251
+ const loginCommand = options.loginCommand ?? "coop login";
1252
+ return new Promise((resolveServer, rejectServer) => {
1253
+ let resolveCallback;
1254
+ let rejectCallback;
1255
+ const callbackPromise = new Promise((res, rej) => {
1256
+ resolveCallback = res;
1257
+ rejectCallback = rej;
1258
+ });
1259
+ const timer = setTimeout(() => {
1260
+ rejectCallback(new Error(`Login timed out. No browser callback received. Check your browser and try \`${loginCommand}\` again.`));
1261
+ server.close();
1262
+ }, timeout);
1263
+ const server = createServer((req, res) => {
1264
+ const url = new URL2(req.url ?? "/", `http://127.0.0.1`);
1265
+ if (url.pathname === "/") {
1266
+ res.writeHead(200, { "Content-Type": "text/html" });
1267
+ res.end(htmlPage("Waiting for Authentication", `This page was opened by <code>${loginCommand}</code>. Complete sign-in in the browser window.`));
1268
+ return;
1269
+ }
1270
+ if (url.pathname === "/callback") {
1271
+ const state = url.searchParams.get("state") ?? "";
1272
+ if (state !== options.expectedState) {
1273
+ res.writeHead(400, { "Content-Type": "text/html" });
1274
+ res.end(htmlPage("State Mismatch", `Possible CSRF attack. Try <code>${loginCommand}</code> again.`));
1275
+ return;
1276
+ }
1277
+ const key = url.searchParams.get("key") ?? "";
1278
+ if (!key) {
1279
+ res.writeHead(400, { "Content-Type": "text/html" });
1280
+ res.end(htmlPage("Missing Key", `No API key received. Try <code>${loginCommand}</code> again.`));
1281
+ return;
1282
+ }
1283
+ res.writeHead(200, { "Content-Type": "text/html" });
1284
+ res.end(htmlPage("Authentication Complete", "You can close this tab."));
1285
+ clearTimeout(timer);
1286
+ resolveCallback({
1287
+ key,
1288
+ userId: url.searchParams.get("userId") ?? "",
1289
+ email: url.searchParams.get("email") ?? "",
1290
+ displayName: url.searchParams.get("displayName") ?? "",
1291
+ state,
1292
+ apiUrl: url.searchParams.get("apiUrl") ?? ""
1293
+ });
1294
+ return;
1295
+ }
1296
+ res.writeHead(404, { "Content-Type": "text/plain" });
1297
+ res.end("Not Found");
1298
+ });
1299
+ server.listen(0, "127.0.0.1", () => {
1300
+ const addr = server.address();
1301
+ const port = typeof addr === "object" && addr ? addr.port : 0;
1302
+ resolveServer({
1303
+ port,
1304
+ close: () => {
1305
+ clearTimeout(timer);
1306
+ server.close();
1307
+ },
1308
+ waitForCallback: () => callbackPromise
1309
+ });
1310
+ });
1311
+ server.on("error", (err) => {
1312
+ clearTimeout(timer);
1313
+ rejectServer(err);
1314
+ });
1315
+ });
1316
+ }
1317
+ function deriveApiUrl(webUrl) {
1318
+ const url = new URL2(webUrl);
1319
+ if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
1320
+ return "http://localhost:8788";
1321
+ }
1322
+ return `https://api.${url.hostname}`;
1323
+ }
1324
+ async function login(input, deps) {
1325
+ const state = randomBytes(16).toString("hex");
1326
+ const machineId = input.machine ?? hostname2();
1327
+ const server = await startLocalhostServer({ expectedState: state, loginCommand: input.loginCommand });
1328
+ try {
1329
+ const webUrl = input.webUrl ?? "https://coopcli.com";
1330
+ const authUrl = `${webUrl}/cli-auth?port=${server.port}&state=${state}&machine=${encodeURIComponent(machineId)}`;
1331
+ process.stderr.write(`Opening browser for authentication...
1332
+ `);
1333
+ await deps.openBrowser(authUrl);
1334
+ process.stderr.write(`Waiting for callback on port ${server.port}...
1335
+ `);
1336
+ const params = await server.waitForCallback();
1337
+ const displayName = resolveDisplayName(params.displayName, params.email, params.userId);
1338
+ const resolvedApiUrl = input.apiUrl ?? (params.apiUrl || deriveApiUrl(webUrl));
1339
+ const isFirstLogin = !await deps.config.hasAnyAuth();
1340
+ await deps.config.ensureActiveProfile(resolvedApiUrl);
1341
+ await deps.config.setAuth({
1342
+ key: params.key,
1343
+ userId: params.userId,
1344
+ displayName,
1345
+ email: params.email || null,
1346
+ machineId,
1347
+ authenticatedAt: (/* @__PURE__ */ new Date()).toISOString()
1348
+ });
1349
+ const config = await deps.config.load();
1350
+ if (params.apiUrl && config.apiUrl && params.apiUrl !== config.apiUrl) {
1351
+ process.stderr.write(
1352
+ `
1353
+ Backend mismatch: you authenticated against ${params.apiUrl}
1354
+ but your config points to ${config.apiUrl}
1355
+ Updating config to ${resolvedApiUrl}
1356
+
1357
+ `
1358
+ );
1359
+ }
1360
+ if (resolvedApiUrl !== config.apiUrl) {
1361
+ await deps.config.setApiUrl(resolvedApiUrl);
1362
+ }
1363
+ if (isFirstLogin) {
1364
+ await deps.config.setDefaultProfile(config.profileName);
1365
+ }
1366
+ return {
1367
+ userId: params.userId,
1368
+ email: params.email || null,
1369
+ displayName
1370
+ };
1371
+ } finally {
1372
+ server.close();
1373
+ }
1374
+ }
1375
+ var CliAuthError = class extends Error {
1376
+ constructor(message, kind) {
1377
+ super(message);
1378
+ this.kind = kind;
1379
+ this.name = "CliAuthError";
1380
+ }
1381
+ kind;
1382
+ };
1383
+ async function resolveCoopCredentials(opts) {
1384
+ const env = opts.env ?? process.env;
1385
+ const { configPath, loginHint } = opts;
1386
+ if (!existsSync(configPath)) {
1387
+ throw new CliAuthError(`No CoopCLI config found at ${configPath}.
1388
+ ${loginHint}`, "missing-config");
1389
+ }
1390
+ const store = new ConfigStore(configPath);
1391
+ let raw;
1392
+ try {
1393
+ raw = await store.loadRaw();
1394
+ } catch {
1395
+ throw new CliAuthError(
1396
+ `Could not read CoopCLI credentials from ${configPath}.
1397
+ ${loginHint}`,
1398
+ "unreadable-config"
1399
+ );
1400
+ }
1401
+ const profileName = profileFromEnv(env, opts.profileEnvVars ?? ["COOP_PROFILE"]) ?? raw.defaultProfile;
1402
+ const profile = raw.profiles[profileName];
1403
+ if (!profile) {
1404
+ const available = Object.keys(raw.profiles).join(", ");
1405
+ throw new CliAuthError(
1406
+ `CoopCLI config at ${configPath} has no profile "${profileName}" (available: ${available}).
1407
+ ${loginHint}`,
1408
+ "unknown-profile"
1409
+ );
1410
+ }
1411
+ if (!profile.auth) {
1412
+ throw new CliAuthError(`CoopCLI config at ${configPath} has no login.
1413
+ ${loginHint}`, "no-login");
1414
+ }
1415
+ const apiUrl = (opts.apiUrlEnvVar && env[opts.apiUrlEnvVar] || profile.apiUrl).replace(/\/+$/, "");
1416
+ return {
1417
+ apiUrl,
1418
+ key: profile.auth.key,
1419
+ userId: profile.auth.userId,
1420
+ displayName: profile.auth.displayName ?? null
1421
+ };
1422
+ }
1423
+
1424
+ // server/coop-auth.ts
1425
+ var CoopAuthError = class extends Error {
1426
+ };
1427
+ var LOGIN_HINT = "--share needs a CoopCLI account. Run `specsketch login` (it opens your browser and saves credentials to ~/.coopcli/config.json), then rerun with --share.";
1428
+ async function readCoopAuth(configPath = join3(homedir2(), ".coopcli", "config.json"), env = process.env) {
1429
+ try {
1430
+ return await resolveCoopCredentials({
1431
+ configPath,
1432
+ env,
1433
+ profileEnvVars: ["SPECSKETCH_COOP_PROFILE", "COOP_PROFILE"],
1434
+ apiUrlEnvVar: "SPECSKETCH_COOP_API_URL",
1435
+ loginHint: LOGIN_HINT
1436
+ });
1437
+ } catch (err) {
1438
+ if (err instanceof CliAuthError) throw new CoopAuthError(err.message);
1439
+ throw err;
1440
+ }
1441
+ }
1442
+
1443
+ // server/login.ts
1444
+ import { parseArgs } from "node:util";
1445
+ var LOGIN_USAGE = `Usage: specsketch login [--profile <name>] [--web-url <url>]
1446
+
1447
+ Opens your browser to sign in to CoopCLI and saves credentials to
1448
+ ~/.coopcli/config.json \u2014 the same file and format \`coop login\` uses.
1449
+
1450
+ --profile config profile to log into (default: SPECSKETCH_COOP_PROFILE,
1451
+ then COOP_PROFILE, then the config's default profile). A profile
1452
+ that doesn't exist yet is created.
1453
+ --web-url web app URL for browser auth (default: https://coopcli.com)`;
1454
+ var LoginUsageError = class extends Error {
1455
+ };
1456
+ function parseLoginArgs(argv) {
1457
+ let parsed;
1458
+ try {
1459
+ parsed = parseArgs({
1460
+ args: argv,
1461
+ options: { profile: { type: "string" }, "web-url": { type: "string" } },
1462
+ allowPositionals: true
1463
+ });
1464
+ } catch (err) {
1465
+ throw new LoginUsageError(err instanceof Error ? err.message : String(err));
1466
+ }
1467
+ if (parsed.positionals.length > 0) {
1468
+ throw new LoginUsageError(`unexpected argument: ${parsed.positionals[0]}`);
1469
+ }
1470
+ return {
1471
+ profile: parsed.values.profile ?? null,
1472
+ ...parsed.values["web-url"] !== void 0 ? { webUrl: parsed.values["web-url"] } : {}
1473
+ };
1474
+ }
1475
+ async function runLogin(args, deps = {}) {
1476
+ const env = deps.env ?? process.env;
1477
+ const config = deps.config ?? new ConfigStore();
1478
+ const profile = args.profile ?? profileFromEnv(env, ["SPECSKETCH_COOP_PROFILE", "COOP_PROFILE"]);
1479
+ if (profile) config.selectProfile(profile);
1480
+ const result = await login(
1481
+ { webUrl: args.webUrl, loginCommand: "specsketch login" },
1482
+ { config, openBrowser: deps.openBrowser ?? openBrowser }
1483
+ );
1484
+ (deps.log ?? console.log)(`Authenticated as ${result.email ?? result.userId}. Credentials saved.`);
1485
+ }
1486
+
1487
+ // server/remote-session.ts
1488
+ import { WebSocket } from "ws";
1489
+
1490
+ // ../shared/src/errors.ts
1491
+ function isApiErrorBody(value) {
1492
+ if (typeof value !== "object" || value === null) return false;
1493
+ const v = value;
1494
+ return typeof v.category === "string" && typeof v.code === "string" && typeof v.message === "string";
1495
+ }
1496
+
1497
+ // ../shared/src/api-client.ts
1498
+ var CoopApiError = class extends Error {
1499
+ category;
1500
+ code;
1501
+ status;
1502
+ details;
1503
+ constructor(status, body) {
1504
+ super(body.message);
1505
+ this.name = "CoopApiError";
1506
+ this.category = body.category;
1507
+ this.code = body.code;
1508
+ this.status = status;
1509
+ this.details = body.details;
1510
+ }
1511
+ };
1512
+ function isCoopApiError(err) {
1513
+ return err instanceof CoopApiError;
1514
+ }
1515
+ async function streamToBuffer(stream) {
1516
+ const reader = stream.getReader();
1517
+ const chunks = [];
1518
+ for (; ; ) {
1519
+ const { done, value } = await reader.read();
1520
+ if (done) break;
1521
+ chunks.push(value);
1522
+ }
1523
+ const total = chunks.reduce((sum, c) => sum + c.length, 0);
1524
+ const result = new Uint8Array(total);
1525
+ let offset = 0;
1526
+ for (const chunk of chunks) {
1527
+ result.set(chunk, offset);
1528
+ offset += chunk.length;
1529
+ }
1530
+ return result;
1531
+ }
1532
+ var verbose = false;
1533
+ var ApiClient = class _ApiClient {
1534
+ constructor(apiUrl, token, userId) {
1535
+ this.apiUrl = apiUrl;
1536
+ this.token = token;
1537
+ this.userId = userId;
1538
+ }
1539
+ apiUrl;
1540
+ token;
1541
+ userId;
1542
+ /** Build a user-scoped path. Only callable when userId is set. */
1543
+ userPath(sub) {
1544
+ return `/api/u/${this.userId}${sub}`;
1545
+ }
1546
+ async request(method, path, body, params) {
1547
+ const url = new URL(path, this.apiUrl);
1548
+ if (params) {
1549
+ for (const [key, val] of Object.entries(params)) {
1550
+ if (val !== void 0) {
1551
+ url.searchParams.set(key, String(val));
1552
+ }
1553
+ }
1554
+ }
1555
+ if (verbose) {
1556
+ process.stderr.write(`[verbose] ${method} ${url.toString()}
1557
+ `);
1558
+ if (body) process.stderr.write(`[verbose] body: ${JSON.stringify(body)}
1559
+ `);
1560
+ }
1561
+ const res = await fetch(url.toString(), {
1562
+ method,
1563
+ headers: {
1564
+ Authorization: `Bearer ${this.token}`,
1565
+ "Content-Type": "application/json"
1566
+ },
1567
+ body: body !== void 0 ? JSON.stringify(body) : void 0
1568
+ });
1569
+ if (verbose) {
1570
+ process.stderr.write(`[verbose] ${res.status} ${res.statusText}
1571
+ `);
1572
+ }
1573
+ if (!res.ok) {
1574
+ let parsed = null;
1575
+ try {
1576
+ const json = await res.json();
1577
+ if (isApiErrorBody(json)) {
1578
+ parsed = json;
1579
+ } else {
1580
+ const legacy = json;
1581
+ parsed = {
1582
+ category: res.status >= 500 ? "server_error" : "user_action",
1583
+ code: "UNKNOWN",
1584
+ message: legacy.error ?? legacy.message ?? res.statusText
1585
+ };
1586
+ }
1587
+ } catch {
1588
+ parsed = {
1589
+ category: res.status >= 500 ? "server_error" : "user_action",
1590
+ code: "UNKNOWN",
1591
+ message: res.statusText
1592
+ };
1593
+ }
1594
+ if (verbose) {
1595
+ process.stderr.write(`[verbose] error: [${parsed.category}/${parsed.code}] ${parsed.message}
1596
+ `);
1597
+ }
1598
+ throw new CoopApiError(res.status, parsed);
1599
+ }
1600
+ return res.json();
1601
+ }
1602
+ async getMe() {
1603
+ const path = this.userId ? this.userPath("/me") : "/api/me";
1604
+ return this.request("GET", path);
1605
+ }
1606
+ async createTeam(input) {
1607
+ if (this.userId) {
1608
+ }
1609
+ return this.request("POST", "/api/teams", input);
1610
+ }
1611
+ async listTeams() {
1612
+ if (this.userId) {
1613
+ const me = await this.request("GET", this.userPath("/me"));
1614
+ return (me.teams ?? []).map((m) => ({
1615
+ id: m.teamId,
1616
+ name: m.teamName,
1617
+ type: m.teamId.startsWith("team:personal:") ? "personal" : "shared",
1618
+ plan: "hobby",
1619
+ createdBy: "",
1620
+ createdAt: m.joinedAt
1621
+ }));
1622
+ }
1623
+ return this.request("GET", "/api/teams");
1624
+ }
1625
+ async inviteToTeam(team, input) {
1626
+ return this.request("POST", `/api/teams/${encodeURIComponent(team)}/invite`, input);
1627
+ }
1628
+ async joinTeam(team, inviteCode) {
1629
+ await this.request("POST", `/api/teams/${encodeURIComponent(team)}/join`, { inviteCode });
1630
+ }
1631
+ async listTeamMembers(team) {
1632
+ return this.request("GET", `/api/teams/${encodeURIComponent(team)}/members`);
1633
+ }
1634
+ async createSession(input) {
1635
+ const path = this.userId ? this.userPath("/sessions") : "/api/sessions";
1636
+ return this.request("POST", path, input);
1637
+ }
1638
+ async listSessions() {
1639
+ const path = this.userId ? this.userPath("/sessions") : "/api/sessions";
1640
+ const result = await this.request("GET", path);
1641
+ return result.sessions;
1642
+ }
1643
+ async joinSession(name) {
1644
+ await this.request("POST", `/api/sessions/${encodeURIComponent(name)}/join`);
1645
+ }
1646
+ async leaveSession(name) {
1647
+ await this.request("POST", `/api/sessions/${encodeURIComponent(name)}/leave`);
1648
+ }
1649
+ async deleteSession(name) {
1650
+ const path = this.userId ? this.userPath(`/sessions/${encodeURIComponent(name)}`) : `/api/sessions/${encodeURIComponent(name)}`;
1651
+ return this.request("DELETE", path);
1652
+ }
1653
+ async sendMessage(teamId, session, input) {
1654
+ if (this.userId) {
1655
+ return this.request(
1656
+ "POST",
1657
+ this.userPath(`/sessions/${encodeURIComponent(session)}/messages`),
1658
+ input,
1659
+ { team: teamId }
1660
+ );
1661
+ }
1662
+ return this.request("POST", `/api/teams/${encodeURIComponent(teamId)}/sessions/${encodeURIComponent(session)}/messages`, input);
1663
+ }
1664
+ async getMessages(teamId, session, cursor, limit) {
1665
+ if (this.userId) {
1666
+ return this.request(
1667
+ "GET",
1668
+ this.userPath(`/sessions/${encodeURIComponent(session)}/messages`),
1669
+ void 0,
1670
+ { team: teamId, cursor, limit }
1671
+ );
1672
+ }
1673
+ return this.request("GET", `/api/teams/${encodeURIComponent(teamId)}/sessions/${encodeURIComponent(session)}/messages`, void 0, {
1674
+ cursor,
1675
+ limit
1676
+ });
1677
+ }
1678
+ async getLatestMessages(teamId, session, count = 5) {
1679
+ if (this.userId) {
1680
+ return this.request(
1681
+ "GET",
1682
+ this.userPath(`/sessions/${encodeURIComponent(session)}/messages`),
1683
+ void 0,
1684
+ { team: teamId, latest: count }
1685
+ );
1686
+ }
1687
+ return this.request("GET", `/api/teams/${encodeURIComponent(teamId)}/sessions/${encodeURIComponent(session)}/messages`, void 0, {
1688
+ latest: count
1689
+ });
1690
+ }
1691
+ async queryAIContext(teamId, sessionName, input) {
1692
+ return this.request(
1693
+ "POST",
1694
+ this.userPath(`/sessions/${encodeURIComponent(sessionName)}/ai-context`),
1695
+ input,
1696
+ { team: teamId }
1697
+ );
1698
+ }
1699
+ async listSessionFiles(teamId, sessionName) {
1700
+ const result = await this.request(
1701
+ "GET",
1702
+ this.userPath(`/sessions/${encodeURIComponent(sessionName)}/files`),
1703
+ void 0,
1704
+ { team: teamId }
1705
+ );
1706
+ return result.files;
1707
+ }
1708
+ async getNotifications(params) {
1709
+ const path = this.userId ? this.userPath("/notifications") : "/api/notifications";
1710
+ return this.request("GET", path, void 0, params);
1711
+ }
1712
+ async markRead(ids) {
1713
+ const path = this.userId ? this.userPath("/notifications/read") : "/api/notifications/read";
1714
+ await this.request("POST", path, { notificationIds: ids });
1715
+ }
1716
+ /** List the caller's user-scoped API keys. Requires user-level auth. */
1717
+ async listUserApiKeys() {
1718
+ const body = await this.request("GET", this.userPath("/keys"));
1719
+ return body.keys;
1720
+ }
1721
+ /** Revoke a user-scoped API key by id. Requires user-level auth. */
1722
+ async revokeUserApiKey(id) {
1723
+ await this.request("DELETE", this.userPath(`/keys/${encodeURIComponent(id)}`));
1724
+ }
1725
+ /**
1726
+ * Create a live canvas session (specsketch --share host). User-scoped
1727
+ * route; the caller validates the response shape against
1728
+ * canvasSessionCreatedSchema.
1729
+ */
1730
+ async createCanvasSession(input) {
1731
+ return this.request("POST", this.userPath("/canvas/sessions"), input);
1732
+ }
1733
+ /** End a canvas session server-side (invalidates the share link). */
1734
+ async endCanvasSession(sessionId) {
1735
+ await this.request("DELETE", this.userPath(`/canvas/sessions/${encodeURIComponent(sessionId)}`));
1736
+ }
1737
+ async uploadFile(teamId, session, filename, body, size) {
1738
+ const path = this.userId ? this.userPath(`/sessions/${encodeURIComponent(session)}/upload`) : `/api/teams/${encodeURIComponent(teamId)}/sessions/${encodeURIComponent(session)}/upload`;
1739
+ const url = new URL(path, this.apiUrl);
1740
+ if (this.userId) url.searchParams.set("team", teamId);
1741
+ const form = new FormData();
1742
+ const bytes = new Uint8Array(
1743
+ body instanceof Buffer || body instanceof Uint8Array ? body : await streamToBuffer(body)
1744
+ );
1745
+ form.append("file", new Blob([bytes]), filename);
1746
+ form.append("filename", filename);
1747
+ const res = await fetch(url.toString(), {
1748
+ method: "POST",
1749
+ headers: { Authorization: `Bearer ${this.token}` },
1750
+ body: form
1751
+ });
1752
+ if (!res.ok) {
1753
+ const text = await res.text();
1754
+ let msg = res.statusText;
1755
+ try {
1756
+ msg = JSON.parse(text).message ?? msg;
1757
+ } catch {
1758
+ }
1759
+ throw new CoopApiError(res.status, { category: "user_action", code: "UPLOAD_FAILED", message: msg });
1760
+ }
1761
+ return res.json();
1762
+ }
1763
+ async downloadFile(teamId, session, fileId) {
1764
+ const path = this.userId ? this.userPath(`/sessions/${encodeURIComponent(session)}/files/${encodeURIComponent(fileId)}`) : `/api/teams/${encodeURIComponent(teamId)}/sessions/${encodeURIComponent(session)}/files/${encodeURIComponent(fileId)}`;
1765
+ const url = new URL(path, this.apiUrl);
1766
+ if (this.userId) url.searchParams.set("team", teamId);
1767
+ const res = await fetch(url.toString(), {
1768
+ method: "GET",
1769
+ headers: { Authorization: `Bearer ${this.token}` }
1770
+ });
1771
+ if (!res.ok) {
1772
+ const text = await res.text();
1773
+ let msg = res.statusText;
1774
+ try {
1775
+ msg = JSON.parse(text).message ?? msg;
1776
+ } catch {
1777
+ }
1778
+ throw new CoopApiError(res.status, { category: "user_action", code: "DOWNLOAD_FAILED", message: msg });
1779
+ }
1780
+ const disposition = res.headers.get("Content-Disposition") ?? "";
1781
+ const filenameMatch = disposition.match(/filename="?([^"]+)"?/);
1782
+ const filename = filenameMatch ? decodeURIComponent(filenameMatch[1]) : fileId;
1783
+ const size = parseInt(res.headers.get("Content-Length") ?? "0", 10);
1784
+ return { filename, body: res.body, size };
1785
+ }
1786
+ getWebSocketUrl(session) {
1787
+ const wsBase = this.apiUrl.replace(
1788
+ /^https?:\/\//,
1789
+ (match) => match === "https://" ? "wss://" : "ws://"
1790
+ );
1791
+ return `${wsBase}/api/sessions/${encodeURIComponent(session)}/ws?token=${encodeURIComponent(this.token)}`;
1792
+ }
1793
+ withToken(token) {
1794
+ return new _ApiClient(this.apiUrl, token, this.userId);
1795
+ }
1796
+ };
1797
+
1798
+ // ../shared/src/canvas-protocol.ts
1799
+ import { z as z3 } from "zod";
1800
+ var CANVAS_PROTOCOL_VERSION = 1;
1801
+ var canvasElementSchema = z3.object({
1802
+ id: z3.string().min(1),
1803
+ version: z3.number().int().nonnegative(),
1804
+ versionNonce: z3.number().int(),
1805
+ isDeleted: z3.boolean().optional()
1806
+ }).passthrough();
1807
+ var canvasFilesSchema = z3.record(z3.string(), z3.unknown());
1808
+ var canvasSceneSchema = z3.object({
1809
+ elements: z3.array(canvasElementSchema),
1810
+ files: canvasFilesSchema.optional()
1811
+ });
1812
+ var canvasParticipantSchema = z3.object({
1813
+ id: z3.string().min(1),
1814
+ role: z3.enum(["host", "guest"]),
1815
+ name: z3.string().optional(),
1816
+ color: z3.string().optional()
1817
+ });
1818
+ var canvasHelloSchema = z3.object({
1819
+ type: z3.literal("hello"),
1820
+ protocolVersion: z3.number().int().positive(),
1821
+ role: z3.enum(["host", "guest"]),
1822
+ /** Stable id across reconnects, if the client has one. */
1823
+ participantId: z3.string().min(1).max(64).optional(),
1824
+ name: z3.string().max(120).optional()
1825
+ });
1826
+ var canvasClientOpSchema = z3.object({
1827
+ type: z3.literal("op"),
1828
+ /** Changed or soft-deleted elements only — never the full scene. */
1829
+ elements: z3.array(canvasElementSchema).min(1),
1830
+ /** New binary files referenced by the changed elements, if any. */
1831
+ files: canvasFilesSchema.optional(),
1832
+ /** Client-local monotonic sequence, used by the sender for echo dedupe. */
1833
+ clientSeq: z3.number().int().nonnegative()
1834
+ });
1835
+ var canvasClientMessageSchema = z3.discriminatedUnion("type", [
1836
+ canvasHelloSchema,
1837
+ canvasClientOpSchema
1838
+ ]);
1839
+ var canvasSnapshotSchema = z3.object({
1840
+ type: z3.literal("snapshot"),
1841
+ scene: canvasSceneSchema,
1842
+ revision: z3.number().int().nonnegative(),
1843
+ participants: z3.array(canvasParticipantSchema),
1844
+ /** Echoes the server's protocol version so clients can detect skew. */
1845
+ protocolVersion: z3.number().int().positive(),
1846
+ /** The participant id the server assigned to (or kept for) this client. */
1847
+ participantId: z3.string()
1848
+ });
1849
+ var canvasServerOpSchema = z3.object({
1850
+ type: z3.literal("op"),
1851
+ /** The elements that won reconciliation (never echoed to the sender). */
1852
+ elements: z3.array(canvasElementSchema),
1853
+ files: canvasFilesSchema.optional(),
1854
+ revision: z3.number().int().positive(),
1855
+ /** Participant id of the sender. */
1856
+ from: z3.string()
1857
+ });
1858
+ var canvasPresenceSchema = z3.object({
1859
+ type: z3.literal("presence"),
1860
+ participants: z3.array(canvasParticipantSchema)
1861
+ });
1862
+ var canvasEndSchema = z3.object({
1863
+ type: z3.literal("end"),
1864
+ reason: z3.string()
1865
+ });
1866
+ var canvasErrorSchema = z3.object({
1867
+ type: z3.literal("error"),
1868
+ code: z3.string(),
1869
+ message: z3.string()
1870
+ });
1871
+ var canvasServerMessageSchema = z3.discriminatedUnion("type", [
1872
+ canvasSnapshotSchema,
1873
+ canvasServerOpSchema,
1874
+ canvasPresenceSchema,
1875
+ canvasEndSchema,
1876
+ canvasErrorSchema
1877
+ ]);
1878
+ var canvasSessionCreatedSchema = z3.object({
1879
+ sessionId: z3.string().min(1),
1880
+ /** Capability token — shown once; the server stores only its hash. */
1881
+ token: z3.string().min(1),
1882
+ shareUrl: z3.string().min(1),
1883
+ wsUrl: z3.string().min(1)
1884
+ });
1885
+ var canvasSessionSummarySchema = z3.object({
1886
+ sessionId: z3.string(),
1887
+ title: z3.string().nullable(),
1888
+ status: z3.enum(["active", "ended"]),
1889
+ createdAt: z3.number(),
1890
+ endedAt: z3.number().nullable()
1891
+ });
1892
+ function elementWins(local, incoming) {
1893
+ if (incoming.version !== local.version) return incoming.version > local.version;
1894
+ return incoming.versionNonce < local.versionNonce;
1895
+ }
1896
+ function reconcileCanvasElements(local, incoming) {
1897
+ const byId = new Map(local.map((el) => [el.id, el]));
1898
+ const accepted = [];
1899
+ for (const el of incoming) {
1900
+ const existing = byId.get(el.id);
1901
+ if (!existing || elementWins(existing, el)) {
1902
+ byId.set(el.id, el);
1903
+ accepted.push(el);
1904
+ }
1905
+ }
1906
+ return { elements: [...byId.values()], accepted };
1907
+ }
1908
+
1909
+ // server/remote-session.ts
1910
+ var HEARTBEAT_INTERVAL_MS = 3e4;
1911
+ var HEARTBEAT_TIMEOUT_MS = 75e3;
1912
+ var BACKOFF_MIN_MS = 1e3;
1913
+ var BACKOFF_MAX_MS = 3e4;
1914
+ var PENDING_OP_LIMIT = 200;
1915
+ function asCanvasElements(elements) {
1916
+ if (!elements) return [];
1917
+ return elements.filter(
1918
+ (el) => typeof el === "object" && el !== null && typeof el.id === "string" && typeof el.version === "number" && typeof el.versionNonce === "number"
1919
+ );
1920
+ }
1921
+ var RemoteSession = class _RemoteSession {
1922
+ sessionId;
1923
+ shareUrl;
1924
+ token;
1925
+ wsUrl;
1926
+ opts;
1927
+ status = "connecting";
1928
+ revision = 0;
1929
+ participants = [];
1930
+ ws = null;
1931
+ closed = false;
1932
+ clientSeq = 0;
1933
+ backoffMs;
1934
+ lastTrafficAt = 0;
1935
+ heartbeatTimer = null;
1936
+ reconnectTimer = null;
1937
+ pendingOps = [];
1938
+ api;
1939
+ constructor(opts, api, created) {
1940
+ this.opts = opts;
1941
+ this.api = api;
1942
+ this.sessionId = created.sessionId;
1943
+ this.token = created.token;
1944
+ this.shareUrl = created.shareUrl;
1945
+ this.wsUrl = created.wsUrl;
1946
+ this.backoffMs = opts.backoffMinMs ?? BACKOFF_MIN_MS;
1947
+ }
1948
+ /** Create the session server-side and open the host connection. */
1949
+ static async start(opts) {
1950
+ const api = opts.api ?? new ApiClient(opts.apiUrl, opts.key, opts.userId);
1951
+ let response;
1952
+ try {
1953
+ response = await api.createCanvasSession({ title: opts.title });
1954
+ } catch (err) {
1955
+ if (isCoopApiError(err)) {
1956
+ if (err.status === 401 || err.status === 403) {
1957
+ throw new Error(
1958
+ "CoopCLI rejected the saved credentials (revoked or expired key). Run `specsketch login` and retry."
1959
+ );
1960
+ }
1961
+ throw new Error(`Could not create the shared session: HTTP ${err.status} ${err.message}`);
1962
+ }
1963
+ throw err;
1964
+ }
1965
+ const created = canvasSessionCreatedSchema.parse(response);
1966
+ const session = new _RemoteSession(opts, api, created);
1967
+ session.connect();
1968
+ return session;
1969
+ }
1970
+ // ── Upstream connection ──
1971
+ connect() {
1972
+ if (this.closed) return;
1973
+ const factory = this.opts.wsFactory ?? ((url, headers) => new WebSocket(url, { headers }));
1974
+ const ws = factory(this.wsUrl, { Authorization: `Bearer ${this.opts.key}` });
1975
+ this.ws = ws;
1976
+ this.lastTrafficAt = Date.now();
1977
+ ws.on("open", () => {
1978
+ this.backoffMs = this.opts.backoffMinMs ?? BACKOFF_MIN_MS;
1979
+ this.status = "connected";
1980
+ ws.send(
1981
+ JSON.stringify({
1982
+ type: "hello",
1983
+ protocolVersion: CANVAS_PROTOCOL_VERSION,
1984
+ role: "host",
1985
+ name: this.opts.hostName ?? void 0
1986
+ })
1987
+ );
1988
+ for (const frame of this.pendingOps.splice(0)) ws.send(frame);
1989
+ });
1990
+ ws.on("message", (data) => this.handleUpstreamFrame(String(data)));
1991
+ ws.on("close", (code) => {
1992
+ if (ws !== this.ws) return;
1993
+ this.stopHeartbeat();
1994
+ if (this.closed) return;
1995
+ this.status = "reconnecting";
1996
+ this.opts.log?.(`share: connection lost (code ${code}) \u2014 reconnecting`);
1997
+ this.scheduleReconnect();
1998
+ });
1999
+ ws.on("error", () => {
2000
+ });
2001
+ this.startHeartbeat();
2002
+ }
2003
+ scheduleReconnect() {
2004
+ if (this.closed || this.reconnectTimer) return;
2005
+ const jitter = Math.random() * 0.3 + 0.85;
2006
+ const delay = Math.round(this.backoffMs * jitter);
2007
+ this.backoffMs = Math.min(this.backoffMs * 2, BACKOFF_MAX_MS);
2008
+ this.reconnectTimer = setTimeout(() => {
2009
+ this.reconnectTimer = null;
2010
+ this.connect();
2011
+ }, delay);
2012
+ }
2013
+ startHeartbeat() {
2014
+ this.stopHeartbeat();
2015
+ this.heartbeatTimer = setInterval(() => {
2016
+ const ws = this.ws;
2017
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
2018
+ if (Date.now() - this.lastTrafficAt > HEARTBEAT_TIMEOUT_MS) {
2019
+ ws.terminate();
2020
+ return;
2021
+ }
2022
+ ws.send("ping");
2023
+ }, HEARTBEAT_INTERVAL_MS);
2024
+ this.heartbeatTimer.unref?.();
2025
+ }
2026
+ stopHeartbeat() {
2027
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
2028
+ this.heartbeatTimer = null;
2029
+ }
2030
+ // ── Frames from the DO ──
2031
+ handleUpstreamFrame(text) {
2032
+ this.lastTrafficAt = Date.now();
2033
+ if (text === "pong") return;
2034
+ const parsed = canvasServerMessageSchema.safeParse(
2035
+ (() => {
2036
+ try {
2037
+ return JSON.parse(text);
2038
+ } catch {
2039
+ return null;
2040
+ }
2041
+ })()
2042
+ );
2043
+ if (!parsed.success) return;
2044
+ const frame = parsed.data;
2045
+ switch (frame.type) {
2046
+ case "snapshot": {
2047
+ const local = this.opts.workspace.loadScene();
2048
+ const { elements, accepted } = reconcileCanvasElements(
2049
+ asCanvasElements(frame.scene.elements),
2050
+ asCanvasElements(local?.elements)
2051
+ );
2052
+ this.revision = frame.revision;
2053
+ this.participants = frame.participants;
2054
+ this.opts.workspace.saveScene({
2055
+ elements,
2056
+ appState: local?.appState,
2057
+ files: { ...frame.scene.files ?? {}, ...local?.files ?? {} }
2058
+ });
2059
+ if (accepted.length > 0) {
2060
+ this.sendUpstream(
2061
+ JSON.stringify({ type: "op", elements: accepted, clientSeq: ++this.clientSeq })
2062
+ );
2063
+ }
2064
+ this.opts.onLocalBroadcast?.(text);
2065
+ return;
2066
+ }
2067
+ case "op": {
2068
+ if (frame.revision > this.revision + 1) {
2069
+ this.ws?.terminate();
2070
+ return;
2071
+ }
2072
+ this.revision = frame.revision;
2073
+ this.applyRemoteElements(frame.elements, frame.files);
2074
+ this.opts.onLocalBroadcast?.(text);
2075
+ return;
2076
+ }
2077
+ case "presence": {
2078
+ this.participants = frame.participants;
2079
+ this.opts.onLocalBroadcast?.(text);
2080
+ return;
2081
+ }
2082
+ case "end": {
2083
+ this.opts.log?.(`share: session ended (${frame.reason})`);
2084
+ this.opts.onLocalBroadcast?.(text);
2085
+ this.closed = true;
2086
+ this.status = "ended";
2087
+ this.teardown();
2088
+ return;
2089
+ }
2090
+ case "error": {
2091
+ this.opts.log?.(`share: server error ${frame.code}: ${frame.message}`);
2092
+ return;
2093
+ }
2094
+ }
2095
+ }
2096
+ /** Persist remote edits into the change directory — the durable copy. */
2097
+ applyRemoteElements(incoming, files) {
2098
+ const local = this.opts.workspace.loadScene();
2099
+ const { elements } = reconcileCanvasElements(asCanvasElements(local?.elements), incoming);
2100
+ this.opts.workspace.saveScene({
2101
+ elements,
2102
+ appState: local?.appState,
2103
+ files: { ...local?.files ?? {}, ...files ?? {} }
2104
+ });
2105
+ }
2106
+ // ── Frames from the host browser (via /ws/local) ──
2107
+ /**
2108
+ * Handle one message from a local browser socket. Ops are relayed upstream
2109
+ * verbatim (the browser already persists via its autosave PUT). A `hello`
2110
+ * is answered locally with a synthetic snapshot so the browser can seed
2111
+ * its revision/participant state without a round-trip to the DO.
2112
+ */
2113
+ handleLocalFrame(text, reply) {
2114
+ const parsed = canvasClientMessageSchema.safeParse(
2115
+ (() => {
2116
+ try {
2117
+ return JSON.parse(text);
2118
+ } catch {
2119
+ return null;
2120
+ }
2121
+ })()
2122
+ );
2123
+ if (!parsed.success) return;
2124
+ if (parsed.data.type === "hello") {
2125
+ const scene = this.opts.workspace.loadScene();
2126
+ reply(
2127
+ JSON.stringify({
2128
+ type: "snapshot",
2129
+ scene: { elements: scene?.elements ?? [], files: scene?.files ?? void 0 },
2130
+ revision: this.revision,
2131
+ participants: this.participants,
2132
+ protocolVersion: CANVAS_PROTOCOL_VERSION,
2133
+ participantId: "host"
2134
+ })
2135
+ );
2136
+ return;
2137
+ }
2138
+ this.sendUpstream(text);
2139
+ }
2140
+ sendUpstream(frameText) {
2141
+ const ws = this.ws;
2142
+ if (ws && ws.readyState === WebSocket.OPEN) {
2143
+ ws.send(frameText);
2144
+ return;
2145
+ }
2146
+ this.pendingOps.push(frameText);
2147
+ if (this.pendingOps.length > PENDING_OP_LIMIT) this.pendingOps.shift();
2148
+ }
2149
+ // ── Teardown ──
2150
+ /** End the session server-side (invalidates the share link) and close. */
2151
+ async end() {
2152
+ if (this.status === "ended") return;
2153
+ this.closed = true;
2154
+ this.status = "ended";
2155
+ this.teardown();
2156
+ try {
2157
+ await this.api.endCanvasSession(this.sessionId);
2158
+ } catch {
2159
+ }
2160
+ }
2161
+ teardown() {
2162
+ this.stopHeartbeat();
2163
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
2164
+ this.reconnectTimer = null;
2165
+ try {
2166
+ this.ws?.close(1e3, "host shutting down");
2167
+ } catch {
2168
+ }
2169
+ this.ws = null;
2170
+ }
2171
+ };
2172
+
226
2173
  // server/workspace.ts
227
2174
  import { createHash } from "node:crypto";
228
2175
  import {
229
- existsSync,
2176
+ existsSync as existsSync2,
230
2177
  mkdirSync,
231
- readFileSync,
232
- readdirSync,
2178
+ readFileSync as readFileSync2,
2179
+ readdirSync as readdirSync2,
233
2180
  renameSync,
234
2181
  writeFileSync
235
2182
  } from "node:fs";
236
- import { basename, dirname, join, resolve } from "node:path";
2183
+ import { basename as basename2, dirname as dirname2, join as join4, resolve as resolve2 } from "node:path";
237
2184
  var DIAGRAM_FILE = "diagram.excalidraw";
238
2185
  var SIDECAR_FILE = "specsketch.json";
239
2186
  var sha256 = (s) => createHash("sha256").update(s).digest("hex");
@@ -241,20 +2188,20 @@ var ChangeWorkspace = class {
241
2188
  dir;
242
2189
  changeName;
243
2190
  constructor(dir) {
244
- this.dir = resolve(dir);
245
- this.changeName = basename(this.dir);
2191
+ this.dir = resolve2(dir);
2192
+ this.changeName = basename2(this.dir);
246
2193
  mkdirSync(this.dir, { recursive: true });
247
2194
  }
248
2195
  writeAtomic(relPath, content) {
249
- const target = join(this.dir, relPath);
250
- mkdirSync(dirname(target), { recursive: true });
2196
+ const target = join4(this.dir, relPath);
2197
+ mkdirSync(dirname2(target), { recursive: true });
251
2198
  const tmp = `${target}.tmp`;
252
2199
  writeFileSync(tmp, content, "utf8");
253
2200
  renameSync(tmp, target);
254
2201
  }
255
2202
  readIfExists(relPath) {
256
- const target = join(this.dir, relPath);
257
- return existsSync(target) ? readFileSync(target, "utf8") : void 0;
2203
+ const target = join4(this.dir, relPath);
2204
+ return existsSync2(target) ? readFileSync2(target, "utf8") : void 0;
258
2205
  }
259
2206
  // ── Scene ────────────────────────────────────────────────────────────────
260
2207
  loadScene() {
@@ -315,11 +2262,11 @@ var ChangeWorkspace = class {
315
2262
  /** Current artifact files, however they were authored. */
316
2263
  readArtifacts() {
317
2264
  const specs = [];
318
- const specsDir = join(this.dir, "specs");
319
- if (existsSync(specsDir)) {
320
- for (const entry of readdirSync(specsDir, { withFileTypes: true })) {
2265
+ const specsDir = join4(this.dir, "specs");
2266
+ if (existsSync2(specsDir)) {
2267
+ for (const entry of readdirSync2(specsDir, { withFileTypes: true })) {
321
2268
  if (!entry.isDirectory()) continue;
322
- const spec = this.readIfExists(join("specs", entry.name, "spec.md"));
2269
+ const spec = this.readIfExists(join4("specs", entry.name, "spec.md"));
323
2270
  if (spec !== void 0) specs.push({ capability: entry.name, spec });
324
2271
  }
325
2272
  }
@@ -353,7 +2300,7 @@ var ChangeWorkspace = class {
353
2300
  "design.md": bundle.design,
354
2301
  "tasks.md": bundle.tasks
355
2302
  };
356
- for (const s of bundle.specs) files[join("specs", s.capability, "spec.md")] = s.spec;
2303
+ for (const s of bundle.specs) files[join4("specs", s.capability, "spec.md")] = s.spec;
357
2304
  const written = [];
358
2305
  const manifest = {};
359
2306
  for (const [relPath, content] of Object.entries(files)) {
@@ -362,27 +2309,64 @@ var ChangeWorkspace = class {
362
2309
  this.writeAtomic(relPath, content);
363
2310
  written.push(relPath);
364
2311
  }
365
- this.updateSidecar((s) => ({ ...s, summary: bundle.summary, generated: manifest }));
2312
+ this.updateSidecar((s) => ({
2313
+ ...s,
2314
+ summary: bundle.summary,
2315
+ generated: manifest,
2316
+ // The diff-before-write result IS the changed-file signal for the
2317
+ // post-generation chat seed (generation-chat-reset-summary).
2318
+ lastGeneration: { at: (/* @__PURE__ */ new Date()).toISOString(), changedFiles: written }
2319
+ }));
366
2320
  return written;
367
2321
  }
2322
+ // ── Spec review chat ─────────────────────────────────────────────────────
2323
+ readChat() {
2324
+ return this.readSidecar().chat ?? [];
2325
+ }
2326
+ writeChat(chat) {
2327
+ this.updateSidecar((s) => ({ ...s, chat }));
2328
+ }
2329
+ // ── Generation system prompt override ────────────────────────────────────
2330
+ readSystemPrompt() {
2331
+ return this.readSidecar().systemPrompt;
2332
+ }
2333
+ /** `undefined` clears the override (reset to the built-in default). */
2334
+ writeSystemPrompt(systemPrompt) {
2335
+ this.updateSidecar(({ systemPrompt: _prev, ...s }) => ({
2336
+ ...s,
2337
+ ...systemPrompt !== void 0 ? { systemPrompt } : {}
2338
+ }));
2339
+ }
368
2340
  };
369
2341
 
370
2342
  // server/cli.ts
371
- var USAGE = `Usage: specsketch <path-to-openspec-change-dir> [--port <n>]
2343
+ var USAGE = `Usage: specsketch <path-to-openspec-change-dir> [--port <n>] [--share]
2344
+ specsketch login [--profile <name>] [--web-url <url>]
372
2345
 
373
2346
  specsketch ./openspec/changes/new-feature-change
374
2347
 
375
2348
  The directory is created if it doesn't exist. Diagrams autosave to
376
2349
  diagram.excalidraw inside it; Generate Spec writes proposal.md, design.md,
377
- tasks.md and specs/<capability>/spec.md alongside.`;
2350
+ tasks.md and specs/<capability>/spec.md alongside.
2351
+
2352
+ --port sets the preferred port (default 8787); if it's already in use the
2353
+ server moves to the next available port and prints the actual URL.
2354
+
2355
+ --share hosts the canvas as a live multiplayer session on your CoopCLI
2356
+ account and prints a link coworkers can open in a browser \u2014 no account
2357
+ needed on their side. Requires \`specsketch login\` first. The local server
2358
+ still binds to 127.0.0.1 only; sharing runs over an outbound connection.
2359
+
2360
+ login authenticates this machine against your CoopCLI account (opens a
2361
+ browser, saves credentials to ~/.coopcli/config.json).`;
378
2362
  var UsageError = class extends Error {
379
2363
  };
380
2364
  function parseCliArgs(argv) {
381
2365
  let parsed;
382
2366
  try {
383
- parsed = parseArgs({
2367
+ parsed = parseArgs2({
384
2368
  args: argv,
385
- options: { port: { type: "string" } },
2369
+ options: { port: { type: "string" }, share: { type: "boolean" } },
386
2370
  allowPositionals: true
387
2371
  });
388
2372
  } catch (err) {
@@ -394,7 +2378,19 @@ function parseCliArgs(argv) {
394
2378
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
395
2379
  throw new UsageError(`invalid --port: ${String(parsed.values.port)}`);
396
2380
  }
397
- return { dir, port };
2381
+ return { dir, port, share: parsed.values.share === true };
2382
+ }
2383
+ var PORT_SCAN_LIMIT = 100;
2384
+ async function bindAvailablePort(start, tryBind) {
2385
+ const end = Math.min(start + PORT_SCAN_LIMIT - 1, 65535);
2386
+ for (let port = start; port <= end; port++) {
2387
+ try {
2388
+ return { port: await tryBind(port), moved: port !== start };
2389
+ } catch (err) {
2390
+ if (err.code !== "EADDRINUSE") throw err;
2391
+ }
2392
+ }
2393
+ throw new Error(`no free port between ${start} and ${end} \u2014 free one up or pass --port`);
398
2394
  }
399
2395
  var MIME = {
400
2396
  ".html": "text/html; charset=utf-8",
@@ -409,25 +2405,25 @@ var MIME = {
409
2405
  ".woff": "font/woff"
410
2406
  };
411
2407
  function resolveClientDir(moduleUrl) {
412
- const here = dirname2(fileURLToPath(moduleUrl));
2408
+ const here = dirname3(fileURLToPath(moduleUrl));
413
2409
  const candidates = [
414
- join2(here, "..", "client"),
2410
+ join5(here, "..", "client"),
415
2411
  // packed: dist/cli -> dist/client
416
- join2(here, "..", "dist", "client")
2412
+ join5(here, "..", "dist", "client")
417
2413
  // repo: server/ -> dist/client
418
2414
  ];
419
2415
  for (const dir of candidates) {
420
- if (existsSync2(join2(dir, "index.html"))) return dir;
2416
+ if (existsSync3(join5(dir, "index.html"))) return dir;
421
2417
  }
422
2418
  return null;
423
2419
  }
424
2420
  function staticResponse(clientDir, pathname) {
425
- const rel = normalize(decodeURIComponent(pathname)).replace(/^\/+/, "");
426
- const target = resolve2(clientDir, rel === "" ? "index.html" : rel);
427
- if (!target.startsWith(resolve2(clientDir))) return null;
428
- const file = existsSync2(target) && extname(target) ? target : join2(clientDir, "index.html");
429
- if (!existsSync2(file)) return null;
430
- return new Response(readFileSync2(file), {
2421
+ const rel = normalize2(decodeURIComponent(pathname)).replace(/^\/+/, "");
2422
+ const target = resolve3(clientDir, rel === "" ? "index.html" : rel);
2423
+ if (!target.startsWith(resolve3(clientDir))) return null;
2424
+ const file = existsSync3(target) && extname(target) ? target : join5(clientDir, "index.html");
2425
+ if (!existsSync3(file)) return null;
2426
+ return new Response(readFileSync3(file), {
431
2427
  headers: { "Content-Type": MIME[extname(file)] ?? "application/octet-stream" }
432
2428
  });
433
2429
  }
@@ -439,12 +2435,12 @@ but "Generate Spec" needs one of:
439
2435
  \u2022 or the Anthropic CLI's login profile (no key handling needed):
440
2436
  brew install anthropics/tap/ant # macOS
441
2437
  ant auth login`;
442
- function detectCredentialSource(env = process.env, home = homedir()) {
2438
+ function detectCredentialSource(env = process.env, home = homedir3()) {
443
2439
  if (env.ANTHROPIC_API_KEY) return "ANTHROPIC_API_KEY";
444
2440
  if (env.ANTHROPIC_AUTH_TOKEN) return "ANTHROPIC_AUTH_TOKEN";
445
2441
  try {
446
- const dir = join2(home, ".config", "anthropic", "credentials");
447
- if (readdirSync2(dir).some((f) => f.endsWith(".json"))) return "anthropic profile";
2442
+ const dir = join5(home, ".config", "anthropic", "credentials");
2443
+ if (readdirSync3(dir).some((f) => f.endsWith(".json"))) return "anthropic profile";
448
2444
  } catch {
449
2445
  }
450
2446
  return null;
@@ -457,8 +2453,28 @@ function preflight(log = console.log) {
457
2453
  process.exit(1);
458
2454
  }
459
2455
  }
460
- function main(argv) {
2456
+ async function main(argv) {
461
2457
  preflight();
2458
+ if (argv[0] === "login") {
2459
+ let loginArgs;
2460
+ try {
2461
+ loginArgs = parseLoginArgs(argv.slice(1));
2462
+ } catch (err) {
2463
+ console.error(
2464
+ err instanceof LoginUsageError ? `specsketch: ${err.message}
2465
+
2466
+ ${LOGIN_USAGE}` : err
2467
+ );
2468
+ process.exit(1);
2469
+ }
2470
+ try {
2471
+ await runLogin(loginArgs);
2472
+ } catch (err) {
2473
+ console.error(`specsketch: ${err instanceof Error ? err.message : String(err)}`);
2474
+ process.exit(1);
2475
+ }
2476
+ return;
2477
+ }
462
2478
  let args;
463
2479
  try {
464
2480
  args = parseCliArgs(argv);
@@ -473,26 +2489,125 @@ ${USAGE}` : err);
473
2489
  console.error("specsketch: built UI assets not found (dist/client missing).");
474
2490
  process.exit(1);
475
2491
  }
2492
+ if (args.share) {
2493
+ try {
2494
+ await readCoopAuth();
2495
+ } catch (err) {
2496
+ console.error(`specsketch: ${err instanceof CoopAuthError ? err.message : String(err)}`);
2497
+ process.exit(1);
2498
+ }
2499
+ }
476
2500
  const workspace = new ChangeWorkspace(args.dir);
477
- const app = createApp(new Anthropic(), workspace);
2501
+ let remote = null;
2502
+ const app = createApp(
2503
+ new Anthropic(),
2504
+ workspace,
2505
+ () => remote ? {
2506
+ active: true,
2507
+ shareUrl: remote.shareUrl,
2508
+ status: remote.status,
2509
+ participants: remote.participants.length
2510
+ } : { active: false }
2511
+ );
478
2512
  app.get("*", (c) => {
479
2513
  const res = staticResponse(clientDir, new URL(c.req.url).pathname);
480
2514
  return res ?? c.notFound();
481
2515
  });
482
- serve({ fetch: app.fetch, port: args.port, hostname: "127.0.0.1" }, () => {
483
- const source = detectCredentialSource();
484
- console.log(`coopcli specsketch`);
485
- console.log(` workspace: ${workspace.dir}`);
486
- console.log(` open: http://127.0.0.1:${args.port}`);
487
- if (source) {
488
- console.log(` auth: ${source}`);
489
- } else {
490
- console.log(`
2516
+ let httpServer = null;
2517
+ const tryBind = (port2) => new Promise((bound, failed) => {
2518
+ const server = serve({ fetch: app.fetch, port: port2, hostname: "127.0.0.1" }, (info) => {
2519
+ httpServer = server;
2520
+ bound(info.port);
2521
+ });
2522
+ server.once("error", (err) => {
2523
+ try {
2524
+ server.close();
2525
+ } catch {
2526
+ }
2527
+ failed(err);
2528
+ });
2529
+ });
2530
+ let port;
2531
+ let moved;
2532
+ try {
2533
+ ({ port, moved } = await bindAvailablePort(args.port, tryBind));
2534
+ } catch (err) {
2535
+ console.error(`specsketch: ${err instanceof Error ? err.message : String(err)}`);
2536
+ process.exit(1);
2537
+ }
2538
+ if (args.share) {
2539
+ try {
2540
+ remote = await startShare(workspace, httpServer);
2541
+ } catch (err) {
2542
+ console.error(`specsketch: ${err instanceof Error ? err.message : String(err)}`);
2543
+ process.exit(1);
2544
+ }
2545
+ }
2546
+ const source = detectCredentialSource();
2547
+ console.log(`coopcli specsketch`);
2548
+ console.log(` workspace: ${workspace.dir}`);
2549
+ console.log(` open: http://127.0.0.1:${port}`);
2550
+ if (moved) {
2551
+ console.log(` port: ${args.port} was in use \u2014 moved to ${port}`);
2552
+ }
2553
+ if (source) {
2554
+ console.log(` auth: ${source}`);
2555
+ } else {
2556
+ console.log(`
491
2557
  \u26A0 ${CREDENTIALS_HINT}
492
2558
  `);
2559
+ }
2560
+ if (remote) {
2561
+ console.log(`
2562
+ \u2500\u2500 shared session \u2500\u2500`);
2563
+ console.log(` anyone with this link can view AND edit the canvas (no account needed):`);
2564
+ console.log(`
2565
+ ${remote.shareUrl}
2566
+ `);
2567
+ console.log(` Ctrl-C ends the session and invalidates the link.`);
2568
+ }
2569
+ }
2570
+ async function startShare(workspace, server) {
2571
+ const auth = await readCoopAuth();
2572
+ const localClients = /* @__PURE__ */ new Set();
2573
+ const remote = await RemoteSession.start({
2574
+ ...auth,
2575
+ workspace,
2576
+ title: workspace.changeName,
2577
+ hostName: auth.displayName,
2578
+ log: (msg) => console.log(` ${msg}`),
2579
+ onLocalBroadcast: (frame) => {
2580
+ for (const client of localClients) client.send(frame);
493
2581
  }
494
2582
  });
2583
+ const wss = new WebSocketServer({ noServer: true });
2584
+ server.on("upgrade", (req, socket, head) => {
2585
+ const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
2586
+ if (pathname !== "/ws/local") {
2587
+ socket.destroy();
2588
+ return;
2589
+ }
2590
+ wss.handleUpgrade(req, socket, head, (ws) => {
2591
+ localClients.add(ws);
2592
+ ws.on("close", () => localClients.delete(ws));
2593
+ ws.on(
2594
+ "message",
2595
+ (data) => remote.handleLocalFrame(String(data), (reply) => ws.send(reply))
2596
+ );
2597
+ });
2598
+ });
2599
+ let ending = false;
2600
+ const endAndExit = async () => {
2601
+ if (ending) return;
2602
+ ending = true;
2603
+ console.log("\nspecsketch: ending shared session\u2026");
2604
+ await remote.end();
2605
+ process.exit(0);
2606
+ };
2607
+ process.on("SIGINT", () => void endAndExit());
2608
+ process.on("SIGTERM", () => void endAndExit());
2609
+ return remote;
495
2610
  }
496
2611
 
497
2612
  // server/cli-entry.ts
498
- main(process.argv.slice(2));
2613
+ void main(process.argv.slice(2));