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,374 @@
|
|
|
1
|
+
"""Commands Manager API.
|
|
2
|
+
|
|
3
|
+
Endpoints for discovering, viewing, editing, and upgrading legacy `.md`
|
|
4
|
+
command files. Built-in Claude CLI commands are surfaced as read-only
|
|
5
|
+
entries for reference.
|
|
6
|
+
|
|
7
|
+
Sources:
|
|
8
|
+
builtin: Claude CLI binary (extracted via `strings`, ~77 names)
|
|
9
|
+
project: <cwd>/.claude/commands/<name>.md (editable)
|
|
10
|
+
personal: ~/.claude/commands/<name>.md (editable)
|
|
11
|
+
plugin: ~/.claude/plugins/marketplaces/*/plugins/*/commands/<name>.md (read-only)
|
|
12
|
+
|
|
13
|
+
Folder-form skills (`skills/<name>/SKILL.md`) live in `routes/api_skills.py`.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import shutil
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Optional
|
|
22
|
+
|
|
23
|
+
import yaml
|
|
24
|
+
from fastapi import APIRouter, HTTPException
|
|
25
|
+
from pydantic import BaseModel
|
|
26
|
+
from painapple_code.utils.file_paths import safe_resolve
|
|
27
|
+
|
|
28
|
+
router = APIRouter(prefix="/api/commands", tags=["commands"])
|
|
29
|
+
|
|
30
|
+
NAME_RE = re.compile(r"^[a-z][a-z0-9_-]{0,63}$")
|
|
31
|
+
FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n?(.*)\Z", re.DOTALL)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------- helpers ---------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _validate_name(name: str) -> None:
|
|
38
|
+
if not NAME_RE.match(name):
|
|
39
|
+
raise HTTPException(
|
|
40
|
+
status_code=400,
|
|
41
|
+
detail=f"Invalid command name {name!r} — must match {NAME_RE.pattern}",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _safe_under(base: Path, candidate: Path) -> bool:
|
|
46
|
+
try:
|
|
47
|
+
candidate.resolve().relative_to(base.resolve())
|
|
48
|
+
return True
|
|
49
|
+
except (ValueError, OSError):
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _parse_frontmatter(text: str) -> tuple[dict[str, Any], str]:
|
|
54
|
+
m = FRONTMATTER_RE.match(text)
|
|
55
|
+
if not m:
|
|
56
|
+
return {}, text
|
|
57
|
+
try:
|
|
58
|
+
data = yaml.safe_load(m.group(1)) or {}
|
|
59
|
+
if not isinstance(data, dict):
|
|
60
|
+
data = {"_parse_error": "frontmatter is not a mapping"}
|
|
61
|
+
return data, m.group(2)
|
|
62
|
+
except yaml.YAMLError as e:
|
|
63
|
+
return {"_parse_error": str(e)}, m.group(2)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _serialize(frontmatter: dict[str, Any], body: str) -> str:
|
|
67
|
+
if not frontmatter:
|
|
68
|
+
return body
|
|
69
|
+
clean = {k: v for k, v in frontmatter.items() if not k.startswith("_")}
|
|
70
|
+
if not clean:
|
|
71
|
+
return body
|
|
72
|
+
yaml_text = yaml.safe_dump(
|
|
73
|
+
clean, sort_keys=False, default_flow_style=False, allow_unicode=True
|
|
74
|
+
).rstrip()
|
|
75
|
+
body_part = body if body.startswith("\n") else "\n" + body
|
|
76
|
+
return f"---\n{yaml_text}\n---{body_part}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _body_preview(body: str, n: int = 280) -> str:
|
|
80
|
+
body = body.strip()
|
|
81
|
+
if len(body) <= n:
|
|
82
|
+
return body
|
|
83
|
+
return body[:n].rstrip() + "…"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _iter_legacy_commands(cwd: str):
|
|
87
|
+
"""Yield legacy `.md` command files across project/personal/plugin scopes."""
|
|
88
|
+
home = Path.home()
|
|
89
|
+
cwd_path = Path(cwd)
|
|
90
|
+
|
|
91
|
+
for scope, base in (("project", cwd_path), ("personal", home)):
|
|
92
|
+
cmd_dir = base / ".claude" / "commands"
|
|
93
|
+
if cmd_dir.is_dir():
|
|
94
|
+
for f in cmd_dir.iterdir():
|
|
95
|
+
if f.is_file() and f.suffix == ".md":
|
|
96
|
+
yield {
|
|
97
|
+
"scope": scope,
|
|
98
|
+
"name": f.stem,
|
|
99
|
+
"path": f,
|
|
100
|
+
"editable": True,
|
|
101
|
+
"plugin_label": None,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
plugins_root = home / ".claude" / "plugins" / "marketplaces"
|
|
105
|
+
if plugins_root.is_dir():
|
|
106
|
+
for marketplace in plugins_root.iterdir():
|
|
107
|
+
plugins_dir = marketplace / "plugins"
|
|
108
|
+
if not plugins_dir.is_dir():
|
|
109
|
+
continue
|
|
110
|
+
for plugin in plugins_dir.iterdir():
|
|
111
|
+
cmd_dir = plugin / "commands"
|
|
112
|
+
if cmd_dir.is_dir():
|
|
113
|
+
for f in cmd_dir.iterdir():
|
|
114
|
+
if f.is_file() and f.suffix == ".md":
|
|
115
|
+
yield {
|
|
116
|
+
"scope": "plugin",
|
|
117
|
+
"name": f.stem,
|
|
118
|
+
"path": f,
|
|
119
|
+
"editable": False,
|
|
120
|
+
"plugin_label": f"{plugin.name} ({marketplace.name})",
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _resolve_path(scope: str, name: str, cwd: str, allow_plugin: bool = False) -> dict[str, Any]:
|
|
125
|
+
"""Locate a legacy `.md` command on disk."""
|
|
126
|
+
_validate_name(name)
|
|
127
|
+
if scope == "plugin":
|
|
128
|
+
if not allow_plugin:
|
|
129
|
+
raise HTTPException(status_code=405, detail="Plugin commands are read-only")
|
|
130
|
+
for rec in _iter_legacy_commands(cwd):
|
|
131
|
+
if rec["scope"] == "plugin" and rec["name"] == name:
|
|
132
|
+
return rec
|
|
133
|
+
raise HTTPException(status_code=404, detail=f"Plugin command {name!r} not found")
|
|
134
|
+
|
|
135
|
+
if scope not in ("project", "personal"):
|
|
136
|
+
raise HTTPException(status_code=400, detail=f"Unknown scope {scope!r}")
|
|
137
|
+
|
|
138
|
+
base = (Path(cwd) if scope == "project" else Path.home()) / ".claude"
|
|
139
|
+
cmd_file = base / "commands" / f"{name}.md"
|
|
140
|
+
if cmd_file.is_file() and _safe_under(base, cmd_file):
|
|
141
|
+
return {
|
|
142
|
+
"scope": scope,
|
|
143
|
+
"name": name,
|
|
144
|
+
"path": cmd_file,
|
|
145
|
+
"editable": True,
|
|
146
|
+
"plugin_label": None,
|
|
147
|
+
}
|
|
148
|
+
raise HTTPException(status_code=404, detail=f"Command {name!r} not found in {scope}")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---------- endpoints -------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@router.get("")
|
|
155
|
+
async def list_commands(cwd: str):
|
|
156
|
+
"""List built-in CLI commands and legacy `.md` files."""
|
|
157
|
+
# Lazy import to avoid load-order issues
|
|
158
|
+
from painapple_code.routes.api_project_config import cli_command_descriptions, _get_command_descriptions
|
|
159
|
+
|
|
160
|
+
resolved_cwd = str(safe_resolve(cwd))
|
|
161
|
+
# Off the event loop — scanning the CLI bundle takes ~2s on a cold
|
|
162
|
+
# cache. Every lookup after this hits the memo, including the sync
|
|
163
|
+
# `_get_command_descriptions` below.
|
|
164
|
+
builtin_descriptions = await cli_command_descriptions()
|
|
165
|
+
descriptions = _get_command_descriptions(resolved_cwd)
|
|
166
|
+
commands: list[dict[str, Any]] = []
|
|
167
|
+
counts = {"builtin": 0, "project": 0, "personal": 0, "plugin": 0}
|
|
168
|
+
|
|
169
|
+
# Built-in CLI commands (read-only — extracted from binary)
|
|
170
|
+
for name, desc in sorted(builtin_descriptions.items()):
|
|
171
|
+
commands.append({
|
|
172
|
+
"id": f"builtin:{name}",
|
|
173
|
+
"name": name,
|
|
174
|
+
"scope": "builtin",
|
|
175
|
+
"scope_label": "Built-in",
|
|
176
|
+
"kind": "builtin",
|
|
177
|
+
"path": None,
|
|
178
|
+
"description": desc,
|
|
179
|
+
"editable": False,
|
|
180
|
+
"frontmatter": {},
|
|
181
|
+
"body_preview": "",
|
|
182
|
+
"body_length": 0,
|
|
183
|
+
})
|
|
184
|
+
counts["builtin"] += 1
|
|
185
|
+
|
|
186
|
+
# Legacy `.md` files
|
|
187
|
+
for rec in _iter_legacy_commands(resolved_cwd):
|
|
188
|
+
try:
|
|
189
|
+
text = rec["path"].read_text(encoding="utf-8", errors="replace")
|
|
190
|
+
except OSError:
|
|
191
|
+
continue
|
|
192
|
+
fm, body = _parse_frontmatter(text)
|
|
193
|
+
desc = (
|
|
194
|
+
fm.get("description")
|
|
195
|
+
or descriptions.get(rec["name"])
|
|
196
|
+
or _body_preview(body, 80)
|
|
197
|
+
)
|
|
198
|
+
commands.append({
|
|
199
|
+
"id": f"{rec['scope']}:{rec['name']}",
|
|
200
|
+
"name": rec["name"],
|
|
201
|
+
"scope": rec["scope"],
|
|
202
|
+
"scope_label": rec["plugin_label"] or rec["scope"].capitalize(),
|
|
203
|
+
"kind": "file",
|
|
204
|
+
"path": str(rec["path"]),
|
|
205
|
+
"description": desc,
|
|
206
|
+
"editable": rec["editable"],
|
|
207
|
+
"frontmatter": fm,
|
|
208
|
+
"body_preview": _body_preview(body),
|
|
209
|
+
"body_length": len(body),
|
|
210
|
+
})
|
|
211
|
+
counts[rec["scope"]] += 1
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
"cwd": resolved_cwd,
|
|
215
|
+
"commands": commands,
|
|
216
|
+
"counts": counts,
|
|
217
|
+
"total": len(commands),
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@router.get("/{scope}/{name}")
|
|
222
|
+
async def get_command(scope: str, name: str, cwd: str):
|
|
223
|
+
"""Return full command metadata + body. Builtins return description only."""
|
|
224
|
+
if scope == "builtin":
|
|
225
|
+
from painapple_code.routes.api_project_config import cli_command_descriptions
|
|
226
|
+
cli_descriptions = await cli_command_descriptions()
|
|
227
|
+
if name not in cli_descriptions:
|
|
228
|
+
raise HTTPException(status_code=404, detail=f"Built-in command {name!r} not found")
|
|
229
|
+
return {
|
|
230
|
+
"id": f"builtin:{name}",
|
|
231
|
+
"name": name,
|
|
232
|
+
"scope": "builtin",
|
|
233
|
+
"scope_label": "Built-in",
|
|
234
|
+
"kind": "builtin",
|
|
235
|
+
"path": None,
|
|
236
|
+
"description": cli_descriptions[name],
|
|
237
|
+
"editable": False,
|
|
238
|
+
"frontmatter": {},
|
|
239
|
+
"body": "",
|
|
240
|
+
"raw": "",
|
|
241
|
+
"mtime": 0,
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
resolved_cwd = str(safe_resolve(cwd))
|
|
245
|
+
rec = _resolve_path(scope, name, resolved_cwd, allow_plugin=True)
|
|
246
|
+
text = rec["path"].read_text(encoding="utf-8", errors="replace")
|
|
247
|
+
fm, body = _parse_frontmatter(text)
|
|
248
|
+
return {
|
|
249
|
+
"id": f"{scope}:{name}",
|
|
250
|
+
"name": name,
|
|
251
|
+
"scope": scope,
|
|
252
|
+
"scope_label": rec["plugin_label"] or scope.capitalize(),
|
|
253
|
+
"kind": "file",
|
|
254
|
+
"path": str(rec["path"]),
|
|
255
|
+
"editable": rec["editable"],
|
|
256
|
+
"frontmatter": fm,
|
|
257
|
+
"body": body,
|
|
258
|
+
"raw": text,
|
|
259
|
+
"description": fm.get("description") or "",
|
|
260
|
+
"mtime": rec["path"].stat().st_mtime,
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class UpdateCommandBody(BaseModel):
|
|
265
|
+
frontmatter: Optional[dict[str, Any]] = None
|
|
266
|
+
body: Optional[str] = None
|
|
267
|
+
raw: Optional[str] = None
|
|
268
|
+
expected_mtime: Optional[float] = None
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@router.put("/{scope}/{name}")
|
|
272
|
+
async def update_command(scope: str, name: str, cwd: str, payload: UpdateCommandBody):
|
|
273
|
+
"""Update a legacy `.md` command file (project or personal scope only)."""
|
|
274
|
+
if scope == "builtin":
|
|
275
|
+
raise HTTPException(status_code=405, detail="Built-in commands are not editable")
|
|
276
|
+
resolved_cwd = str(safe_resolve(cwd))
|
|
277
|
+
rec = _resolve_path(scope, name, resolved_cwd, allow_plugin=False)
|
|
278
|
+
path: Path = rec["path"]
|
|
279
|
+
|
|
280
|
+
if payload.expected_mtime is not None:
|
|
281
|
+
try:
|
|
282
|
+
current = path.stat().st_mtime
|
|
283
|
+
if abs(current - payload.expected_mtime) > 0.5:
|
|
284
|
+
raise HTTPException(
|
|
285
|
+
status_code=409,
|
|
286
|
+
detail="File changed on disk since you started editing",
|
|
287
|
+
)
|
|
288
|
+
except OSError:
|
|
289
|
+
pass
|
|
290
|
+
|
|
291
|
+
if payload.raw is not None:
|
|
292
|
+
new_text = payload.raw
|
|
293
|
+
elif payload.frontmatter is not None or payload.body is not None:
|
|
294
|
+
existing = path.read_text(encoding="utf-8", errors="replace")
|
|
295
|
+
ex_fm, ex_body = _parse_frontmatter(existing)
|
|
296
|
+
fm = payload.frontmatter if payload.frontmatter is not None else ex_fm
|
|
297
|
+
body = payload.body if payload.body is not None else ex_body
|
|
298
|
+
new_text = _serialize(fm, body)
|
|
299
|
+
else:
|
|
300
|
+
raise HTTPException(status_code=400, detail="No content provided")
|
|
301
|
+
|
|
302
|
+
tmp = path.with_suffix(path.suffix + f".tmp.{os.getpid()}")
|
|
303
|
+
tmp.write_text(new_text, encoding="utf-8", newline="")
|
|
304
|
+
os.replace(tmp, path)
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
"ok": True,
|
|
308
|
+
"path": str(path),
|
|
309
|
+
"mtime": path.stat().st_mtime,
|
|
310
|
+
"bytes": len(new_text.encode("utf-8")),
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@router.delete("/{scope}/{name}")
|
|
315
|
+
async def delete_command(scope: str, name: str, cwd: str):
|
|
316
|
+
"""Delete a legacy `.md` command file."""
|
|
317
|
+
if scope == "builtin":
|
|
318
|
+
raise HTTPException(status_code=405, detail="Built-in commands cannot be deleted")
|
|
319
|
+
resolved_cwd = str(safe_resolve(cwd))
|
|
320
|
+
rec = _resolve_path(scope, name, resolved_cwd, allow_plugin=False)
|
|
321
|
+
path: Path = rec["path"]
|
|
322
|
+
|
|
323
|
+
parts = path.resolve().parts
|
|
324
|
+
if ".claude" not in parts:
|
|
325
|
+
raise HTTPException(status_code=400, detail="Refusing to delete outside .claude/")
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
path.unlink()
|
|
329
|
+
except OSError as e:
|
|
330
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
331
|
+
return {"ok": True, "removed": str(path), "kind": "file"}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@router.post("/{scope}/{name}/upgrade")
|
|
335
|
+
async def upgrade_to_skill(scope: str, name: str, cwd: str):
|
|
336
|
+
"""Convert a legacy `commands/<name>.md` to `skills/<name>/SKILL.md` folder.
|
|
337
|
+
|
|
338
|
+
The original flat file is removed after the new directory is in place.
|
|
339
|
+
"""
|
|
340
|
+
if scope == "builtin":
|
|
341
|
+
raise HTTPException(status_code=405, detail="Built-in commands cannot be upgraded")
|
|
342
|
+
resolved_cwd = str(safe_resolve(cwd))
|
|
343
|
+
rec = _resolve_path(scope, name, resolved_cwd, allow_plugin=False)
|
|
344
|
+
src_file: Path = rec["path"]
|
|
345
|
+
|
|
346
|
+
scope_base = Path(resolved_cwd) / ".claude" if scope == "project" else Path.home() / ".claude"
|
|
347
|
+
target_dir = scope_base / "skills" / name
|
|
348
|
+
if target_dir.exists():
|
|
349
|
+
raise HTTPException(status_code=409, detail=f"Skill folder already exists: {target_dir}")
|
|
350
|
+
|
|
351
|
+
text = src_file.read_text(encoding="utf-8", errors="replace")
|
|
352
|
+
fm, body = _parse_frontmatter(text)
|
|
353
|
+
fm = dict(fm or {})
|
|
354
|
+
fm = {k: v for k, v in fm.items() if not k.startswith("_")}
|
|
355
|
+
fm.setdefault("name", name)
|
|
356
|
+
|
|
357
|
+
target_dir.mkdir(parents=True, exist_ok=False)
|
|
358
|
+
(target_dir / "SKILL.md").write_text(_serialize(fm, body), encoding="utf-8", newline="")
|
|
359
|
+
|
|
360
|
+
try:
|
|
361
|
+
src_file.unlink()
|
|
362
|
+
except OSError as e:
|
|
363
|
+
shutil.rmtree(target_dir, ignore_errors=True)
|
|
364
|
+
raise HTTPException(status_code=500, detail=f"Could not remove legacy file: {e}")
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
"ok": True,
|
|
368
|
+
"id": f"skill:{scope}:{name}",
|
|
369
|
+
"scope": scope,
|
|
370
|
+
"name": name,
|
|
371
|
+
"path": str(target_dir / "SKILL.md"),
|
|
372
|
+
"dir": str(target_dir),
|
|
373
|
+
"removed_legacy": str(src_file),
|
|
374
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cost Analytics API Routes - Cost tracking and analysis across sessions
|
|
3
|
+
|
|
4
|
+
These endpoints provide comprehensive cost tracking including summaries,
|
|
5
|
+
session rankings, tool attribution, trends, and efficiency metrics.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
from fastapi import APIRouter
|
|
11
|
+
|
|
12
|
+
from painapple_code.cost_analytics import CostAnalyzer
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
router = APIRouter(prefix="/api/costs", tags=["costs"])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
20
|
+
# Cost Analyzer Singleton
|
|
21
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
22
|
+
|
|
23
|
+
_cost_analyzer = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_cost_analyzer() -> CostAnalyzer:
|
|
27
|
+
"""Get or create the cost analyzer singleton."""
|
|
28
|
+
global _cost_analyzer
|
|
29
|
+
if _cost_analyzer is None:
|
|
30
|
+
_cost_analyzer = CostAnalyzer()
|
|
31
|
+
return _cost_analyzer
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
35
|
+
# Cost Endpoints
|
|
36
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
37
|
+
|
|
38
|
+
@router.get("")
|
|
39
|
+
async def get_costs_summary(
|
|
40
|
+
project: str = None,
|
|
41
|
+
session: str = None,
|
|
42
|
+
since: str = None,
|
|
43
|
+
until: str = None,
|
|
44
|
+
):
|
|
45
|
+
"""
|
|
46
|
+
Get comprehensive cost summary.
|
|
47
|
+
|
|
48
|
+
Query params:
|
|
49
|
+
- project: Filter by project hash
|
|
50
|
+
- session: Filter by session ID
|
|
51
|
+
- since: Start date (ISO format, e.g., 2025-12-01)
|
|
52
|
+
- until: End date (ISO format, e.g., 2025-12-31)
|
|
53
|
+
"""
|
|
54
|
+
analyzer = get_cost_analyzer()
|
|
55
|
+
return analyzer.get_summary(
|
|
56
|
+
project_hash=project,
|
|
57
|
+
session_id=session,
|
|
58
|
+
since=since,
|
|
59
|
+
until=until,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@router.get("/sessions")
|
|
64
|
+
async def get_costs_sessions(
|
|
65
|
+
limit: int = 20,
|
|
66
|
+
project: str = None,
|
|
67
|
+
):
|
|
68
|
+
"""
|
|
69
|
+
Get sessions ranked by cost.
|
|
70
|
+
|
|
71
|
+
Query params:
|
|
72
|
+
- limit: Max sessions to return (default 20)
|
|
73
|
+
- project: Filter by project hash
|
|
74
|
+
"""
|
|
75
|
+
analyzer = get_cost_analyzer()
|
|
76
|
+
return {"sessions": analyzer.get_sessions_ranked(limit=limit, project_hash=project)}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@router.get("/tools")
|
|
80
|
+
async def get_costs_tools(
|
|
81
|
+
project: str = None,
|
|
82
|
+
session: str = None,
|
|
83
|
+
):
|
|
84
|
+
"""
|
|
85
|
+
Get tool cost attribution (estimated).
|
|
86
|
+
|
|
87
|
+
Attributes turn cost proportionally to tools used in that turn.
|
|
88
|
+
|
|
89
|
+
Query params:
|
|
90
|
+
- project: Filter by project hash
|
|
91
|
+
- session: Filter by session ID
|
|
92
|
+
"""
|
|
93
|
+
analyzer = get_cost_analyzer()
|
|
94
|
+
return analyzer.get_tool_attribution(project_hash=project, session_id=session)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@router.get("/trends")
|
|
98
|
+
async def get_costs_trends(
|
|
99
|
+
period: str = "daily",
|
|
100
|
+
project: str = None,
|
|
101
|
+
days: int = 30,
|
|
102
|
+
):
|
|
103
|
+
"""
|
|
104
|
+
Get cost trends over time.
|
|
105
|
+
|
|
106
|
+
Query params:
|
|
107
|
+
- period: Aggregation period (daily, weekly, monthly)
|
|
108
|
+
- project: Filter by project hash
|
|
109
|
+
- days: Number of periods to return (default 30)
|
|
110
|
+
"""
|
|
111
|
+
analyzer = get_cost_analyzer()
|
|
112
|
+
return analyzer.get_trends(period=period, project_hash=project, days=days)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@router.get("/efficiency")
|
|
116
|
+
async def get_costs_efficiency(
|
|
117
|
+
project: str = None,
|
|
118
|
+
):
|
|
119
|
+
"""
|
|
120
|
+
Get efficiency metrics.
|
|
121
|
+
|
|
122
|
+
Returns cost per turn, tokens per dollar, cache efficiency, etc.
|
|
123
|
+
|
|
124
|
+
Query params:
|
|
125
|
+
- project: Filter by project hash
|
|
126
|
+
"""
|
|
127
|
+
analyzer = get_cost_analyzer()
|
|
128
|
+
return analyzer.get_efficiency_metrics(project_hash=project)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Prompt Drafts API Routes
|
|
3
|
+
|
|
4
|
+
Explicit "save this prompt for later" drafts — global (cross-session,
|
|
5
|
+
cross-project), stored server-side at ~/.painapple-code/drafts.json so
|
|
6
|
+
they survive the iPad PWA's flaky localStorage persistence.
|
|
7
|
+
|
|
8
|
+
Distinct from the per-session auto-draft (client-side, one slot per
|
|
9
|
+
session, restored on refresh) — these are deliberately banked prompts.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import uuid
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from fastapi import APIRouter, HTTPException
|
|
18
|
+
from pydantic import BaseModel
|
|
19
|
+
|
|
20
|
+
from painapple_code.paths import load_drafts, save_drafts
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
router = APIRouter(tags=["drafts"])
|
|
25
|
+
|
|
26
|
+
# Safety cap — trim oldest on write so drafts.json can't grow unbounded
|
|
27
|
+
MAX_DRAFTS = 200
|
|
28
|
+
TITLE_MAX_LEN = 60
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DraftCreate(BaseModel):
|
|
32
|
+
text: str
|
|
33
|
+
cwd: Optional[str] = None
|
|
34
|
+
session_id: Optional[str] = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DraftUpdate(BaseModel):
|
|
38
|
+
text: str
|
|
39
|
+
cwd: Optional[str] = None
|
|
40
|
+
session_id: Optional[str] = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _title_from_text(text: str) -> str:
|
|
44
|
+
"""Auto title: first non-empty line, truncated to ~60 chars."""
|
|
45
|
+
for line in text.splitlines():
|
|
46
|
+
line = line.strip()
|
|
47
|
+
if line:
|
|
48
|
+
return line[:TITLE_MAX_LEN] + ("…" if len(line) > TITLE_MAX_LEN else "")
|
|
49
|
+
return text.strip()[:TITLE_MAX_LEN] or "Draft"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@router.get("/api/drafts")
|
|
53
|
+
async def list_drafts():
|
|
54
|
+
"""List all saved drafts, newest first."""
|
|
55
|
+
data = load_drafts()
|
|
56
|
+
drafts = data.get("drafts", [])
|
|
57
|
+
drafts.sort(key=lambda d: d.get("updatedAt") or "", reverse=True)
|
|
58
|
+
return {"drafts": drafts, "count": len(drafts)}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@router.post("/api/drafts")
|
|
62
|
+
async def create_draft(req: DraftCreate):
|
|
63
|
+
"""Save a new draft. Title is derived from the first line of text."""
|
|
64
|
+
if not (req.text or "").strip():
|
|
65
|
+
raise HTTPException(status_code=400, detail="Draft text is empty")
|
|
66
|
+
|
|
67
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
68
|
+
draft = {
|
|
69
|
+
"id": uuid.uuid4().hex[:12],
|
|
70
|
+
"text": req.text,
|
|
71
|
+
"title": _title_from_text(req.text),
|
|
72
|
+
"cwd": req.cwd,
|
|
73
|
+
"sessionId": req.session_id,
|
|
74
|
+
"createdAt": now,
|
|
75
|
+
"updatedAt": now,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
data = load_drafts()
|
|
79
|
+
data.setdefault("drafts", []).insert(0, draft)
|
|
80
|
+
# Trim oldest beyond the cap (list is kept newest-first)
|
|
81
|
+
if len(data["drafts"]) > MAX_DRAFTS:
|
|
82
|
+
data["drafts"] = data["drafts"][:MAX_DRAFTS]
|
|
83
|
+
|
|
84
|
+
if not save_drafts(data):
|
|
85
|
+
raise HTTPException(status_code=500, detail="Failed to save draft")
|
|
86
|
+
return {"success": True, "draft": draft}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@router.put("/api/drafts/{draft_id}")
|
|
90
|
+
async def update_draft(draft_id: str, req: DraftUpdate):
|
|
91
|
+
"""Update a draft in place (auto-sync keeps the live input's draft fresh).
|
|
92
|
+
|
|
93
|
+
Title is re-derived from the new text; updatedAt bumps so the draft
|
|
94
|
+
floats to the top of the newest-first list.
|
|
95
|
+
"""
|
|
96
|
+
if not (req.text or "").strip():
|
|
97
|
+
raise HTTPException(status_code=400, detail="Draft text is empty")
|
|
98
|
+
|
|
99
|
+
data = load_drafts()
|
|
100
|
+
for draft in data.get("drafts", []):
|
|
101
|
+
if draft.get("id") == draft_id:
|
|
102
|
+
draft["text"] = req.text
|
|
103
|
+
draft["title"] = _title_from_text(req.text)
|
|
104
|
+
if req.cwd is not None:
|
|
105
|
+
draft["cwd"] = req.cwd
|
|
106
|
+
if req.session_id is not None:
|
|
107
|
+
draft["sessionId"] = req.session_id
|
|
108
|
+
draft["updatedAt"] = datetime.now(timezone.utc).isoformat()
|
|
109
|
+
if not save_drafts(data):
|
|
110
|
+
raise HTTPException(status_code=500, detail="Failed to save draft")
|
|
111
|
+
return {"success": True, "draft": draft}
|
|
112
|
+
|
|
113
|
+
raise HTTPException(status_code=404, detail="Draft not found")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@router.delete("/api/drafts")
|
|
117
|
+
async def clear_drafts():
|
|
118
|
+
"""Wipe all saved drafts at once (Clear All)."""
|
|
119
|
+
data = load_drafts()
|
|
120
|
+
count = len(data.get("drafts", []))
|
|
121
|
+
data["drafts"] = []
|
|
122
|
+
if not save_drafts(data):
|
|
123
|
+
raise HTTPException(status_code=500, detail="Failed to clear drafts")
|
|
124
|
+
return {"success": True, "cleared": count}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@router.delete("/api/drafts/{draft_id}")
|
|
128
|
+
async def delete_draft(draft_id: str):
|
|
129
|
+
"""Delete a draft (explicit delete, or consumed on send)."""
|
|
130
|
+
data = load_drafts()
|
|
131
|
+
drafts = data.get("drafts", [])
|
|
132
|
+
remaining = [d for d in drafts if d.get("id") != draft_id]
|
|
133
|
+
if len(remaining) == len(drafts):
|
|
134
|
+
raise HTTPException(status_code=404, detail="Draft not found")
|
|
135
|
+
|
|
136
|
+
data["drafts"] = remaining
|
|
137
|
+
if not save_drafts(data):
|
|
138
|
+
raise HTTPException(status_code=500, detail="Failed to save drafts")
|
|
139
|
+
return {"success": True}
|