painapple-code 1.0.0__py3-none-any.whl

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 (436) hide show
  1. painapple_code/__init__.py +30 -0
  2. painapple_code/__main__.py +18 -0
  3. painapple_code/_version.py +24 -0
  4. painapple_code/auth_middleware.py +930 -0
  5. painapple_code/cli/__init__.py +204 -0
  6. painapple_code/cli/deploy/__init__.py +15 -0
  7. painapple_code/cli/deploy/claude_seed.py +41 -0
  8. painapple_code/cli/deploy/config.py +260 -0
  9. painapple_code/cli/deploy/container.py +715 -0
  10. painapple_code/cli/deploy/launch.py +163 -0
  11. painapple_code/cli/deploy/runtime.py +223 -0
  12. painapple_code/cli/lifecycle_cmd.py +443 -0
  13. painapple_code/cli/list_cmd.py +572 -0
  14. painapple_code/cli/manage_cmd.py +670 -0
  15. painapple_code/cli/netinfo.py +125 -0
  16. painapple_code/cli/profiles.py +390 -0
  17. painapple_code/cli/serve_args.py +163 -0
  18. painapple_code/cli/serve_config.py +256 -0
  19. painapple_code/cli/setup_cmd.py +664 -0
  20. painapple_code/cli/ui.py +438 -0
  21. painapple_code/cost_analytics.py +879 -0
  22. painapple_code/data/models.defaults.yaml +18 -0
  23. painapple_code/data/models.yaml +18 -0
  24. painapple_code/data/presets.defaults.json +32 -0
  25. painapple_code/data/strings.yaml +2269 -0
  26. painapple_code/helpers.py +406 -0
  27. painapple_code/paths.py +1514 -0
  28. painapple_code/prompt_explorer.py +984 -0
  29. painapple_code/providers/__init__.py +224 -0
  30. painapple_code/providers/base.py +710 -0
  31. painapple_code/providers/claude/__init__.py +74 -0
  32. painapple_code/providers/claude/capabilities.py +218 -0
  33. painapple_code/providers/claude/errors.py +79 -0
  34. painapple_code/providers/claude/launch.py +50 -0
  35. painapple_code/providers/claude/summary.py +89 -0
  36. painapple_code/providers/claude/translate.py +74 -0
  37. painapple_code/providers/claude_sdk/__init__.py +139 -0
  38. painapple_code/providers/claude_sdk/driver.py +412 -0
  39. painapple_code/providers/codex/__init__.py +87 -0
  40. painapple_code/providers/codex/capabilities.py +321 -0
  41. painapple_code/providers/codex/errors.py +70 -0
  42. painapple_code/providers/codex/launch.py +142 -0
  43. painapple_code/providers/codex/summary.py +196 -0
  44. painapple_code/providers/codex/translate.py +204 -0
  45. painapple_code/providers/codex_app_server/__init__.py +100 -0
  46. painapple_code/providers/codex_app_server/capabilities.py +41 -0
  47. painapple_code/providers/codex_app_server/launch.py +202 -0
  48. painapple_code/providers/codex_app_server/summary.py +291 -0
  49. painapple_code/providers/codex_app_server/translate.py +263 -0
  50. painapple_code/providers/codex_app_server/transport.py +238 -0
  51. painapple_code/routes/__init__.py +0 -0
  52. painapple_code/routes/api_agents.py +441 -0
  53. painapple_code/routes/api_app.py +368 -0
  54. painapple_code/routes/api_app_commit_sections.py +127 -0
  55. painapple_code/routes/api_app_config.py +997 -0
  56. painapple_code/routes/api_app_session_prefs.py +217 -0
  57. painapple_code/routes/api_browser.py +690 -0
  58. painapple_code/routes/api_commands.py +374 -0
  59. painapple_code/routes/api_costs.py +128 -0
  60. painapple_code/routes/api_drafts.py +139 -0
  61. painapple_code/routes/api_exec.py +149 -0
  62. painapple_code/routes/api_files.py +542 -0
  63. painapple_code/routes/api_git.py +657 -0
  64. painapple_code/routes/api_logs.py +649 -0
  65. painapple_code/routes/api_plugins.py +275 -0
  66. painapple_code/routes/api_project_config.py +418 -0
  67. painapple_code/routes/api_prompts.py +231 -0
  68. painapple_code/routes/api_providers.py +53 -0
  69. painapple_code/routes/api_search.py +411 -0
  70. painapple_code/routes/api_session_stash.py +234 -0
  71. painapple_code/routes/api_session_welcome.py +151 -0
  72. painapple_code/routes/api_sessions.py +742 -0
  73. painapple_code/routes/api_shadow.py +323 -0
  74. painapple_code/routes/api_shadow_db.py +512 -0
  75. painapple_code/routes/api_shadow_files.py +332 -0
  76. painapple_code/routes/api_shadow_search.py +476 -0
  77. painapple_code/routes/api_skills.py +610 -0
  78. painapple_code/routes/api_tasks.py +147 -0
  79. painapple_code/routes/api_terminal.py +540 -0
  80. painapple_code/routes/api_upload.py +293 -0
  81. painapple_code/routes/api_viewer.py +229 -0
  82. painapple_code/routes/dependencies.py +109 -0
  83. painapple_code/routes/ws_chat.py +837 -0
  84. painapple_code/server.py +2208 -0
  85. painapple_code/server_logging.py +263 -0
  86. painapple_code/services/__init__.py +0 -0
  87. painapple_code/services/agent_session.py +2738 -0
  88. painapple_code/session_store.py +253 -0
  89. painapple_code/session_store_core.py +879 -0
  90. painapple_code/shadow_db.py +830 -0
  91. painapple_code/shadow_db_plans.py +369 -0
  92. painapple_code/shadow_db_queries.py +595 -0
  93. painapple_code/shadow_db_schema.py +676 -0
  94. painapple_code/shadow_git.py +1296 -0
  95. painapple_code/shadow_git_frontmatter.py +314 -0
  96. painapple_code/shadow_git_sections.py +520 -0
  97. painapple_code/shadow_git_summary.py +462 -0
  98. painapple_code/shadow_parser.py +560 -0
  99. painapple_code/static/css/00-variables.css +276 -0
  100. painapple_code/static/css/01-base.css +245 -0
  101. painapple_code/static/css/02-tooltips.css +36 -0
  102. painapple_code/static/css/09-left-rail.css +448 -0
  103. painapple_code/static/css/10-header.css +777 -0
  104. painapple_code/static/css/11-connection-bar.css +120 -0
  105. painapple_code/static/css/20-messages.css +750 -0
  106. painapple_code/static/css/21-tool-blocks.css +2340 -0
  107. painapple_code/static/css/22-edit-diff.css +619 -0
  108. painapple_code/static/css/23-thinking.css +1584 -0
  109. painapple_code/static/css/24-background-tasks.css +359 -0
  110. painapple_code/static/css/25-context-block.css +252 -0
  111. painapple_code/static/css/26-session-setup.css +144 -0
  112. painapple_code/static/css/30-markdown.css +854 -0
  113. painapple_code/static/css/40-input.css +2418 -0
  114. painapple_code/static/css/41-autocomplete.css +127 -0
  115. painapple_code/static/css/42-activity-strip.css +97 -0
  116. painapple_code/static/css/43-file-autocomplete.css +121 -0
  117. painapple_code/static/css/44-snippets-autocomplete.css +224 -0
  118. painapple_code/static/css/45-selection-bar.css +433 -0
  119. painapple_code/static/css/46-keyboard-bar.css +119 -0
  120. painapple_code/static/css/47-keyboard-longpress.css +116 -0
  121. painapple_code/static/css/47-token-profile.css +106 -0
  122. painapple_code/static/css/50-welcome.css +3095 -0
  123. painapple_code/static/css/51-modals.css +301 -0
  124. painapple_code/static/css/52-utilities.css +77 -0
  125. painapple_code/static/css/53-image-upload.css +456 -0
  126. painapple_code/static/css/54-todo-list.css +102 -0
  127. painapple_code/static/css/55-file-upload.css +113 -0
  128. painapple_code/static/css/56-chat-navigator.css +218 -0
  129. painapple_code/static/css/56-permission-card.css +332 -0
  130. painapple_code/static/css/56-plan-approval.css +204 -0
  131. painapple_code/static/css/56-question-form.css +602 -0
  132. painapple_code/static/css/57-swipe-indicator.css +95 -0
  133. painapple_code/static/css/58-session-families.css +627 -0
  134. painapple_code/static/css/59-about.css +196 -0
  135. painapple_code/static/css/61-log-explorer.css +723 -0
  136. painapple_code/static/css/62-json-tree.css +489 -0
  137. painapple_code/static/css/62-skills-widget.css +650 -0
  138. painapple_code/static/css/63-commands-widget.css +30 -0
  139. painapple_code/static/css/63-plugins-widget.css +174 -0
  140. painapple_code/static/css/63-terminal.css +516 -0
  141. painapple_code/static/css/64-search-files-widget.css +448 -0
  142. painapple_code/static/css/65-snippets-widget.css +426 -0
  143. painapple_code/static/css/66-active-sessions.css +979 -0
  144. painapple_code/static/css/66-agents-widget.css +265 -0
  145. painapple_code/static/css/66-file-explorer-widget.css +855 -0
  146. painapple_code/static/css/66-git-panel.css +791 -0
  147. painapple_code/static/css/66-history-explorer.css +1496 -0
  148. painapple_code/static/css/67-diff-viewer.css +602 -0
  149. painapple_code/static/css/67-editor-view.css +748 -0
  150. painapple_code/static/css/67-file-preview-widget.css +1873 -0
  151. painapple_code/static/css/68-browser-widget.css +337 -0
  152. painapple_code/static/css/68-compare-wizard.css +355 -0
  153. painapple_code/static/css/68-csv-preview.css +106 -0
  154. painapple_code/static/css/68-diff-preview.css +103 -0
  155. painapple_code/static/css/68-helpers-install-widget.css +491 -0
  156. painapple_code/static/css/68-image-annotate.css +346 -0
  157. painapple_code/static/css/68-terminal-view.css +98 -0
  158. painapple_code/static/css/68-uploads-widget.css +229 -0
  159. painapple_code/static/css/69-cost-analytics.css +537 -0
  160. painapple_code/static/css/70-config-panel.css +3736 -0
  161. painapple_code/static/css/70-excalidraw.css +136 -0
  162. painapple_code/static/css/70-prompt-explorer.css +890 -0
  163. painapple_code/static/css/71-chart.css +111 -0
  164. painapple_code/static/css/72-discussion-sidebar.css +678 -0
  165. painapple_code/static/css/74-debug-logs.css +656 -0
  166. painapple_code/static/css/80-open-dialog.css +146 -0
  167. painapple_code/static/css/80-quick-switcher.css +383 -0
  168. painapple_code/static/css/80-widget-system.css +1763 -0
  169. painapple_code/static/css/81-quick-actions.css +1020 -0
  170. painapple_code/static/css/82-context-popover.css +1233 -0
  171. painapple_code/static/css/83-context-menu.css +339 -0
  172. painapple_code/static/css/85-lazy-loading.css +102 -0
  173. painapple_code/static/css/86-zen-mode.css +708 -0
  174. painapple_code/static/css/87-grid-switcher.css +346 -0
  175. painapple_code/static/css/login.css +198 -0
  176. painapple_code/static/dist/app.bundle.js +7177 -0
  177. painapple_code/static/feature-triage.html +446 -0
  178. painapple_code/static/icons/apple-touch-icon.png +0 -0
  179. painapple_code/static/icons/favicon-32.png +0 -0
  180. painapple_code/static/icons/favicon.ico +0 -0
  181. painapple_code/static/icons/icon-128.png +0 -0
  182. painapple_code/static/icons/icon-144.png +0 -0
  183. painapple_code/static/icons/icon-152.png +0 -0
  184. painapple_code/static/icons/icon-180.png +0 -0
  185. painapple_code/static/icons/icon-192.png +0 -0
  186. painapple_code/static/icons/icon-384.png +0 -0
  187. painapple_code/static/icons/icon-512.png +0 -0
  188. painapple_code/static/icons/icon-72.png +0 -0
  189. painapple_code/static/icons/icon-96.png +0 -0
  190. painapple_code/static/js/action-delegate.js +187 -0
  191. painapple_code/static/js/activity-strip.js +193 -0
  192. painapple_code/static/js/app/context-menus.js +619 -0
  193. painapple_code/static/js/app/file-preview.js +163 -0
  194. painapple_code/static/js/app/init-ui.js +574 -0
  195. painapple_code/static/js/app/input-events.js +561 -0
  196. painapple_code/static/js/app/input-resize.js +313 -0
  197. painapple_code/static/js/app/message-actions.js +419 -0
  198. painapple_code/static/js/app/panels.js +210 -0
  199. painapple_code/static/js/app/rendering-delegators.js +246 -0
  200. painapple_code/static/js/app/server-loading.js +365 -0
  201. painapple_code/static/js/app/session-ops.js +287 -0
  202. painapple_code/static/js/app/session-switch.js +443 -0
  203. painapple_code/static/js/app/settings-helpers.js +280 -0
  204. painapple_code/static/js/app/startup-welcome.js +290 -0
  205. painapple_code/static/js/app-context.js +157 -0
  206. painapple_code/static/js/app.js +2134 -0
  207. painapple_code/static/js/auth-fetch.js +46 -0
  208. painapple_code/static/js/background-tasks.js +274 -0
  209. painapple_code/static/js/boot.js +73 -0
  210. painapple_code/static/js/caret-position.js +171 -0
  211. painapple_code/static/js/chat-navigator.js +710 -0
  212. painapple_code/static/js/chat-search.js +323 -0
  213. painapple_code/static/js/command-executor.js +176 -0
  214. painapple_code/static/js/command-store.js +497 -0
  215. painapple_code/static/js/components.js +1414 -0
  216. painapple_code/static/js/config.js +218 -0
  217. painapple_code/static/js/context-menu.js +740 -0
  218. painapple_code/static/js/controllers/chat-controller.js +4252 -0
  219. painapple_code/static/js/controllers/dialog-controller.js +217 -0
  220. painapple_code/static/js/controllers/tab-controller.js +1828 -0
  221. painapple_code/static/js/controllers/thinking-controller.js +915 -0
  222. painapple_code/static/js/diff-utils.js +486 -0
  223. painapple_code/static/js/drafts-pill.js +95 -0
  224. painapple_code/static/js/editor-view.js +815 -0
  225. painapple_code/static/js/effort-settings.js +430 -0
  226. painapple_code/static/js/feature-triage.js +811 -0
  227. painapple_code/static/js/file-autocomplete.js +516 -0
  228. painapple_code/static/js/file-tabs.js +123 -0
  229. painapple_code/static/js/gestures.js +448 -0
  230. painapple_code/static/js/grid-switcher.js +334 -0
  231. painapple_code/static/js/input-handler.js +1472 -0
  232. painapple_code/static/js/keyboard-bar.js +495 -0
  233. painapple_code/static/js/layout-switcher.js +109 -0
  234. painapple_code/static/js/linkify-utils.js +200 -0
  235. painapple_code/static/js/login.js +141 -0
  236. painapple_code/static/js/open-dialog.js +879 -0
  237. painapple_code/static/js/orphan-terminals.js +373 -0
  238. painapple_code/static/js/path-utils.js +179 -0
  239. painapple_code/static/js/perf-marks.js +102 -0
  240. painapple_code/static/js/permission-settings.js +475 -0
  241. painapple_code/static/js/pointer-input.js +59 -0
  242. painapple_code/static/js/preview/json-tree-menu.js +226 -0
  243. painapple_code/static/js/preview/json-tree-toolbar.js +291 -0
  244. painapple_code/static/js/preview/json-tree.js +457 -0
  245. painapple_code/static/js/preview/preview-breadcrumb.js +387 -0
  246. painapple_code/static/js/preview/preview-edit.js +367 -0
  247. painapple_code/static/js/preview/preview-events.js +361 -0
  248. painapple_code/static/js/preview/preview-history.js +567 -0
  249. painapple_code/static/js/preview/preview-inline-edit.js +869 -0
  250. painapple_code/static/js/preview/preview-poll.js +108 -0
  251. painapple_code/static/js/preview/preview-render.js +572 -0
  252. painapple_code/static/js/preview/preview-search.js +421 -0
  253. painapple_code/static/js/preview/preview-state.js +256 -0
  254. painapple_code/static/js/preview/preview-utils.js +251 -0
  255. painapple_code/static/js/preview-plugins/chart-plugin.js +22 -0
  256. painapple_code/static/js/preview-plugins/csv-plugin.js +188 -0
  257. painapple_code/static/js/preview-plugins/diff-plugin.js +258 -0
  258. painapple_code/static/js/preview-plugins/excalidraw-plugin.js +25 -0
  259. painapple_code/static/js/preview-plugins/html-plugin.js +104 -0
  260. painapple_code/static/js/preview-plugins/image-plugin.js +20 -0
  261. painapple_code/static/js/preview-plugins/index.js +34 -0
  262. painapple_code/static/js/preview-plugins/json-plugin.js +99 -0
  263. painapple_code/static/js/preview-plugins/jsonl-plugin.js +152 -0
  264. painapple_code/static/js/preview-plugins/markdown-plugin.js +266 -0
  265. painapple_code/static/js/preview-plugins/panzoom-plugin.js +63 -0
  266. painapple_code/static/js/preview-plugins/plugin-helpers.js +282 -0
  267. painapple_code/static/js/project-color-picker.js +110 -0
  268. painapple_code/static/js/project-colors.js +163 -0
  269. painapple_code/static/js/prompt-favorites.js +187 -0
  270. painapple_code/static/js/quick-actions-menu.js +1736 -0
  271. painapple_code/static/js/quick-actions-registry.js +1239 -0
  272. painapple_code/static/js/quick-switcher/controller.js +294 -0
  273. painapple_code/static/js/quick-switcher/fuzzy-scorer.js +123 -0
  274. painapple_code/static/js/quick-switcher/index.js +30 -0
  275. painapple_code/static/js/quick-switcher/providers/base-provider.js +51 -0
  276. painapple_code/static/js/quick-switcher/providers/command-provider.js +56 -0
  277. painapple_code/static/js/quick-switcher/providers/file-provider.js +291 -0
  278. painapple_code/static/js/quick-switcher/providers/panel-provider.js +132 -0
  279. painapple_code/static/js/quick-switcher/providers/project-provider.js +361 -0
  280. painapple_code/static/js/quick-switcher/providers/read-files-provider.js +104 -0
  281. painapple_code/static/js/quick-switcher/providers/skills-provider.js +129 -0
  282. painapple_code/static/js/quick-switcher/registry.js +69 -0
  283. painapple_code/static/js/quick-switcher/ui/item.js +54 -0
  284. painapple_code/static/js/quick-switcher/ui/picker.js +464 -0
  285. painapple_code/static/js/recent-opens.js +40 -0
  286. painapple_code/static/js/scroll-manager.js +530 -0
  287. painapple_code/static/js/scroll-state-machine.js +359 -0
  288. painapple_code/static/js/selection/action-bar.js +793 -0
  289. painapple_code/static/js/selection/index.js +15 -0
  290. painapple_code/static/js/selection/selection-handler.js +877 -0
  291. painapple_code/static/js/selection/state.js +181 -0
  292. painapple_code/static/js/session/agent-progress.js +202 -0
  293. painapple_code/static/js/session/handle-agent-message.js +481 -0
  294. painapple_code/static/js/session/handle-message.js +550 -0
  295. painapple_code/static/js/session/interactive.js +587 -0
  296. painapple_code/static/js/session/message-store.js +271 -0
  297. painapple_code/static/js/session/messages.js +253 -0
  298. painapple_code/static/js/session/persistence.js +179 -0
  299. painapple_code/static/js/session/restore.js +136 -0
  300. painapple_code/static/js/session/sync.js +333 -0
  301. painapple_code/static/js/session-container-pool.js +371 -0
  302. painapple_code/static/js/session-setup-panel.js +274 -0
  303. painapple_code/static/js/session.js +1047 -0
  304. painapple_code/static/js/shortcut-hints.js +272 -0
  305. painapple_code/static/js/shortcuts.js +1146 -0
  306. painapple_code/static/js/skills-autocomplete.js +262 -0
  307. painapple_code/static/js/snippets-autocomplete.js +738 -0
  308. painapple_code/static/js/stash-refs-view.js +83 -0
  309. painapple_code/static/js/stash-ui.js +768 -0
  310. painapple_code/static/js/stash.js +785 -0
  311. painapple_code/static/js/status-bar.js +1184 -0
  312. painapple_code/static/js/token-profile.js +274 -0
  313. painapple_code/static/js/tool-renderer-blocks.js +1626 -0
  314. painapple_code/static/js/tool-renderer-thinking.js +813 -0
  315. painapple_code/static/js/tool-renderer.js +1500 -0
  316. painapple_code/static/js/tooltips.js +197 -0
  317. painapple_code/static/js/upload-manager.js +1019 -0
  318. painapple_code/static/js/utils.js +521 -0
  319. painapple_code/static/js/welcome/api.js +363 -0
  320. painapple_code/static/js/welcome/cards.js +626 -0
  321. painapple_code/static/js/welcome/context-menu.js +585 -0
  322. painapple_code/static/js/welcome/families.js +801 -0
  323. painapple_code/static/js/welcome/preview.js +234 -0
  324. painapple_code/static/js/welcome/state.js +129 -0
  325. painapple_code/static/js/welcome.js +1874 -0
  326. painapple_code/static/js/widget-system/base-widget.js +717 -0
  327. painapple_code/static/js/widget-system/device-manager.js +178 -0
  328. painapple_code/static/js/widget-system/event-bus.js +152 -0
  329. painapple_code/static/js/widget-system/icons.js +120 -0
  330. painapple_code/static/js/widget-system/index.js +49 -0
  331. painapple_code/static/js/widget-system/init.js +54 -0
  332. painapple_code/static/js/widget-system/types/bottom-sheet.js +307 -0
  333. painapple_code/static/js/widget-system/types/floating.js +915 -0
  334. painapple_code/static/js/widget-system/types/index.js +40 -0
  335. painapple_code/static/js/widget-system/types/modal.js +147 -0
  336. painapple_code/static/js/widget-system/types/sidebar.js +158 -0
  337. painapple_code/static/js/widget-system/types/tab.js +146 -0
  338. painapple_code/static/js/widget-system/types/top-sheet.js +203 -0
  339. painapple_code/static/js/widget-system/widget-manager.js +1187 -0
  340. painapple_code/static/js/widgets/about-widget.js +260 -0
  341. painapple_code/static/js/widgets/active-sessions-widget.js +760 -0
  342. painapple_code/static/js/widgets/agents-widget.js +897 -0
  343. painapple_code/static/js/widgets/browser-widget.js +579 -0
  344. painapple_code/static/js/widgets/commands-widget.js +694 -0
  345. painapple_code/static/js/widgets/config/commit-sections.js +449 -0
  346. painapple_code/static/js/widgets/config/dir-autocomplete.js +224 -0
  347. painapple_code/static/js/widgets/config/gestures.js +161 -0
  348. painapple_code/static/js/widgets/config/models-tab.js +988 -0
  349. painapple_code/static/js/widgets/config/quick-actions-tab.js +882 -0
  350. painapple_code/static/js/widgets/config/shortcut-editor.js +587 -0
  351. painapple_code/static/js/widgets/config/state.js +517 -0
  352. painapple_code/static/js/widgets/config/system-controls.js +90 -0
  353. painapple_code/static/js/widgets/config-widget.js +1535 -0
  354. painapple_code/static/js/widgets/cost-analytics-widget.js +621 -0
  355. painapple_code/static/js/widgets/debug-widget.js +693 -0
  356. painapple_code/static/js/widgets/diff-viewer-widget.js +1420 -0
  357. painapple_code/static/js/widgets/discussion-widget.js +1107 -0
  358. painapple_code/static/js/widgets/file-explorer-widget.js +2166 -0
  359. painapple_code/static/js/widgets/file-preview-widget.js +861 -0
  360. painapple_code/static/js/widgets/git-widget.js +794 -0
  361. painapple_code/static/js/widgets/helpers-install-widget.js +720 -0
  362. painapple_code/static/js/widgets/history-explorer-widget.js +1804 -0
  363. painapple_code/static/js/widgets/image-annotate-widget.js +951 -0
  364. painapple_code/static/js/widgets/image-preview-widget.js +692 -0
  365. painapple_code/static/js/widgets/index.js +176 -0
  366. painapple_code/static/js/widgets/log-explorer-widget.js +1057 -0
  367. painapple_code/static/js/widgets/plugins-widget.js +305 -0
  368. painapple_code/static/js/widgets/prompt-explorer-widget.js +1119 -0
  369. painapple_code/static/js/widgets/search-files-widget.js +529 -0
  370. painapple_code/static/js/widgets/skills-widget.js +843 -0
  371. painapple_code/static/js/widgets/snippets-widget.js +479 -0
  372. painapple_code/static/js/widgets/sub-agents-widget.js +333 -0
  373. painapple_code/static/js/widgets/tasks-widget.js +379 -0
  374. painapple_code/static/js/widgets/terminal/connection.js +283 -0
  375. painapple_code/static/js/widgets/terminal/gestures.js +357 -0
  376. painapple_code/static/js/widgets/terminal/init.js +642 -0
  377. painapple_code/static/js/widgets/terminal/link-providers.js +532 -0
  378. painapple_code/static/js/widgets/terminal/osc52.js +213 -0
  379. painapple_code/static/js/widgets/terminal/render.js +159 -0
  380. painapple_code/static/js/widgets/terminal/size.js +54 -0
  381. painapple_code/static/js/widgets/terminal/state.js +211 -0
  382. painapple_code/static/js/widgets/terminal/wrap-utils.js +101 -0
  383. painapple_code/static/js/widgets/terminal-widget.js +793 -0
  384. painapple_code/static/js/widgets/uploads-widget.js +461 -0
  385. painapple_code/static/js/widgets/zen-widget.js +1001 -0
  386. painapple_code/static/login.html +51 -0
  387. painapple_code/static/manifest.json +73 -0
  388. painapple_code/static/sw.js +293 -0
  389. painapple_code/static/vendor/LICENSES.txt +769 -0
  390. painapple_code/static/vendor/README.md +103 -0
  391. painapple_code/static/vendor/codemirror.js +35 -0
  392. painapple_code/static/vendor/codemirror.js.LEGAL.txt +304 -0
  393. painapple_code/static/vendor/github-markdown-dark.min.css +1124 -0
  394. painapple_code/static/vendor/highlight-github-dark.min.css +10 -0
  395. painapple_code/static/vendor/highlight-lang-dockerfile.min.js +8 -0
  396. painapple_code/static/vendor/highlight-lang-nginx.min.js +21 -0
  397. painapple_code/static/vendor/highlight-lang-properties.min.js +10 -0
  398. painapple_code/static/vendor/highlight-lang-scala.min.js +28 -0
  399. painapple_code/static/vendor/highlight.min.js +1213 -0
  400. painapple_code/static/vendor/marked.min.js +6 -0
  401. painapple_code/static/vendor/purify.min.js +3 -0
  402. painapple_code/static/vendor/xterm-addon-fit.js +2 -0
  403. painapple_code/static/vendor/xterm.css +209 -0
  404. painapple_code/static/vendor/xterm.js +2 -0
  405. painapple_code/static/web-client.html +635 -0
  406. painapple_code/subprocess_registry.py +535 -0
  407. painapple_code/tls_cert.py +79 -0
  408. painapple_code/tools/agents/shadow-git-helper.md +400 -0
  409. painapple_code/tools/excalidraw-to-svg.js +143 -0
  410. painapple_code/tools/install-helpers.sh +148 -0
  411. painapple_code/tools/shadow-git +320 -0
  412. painapple_code/tools/shadow-query +120 -0
  413. painapple_code/tools/vegalite-to-svg.js +79 -0
  414. painapple_code/turn_query.py +766 -0
  415. painapple_code/turn_tracker.py +221 -0
  416. painapple_code/utils/__init__.py +0 -0
  417. painapple_code/utils/agent_cli.py +306 -0
  418. painapple_code/utils/chart.py +103 -0
  419. painapple_code/utils/excalidraw.py +107 -0
  420. painapple_code/utils/file_lock.py +162 -0
  421. painapple_code/utils/file_paths.py +648 -0
  422. painapple_code/utils/generate_icons.py +251 -0
  423. painapple_code/utils/proc.py +205 -0
  424. painapple_code/utils/pty_backend.py +59 -0
  425. painapple_code/utils/pty_posix.py +160 -0
  426. painapple_code/utils/pty_windows.py +245 -0
  427. painapple_code/utils/token_profiles.py +91 -0
  428. painapple_code/viewer_templates.py +657 -0
  429. painapple_code/welcome_search.py +879 -0
  430. painapple_code-1.0.0.dist-info/METADATA +292 -0
  431. painapple_code-1.0.0.dist-info/RECORD +436 -0
  432. painapple_code-1.0.0.dist-info/WHEEL +5 -0
  433. painapple_code-1.0.0.dist-info/entry_points.txt +2 -0
  434. painapple_code-1.0.0.dist-info/licenses/LICENSE +661 -0
  435. painapple_code-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +261 -0
  436. painapple_code-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,275 @@
