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,610 @@
1
+ """Skills Manager API.
2
+
3
+ Endpoints for discovering, viewing, and editing Claude Code skills.
4
+
5
+ Skill locations (priority order — higher wins on name collisions):
6
+ personal: ~/.claude/skills/<name>/SKILL.md (editable)
7
+ project: <cwd>/.claude/skills/<name>/SKILL.md (editable)
8
+ plugin: ~/.claude/plugins/marketplaces/*/plugins/*/skills/<name>/SKILL.md (read-only)
9
+
10
+ This API only returns folder-form skills (`SKILL.md` inside a directory).
11
+ Legacy flat-file commands (`commands/<name>.md`) are managed by `routes/api_commands.py`,
12
+ which also hosts the upgrade-to-skill action.
13
+
14
+ See docs-ai/plans/2026-04-20-skills-manager-widget.md for the full design.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import re
21
+ import shutil
22
+ from pathlib import Path
23
+ from typing import Any, Optional
24
+
25
+ import yaml
26
+ from fastapi import APIRouter, HTTPException
27
+ from pydantic import BaseModel
28
+ from painapple_code.utils.file_paths import safe_resolve
29
+
30
+ router = APIRouter(prefix="/api/skills", tags=["skills"])
31
+
32
+ NAME_RE = re.compile(r"^[a-z][a-z0-9_-]{0,63}$")
33
+ FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n?(.*)\Z", re.DOTALL)
34
+
35
+
36
+ # ---------- helpers ---------------------------------------------------------
37
+
38
+
39
+ def _validate_name(name: str) -> None:
40
+ if not NAME_RE.match(name):
41
+ raise HTTPException(
42
+ status_code=400,
43
+ detail=f"Invalid skill name {name!r} — must match {NAME_RE.pattern}",
44
+ )
45
+
46
+
47
+ def _scope_roots(cwd: str) -> dict[str, Path]:
48
+ """Return the editable-scope roots (project + personal)."""
49
+ return {
50
+ "project": Path(cwd) / ".claude",
51
+ "personal": Path.home() / ".claude",
52
+ }
53
+
54
+
55
+ def _safe_under(base: Path, candidate: Path) -> bool:
56
+ """True iff candidate is inside base (after resolution), preventing traversal."""
57
+ try:
58
+ candidate.resolve().relative_to(base.resolve())
59
+ return True
60
+ except (ValueError, OSError):
61
+ return False
62
+
63
+
64
+ def _parse_frontmatter(text: str) -> tuple[dict[str, Any], str]:
65
+ """Split SKILL.md contents into (frontmatter dict, body string).
66
+
67
+ Returns ({}, text) if no frontmatter block is present.
68
+ On YAML parse error, returns ({"_parse_error": str}, text).
69
+ """
70
+ m = FRONTMATTER_RE.match(text)
71
+ if not m:
72
+ return {}, text
73
+ try:
74
+ data = yaml.safe_load(m.group(1)) or {}
75
+ if not isinstance(data, dict):
76
+ data = {"_parse_error": "frontmatter is not a mapping"}
77
+ return data, m.group(2)
78
+ except yaml.YAMLError as e:
79
+ return {"_parse_error": str(e)}, m.group(2)
80
+
81
+
82
+ def _serialize_skill(frontmatter: dict[str, Any], body: str) -> str:
83
+ """Rebuild a SKILL.md-formatted string from parsed parts."""
84
+ if not frontmatter:
85
+ return body
86
+ # Strip internal parse-error marker if present
87
+ clean = {k: v for k, v in frontmatter.items() if not k.startswith("_")}
88
+ yaml_text = yaml.safe_dump(
89
+ clean, sort_keys=False, default_flow_style=False, allow_unicode=True
90
+ ).rstrip()
91
+ body_part = body if body.startswith("\n") else "\n" + body
92
+ return f"---\n{yaml_text}\n---{body_part}"
93
+
94
+
95
+ def _invocation_mode(fm: dict[str, Any]) -> str:
96
+ """Derive user-facing invocation mode from frontmatter flags."""
97
+ disable_model = bool(fm.get("disable-model-invocation"))
98
+ user = fm.get("user-invocable")
99
+ user_ok = True if user is None else bool(user)
100
+ if disable_model and user_ok:
101
+ return "manual-only"
102
+ if (not disable_model) and (not user_ok):
103
+ return "auto-only"
104
+ if (not disable_model) and user_ok:
105
+ return "auto-and-manual"
106
+ return "none" # both disabled — unusual
107
+
108
+
109
+ def _list_supporting(skill_dir: Path) -> list[dict[str, Any]]:
110
+ """List sibling files in a skill directory (excluding SKILL.md, dotfiles)."""
111
+ out: list[dict[str, Any]] = []
112
+ if not skill_dir.is_dir():
113
+ return out
114
+ for entry in sorted(skill_dir.rglob("*")):
115
+ if entry.name == "SKILL.md" or entry.name.startswith("."):
116
+ continue
117
+ # Skip entries under any dotdir (e.g. .git inside a skill dir)
118
+ rel = entry.relative_to(skill_dir)
119
+ if any(part.startswith(".") for part in rel.parts[:-1]):
120
+ continue
121
+ if entry.is_file():
122
+ try:
123
+ size = entry.stat().st_size
124
+ except OSError:
125
+ size = 0
126
+ out.append({"path": rel.as_posix(), "size": size})
127
+ return out
128
+
129
+
130
+ def _body_preview(body: str, n: int = 280) -> str:
131
+ body = body.strip()
132
+ if len(body) <= n:
133
+ return body
134
+ return body[:n].rstrip() + "…"
135
+
136
+
137
+ # ---------- scope discovery -------------------------------------------------
138
+
139
+
140
+ def _load_installed_plugin_keys() -> set[str]:
141
+ """Return `{plugin@marketplace}` keys from `~/.claude/plugins/installed_plugins.json`.
142
+
143
+ Claude Code's plugin manager walks every marketplace it knows about and
144
+ caches the *entire* catalog under `~/.claude/plugins/marketplaces/`, but only
145
+ a subset is actually installed. `installed_plugins.json` is the authoritative
146
+ list — its top-level `plugins` map is keyed by `<plugin>@<marketplace>`.
147
+ Returns an empty set if the file is missing or malformed (treat as "nothing
148
+ installed" rather than "show everything", to match user expectation that the
149
+ picker reflects their /plugin install state).
150
+ """
151
+ path = Path.home() / ".claude" / "plugins" / "installed_plugins.json"
152
+ try:
153
+ with path.open(encoding="utf-8") as f:
154
+ data = json.load(f)
155
+ except (OSError, json.JSONDecodeError):
156
+ return set()
157
+ plugins = data.get("plugins")
158
+ if not isinstance(plugins, dict):
159
+ return set()
160
+ return set(plugins.keys())
161
+
162
+
163
+ def _iter_skills(cwd: str):
164
+ """Yield folder-form skill descriptors (SKILL.md inside a directory).
165
+
166
+ Order: project skills + personal skills + plugin skills.
167
+ Legacy flat `commands/<name>.md` files are NOT included — they live in
168
+ `routes/api_commands.py`.
169
+ """
170
+ home = Path.home()
171
+ cwd_path = Path(cwd)
172
+
173
+ # Editable: skill dirs under project + personal .claude/skills/
174
+ for scope, base in (("project", cwd_path), ("personal", home)):
175
+ skill_root = base / ".claude" / "skills"
176
+ if skill_root.is_dir():
177
+ for entry in skill_root.iterdir():
178
+ if entry.is_dir() and (entry / "SKILL.md").is_file():
179
+ yield {
180
+ "scope": scope,
181
+ "name": entry.name,
182
+ "path": entry / "SKILL.md",
183
+ "dir": entry,
184
+ "editable": True,
185
+ "plugin_label": None,
186
+ }
187
+
188
+ # Read-only: plugin marketplace skills — filtered by installed_plugins.json
189
+ plugins_root = home / ".claude" / "plugins" / "marketplaces"
190
+ if plugins_root.is_dir():
191
+ installed = _load_installed_plugin_keys()
192
+ for marketplace in plugins_root.iterdir():
193
+ plugins_dir = marketplace / "plugins"
194
+ if not plugins_dir.is_dir():
195
+ continue
196
+ for plugin in plugins_dir.iterdir():
197
+ if f"{plugin.name}@{marketplace.name}" not in installed:
198
+ continue
199
+ label = f"{plugin.name} ({marketplace.name})"
200
+ skill_root = plugin / "skills"
201
+ if skill_root.is_dir():
202
+ for entry in skill_root.iterdir():
203
+ if entry.is_dir() and (entry / "SKILL.md").is_file():
204
+ yield {
205
+ "scope": "plugin",
206
+ "name": entry.name,
207
+ "path": entry / "SKILL.md",
208
+ "dir": entry,
209
+ "editable": False,
210
+ "plugin_label": label,
211
+ }
212
+
213
+
214
+ def _resolve_path(scope: str, name: str, cwd: str, allow_plugin: bool = False) -> dict[str, Any]:
215
+ """Locate a folder-form skill on disk. Plugin scope is read-only."""
216
+ _validate_name(name)
217
+ if scope == "plugin":
218
+ if not allow_plugin:
219
+ raise HTTPException(status_code=405, detail="Plugin skills are read-only")
220
+ for rec in _iter_skills(cwd):
221
+ if rec["scope"] == "plugin" and rec["name"] == name:
222
+ return rec
223
+ raise HTTPException(status_code=404, detail=f"Plugin skill {name!r} not found")
224
+
225
+ if scope not in ("project", "personal"):
226
+ raise HTTPException(status_code=400, detail=f"Unknown scope {scope!r}")
227
+
228
+ roots = _scope_roots(cwd)
229
+ base = roots[scope]
230
+ skill_dir = base / "skills" / name
231
+ skill_file = skill_dir / "SKILL.md"
232
+
233
+ if skill_file.is_file() and _safe_under(base, skill_file):
234
+ return {
235
+ "scope": scope,
236
+ "name": name,
237
+ "path": skill_file,
238
+ "dir": skill_dir,
239
+ "editable": True,
240
+ "plugin_label": None,
241
+ }
242
+ raise HTTPException(status_code=404, detail=f"Skill {name!r} not found in {scope}")
243
+
244
+
245
+ # ---------- endpoints -------------------------------------------------------
246
+
247
+
248
+ @router.get("")
249
+ async def list_skills(cwd: str):
250
+ """List every skill across scopes. Returns rich metadata for the gallery."""
251
+ resolved_cwd = str(safe_resolve(cwd))
252
+ skills = []
253
+ # Track name-per-scope for conflict detection
254
+ by_name: dict[str, list[tuple[str, str]]] = {}
255
+ counts = {"project": 0, "personal": 0, "plugin": 0}
256
+
257
+ for rec in _iter_skills(resolved_cwd):
258
+ try:
259
+ text = rec["path"].read_text(encoding="utf-8", errors="replace")
260
+ except OSError:
261
+ continue
262
+ fm, body = _parse_frontmatter(text)
263
+ item = {
264
+ "id": f"{rec['scope']}:{rec['name']}",
265
+ "name": rec["name"],
266
+ "scope": rec["scope"],
267
+ "scope_label": rec["plugin_label"] or rec["scope"].capitalize(),
268
+ "path": str(rec["path"]),
269
+ "dir": str(rec["dir"]),
270
+ "editable": rec["editable"],
271
+ "frontmatter": fm,
272
+ "description": fm.get("description") or "",
273
+ "body_preview": _body_preview(body),
274
+ "body_length": len(body),
275
+ "supporting_files": _list_supporting(rec["dir"]),
276
+ "invocation_mode": _invocation_mode(fm),
277
+ }
278
+ skills.append(item)
279
+ counts[rec["scope"]] += 1
280
+ by_name.setdefault(rec["name"], []).append((rec["scope"], item["id"]))
281
+
282
+ # Mark conflicts: same name in multiple scopes → note shadowing
283
+ priority = {"personal": 0, "project": 1, "plugin": 2}
284
+ for item in skills:
285
+ peers = by_name.get(item["name"], [])
286
+ if len(peers) <= 1:
287
+ continue
288
+ # Sort by priority; lower number wins
289
+ winner = min(peers, key=lambda p: priority.get(p[0], 99))
290
+ if item["id"] != winner[1]:
291
+ item["shadowed_by"] = winner[1]
292
+
293
+ return {
294
+ "cwd": resolved_cwd,
295
+ "skills": skills,
296
+ "counts": counts,
297
+ "total": len(skills),
298
+ }
299
+
300
+
301
+ @router.get("/{scope}/{name}")
302
+ async def get_skill(scope: str, name: str, cwd: str):
303
+ """Return full skill metadata + body for viewing or editing."""
304
+ resolved_cwd = str(safe_resolve(cwd))
305
+ rec = _resolve_path(scope, name, resolved_cwd, allow_plugin=True)
306
+ text = rec["path"].read_text(encoding="utf-8", errors="replace")
307
+ fm, body = _parse_frontmatter(text)
308
+ return {
309
+ "id": f"{scope}:{name}",
310
+ "name": name,
311
+ "scope": scope,
312
+ "scope_label": rec["plugin_label"] or scope.capitalize(),
313
+ "path": str(rec["path"]),
314
+ "dir": str(rec["dir"]),
315
+ "editable": rec["editable"],
316
+ "frontmatter": fm,
317
+ "body": body,
318
+ "raw": text,
319
+ "supporting_files": _list_supporting(rec["dir"]),
320
+ "invocation_mode": _invocation_mode(fm),
321
+ "mtime": rec["path"].stat().st_mtime,
322
+ }
323
+
324
+
325
+ class UpdateSkillBody(BaseModel):
326
+ frontmatter: Optional[dict[str, Any]] = None
327
+ body: Optional[str] = None
328
+ raw: Optional[str] = None # full file contents — takes precedence if set
329
+ expected_mtime: Optional[float] = None # optimistic concurrency check
330
+
331
+
332
+ @router.put("/{scope}/{name}")
333
+ async def update_skill(scope: str, name: str, cwd: str, payload: UpdateSkillBody):
334
+ """Update an existing skill's SKILL.md.
335
+
336
+ Supply either `raw` (wins) or `frontmatter`+`body`. Plugin scope rejected.
337
+ """
338
+ resolved_cwd = str(safe_resolve(cwd))
339
+ rec = _resolve_path(scope, name, resolved_cwd, allow_plugin=False)
340
+ path: Path = rec["path"]
341
+
342
+ # Optimistic concurrency — refuse if file changed under us
343
+ if payload.expected_mtime is not None:
344
+ try:
345
+ current = path.stat().st_mtime
346
+ if abs(current - payload.expected_mtime) > 0.5:
347
+ raise HTTPException(
348
+ status_code=409,
349
+ detail="File changed on disk since you started editing",
350
+ )
351
+ except OSError:
352
+ pass
353
+
354
+ if payload.raw is not None:
355
+ new_text = payload.raw
356
+ elif payload.frontmatter is not None or payload.body is not None:
357
+ # Read existing parts to fill in whichever side was omitted
358
+ existing = path.read_text(encoding="utf-8", errors="replace")
359
+ ex_fm, ex_body = _parse_frontmatter(existing)
360
+ fm = payload.frontmatter if payload.frontmatter is not None else ex_fm
361
+ body = payload.body if payload.body is not None else ex_body
362
+ new_text = _serialize_skill(fm, body)
363
+ else:
364
+ raise HTTPException(status_code=400, detail="No content provided")
365
+
366
+ # Atomic write
367
+ tmp = path.with_suffix(path.suffix + f".tmp.{os.getpid()}")
368
+ tmp.write_text(new_text, encoding="utf-8", newline="")
369
+ os.replace(tmp, path)
370
+
371
+ return {
372
+ "ok": True,
373
+ "path": str(path),
374
+ "mtime": path.stat().st_mtime,
375
+ "bytes": len(new_text.encode("utf-8")),
376
+ }
377
+
378
+
379
+ # ---------- v2: create, delete, duplicate, upgrade --------------------------
380
+
381
+
382
+ TEMPLATES: dict[str, dict[str, Any]] = {
383
+ "blank": {
384
+ "frontmatter": {},
385
+ "body": "\nWrite your skill's instructions here. Claude reads this text when the skill is invoked.\n",
386
+ },
387
+ "task": {
388
+ "frontmatter": {
389
+ "disable-model-invocation": True,
390
+ "argument-hint": "[arg]",
391
+ },
392
+ "body": (
393
+ "\n"
394
+ "Describe the task that runs when the user invokes this skill.\n\n"
395
+ "1. Step one\n"
396
+ "2. Step two\n"
397
+ "3. Step three\n"
398
+ ),
399
+ },
400
+ "reference": {
401
+ "frontmatter": {},
402
+ "body": (
403
+ "\n"
404
+ "Reference content Claude applies while relevant (conventions, style guides,\n"
405
+ "domain knowledge). Write it as standing instructions, not one-time steps.\n"
406
+ ),
407
+ },
408
+ "fork": {
409
+ "frontmatter": {"context": "fork", "agent": "Explore"},
410
+ "body": (
411
+ "\n"
412
+ "Task for an isolated subagent. The skill content becomes the agent's prompt —\n"
413
+ "it won't see your conversation history.\n\n"
414
+ "Investigate $ARGUMENTS and return findings with file + line references.\n"
415
+ ),
416
+ },
417
+ "visual": {
418
+ "frontmatter": {
419
+ "allowed-tools": "Bash(python *)",
420
+ },
421
+ "body": (
422
+ "\n"
423
+ "Skill that runs a bundled script to produce visual output.\n\n"
424
+ "Run the script:\n\n"
425
+ "```bash\n"
426
+ "python ${CLAUDE_SKILL_DIR}/scripts/run.py\n"
427
+ "```\n"
428
+ ),
429
+ },
430
+ }
431
+
432
+
433
+ def _scope_skill_dir(scope: str, name: str, cwd: str) -> Path:
434
+ """Return the target directory for a new skill (project or personal)."""
435
+ if scope == "project":
436
+ return Path(cwd) / ".claude" / "skills" / name
437
+ if scope == "personal":
438
+ return Path.home() / ".claude" / "skills" / name
439
+ raise HTTPException(status_code=400, detail=f"Cannot write to scope {scope!r}")
440
+
441
+
442
+ def _write_skill_dir(target_dir: Path, frontmatter: dict[str, Any], body: str) -> Path:
443
+ """Create `target_dir/SKILL.md` with serialized contents. Fails if exists."""
444
+ if target_dir.exists():
445
+ raise HTTPException(
446
+ status_code=409,
447
+ detail=f"Path already exists: {target_dir}",
448
+ )
449
+ target_dir.mkdir(parents=True, exist_ok=False)
450
+ skill_path = target_dir / "SKILL.md"
451
+ text = _serialize_skill(frontmatter, body)
452
+ # Atomic write
453
+ tmp = skill_path.with_suffix(skill_path.suffix + f".tmp.{os.getpid()}")
454
+ tmp.write_text(text, encoding="utf-8", newline="")
455
+ os.replace(tmp, skill_path)
456
+ return skill_path
457
+
458
+
459
+ def _next_duplicate_name(existing_names: set[str], base: str) -> str:
460
+ """Pick a name like `duplicate-of-<base>[-N]` avoiding collisions."""
461
+ candidate = f"duplicate-of-{base}"[:64]
462
+ if candidate not in existing_names:
463
+ return candidate
464
+ for i in range(2, 100):
465
+ cand = f"{candidate}-{i}"[:64]
466
+ if cand not in existing_names:
467
+ return cand
468
+ raise HTTPException(status_code=409, detail="Too many duplicates")
469
+
470
+
471
+ def _all_names_in_scope(scope: str, cwd: str) -> set[str]:
472
+ """Collect all skill/command names already present in the given scope."""
473
+ names: set[str] = set()
474
+ if scope == "project":
475
+ base = Path(cwd) / ".claude"
476
+ elif scope == "personal":
477
+ base = Path.home() / ".claude"
478
+ else:
479
+ return names
480
+ for sub in (base / "skills", base / "commands"):
481
+ if sub.is_dir():
482
+ for entry in sub.iterdir():
483
+ if entry.is_dir() and (entry / "SKILL.md").is_file():
484
+ names.add(entry.name)
485
+ elif entry.is_file() and entry.suffix == ".md":
486
+ names.add(entry.stem)
487
+ return names
488
+
489
+
490
+ class CreateSkillBody(BaseModel):
491
+ description: Optional[str] = ""
492
+ template: Optional[str] = "blank"
493
+ frontmatter: Optional[dict[str, Any]] = None
494
+ body: Optional[str] = None
495
+
496
+
497
+ @router.post("/{scope}/{name}")
498
+ async def create_skill(scope: str, name: str, cwd: str, payload: CreateSkillBody):
499
+ """Create a new skill directory with SKILL.md from a template."""
500
+ _validate_name(name)
501
+ resolved_cwd = str(safe_resolve(cwd))
502
+ target_dir = _scope_skill_dir(scope, name, resolved_cwd)
503
+
504
+ existing = _all_names_in_scope(scope, resolved_cwd)
505
+ if name in existing:
506
+ raise HTTPException(status_code=409, detail=f"Skill {name!r} already exists in {scope}")
507
+
508
+ template_key = payload.template or "blank"
509
+ template = TEMPLATES.get(template_key, TEMPLATES["blank"])
510
+ fm: dict[str, Any] = {"name": name}
511
+ if payload.description:
512
+ fm["description"] = payload.description
513
+ # Merge template frontmatter (template keys win over just name+description)
514
+ fm.update(template.get("frontmatter") or {})
515
+ # If caller supplied explicit frontmatter, merge on top (caller wins)
516
+ if payload.frontmatter:
517
+ fm.update(payload.frontmatter)
518
+ body = payload.body if payload.body is not None else (template.get("body") or "")
519
+
520
+ skill_path = _write_skill_dir(target_dir, fm, body)
521
+
522
+ return {
523
+ "ok": True,
524
+ "id": f"{scope}:{name}",
525
+ "scope": scope,
526
+ "name": name,
527
+ "path": str(skill_path),
528
+ "dir": str(target_dir),
529
+ "mtime": skill_path.stat().st_mtime,
530
+ }
531
+
532
+
533
+ @router.delete("/{scope}/{name}")
534
+ async def delete_skill(scope: str, name: str, cwd: str):
535
+ """Delete a skill directory (rmtree)."""
536
+ resolved_cwd = str(safe_resolve(cwd))
537
+ rec = _resolve_path(scope, name, resolved_cwd, allow_plugin=False)
538
+ path: Path = rec["path"]
539
+
540
+ parts = path.resolve().parts
541
+ if ".claude" not in parts:
542
+ raise HTTPException(status_code=400, detail="Refusing to delete outside .claude/")
543
+
544
+ skill_dir: Path = rec["dir"]
545
+ if skill_dir.parent.name != "skills":
546
+ raise HTTPException(status_code=400, detail="Unexpected skill dir layout")
547
+ try:
548
+ shutil.rmtree(skill_dir)
549
+ except OSError as e:
550
+ raise HTTPException(status_code=500, detail=str(e))
551
+ return {"ok": True, "removed": str(skill_dir), "kind": "dir"}
552
+
553
+
554
+ class DuplicateBody(BaseModel):
555
+ target_scope: Optional[str] = None # "project" | "personal"; defaults to source scope
556
+ target_name: Optional[str] = None # if omitted, auto "duplicate-of-<name>"
557
+
558
+
559
+ @router.post("/{scope}/{name}/duplicate")
560
+ async def duplicate_skill(scope: str, name: str, cwd: str, payload: DuplicateBody):
561
+ """Copy a skill into a new name/scope. Description is prefixed "Duplicate of …"."""
562
+ resolved_cwd = str(safe_resolve(cwd))
563
+ src = _resolve_path(scope, name, resolved_cwd, allow_plugin=True)
564
+
565
+ target_scope = payload.target_scope or (scope if scope != "plugin" else "personal")
566
+ if target_scope not in ("project", "personal"):
567
+ raise HTTPException(status_code=400, detail=f"Cannot duplicate to scope {target_scope!r}")
568
+
569
+ existing = _all_names_in_scope(target_scope, resolved_cwd)
570
+ new_name = payload.target_name or _next_duplicate_name(existing, name)
571
+ _validate_name(new_name)
572
+ if new_name in existing:
573
+ raise HTTPException(status_code=409, detail=f"Skill {new_name!r} already exists in {target_scope}")
574
+
575
+ target_dir = _scope_skill_dir(target_scope, new_name, resolved_cwd)
576
+
577
+ # Copy the directory tree if source has supporting files; else just write SKILL.md
578
+ src_text = src["path"].read_text(encoding="utf-8", errors="replace")
579
+ fm, body = _parse_frontmatter(src_text)
580
+
581
+ # Prefix description and rename the `name` field
582
+ fm = dict(fm or {})
583
+ # Strip internal parse-error markers that aren't real frontmatter fields
584
+ fm = {k: v for k, v in fm.items() if not k.startswith("_")}
585
+ fm["name"] = new_name
586
+ original_desc = fm.get("description") or ""
587
+ if original_desc:
588
+ fm["description"] = f"Duplicate of {name}: {original_desc}"
589
+ else:
590
+ fm["description"] = f"Duplicate of {name}"
591
+
592
+ if target_dir.exists():
593
+ raise HTTPException(status_code=409, detail=f"Path already exists: {target_dir}")
594
+
595
+ # Copy the whole source dir, then rewrite SKILL.md with patched frontmatter
596
+ try:
597
+ shutil.copytree(src["dir"], target_dir)
598
+ except OSError as e:
599
+ raise HTTPException(status_code=500, detail=f"Copy failed: {e}")
600
+ new_skill = target_dir / "SKILL.md"
601
+ new_skill.write_text(_serialize_skill(fm, body), encoding="utf-8", newline="")
602
+
603
+ return {
604
+ "ok": True,
605
+ "id": f"{target_scope}:{new_name}",
606
+ "scope": target_scope,
607
+ "name": new_name,
608
+ "path": str(target_dir / "SKILL.md"),
609
+ "dir": str(target_dir),
610
+ }