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