1
+ """Plugins Manager API.
2
+
3
+ Endpoints for browsing the plugin marketplace and managing installed plugins.
4
+
5
+ Backed by the `claude plugins` CLI:
6
+ claude plugins list --available --json → { installed: [...], available: [...] }
7
+ claude plugins install <id>
8
+ claude plugins uninstall <id>
9
+ claude plugins enable <id>
10
+ claude plugins disable <id>
11
+
12
+ Component inventory (skills/agents/commands/hooks) is computed by walking the
13
+ marketplace directory on disk, since `claude plugins details` has no --json.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import json
19
+ import re
20
+ from pathlib import Path
21
+ from typing import Any, Optional
22
+
23
+ from fastapi import APIRouter, HTTPException
24
+ from pydantic import BaseModel
25
+
26
+ router = APIRouter(prefix="/api/plugins", tags=["plugins"])
27
+
28
+ # Forbid a leading '-'/'.' so a plugin id can't smuggle a CLI option.
29
+ PLUGIN_ID_RE = re.compile(r"^(?![-.])[a-zA-Z0-9_./-]+@[a-zA-Z0-9_./-]+$")
30
+ CLI_TIMEOUT_SEC = 120 # install pulls from git; allow some slack
31
+
32
+
33
+ # ---------- helpers ---------------------------------------------------------
34
+
35
+
36
+ def _validate_plugin_id(plugin_id: str) -> None:
37
+ """Reject anything that isn't shaped like `<plugin>@<marketplace>`."""
38
+ if not PLUGIN_ID_RE.match(plugin_id):
39
+ raise HTTPException(
40
+ status_code=400,
41
+ detail=f"Invalid plugin id {plugin_id!r}",
42
+ )
43
+
44
+
45
+ async def _run_claude(*args: str) -> tuple[int, str, str]:
46
+ """Spawn `claude` with the given args; return (returncode, stdout, stderr)."""
47
+ try:
48
+ from painapple_code.utils.proc import resolve_binary
49
+ proc = await asyncio.create_subprocess_exec(
50
+ resolve_binary("claude"), *args,
51
+ stdout=asyncio.subprocess.PIPE,
52
+ stderr=asyncio.subprocess.PIPE,
53
+ )
54
+ out, err = await asyncio.wait_for(
55
+ proc.communicate(), timeout=CLI_TIMEOUT_SEC
56
+ )
57
+ except asyncio.TimeoutError:
58
+ raise HTTPException(status_code=504, detail=f"claude plugins {args[0]} timed out")
59
+ except FileNotFoundError:
60
+ raise HTTPException(status_code=500, detail="`claude` CLI not on PATH")
61
+ return proc.returncode or 0, out.decode("utf-8", "replace"), err.decode("utf-8", "replace")
62
+
63
+
64
+ def _plugin_dir(plugin_id: str) -> Optional[Path]:
65
+ """Return the marketplace directory for `<plugin>@<marketplace>`, or None."""
66
+ name, _, marketplace = plugin_id.partition("@")
67
+ if not name or not marketplace:
68
+ return None
69
+ candidate = (
70
+ Path.home() / ".claude" / "plugins" / "marketplaces"
71
+ / marketplace / "plugins" / name
72
+ )
73
+ return candidate if candidate.is_dir() else None
74
+
75
+
76
+ def _component_inventory(plugin_dir: Path) -> dict[str, int]:
77
+ """Count plugin components by walking the on-disk plugin directory.
78
+
79
+ Mirrors how Claude Code itself discovers components — folder-form skills,
80
+ flat `.md` agents/commands, and a single `hooks.json` or `hooks/hooks.json`.
81
+ """
82
+ counts = {"skills": 0, "agents": 0, "commands": 0, "hooks": 0, "mcp_servers": 0}
83
+
84
+ skills_dir = plugin_dir / "skills"
85
+ if skills_dir.is_dir():
86
+ counts["skills"] = sum(
87
+ 1 for d in skills_dir.iterdir()
88
+ if d.is_dir() and (d / "SKILL.md").is_file()
89
+ )
90
+
91
+ for kind, subdir, suffix in (
92
+ ("agents", "agents", ".md"),
93
+ ("commands", "commands", ".md"),
94
+ ):
95
+ d = plugin_dir / subdir
96
+ if d.is_dir():
97
+ counts[kind] = sum(
98
+ 1 for f in d.iterdir()
99
+ if f.is_file() and f.suffix == suffix and not f.name.startswith(".")
100
+ )
101
+
102
+ for hooks_path in (plugin_dir / "hooks.json", plugin_dir / "hooks" / "hooks.json"):
103
+ if hooks_path.is_file():
104
+ try:
105
+ data = json.loads(hooks_path.read_text("utf-8"))
106
+ hooks = data.get("hooks") if isinstance(data, dict) else None
107
+ if isinstance(hooks, list):
108
+ counts["hooks"] = len(hooks)
109
+ elif isinstance(hooks, dict):
110
+ counts["hooks"] = sum(
111
+ len(v) if isinstance(v, list) else 1
112
+ for v in hooks.values()
113
+ )
114
+ except (OSError, json.JSONDecodeError):
115
+ pass
116
+ break
117
+
118
+ mcp_path = plugin_dir / ".mcp.json"
119
+ if mcp_path.is_file():
120
+ try:
121
+ data = json.loads(mcp_path.read_text("utf-8"))
122
+ servers = data.get("mcpServers") if isinstance(data, dict) else None
123
+ if isinstance(servers, dict):
124
+ counts["mcp_servers"] = len(servers)
125
+ except (OSError, json.JSONDecodeError):
126
+ pass
127
+
128
+ return counts
129
+
130
+
131
+ def _plugin_manifest(plugin_dir: Path) -> dict[str, Any]:
132
+ """Read `plugin.json` for author/version/license info."""
133
+ for path in (
134
+ plugin_dir / ".claude-plugin" / "plugin.json",
135
+ plugin_dir / "plugin.json",
136
+ ):
137
+ if path.is_file():
138
+ try:
139
+ return json.loads(path.read_text("utf-8"))
140
+ except (OSError, json.JSONDecodeError):
141
+ return {}
142
+ return {}
143
+
144
+
145
+ # ---------- endpoints -------------------------------------------------------
146
+
147
+
148
+ @router.get("")
149
+ async def list_plugins(available: bool = True):
150
+ """Return installed + (optionally) available plugins, with components inventory.
151
+
152
+ Default includes the marketplace catalog so the widget can render a unified
153
+ list with install/uninstall buttons. Set `?available=false` to skip the
154
+ catalog fetch (faster — no remote refresh).
155
+ """
156
+ args = ["plugins", "list", "--json"]
157
+ if available:
158
+ args.insert(2, "--available")
159
+ rc, out, err = await _run_claude(*args)
160
+ if rc != 0:
161
+ raise HTTPException(status_code=502, detail=f"claude plugins list failed: {err.strip() or out.strip()}")
162
+
163
+ try:
164
+ data = json.loads(out)
165
+ except json.JSONDecodeError as e:
166
+ raise HTTPException(status_code=502, detail=f"unparseable CLI output: {e}")
167
+
168
+ # --available returns {installed, available}; without it returns a flat list
169
+ if available and isinstance(data, dict):
170
+ installed = data.get("installed") or []
171
+ available_list = data.get("available") or []
172
+ else:
173
+ installed = data if isinstance(data, list) else []
174
+ available_list = []
175
+
176
+ installed_by_id: dict[str, dict[str, Any]] = {it["id"]: it for it in installed if "id" in it}
177
+
178
+ def _enrich(item: dict[str, Any], is_installed: bool) -> dict[str, Any]:
179
+ # `available` items use `pluginId`, installed items use `id`. Normalize.
180
+ plugin_id = item.get("id") or item.get("pluginId") or ""
181
+ plugin_dir = _plugin_dir(plugin_id) if plugin_id else None
182
+ components = _component_inventory(plugin_dir) if plugin_dir else {
183
+ "skills": 0, "agents": 0, "commands": 0, "hooks": 0, "mcp_servers": 0,
184
+ }
185
+ manifest = _plugin_manifest(plugin_dir) if plugin_dir else {}
186
+ inst = installed_by_id.get(plugin_id, {})
187
+ return {
188
+ "id": plugin_id,
189
+ "name": item.get("name") or plugin_id.split("@", 1)[0],
190
+ "description": item.get("description") or manifest.get("description") or "",
191
+ "marketplace": item.get("marketplaceName") or (plugin_id.split("@", 1)[1] if "@" in plugin_id else ""),
192
+ "source": item.get("source"),
193
+ "install_count": item.get("installCount"),
194
+ "installed": is_installed,
195
+ "enabled": inst.get("enabled") if is_installed else None,
196
+ "version": inst.get("version") or manifest.get("version"),
197
+ "scope": inst.get("scope"),
198
+ "installed_at": inst.get("installedAt"),
199
+ "install_path": inst.get("installPath"),
200
+ "author": manifest.get("author"),
201
+ "components": components,
202
+ }
203
+
204
+ items: list[dict[str, Any]] = []
205
+ seen: set[str] = set()
206
+ # Installed first (so they sort to the top before any UI-side reorder)
207
+ for it in installed:
208
+ pid = it.get("id")
209
+ if not pid:
210
+ continue
211
+ items.append(_enrich(it, is_installed=True))
212
+ seen.add(pid)
213
+ for it in available_list:
214
+ pid = it.get("pluginId") or it.get("id")
215
+ if not pid or pid in seen:
216
+ continue
217
+ items.append(_enrich(it, is_installed=False))
218
+
219
+ return {
220
+ "plugins": items,
221
+ "counts": {
222
+ "installed": sum(1 for p in items if p["installed"]),
223
+ "enabled": sum(1 for p in items if p.get("enabled")),
224
+ "available": len(items),
225
+ },
226
+ }
227
+
228
+
229
+ class PluginActionBody(BaseModel):
230
+ plugin_id: str
231
+
232
+
233
+ async def _plugin_action(verb: str, plugin_id: str) -> dict[str, Any]:
234
+ """Run `claude plugins <verb> <plugin_id>` and surface stdout/stderr."""
235
+ _validate_plugin_id(plugin_id)
236
+ rc, out, err = await _run_claude("plugins", verb, plugin_id)
237
+ ok = rc == 0
238
+ if not ok:
239
+ raise HTTPException(
240
+ status_code=400,
241
+ detail={
242
+ "verb": verb,
243
+ "plugin_id": plugin_id,
244
+ "stdout": out.strip(),
245
+ "stderr": err.strip(),
246
+ "returncode": rc,
247
+ },
248
+ )
249
+ return {
250
+ "ok": True,
251
+ "verb": verb,
252
+ "plugin_id": plugin_id,
253
+ "stdout": out.strip(),
254
+ "stderr": err.strip(),
255
+ }
256
+
257
+
258
+ @router.post("/install")
259
+ async def install_plugin(payload: PluginActionBody):
260
+ return await _plugin_action("install", payload.plugin_id)
261
+
262
+
263
+ @router.post("/uninstall")
264
+ async def uninstall_plugin(payload: PluginActionBody):
265
+ return await _plugin_action("uninstall", payload.plugin_id)
266
+
267
+
268
+ @router.post("/enable")
269
+ async def enable_plugin(payload: PluginActionBody):
270
+ return await _plugin_action("enable", payload.plugin_id)
271
+
272
+
273
+ @router.post("/disable")
274
+ async def disable_plugin(payload: PluginActionBody):
275
+ return await _plugin_action("disable", payload.plugin_id)
@@ -0,0 +1,418 @@
1
+ """
2
+ Project Config API Routes - project-specific configuration & slash commands.
3
+
4
+ These endpoints manage:
5
+ - Project-specific configuration (get/put/patch)
6
+ - Project rename (human-friendly display name)
7
+ - Project slash commands (cached list, descriptions, source paths)
8
+
9
+ Also exposes module-level helpers used by `routes.api_commands`:
10
+ - `_cli_command_descriptions()` (lazily extracted from the Claude CLI binary, memoized)
11
+ - `_get_command_descriptions` (CLI defaults + .md frontmatter overrides)
12
+ """
13
+
14
+ import asyncio
15
+ import contextlib
16
+ import functools
17
+ import json
18
+ import logging
19
+ import mmap
20
+ import re
21
+ from pathlib import Path
22
+
23
+ from fastapi import APIRouter, Request
24
+ from pydantic import BaseModel
25
+
26
+ from painapple_code import paths
27
+ from painapple_code.session_store import SessionStore
28
+ from painapple_code.utils.file_paths import safe_resolve
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ router = APIRouter(tags=["app:project-config"])
33
+
34
+
35
+ # ═══════════════════════════════════════════════════════════════════
36
+ # Project Config API
37
+ # ═══════════════════════════════════════════════════════════════════
38
+
39
+ @router.get("/api/project/config")
40
+ async def get_project_config(cwd: str):
41
+ """Get project-specific configuration (merged with global)."""
42
+ resolved_cwd = str(safe_resolve(cwd))
43
+ config = paths.load_project_config(resolved_cwd)
44
+
45
+ project_hash = paths.get_project_hash(resolved_cwd)
46
+ project_dir = paths.get_project_dir(resolved_cwd)
47
+
48
+ return {
49
+ "config": config,
50
+ "project": {
51
+ "hash": project_hash,
52
+ "path": resolved_cwd,
53
+ "name": Path(resolved_cwd).name,
54
+ "has_shadow_git": (project_dir / "shadow-git").exists(),
55
+ "color": paths.get_project_color(resolved_cwd),
56
+ }
57
+ }
58
+
59
+
60
+ @router.put("/api/project/config")
61
+ async def update_project_config(cwd: str, request: Request):
62
+ """Update project-specific configuration."""
63
+ resolved_cwd = str(safe_resolve(cwd))
64
+ body = await request.json()
65
+
66
+ paths.save_project_config(resolved_cwd, body)
67
+
68
+ return await get_project_config(cwd)
69
+
70
+
71
+ @router.patch("/api/project/config")
72
+ async def patch_project_config(cwd: str, request: Request):
73
+ """Partially update project configuration (merge with existing)."""
74
+ resolved_cwd = str(safe_resolve(cwd))
75
+ body = await request.json()
76
+
77
+ config_path = paths.get_project_config_path(resolved_cwd)
78
+ existing = {}
79
+ if config_path.exists():
80
+ try:
81
+ existing = json.loads(config_path.read_text(encoding="utf-8"))
82
+ except Exception:
83
+ pass
84
+
85
+ def deep_merge(base, update):
86
+ result = base.copy()
87
+ for key, value in update.items():
88
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
89
+ result[key] = deep_merge(result[key], value)
90
+ else:
91
+ result[key] = value
92
+ return result
93
+
94
+ merged = deep_merge(existing, body)
95
+ paths.save_project_config(resolved_cwd, merged)
96
+
97
+ return await get_project_config(cwd)
98
+
99
+
100
+ class ProjectRenameRequest(BaseModel):
101
+ name: str
102
+
103
+
104
+ @router.post("/api/project/rename")
105
+ async def rename_project(cwd: str, request: ProjectRenameRequest):
106
+ """Set a human-friendly display name for a project."""
107
+ resolved_cwd = str(safe_resolve(cwd))
108
+ paths.set_project_display_name(resolved_cwd, request.name)
109
+
110
+ return {
111
+ "success": True,
112
+ "project_path": resolved_cwd,
113
+ "display_name": paths.get_project_display_name(resolved_cwd),
114
+ "directory_name": Path(resolved_cwd).name,
115
+ }
116
+
117
+
118
+ class ProjectColorRequest(BaseModel):
119
+ # Empty string / null clears the override (revert to the deterministic
120
+ # hash color). Any non-hex value is normalized/rejected server-side.
121
+ color: str = ""
122
+
123
+
124
+ @router.get("/api/project/colors")
125
+ async def get_project_colors():
126
+ """Bulk map of project path → custom color for every project with one set.
127
+
128
+ A single fetch feeds the synchronous client-side `getProjectColor`
129
+ override lookups across the UI (tabs, welcome cards, pickers).
130
+ """
131
+ return {"colors": paths.list_project_colors()}
132
+
133
+
134
+ @router.post("/api/project/color")
135
+ async def set_project_color(cwd: str, request: ProjectColorRequest):
136
+ """Assign (or clear) a custom color for a project.
137
+
138
+ Stored under `display.color`; an empty/invalid color clears the override.
139
+ """
140
+ resolved_cwd = str(safe_resolve(cwd))
141
+ stored = paths.set_project_color(resolved_cwd, request.color)
142
+
143
+ return {
144
+ "success": True,
145
+ "project_path": resolved_cwd,
146
+ "color": stored,
147
+ }
148
+
149
+
150
+ def _get_user_command_names(cwd: str) -> list[str]:
151
+ """Get names of user-defined commands from ~/.claude/commands/ and {cwd}/.claude/commands/."""
152
+ names = set()
153
+ dirs = [
154
+ Path.home() / ".claude" / "commands",
155
+ Path(cwd) / ".claude" / "commands",
156
+ ]
157
+ for d in dirs:
158
+ if d.is_dir():
159
+ for f in d.iterdir():
160
+ if f.is_file() and f.suffix == ".md":
161
+ names.add(f.stem)
162
+ return sorted(names)
163
+
164
+
165
+ _MAX_SCAN_BYTES = 500 * 1024 * 1024
166
+
167
+
168
+ @contextlib.contextmanager
169
+ def _mapped_bytes(path: Path):
170
+ """The file's bytes as a scannable buffer, mmap'd when possible.
171
+
172
+ `re` takes any buffer, so mapping instead of `read_bytes()` keeps the
173
+ ~100-300MB CLI bundle in the page cache the OS already has it in,
174
+ rather than copying all of it into the server's heap. Falls back to a
175
+ real read where mmap isn't available (some network filesystems), and
176
+ yields b"" for an unreadable or implausibly large file.
177
+ """
178
+ try:
179
+ size = path.stat().st_size
180
+ except OSError:
181
+ yield b""
182
+ return
183
+ if size > _MAX_SCAN_BYTES:
184
+ # Kept as a cap rather than removed: this scans whatever `claude`
185
+ # resolves to on PATH, and half a gigabyte is already ~4x the real
186
+ # bundle, so anything past it is a wrong file, not a big one — and
187
+ # the scan is on the request path. But it is no longer SILENT; it
188
+ # used to look identical to "the CLI has no commands", which is
189
+ # what `strings` (unbounded) would never have reported.
190
+ logger.warning(
191
+ f"Skipping slash-command extraction: {path} is {size} bytes, "
192
+ f"over the {_MAX_SCAN_BYTES}-byte scan cap"
193
+ )
194
+ yield b""
195
+ return
196
+ try:
197
+ with open(path, "rb") as fh:
198
+ try:
199
+ with mmap.mmap(fh.fileno(), 0, access=mmap.ACCESS_READ) as mm:
200
+ yield mm
201
+ return
202
+ except (OSError, ValueError):
203
+ fh.seek(0)
204
+ yield fh.read()
205
+ except OSError:
206
+ yield b""
207
+
208
+
209
+ # `name:"xxx",description:"yyy"` / `…,description:'yyy'` as it appears in
210
+ # the CLI's embedded JS, matched against the raw binary.
211
+ #
212
+ # There is no `strings` pass in front of this any more, and none is needed.
213
+ # The point of extracting printable runs first was to stop `[^"]+` from
214
+ # swallowing NUL and other binary noise into a description; restricting the
215
+ # body to printable ASCII does that directly — `[ -!#-~]` is 0x20..0x7e
216
+ # minus `"`, `[ -&(-~]` the same minus `'`. Every other byte of the pattern
217
+ # is printable too, so a match still can't span the boundary `strings`
218
+ # would have cut at, and the result is byte-identical (verified against the
219
+ # old implementation over the real 294MB bundle: same 95 commands, same
220
+ # descriptions) for ~1/40th of the work — 2.13s to 0.05s, because the old
221
+ # form built a ~100MB str of every printable run before searching it.
222
+ _COMMAND_DEF = re.compile(
223
+ rb'name:"([a-z][a-z0-9-]*)",description:(?:"([ -!#-~]+)"|\'([ -&(-~]+)\')'
224
+ )
225
+
226
+
227
+ @functools.lru_cache(maxsize=1)
228
+ def _cli_command_descriptions() -> dict[str, str]:
229
+ """Extract slash command descriptions from the Claude CLI binary.
230
+
231
+ Parses `name:"xxx",description:"yyy"` and `name:"xxx",description:'yyy'` patterns
232
+ from the embedded JS. Some skills use single-quoted descriptions because their
233
+ text contains double quotes (e.g. update-config's description references
234
+ "from now on when X" in quotes).
235
+
236
+ Lazily computed on first use and cached for the server's lifetime —
237
+ scanning the ~100MB CLI bundle takes a couple of seconds, which must
238
+ not happen at import time (it made every `painapple` CLI invocation
239
+ slow) and must not happen on the event loop. Blocking: call it through
240
+ `cli_command_descriptions()` from async code, never directly.
241
+ """
242
+ import shutil
243
+ descriptions = {}
244
+ try:
245
+ claude_bin = shutil.which("claude")
246
+ if claude_bin:
247
+ real_path = Path(claude_bin).resolve()
248
+ # Was `strings <binary>`: absent on Windows and on any Linux
249
+ # without binutils, and a whole subprocess to do what one regex
250
+ # pass does. No dependency, and it can't hang.
251
+ with _mapped_bytes(real_path) as data:
252
+ # Group 2 = double-quoted body, group 3 = single-quoted.
253
+ for m in _COMMAND_DEF.finditer(data):
254
+ name = m.group(1).decode("ascii")
255
+ # Skip non-command entries (CLI args, system tools, etc.)
256
+ if name.startswith("--") or name in ("command", "count", "duration", "definition"):
257
+ continue
258
+ # Keep first match for duplicates (real command defs
259
+ # appear before library defs). Checked before the decode
260
+ # work rather than after it.
261
+ if name in descriptions:
262
+ continue
263
+ raw = m.group(2) if m.group(2) is not None else m.group(3)
264
+ # Decode unicode escapes like &ndash;
265
+ desc = raw.decode("ascii").encode("utf-8").decode(
266
+ "unicode_escape", errors="replace"
267
+ )
268
+ # Truncate multi-line descriptions (skills with trigger rules)
269
+ if "\n" in desc:
270
+ desc = desc.split("\n")[0].rstrip()
271
+ descriptions[name] = desc
272
+ except Exception:
273
+ pass
274
+ return descriptions
275
+
276
+
277
+ async def cli_command_descriptions() -> dict[str, str]:
278
+ """Async accessor for `_cli_command_descriptions` — the only safe one.
279
+
280
+ The scan reads and regexes the whole Claude CLI bundle: measured ~2.2s
281
+ in-process, and it was being called straight from `async def` handlers,
282
+ so every WS frame, turn heartbeat and unrelated request stalled behind
283
+ it. (The `strings` subprocess it replaced was slower in wall-clock but
284
+ released the GIL, so it never had this effect.) Same
285
+ `asyncio.to_thread` shape as the `--version` probe in
286
+ api_app_config.
287
+
288
+ The `functools.lru_cache` underneath means only the first call pays;
289
+ concurrent first calls can duplicate the work, which costs CPU but not
290
+ correctness (the memo converges on one of the identical results).
291
+ """
292
+ return await asyncio.to_thread(_cli_command_descriptions)
293
+
294
+
295
+ def _get_command_descriptions(cwd: str) -> dict[str, str]:
296
+ """Get descriptions for commands from CLI binary + .md frontmatter."""
297
+ descriptions = dict(_cli_command_descriptions())
298
+ # User command .md frontmatter descriptions override CLI defaults
299
+ dirs = [
300
+ Path.home() / ".claude" / "commands",
301
+ Path(cwd) / ".claude" / "commands",
302
+ ]
303
+ for d in dirs:
304
+ if d.is_dir():
305
+ for f in d.iterdir():
306
+ if f.is_file() and f.suffix == ".md":
307
+ try:
308
+ text = f.read_text(encoding="utf-8", errors="replace")
309
+ # Parse YAML frontmatter: --- ... description: ... ---
310
+ if text.startswith("---"):
311
+ end = text.find("---", 3)
312
+ if end != -1:
313
+ for line in text[3:end].splitlines():
314
+ line = line.strip()
315
+ if line.startswith("description:"):
316
+ desc = line[len("description:"):].strip().strip('"').strip("'")
317
+ if desc:
318
+ descriptions[f.stem] = desc
319
+ break
320
+ except Exception:
321
+ pass
322
+ return descriptions
323
+
324
+
325
+ def _get_command_sources(cwd: str) -> dict[str, str]:
326
+ """Resolve command names to source file paths.
327
+
328
+ Search order (first match wins — project beats global, commands beat skills):
329
+ 1. {cwd}/.claude/commands/{name}.md
330
+ 2. ~/.claude/commands/{name}.md
331
+ 3. {cwd}/.claude/skills/{name}/SKILL.md
332
+ 4. ~/.claude/skills/{name}/SKILL.md
333
+ 5. ~/.claude/plugins/marketplaces/ * /plugins/ * /commands/{name}.md
334
+ 6. ~/.claude/plugins/marketplaces/ * /plugins/ * /skills/{name}/SKILL.md
335
+
336
+ Returns a dict mapping command name (without leading /) to absolute file path.
337
+ Commands whose source is baked into the CLI binary (e.g. /loop, /compact)
338
+ or stored in browser localStorage (user custom commands) are not listed.
339
+ """
340
+ sources: dict[str, str] = {}
341
+ cwd_path = Path(cwd)
342
+ home = Path.home()
343
+
344
+ def _add(name: str, path: Path):
345
+ if name not in sources and path.is_file():
346
+ sources[name] = str(path)
347
+
348
+ # 1 & 2: command files
349
+ for base in (cwd_path, home):
350
+ cmd_dir = base / ".claude" / "commands"
351
+ if cmd_dir.is_dir():
352
+ for f in cmd_dir.iterdir():
353
+ if f.is_file() and f.suffix == ".md":
354
+ _add(f.stem, f)
355
+
356
+ # 3 & 4: skill files
357
+ for base in (cwd_path, home):
358
+ skill_root = base / ".claude" / "skills"
359
+ if skill_root.is_dir():
360
+ for skill_dir in skill_root.iterdir():
361
+ if skill_dir.is_dir():
362
+ skill_file = skill_dir / "SKILL.md"
363
+ if skill_file.is_file():
364
+ _add(skill_dir.name, skill_file)
365
+
366
+ # 5 & 6: plugin marketplace commands/skills
367
+ plugins_root = home / ".claude" / "plugins" / "marketplaces"
368
+ if plugins_root.is_dir():
369
+ for marketplace in plugins_root.iterdir():
370
+ plugins_dir = marketplace / "plugins"
371
+ if not plugins_dir.is_dir():
372
+ continue
373
+ for plugin in plugins_dir.iterdir():
374
+ # Plugin commands
375
+ cmd_dir = plugin / "commands"
376
+ if cmd_dir.is_dir():
377
+ for f in cmd_dir.iterdir():
378
+ if f.is_file() and f.suffix == ".md":
379
+ _add(f.stem, f)
380
+ # Plugin skills
381
+ skill_root = plugin / "skills"
382
+ if skill_root.is_dir():
383
+ for skill_dir in skill_root.iterdir():
384
+ if skill_dir.is_dir():
385
+ skill_file = skill_dir / "SKILL.md"
386
+ if skill_file.is_file():
387
+ _add(skill_dir.name, skill_file)
388
+ return sources
389
+
390
+
391
+ @router.get("/api/project/commands")
392
+ async def get_project_commands(cwd: str):
393
+ """Get cached slash commands for a project (by CWD).
394
+
395
+ Folder-form skills (resolving to `SKILL.md`) are filtered out — they
396
+ are surfaced through the `~` skills picker instead, so `/` autocomplete
397
+ only shows true slash commands.
398
+ """
399
+ resolved_cwd = str(safe_resolve(cwd))
400
+ commands = SessionStore.get_project_commands(resolved_cwd)
401
+ user_commands = _get_user_command_names(resolved_cwd)
402
+ # Warm the binary scan off the event loop; the sync call below then
403
+ # hits the memo instead of stalling the loop for ~2s.
404
+ await cli_command_descriptions()
405
+ descriptions = _get_command_descriptions(resolved_cwd)
406
+ sources = _get_command_sources(resolved_cwd)
407
+ # Drop names whose source is a folder-form skill — those go through `~`.
408
+ filtered_commands = [
409
+ name for name in commands
410
+ if not sources.get(name, "").endswith("SKILL.md")
411
+ ]
412
+ return {
413
+ "cwd": resolved_cwd,
414
+ "commands": filtered_commands,
415
+ "user_commands": user_commands,
416
+ "descriptions": descriptions,
417
+ "sources": sources,
418
+ }