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,204 @@
1
+ """Top-level CLI dispatch for the ``painapple`` command.
2
+
3
+ ``painapple`` is the sole console script (the old ``painapple-code``
4
+ name was retired; ``python -m painapple_code`` still works everywhere).
5
+
6
+ Backward compatibility is the design constraint: bare invocations like
7
+ ``painapple --port 8880 --cwd …`` are baked into systemd units,
8
+ start.sh, deploy.fish, and docker-entrypoint.sh, so they must keep
9
+ working unchanged. Dispatch peeks at the first argument — a known
10
+ subcommand routes to its handler, anything else falls through to the
11
+ server's flat argument parser exactly as before. The one deliberate
12
+ exception: a *leading* ``-h``/``--help`` shows the curated command
13
+ overview below instead of the server's flag dump (``painapple serve
14
+ --help`` still prints the full flag reference).
15
+
16
+ Docker is not a command group — it's a run mode. ``--in-docker`` runs
17
+ the same serve invocation in a container; profiles carry a ``mode:
18
+ host|docker`` and every verb (start/stop/logs/…) dispatches on it.
19
+ The old ``painapple docker`` group prints a "moved" pointer.
20
+
21
+ Subcommands:
22
+ serve explicit alias for the default server invocation
23
+ setup [NAME] global defaults wizard / profile wizard (creates NAME)
24
+ list the fleet view: every deployment + unmanaged processes
25
+ start/stop/restart [NAME] background lifecycle (mode-aware)
26
+ status [NAME] one deployment's detail — bare, it IS the fleet view
27
+ logs [NAME] follow output (host: console.log · docker: container)
28
+ password [NAME] show the server auth URL + password
29
+ pull fetch the prebuilt container image
30
+ shell/extract/claude-login [NAME] docker-mode utilities
31
+ help curated overview (also -h/--help as the first argument)
32
+ version the version string (also -v/--version as a flag)
33
+ """
34
+
35
+ import sys
36
+
37
+ SUBCOMMANDS = ("serve", "setup", "list", "start", "stop", "restart",
38
+ "status", "logs", "password", "pull", "shell", "extract",
39
+ "claude-login", "help", "version")
40
+
41
+ _LIST_ALIASES = ("list", "ls", "instances", "profiles")
42
+ _MANAGE_VERBS = {
43
+ "status": "status", "ps": "status",
44
+ "logs": "logs", "log": "logs",
45
+ "password": "password", "show-password": "password",
46
+ "url": "password", "token": "password",
47
+ "pull": "pull",
48
+ "shell": "shell", "sh": "shell",
49
+ "extract": "extract", "export": "extract",
50
+ "claude-login": "claude-login", "login": "claude-login",
51
+ "profile": "profile", # scripted get/set (desktop launcher)
52
+ }
53
+
54
+
55
+ def _help():
56
+ """Command overview — the front door. Full serve-flag reference
57
+ stays on `painapple serve --help` (argparse)."""
58
+ from painapple_code import __version__
59
+ from painapple_code.cli.ui import BOLD, DIM, GREEN, RESET, say
60
+ p = "painapple"
61
+ say(f"""{BOLD}{p}{RESET} — Claude Code in your browser: self-hosted server + web client {DIM}v{__version__}{RESET}
62
+
63
+ {BOLD}Usage:{RESET}
64
+ {p} {DIM}[flags…]{RESET} Serve the current directory {DIM}(same as: {p} serve){RESET}
65
+ {p} {GREEN}--in-docker{RESET} …the same, inside a container {DIM}(Docker/Podman){RESET}
66
+ {p} {GREEN}setup{RESET} Global defaults {DIM}(network · container runtime){RESET}
67
+ {p} {GREEN}setup{RESET} {DIM}NAME{RESET} Create/edit a named deployment {DIM}(host or docker mode){RESET}
68
+ {p} {GREEN}list{RESET} Every instance on this machine
69
+ {p} {GREEN}start{RESET}|{GREEN}stop{RESET}|{GREEN}restart{RESET} {DIM}[NAME]{RESET} Run/stop in the background {DIM}(NAME = profile · label · pid · port){RESET}
70
+
71
+ {BOLD}First run:{RESET}
72
+ cd ~/code/my-project && {p} {DIM}# → http://127.0.0.1:8765 — login URL printed{RESET}
73
+ {p} {GREEN}pull{RESET} && {p} {GREEN}--in-docker{RESET} {DIM}# same, sandboxed in the prebuilt image{RESET}
74
+
75
+ {BOLD}Common serve flags:{RESET} {DIM}(all of them: {p} serve --help){RESET}
76
+ --port N Port to bind {DIM}(default 8765){RESET}
77
+ --host IP Host interface {DIM}(default 127.0.0.1 · 0.0.0.0 = reachable on your LAN){RESET}
78
+ --workspace PATH Root dir holding your projects {DIM}(default: current dir){RESET}
79
+ --tls auto|on|off TLS {DIM}(auto = on for non-loopback binds; self-signed cert){RESET}
80
+ --in-docker Run it containerized instead
81
+
82
+ {BOLD}Profiles (multiple deployments):{RESET}
83
+ {p} {GREEN}setup{RESET} work {DIM}# create 'work' — pick host mode or docker mode{RESET}
84
+ {p} {GREEN}start{RESET} work {DIM}# run it in the background — own port/data, isolated{RESET}
85
+ {p} {GREEN}restart{RESET} work · {GREEN}stop{RESET} work {DIM}# manage it ({p} --profile work runs it in the foreground){RESET}
86
+ {p} {GREEN}status{RESET} work · {GREEN}logs{RESET} work · {GREEN}password{RESET} work
87
+
88
+ {BOLD}Everything running:{RESET}
89
+ {p} {GREEN}list{RESET} {DIM}(= {p} {GREEN}status{RESET}{DIM}){RESET} Deployments (host + docker) and unmanaged processes
90
+ {p} {GREEN}status{RESET} {DIM}NAME{RESET} One deployment in detail {DIM}(NAME = profile · label · pid · port){RESET}
91
+
92
+ {BOLD}Container extras:{RESET}
93
+ {p} {GREEN}pull{RESET} [TAG] Fetch the prebuilt image
94
+ {p} {GREEN}shell{RESET}|{GREEN}extract{RESET}|{GREEN}claude-login{RESET} {DIM}[NAME]{RESET} Inside-the-sandbox utilities
95
+ {p} {GREEN}password{RESET}|{GREEN}logs{RESET} {GREEN}--in-docker{RESET} {DIM}…for the ad-hoc sandbox (it has no NAME){RESET}
96
+
97
+ {BOLD}Docs:{RESET} https://painapple.ai/""")
98
+ return 0
99
+
100
+
101
+ def _docker_moved():
102
+ from painapple_code.cli.ui import DIM, GREEN, RESET, err, say
103
+ err("`painapple docker` moved — docker is a run mode now, not a "
104
+ "command group.")
105
+ say(f"""
106
+ painapple {GREEN}--in-docker{RESET} {DIM}run the current dir in a container (was: docker quick){RESET}
107
+ painapple {GREEN}setup NAME{RESET} {DIM}create a docker-mode profile (was: docker setup){RESET}
108
+ painapple {GREEN}start{RESET}|{GREEN}stop{RESET}|{GREEN}restart NAME{RESET} {DIM}(was: docker up -d / stop / restart){RESET}
109
+ painapple {GREEN}pull{RESET} · {GREEN}logs{RESET} · {GREEN}password{RESET} · {GREEN}status{RESET} · {GREEN}shell{RESET} · {GREEN}extract{RESET} · {GREEN}claude-login{RESET} {DIM}[NAME]{RESET}
110
+ painapple {GREEN}list{RESET} {DIM}every deployment (was: docker list){RESET}
111
+
112
+ {DIM}Existing docker profiles are adopted automatically on the next
113
+ profile-aware command (painapple list).{RESET}""")
114
+ return 2
115
+
116
+
117
+ def main(argv=None):
118
+ if sys.platform == "win32": # cp1252 console would crash on → / ╔ in help & banner
119
+ from painapple_code.utils.proc import force_utf8_stdio
120
+ force_utf8_stdio()
121
+ argv = list(sys.argv[1:] if argv is None else argv)
122
+
123
+ if argv and argv[0] in ("help", "-h", "--help"):
124
+ return _help()
125
+
126
+ if argv and argv[0] in ("version", "--version", "-v"):
127
+ # Verb form of the flag — same string, no server import.
128
+ from painapple_code import __version__
129
+ print(f"painapple {__version__}")
130
+ return 0
131
+
132
+ if argv and argv[0] == "docker":
133
+ return _docker_moved()
134
+
135
+ if argv and argv[0] == "setup":
136
+ from painapple_code.cli.setup_cmd import main as setup_main
137
+ return setup_main(argv[1:])
138
+
139
+ if argv and argv[0] in _LIST_ALIASES:
140
+ from painapple_code.cli.list_cmd import main as list_main
141
+ return list_main(argv[1:])
142
+
143
+ if argv and argv[0] in ("start", "stop", "restart"):
144
+ from painapple_code.cli.lifecycle_cmd import main as lifecycle_main
145
+ return lifecycle_main(argv[0], argv[1:])
146
+
147
+ if argv and argv[0] in _MANAGE_VERBS:
148
+ from painapple_code.cli.manage_cmd import main as manage_main
149
+ return manage_main(_MANAGE_VERBS[argv[0]], argv[1:])
150
+
151
+ if argv and argv[0] == "serve":
152
+ argv = argv[1:]
153
+
154
+ # Optional --profile NAME: a named deployment run in the foreground.
155
+ # Stripped here so the flags-only serve parser never sees it.
156
+ # serve_config is import-light (no yaml/fastapi).
157
+ from painapple_code.cli import serve_config
158
+ try:
159
+ prof, argv = serve_config.extract_profile(argv)
160
+ except ValueError as e:
161
+ from painapple_code.cli.ui import err
162
+ err(str(e))
163
+ return 2
164
+
165
+ # Fast gate: validate flags BEFORE paying the ~300ms server import —
166
+ # bad flags exit 2, -v / --help exit 0, all in ~40ms. Import stays
167
+ # inside this branch so setup/list/help skip it.
168
+ from painapple_code.cli.serve_args import build_parser
169
+ ns = build_parser().parse_args(argv)
170
+
171
+ if prof:
172
+ # Named deployment: adopt legacy stores, then dispatch on mode.
173
+ from painapple_code.cli import profiles
174
+ try:
175
+ profiles.ensure_migrated()
176
+ loaded = profiles.load(prof)
177
+ if loaded is not None and (loaded.is_docker or ns.in_docker):
178
+ from painapple_code.cli.deploy.launch import run_profile
179
+ try:
180
+ return run_profile(loaded, argv)
181
+ except KeyboardInterrupt:
182
+ return 130
183
+ if loaded is None and ns.in_docker:
184
+ from painapple_code.cli.ui import err
185
+ err(f"Profile {prof!r} doesn't exist — create it with "
186
+ f"`painapple setup {prof}`, or drop --profile for an "
187
+ "ad-hoc --in-docker run.")
188
+ return 2
189
+ profiles.activate(prof)
190
+ except ValueError as e:
191
+ from painapple_code.cli.ui import err
192
+ err(str(e))
193
+ return 2
194
+ elif ns.in_docker:
195
+ from painapple_code.cli.deploy.launch import run_adhoc
196
+ try:
197
+ return run_adhoc(argv)
198
+ except KeyboardInterrupt:
199
+ return 130
200
+
201
+ # Parse succeeded → boot. server.main() re-parses with the same
202
+ # builder (~1ms) so its signature and behavior stay unchanged.
203
+ from painapple_code.server import main as serve_main
204
+ return serve_main(argv)
@@ -0,0 +1,15 @@
1
+ """Container run-mode machinery (Docker/Podman).
2
+
3
+ Docker is not a separate command group — it's *how* a deployment runs.
4
+ This package holds everything the unified CLI needs to run a workspace
5
+ or a profile inside a container:
6
+
7
+ * ``runtime`` — docker/podman resolution + invocation helpers
8
+ * ``config`` — DockerSettings: the docker-mode key model + validation
9
+ * ``container`` — run/stop/logs/shell/password/extract/pull bodies
10
+ * ``claude_seed`` — .claude credential/onboarding seeding for sandboxes
11
+
12
+ Entry points: ``painapple --in-docker`` (cwd sandbox), docker-mode
13
+ profiles (``painapple setup NAME`` → ``painapple start NAME``), and the
14
+ management verbs (``painapple logs/password/shell/extract NAME``).
15
+ """
@@ -0,0 +1,41 @@
1
+ """Seeding an isolated CLAUDE_HOME for container sandboxes."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ # Subset of ~/.claude.json worth carrying into the container: "not a
7
+ # fresh install" flags so the onboarding wizard doesn't re-run. The
8
+ # host file's `projects` map is keyed by host paths (dead in the
9
+ # container) and caches refetch on first run — deliberately skipped.
10
+ # KEEP IN SYNC with CLAUDE_JSON_ALLOW in
11
+ # tauri-app/src-tauri/src/local.rs (the desktop app's setup wizard
12
+ # seeds an isolated CLAUDE_HOME the same way).
13
+ CLAUDE_JSON_ALLOW = {
14
+ "hasCompletedOnboarding",
15
+ "lastOnboardingVersion",
16
+ "installMethod",
17
+ "migrationVersion",
18
+ "claudeCodeFirstTokenDate",
19
+ "opusProMigrationComplete",
20
+ "sonnet1m45MigrationComplete",
21
+ "lastReleaseNotesSeen",
22
+ "opus47LaunchSeenCount",
23
+ }
24
+
25
+
26
+ def seed_claude_json(src, dst):
27
+ """Write a minimal .claude.json (dst) from the allowlisted fields of
28
+ the host file (src). Returns False on any parse/write problem."""
29
+ try:
30
+ from painapple_code.paths import lock_mode
31
+
32
+ data = json.loads(Path(src).read_text(encoding="utf-8"))
33
+ subset = {k: data[k] for k in CLAUDE_JSON_ALLOW if k in data}
34
+ Path(dst).write_text(json.dumps(subset, indent=2), encoding="utf-8")
35
+ # lock_mode, not chmod: on Windows chmod only toggles the
36
+ # read-only attribute and reports success, so the 0600 here was
37
+ # decorative and this file inherited whatever ACEs its parent had.
38
+ lock_mode(dst, 0o600)
39
+ return True
40
+ except (OSError, ValueError):
41
+ return False
@@ -0,0 +1,260 @@
1
+ """Docker-mode settings: the key model + validation for containerized
2
+ deployments.
3
+
4
+ There is no separate docker config file anymore. Docker-mode settings
5
+ live in the unified profile store (``profiles/NAME/profile.yaml`` with
6
+ ``mode: docker`` — see cli/profiles.py), and the ad-hoc
7
+ ``painapple --in-docker`` run assembles its settings from the global
8
+ serve defaults (serve.yaml ``runtime``/``runtime_flags``/``image``)
9
+ plus the serve flags of that invocation.
10
+
11
+ ``DockerSettings`` keeps the KEY-based ``assign()`` validation so
12
+ scripted writers (``painapple profile set``) and the desktop launcher
13
+ accept/reject the same inputs the wizard does.
14
+ """
15
+
16
+ import re
17
+ from pathlib import Path
18
+
19
+ from painapple_code.cli.serve_config import root_home
20
+
21
+ UPSTREAM_IMAGE = "wrotek/painapple-code"
22
+ DEFAULT_IMAGE = "painapple-code:latest"
23
+
24
+ WORKSPACE_MODES = ("project", "parent", "multi")
25
+ TLS_MODES = ("auto", "on", "off")
26
+ ACCENT_NAMES = ("blue", "green", "red", "orange", "purple", "cyan")
27
+ _HEX_RE = re.compile(r"^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
28
+ _IPV4_RE = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$")
29
+
30
+ # Order = order printed by scripted `get` with no args.
31
+ CONFIG_KEYS = (
32
+ "RUNTIME", "RUNTIME_FLAGS", "WORKSPACE", "WORKSPACE_MODE",
33
+ "CLAUDE_HOME", "CLAUDE_JSON", "DATA_VOLUME", "CONFIG_VOLUME",
34
+ "IMAGE", "CONTAINER", "PORT", "LISTEN_HOST", "TLS_MODE",
35
+ "INSTANCE_NAME", "ACCENT",
36
+ )
37
+
38
+ # profile.yaml uses the shared serve-key names where a docker setting
39
+ # is the same concept: LISTEN_HOST ↔ host (the bind), TLS_MODE ↔ tls.
40
+ _YAML_NAME = {"LISTEN_HOST": "host", "TLS_MODE": "tls"}
41
+
42
+
43
+ def _expand(value):
44
+ if value in ("~",) or value.startswith("~/"):
45
+ return str(Path(value).expanduser())
46
+ return value
47
+
48
+
49
+ class ConfigError(ValueError):
50
+ """Bad key or value in a settings assignment. Message is user-facing."""
51
+
52
+
53
+ class DockerSettings:
54
+ """One containerized deployment's settings, with per-profile
55
+ collision-free defaults (container name, data volume, global config
56
+ dir). CLAUDE_HOME defaults to the shared isolated dir for every
57
+ profile, so one ``claude login`` serves all sandboxes."""
58
+
59
+ def __init__(self, profile=None):
60
+ home = root_home()
61
+ shared = home / "shared"
62
+ suffix = f"-{profile}" if profile else ""
63
+ cfg_dirname = f"docker-{profile}" if profile else "shared"
64
+ self.runtime = "" # empty = auto-detect; may be a binary path
65
+ self.runtime_flags = ""
66
+ self.workspace = ""
67
+ self.workspace_mode = "project"
68
+ self.workspaces = [] # multi mode only
69
+ self.claude_home = str(shared / ".claude")
70
+ self.claude_json = "" # empty = derive ${claude_home}.json
71
+ self.data_volume = f"painapple-data{suffix}"
72
+ self.config_volume = str(
73
+ Path(f"~/.config/painapple-code/{cfg_dirname}").expanduser())
74
+ self.image = DEFAULT_IMAGE
75
+ self.container = f"painapple-code{suffix}"
76
+ self.port = 8765
77
+ self.listen_host = "127.0.0.1"
78
+ self.tls_mode = "auto"
79
+ self.instance_name = ""
80
+ self.accent = ""
81
+
82
+ # ── Derived values ──────────────────────────────────────────────────
83
+
84
+ def effective_claude_json(self):
85
+ return self.claude_json or f"{self.claude_home}.json"
86
+
87
+ def effective_tls(self):
88
+ """Resolve tls_mode 'auto' against the real reachability
89
+ (listen_host), since the server always binds 0.0.0.0 in-container."""
90
+ if self.tls_mode != "auto":
91
+ return self.tls_mode
92
+ return "off" if self.listen_host in ("127.0.0.1", "::1", "localhost") else "on"
93
+
94
+ def data_is_bind(self):
95
+ return Path(self.data_volume).is_absolute()
96
+
97
+ def config_is_bind(self):
98
+ return Path(self.config_volume).is_absolute()
99
+
100
+ def container_mount(self):
101
+ """In-container mount path — the path hash keys project identity.
102
+
103
+ The server itself always serves ``--workspace /workspace`` (the dir
104
+ that HOLDS projects); modes only differ in what lands where. Project
105
+ mode therefore mounts the single repo one level down — /workspace
106
+ acts as a synthetic parent, so the welcome screen offers the repo as
107
+ one pickable project instead of listing its own subdirs as projects.
108
+ """
109
+ if self.workspace_mode in ("parent", "multi"):
110
+ return "/workspace"
111
+ return f"/workspace/{Path(self.workspace).name}"
112
+
113
+ # ── get/set by KEY ──────────────────────────────────────────────────
114
+
115
+ _FIELD_BY_KEY = {k: k.lower() for k in CONFIG_KEYS}
116
+
117
+ def get(self, key):
118
+ if key not in CONFIG_KEYS:
119
+ raise ConfigError(f"Unknown key: {key}")
120
+ value = getattr(self, self._FIELD_BY_KEY[key])
121
+ return str(value)
122
+
123
+ def assign(self, key, value):
124
+ """Validate and set one field. Raises ConfigError with a
125
+ user-facing message on bad key/value."""
126
+ if key == "RUNTIME":
127
+ if value and value not in ("docker", "podman"):
128
+ # Custom binary: an explicit path must exist + be a file.
129
+ expanded = _expand(value)
130
+ if not (Path(expanded).is_absolute() and Path(expanded).is_file()):
131
+ raise ConfigError(
132
+ "RUNTIME must be 'docker', 'podman', an absolute path "
133
+ f"to a runtime binary, or empty to auto-detect: {value}")
134
+ value = expanded
135
+ self.runtime = value
136
+ elif key == "RUNTIME_FLAGS":
137
+ self.runtime_flags = value
138
+ elif key == "WORKSPACE":
139
+ value = _expand(value)
140
+ if value and not Path(value).is_dir():
141
+ raise ConfigError(f"WORKSPACE does not exist: {value}")
142
+ self.workspace = str(Path(value).resolve()) if value else ""
143
+ elif key == "WORKSPACE_MODE":
144
+ if value not in WORKSPACE_MODES:
145
+ raise ConfigError(
146
+ f"WORKSPACE_MODE must be 'project', 'parent', or 'multi': {value}")
147
+ self.workspace_mode = value
148
+ elif key == "CLAUDE_HOME":
149
+ value = _expand(value)
150
+ if not value:
151
+ raise ConfigError("CLAUDE_HOME can't be empty")
152
+ # If CLAUDE_JSON was implicitly tracking the previous
153
+ # CLAUDE_HOME, reset it so it re-derives against the new one.
154
+ if self.claude_json == f"{self.claude_home}.json":
155
+ self.claude_json = ""
156
+ self.claude_home = value
157
+ elif key == "CLAUDE_JSON":
158
+ # Empty is meaningful: "derive from CLAUDE_HOME".
159
+ self.claude_json = _expand(value)
160
+ elif key in ("DATA_VOLUME", "CONFIG_VOLUME"):
161
+ value = _expand(value)
162
+ if not value:
163
+ raise ConfigError(f"{key} can't be empty")
164
+ setattr(self, key.lower(), value)
165
+ elif key in ("IMAGE", "CONTAINER"):
166
+ if not value:
167
+ raise ConfigError(f"{key} can't be empty")
168
+ setattr(self, key.lower(), value)
169
+ elif key == "PORT":
170
+ if not value.isdigit() or not 1 <= int(value) <= 65535:
171
+ raise ConfigError(f"PORT must be 1–65535: {value}")
172
+ self.port = int(value)
173
+ elif key == "LISTEN_HOST":
174
+ if value == "localhost":
175
+ value = "127.0.0.1"
176
+ if not _IPV4_RE.match(value):
177
+ raise ConfigError(
178
+ f"LISTEN_HOST must be an IPv4 address (or 'localhost'): {value}")
179
+ self.listen_host = value
180
+ elif key == "TLS_MODE":
181
+ if value not in TLS_MODES:
182
+ raise ConfigError(f"TLS_MODE must be 'auto', 'on', or 'off': {value}")
183
+ self.tls_mode = value
184
+ elif key == "INSTANCE_NAME":
185
+ if len(value) > 12:
186
+ raise ConfigError(f"INSTANCE_NAME too long ({len(value)} chars, max 12)")
187
+ self.instance_name = value
188
+ elif key == "ACCENT":
189
+ if value and value not in ACCENT_NAMES and not _HEX_RE.match(value):
190
+ raise ConfigError(
191
+ "ACCENT must be empty, a named color "
192
+ f"({'|'.join(ACCENT_NAMES)}), or hex (#RGB / #RRGGBB)")
193
+ self.accent = value
194
+ else:
195
+ raise ConfigError(f"Unknown key: {key}")
196
+
197
+
198
+ def flag_to_key(flag):
199
+ """--listen-host → LISTEN_HOST. Returns None for unknown/non-flags."""
200
+ if not flag.startswith("--"):
201
+ return None
202
+ key = flag[2:].replace("-", "_").upper()
203
+ return key if key in CONFIG_KEYS else None
204
+
205
+
206
+ # ──── profile.yaml (docker mode) mapping ─────────────────────────────────
207
+
208
+ def settings_to_data(cfg):
209
+ """DockerSettings → the docker-mode key subset of profile.yaml
210
+ (shared names for shared concepts: host = bind, tls = tls mode)."""
211
+ return {
212
+ "runtime": cfg.runtime,
213
+ "runtime_flags": cfg.runtime_flags,
214
+ "workspace": cfg.workspace,
215
+ "workspace_mode": cfg.workspace_mode,
216
+ "workspaces": list(cfg.workspaces),
217
+ "claude_home": cfg.claude_home,
218
+ "claude_json": cfg.claude_json,
219
+ "data_volume": cfg.data_volume,
220
+ "config_volume": cfg.config_volume,
221
+ "image": cfg.image,
222
+ "container": cfg.container,
223
+ "port": cfg.port,
224
+ "host": cfg.listen_host,
225
+ "tls": cfg.tls_mode,
226
+ "instance_name": cfg.instance_name,
227
+ "accent": cfg.accent,
228
+ }
229
+
230
+
231
+ def settings_from_data(data, profile=None):
232
+ """profile.yaml data (or a legacy docker.yaml dict) → DockerSettings.
233
+ Accepts both the unified names (host/tls) and the legacy ones
234
+ (listen_host/tls_mode). Stale values (e.g. a WORKSPACE dir that no
235
+ longer exists) are kept verbatim so status/list still show them and
236
+ an actual start fails with a clear error."""
237
+ data = data or {}
238
+ cfg = DockerSettings(profile=profile)
239
+ aliases = {"host": "LISTEN_HOST", "listen_host": "LISTEN_HOST",
240
+ "tls": "TLS_MODE", "tls_mode": "TLS_MODE"}
241
+ for name, raw in data.items():
242
+ if name == "workspaces":
243
+ if isinstance(raw, list):
244
+ cfg.workspaces = [str(w) for w in raw]
245
+ continue
246
+ key = aliases.get(name) or (name.upper() if name.upper() in CONFIG_KEYS
247
+ else None)
248
+ if key is None or raw in (None, ""):
249
+ continue
250
+ try:
251
+ cfg.assign(key, str(raw))
252
+ except ConfigError:
253
+ if key == "PORT":
254
+ try:
255
+ cfg.port = int(raw)
256
+ except (TypeError, ValueError):
257
+ pass
258
+ else:
259
+ setattr(cfg, key.lower(), str(raw))
260
+ return cfg