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,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
|
+
}
|