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,149 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Exec API Route - Shell command execution endpoint
|
|
3
|
+
|
|
4
|
+
Provides a POST endpoint for executing shell commands
|
|
5
|
+
with output capture and timeout handling.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import signal
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from fastapi import APIRouter, HTTPException
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
from painapple_code.utils.file_paths import safe_resolve
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
router = APIRouter(tags=["exec"])
|
|
21
|
+
|
|
22
|
+
# Combined stdout+stderr ceiling. `communicate()` buffers without limit, so a
|
|
23
|
+
# `cat` of a huge file or a runaway `yes` grows the server's heap until the OOM
|
|
24
|
+
# killer settles it. Generous enough that no realistic bang-command output is
|
|
25
|
+
# clipped; the truncation is reported, never silent.
|
|
26
|
+
MAX_OUTPUT_BYTES = 1 * 1024 * 1024
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _kill_tree(process) -> None:
|
|
30
|
+
"""Kill the command AND anything it spawned.
|
|
31
|
+
|
|
32
|
+
`process.kill()` signals only the shell. A pipeline or a backgrounded child
|
|
33
|
+
outlives it and keeps holding the pipes, so the timeout fires and the
|
|
34
|
+
orphans keep running. POSIX commands are spawned with `start_new_session`
|
|
35
|
+
so the whole group can be signalled at once; Windows has no equivalent here
|
|
36
|
+
and gets the single-process kill.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
if sys.platform != "win32":
|
|
40
|
+
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
|
41
|
+
else:
|
|
42
|
+
process.kill()
|
|
43
|
+
except (ProcessLookupError, PermissionError):
|
|
44
|
+
pass # already gone
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def _read_capped(stream, limit: int, on_overflow) -> tuple[bytes, bool]:
|
|
48
|
+
"""Read a pipe, keeping at most `limit` bytes.
|
|
49
|
+
|
|
50
|
+
Keeps draining past the cap rather than just stopping: an unread pipe fills
|
|
51
|
+
up and the child blocks forever on write, converting an over-limit command
|
|
52
|
+
into a hang. `on_overflow` kills the process group the first time the cap is
|
|
53
|
+
passed, which is what actually ends the read.
|
|
54
|
+
"""
|
|
55
|
+
buf = bytearray()
|
|
56
|
+
truncated = False
|
|
57
|
+
while True:
|
|
58
|
+
chunk = await stream.read(65536)
|
|
59
|
+
if not chunk:
|
|
60
|
+
return bytes(buf), truncated
|
|
61
|
+
if not truncated:
|
|
62
|
+
buf.extend(chunk[: limit - len(buf)])
|
|
63
|
+
if len(buf) >= limit:
|
|
64
|
+
truncated = True
|
|
65
|
+
on_overflow()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ExecRequest(BaseModel):
|
|
69
|
+
command: str
|
|
70
|
+
cwd: str = "."
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@router.post("/api/exec")
|
|
74
|
+
async def execute_command(req: ExecRequest):
|
|
75
|
+
"""Execute a shell command directly and return the output.
|
|
76
|
+
|
|
77
|
+
Inputs ride in a JSON body (not query params): this forces a
|
|
78
|
+
non-simple cross-origin request (CORS-preflighted), so a hostile
|
|
79
|
+
page can't fire ``POST /api/exec?command=...`` with the user's
|
|
80
|
+
ambient cookie, and the command never lands in an access-log query
|
|
81
|
+
string.
|
|
82
|
+
"""
|
|
83
|
+
command = req.command
|
|
84
|
+
cwd = req.cwd
|
|
85
|
+
try:
|
|
86
|
+
p = safe_resolve(cwd)
|
|
87
|
+
if not p.exists() or not p.is_dir():
|
|
88
|
+
raise HTTPException(status_code=400, detail="Invalid working directory")
|
|
89
|
+
|
|
90
|
+
if sys.platform == "win32":
|
|
91
|
+
# create_subprocess_shell would use COMSPEC (cmd.exe), whose
|
|
92
|
+
# dialect shares almost nothing with the POSIX-shaped commands
|
|
93
|
+
# every doc and quick-action here assumes. PowerShell is both
|
|
94
|
+
# closer and what native Claude Code itself defaults to on
|
|
95
|
+
# Windows. Note for docs: bang commands are PowerShell-flavored
|
|
96
|
+
# on Windows.
|
|
97
|
+
process = await asyncio.create_subprocess_exec(
|
|
98
|
+
"powershell.exe", "-NoProfile", "-NonInteractive",
|
|
99
|
+
"-Command", command,
|
|
100
|
+
stdout=asyncio.subprocess.PIPE,
|
|
101
|
+
stderr=asyncio.subprocess.PIPE,
|
|
102
|
+
cwd=str(p),
|
|
103
|
+
)
|
|
104
|
+
else:
|
|
105
|
+
process = await asyncio.create_subprocess_shell(
|
|
106
|
+
command,
|
|
107
|
+
stdout=asyncio.subprocess.PIPE,
|
|
108
|
+
stderr=asyncio.subprocess.PIPE,
|
|
109
|
+
cwd=str(p),
|
|
110
|
+
start_new_session=True, # own process group, so _kill_tree gets the children too
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
killed = False
|
|
114
|
+
|
|
115
|
+
def _overflow():
|
|
116
|
+
nonlocal killed
|
|
117
|
+
if not killed:
|
|
118
|
+
killed = True
|
|
119
|
+
logger.warning(f"exec output exceeded {MAX_OUTPUT_BYTES} bytes, killing: {command[:80]}")
|
|
120
|
+
_kill_tree(process)
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
(stdout, out_trunc), (stderr, err_trunc) = await asyncio.wait_for(
|
|
124
|
+
asyncio.gather(
|
|
125
|
+
_read_capped(process.stdout, MAX_OUTPUT_BYTES, _overflow),
|
|
126
|
+
_read_capped(process.stderr, MAX_OUTPUT_BYTES, _overflow),
|
|
127
|
+
),
|
|
128
|
+
timeout=30.0,
|
|
129
|
+
)
|
|
130
|
+
await process.wait()
|
|
131
|
+
except asyncio.TimeoutError:
|
|
132
|
+
_kill_tree(process)
|
|
133
|
+
raise HTTPException(status_code=408, detail="Command timed out after 30 seconds")
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
"command": command,
|
|
137
|
+
"cwd": str(p),
|
|
138
|
+
"exit_code": process.returncode,
|
|
139
|
+
"stdout": stdout.decode('utf-8', errors='replace'),
|
|
140
|
+
"stderr": stderr.decode('utf-8', errors='replace'),
|
|
141
|
+
# Reported, not silent: a clipped result must not read as a complete
|
|
142
|
+
# one. Callers that ignore this see less output, never wrong output.
|
|
143
|
+
"truncated": out_trunc or err_trunc,
|
|
144
|
+
"output_limit": MAX_OUTPUT_BYTES,
|
|
145
|
+
}
|
|
146
|
+
except HTTPException:
|
|
147
|
+
raise
|
|
148
|
+
except Exception as e:
|
|
149
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Files API Routes - File operations for the file browser
|
|
3
|
+
|
|
4
|
+
These endpoints provide file listing, reading, writing, and search
|
|
5
|
+
functionality for the web client's file browser and autocomplete.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from fastapi import APIRouter, HTTPException, Query, Request
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
|
|
18
|
+
from painapple_code import paths
|
|
19
|
+
from painapple_code.utils.file_paths import (
|
|
20
|
+
PATH_DENIED_DETAIL,
|
|
21
|
+
is_path_allowed,
|
|
22
|
+
is_path_allowed_for_read,
|
|
23
|
+
resolve_project_dir,
|
|
24
|
+
resolve_project_files,
|
|
25
|
+
safe_resolve,
|
|
26
|
+
verify_file_paths,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
router = APIRouter(tags=["files"])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
35
|
+
# File Listing
|
|
36
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
37
|
+
|
|
38
|
+
@router.get("/api/files")
|
|
39
|
+
async def list_files(path: str = "."):
|
|
40
|
+
"""List files in a directory (for file browser)."""
|
|
41
|
+
try:
|
|
42
|
+
p = safe_resolve(path)
|
|
43
|
+
if not is_path_allowed_for_read(p):
|
|
44
|
+
raise HTTPException(status_code=403, detail=PATH_DENIED_DETAIL)
|
|
45
|
+
if not p.exists():
|
|
46
|
+
raise HTTPException(status_code=404, detail="Path not found")
|
|
47
|
+
if not p.is_dir():
|
|
48
|
+
raise HTTPException(status_code=400, detail="Not a directory")
|
|
49
|
+
|
|
50
|
+
files = []
|
|
51
|
+
for item in sorted(p.iterdir()):
|
|
52
|
+
try:
|
|
53
|
+
st = item.stat()
|
|
54
|
+
mtime = st.st_mtime
|
|
55
|
+
size = st.st_size if item.is_file() else None
|
|
56
|
+
except (OSError, PermissionError):
|
|
57
|
+
mtime = 0
|
|
58
|
+
size = None
|
|
59
|
+
files.append({
|
|
60
|
+
"name": item.name,
|
|
61
|
+
"path": str(item),
|
|
62
|
+
"is_dir": item.is_dir(),
|
|
63
|
+
"size": size,
|
|
64
|
+
"mtime": mtime,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
return {"path": str(p), "files": files}
|
|
68
|
+
except PermissionError:
|
|
69
|
+
raise HTTPException(status_code=403, detail="Permission denied")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Mirrors the fd --exclude list below (and the old find -not -path set).
|
|
73
|
+
_WALK_SKIP_DIRS = {'node_modules', '.git', '__pycache__', 'venv', '.venv',
|
|
74
|
+
'dist', 'build', '.next', 'coverage'}
|
|
75
|
+
_WALK_SKIP_NAMES = {'.DS_Store'}
|
|
76
|
+
_WALK_MAX_FILES = 50000 # same order as fd's practical ceiling; keeps a
|
|
77
|
+
# runaway tree from pinning a worker thread
|
|
78
|
+
_WALK_TIMEOUT = 15.0 # matches the `find` budget this path replaced
|
|
79
|
+
# (5s spawn + 10s communicate)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _walk_files(directory: Path, deadline: float | None = None) -> tuple[list[str], bool]:
|
|
83
|
+
"""Relative file paths under `directory`, POSIX-separated.
|
|
84
|
+
|
|
85
|
+
Returns (paths, truncated) — truncated is True when the file cap or the
|
|
86
|
+
deadline cut the walk short, so the caller can tell the client its list
|
|
87
|
+
is partial instead of silently serving a prefix of the project.
|
|
88
|
+
|
|
89
|
+
Runs in a worker thread (os.walk is blocking). Separators are
|
|
90
|
+
normalized so the client gets the same shape fd produces, and so
|
|
91
|
+
Windows results don't arrive with backslashes the @-autocomplete
|
|
92
|
+
can't match.
|
|
93
|
+
|
|
94
|
+
`deadline` (a time.monotonic value) is checked once per directory. It
|
|
95
|
+
bounds the WORKER; the caller separately bounds the REQUEST, because a
|
|
96
|
+
single scandir stuck on a dead NFS/sshfs mount is uninterruptible and
|
|
97
|
+
no in-loop check can preempt it.
|
|
98
|
+
"""
|
|
99
|
+
out = []
|
|
100
|
+
root = str(directory)
|
|
101
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
102
|
+
if deadline is not None and time.monotonic() > deadline:
|
|
103
|
+
return out, True
|
|
104
|
+
dirnames[:] = [d for d in dirnames if d not in _WALK_SKIP_DIRS]
|
|
105
|
+
# Once per directory, not once per file: os.walk already knows
|
|
106
|
+
# dirpath, and relpath abspath-normalizes and splits BOTH arguments
|
|
107
|
+
# on every call — 50k of them at the cap, for a prefix that changes
|
|
108
|
+
# only when the directory does.
|
|
109
|
+
rel_dir = os.path.relpath(dirpath, root)
|
|
110
|
+
prefix = '' if rel_dir == os.curdir else rel_dir.replace(os.sep, '/') + '/'
|
|
111
|
+
for name in filenames:
|
|
112
|
+
if name in _WALK_SKIP_NAMES or name.endswith('.pyc'):
|
|
113
|
+
continue
|
|
114
|
+
out.append(prefix + name)
|
|
115
|
+
if len(out) >= _WALK_MAX_FILES:
|
|
116
|
+
return out, True
|
|
117
|
+
return out, False
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def _enumerate_files(directory: Path, include_ignored: bool = False) -> tuple[list[str], bool]:
|
|
121
|
+
"""
|
|
122
|
+
Enumerate files in a directory using fd (falls back to a Python walk).
|
|
123
|
+
Returns (relative paths within the directory, truncated).
|
|
124
|
+
|
|
125
|
+
When `include_ignored` is True, .gitignore'd and hidden files are included
|
|
126
|
+
(fd's --no-ignore --hidden). The hardcoded `--exclude` list still applies
|
|
127
|
+
so node_modules/.git etc. are never returned regardless.
|
|
128
|
+
"""
|
|
129
|
+
# Try fd first (respects .gitignore by default)
|
|
130
|
+
try:
|
|
131
|
+
fd_args = [
|
|
132
|
+
'fd', '--type', 'f', '--relative-path',
|
|
133
|
+
'--exclude', 'node_modules',
|
|
134
|
+
'--exclude', '.git',
|
|
135
|
+
'--exclude', '__pycache__',
|
|
136
|
+
'--exclude', 'venv',
|
|
137
|
+
'--exclude', '.venv',
|
|
138
|
+
'--exclude', '*.pyc',
|
|
139
|
+
'--exclude', '.DS_Store',
|
|
140
|
+
'--exclude', 'dist',
|
|
141
|
+
'--exclude', 'build',
|
|
142
|
+
'--exclude', '.next',
|
|
143
|
+
'--exclude', 'coverage',
|
|
144
|
+
]
|
|
145
|
+
if include_ignored:
|
|
146
|
+
fd_args += ['--no-ignore', '--hidden']
|
|
147
|
+
result = await asyncio.wait_for(
|
|
148
|
+
asyncio.create_subprocess_exec(
|
|
149
|
+
*fd_args,
|
|
150
|
+
cwd=str(directory),
|
|
151
|
+
stdout=asyncio.subprocess.PIPE,
|
|
152
|
+
stderr=asyncio.subprocess.PIPE
|
|
153
|
+
),
|
|
154
|
+
timeout=5.0
|
|
155
|
+
)
|
|
156
|
+
stdout, stderr = await asyncio.wait_for(result.communicate(), timeout=10.0)
|
|
157
|
+
|
|
158
|
+
if result.returncode == 0:
|
|
159
|
+
# `.replace(os.sep, '/')`, matching _walk_files: fd emits
|
|
160
|
+
# OS-native separators, so on Windows this branch returned
|
|
161
|
+
# `src\app.js` while the fallback returned `src/app.js` — one
|
|
162
|
+
# endpoint with two shapes depending on whether fd happened to
|
|
163
|
+
# be installed. The client scores the user's typed `src/app`
|
|
164
|
+
# against that string and checks `changedSet.has(path)` against
|
|
165
|
+
# shadow-git paths that are always forward-slashed, so both
|
|
166
|
+
# silently missed on the fd machine. Keyed off os.sep rather
|
|
167
|
+
# than replacing '\\' unconditionally, because on POSIX a
|
|
168
|
+
# backslash is an ordinary filename byte.
|
|
169
|
+
return [f.replace(os.sep, '/') for f in stdout.decode().strip().split('\n') if f], False
|
|
170
|
+
raise FileNotFoundError("fd failed")
|
|
171
|
+
|
|
172
|
+
except (FileNotFoundError, asyncio.TimeoutError):
|
|
173
|
+
# Fallback: pure-Python walk. This used to shell out to `find`,
|
|
174
|
+
# which on Windows resolves to find.exe — a COMPLETELY different
|
|
175
|
+
# tool (it greps for a string in files). It doesn't error on this
|
|
176
|
+
# argv, it just prints something else, so the @-autocomplete file
|
|
177
|
+
# list would have been silently wrong rather than empty. os.walk
|
|
178
|
+
# also drops the last non-Python dependency of this endpoint.
|
|
179
|
+
#
|
|
180
|
+
# Like `find`, this doesn't read .gitignore, so include_ignored
|
|
181
|
+
# stays a no-op on the fallback path.
|
|
182
|
+
#
|
|
183
|
+
# Bounded on both sides. The `find` path it replaced had two
|
|
184
|
+
# timeouts (5s spawn + 10s communicate) and the executor handoff
|
|
185
|
+
# had none, so one directory on a hung autofs/NFS/sshfs mount held
|
|
186
|
+
# a shared ThreadPoolExecutor worker forever and the request never
|
|
187
|
+
# returned. wait_for can't kill the thread — nothing can — but it
|
|
188
|
+
# returns the request, and the in-walk deadline lets the thread
|
|
189
|
+
# retire as soon as it comes back from the stuck syscall.
|
|
190
|
+
loop = asyncio.get_running_loop()
|
|
191
|
+
deadline = time.monotonic() + _WALK_TIMEOUT
|
|
192
|
+
return await asyncio.wait_for(
|
|
193
|
+
loop.run_in_executor(None, _walk_files, directory, deadline),
|
|
194
|
+
timeout=_WALK_TIMEOUT + 1.0,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@router.get("/api/files/list")
|
|
199
|
+
async def list_project_files(cwd: str, refresh: bool = False, include_ignored: bool = False):
|
|
200
|
+
"""
|
|
201
|
+
List all files in a project for @ autocomplete.
|
|
202
|
+
Uses 'fd' command for fast enumeration, falls back to find.
|
|
203
|
+
Also includes files from extra_dirs in project config.
|
|
204
|
+
|
|
205
|
+
`include_ignored=true` returns .gitignore'd and hidden files as well
|
|
206
|
+
(used by the file explorer's "Include ignored" search toggle).
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
p = safe_resolve(cwd)
|
|
210
|
+
if not p.exists() or not p.is_dir():
|
|
211
|
+
raise HTTPException(status_code=404, detail="Directory not found")
|
|
212
|
+
|
|
213
|
+
# Enumerate project files (relative paths)
|
|
214
|
+
files, walk_truncated = await _enumerate_files(p, include_ignored=include_ignored)
|
|
215
|
+
|
|
216
|
+
# Load extra_dirs: merge global (all projects) + project-specific
|
|
217
|
+
try:
|
|
218
|
+
global_config = paths.load_global_config()
|
|
219
|
+
global_extra = global_config.get("extra_dirs", [])
|
|
220
|
+
except Exception as e:
|
|
221
|
+
logger.warning(f"Failed to load global config for extra_dirs: {e}")
|
|
222
|
+
global_extra = []
|
|
223
|
+
try:
|
|
224
|
+
project_config_path = paths.get_project_config_path(str(p))
|
|
225
|
+
if project_config_path.exists():
|
|
226
|
+
project_config = json.loads(project_config_path.read_text(encoding="utf-8"))
|
|
227
|
+
project_extra = project_config.get("extra_dirs", [])
|
|
228
|
+
else:
|
|
229
|
+
project_extra = []
|
|
230
|
+
except Exception as e:
|
|
231
|
+
logger.warning(f"Failed to load project config for extra_dirs: {e}")
|
|
232
|
+
project_extra = []
|
|
233
|
+
# Combine both, deduplicate, preserve order
|
|
234
|
+
seen = set()
|
|
235
|
+
extra_dirs = []
|
|
236
|
+
for d in global_extra + project_extra:
|
|
237
|
+
resolved = str(safe_resolve(d))
|
|
238
|
+
if resolved not in seen:
|
|
239
|
+
seen.add(resolved)
|
|
240
|
+
extra_dirs.append(d)
|
|
241
|
+
|
|
242
|
+
# Enumerate files from each extra directory (absolute paths)
|
|
243
|
+
for extra_dir in extra_dirs:
|
|
244
|
+
extra_path = safe_resolve(extra_dir)
|
|
245
|
+
if not extra_path.is_dir():
|
|
246
|
+
logger.warning(f"Extra dir not found: {extra_dir}")
|
|
247
|
+
continue
|
|
248
|
+
try:
|
|
249
|
+
extra_files, extra_truncated = await _enumerate_files(
|
|
250
|
+
extra_path, include_ignored=include_ignored
|
|
251
|
+
)
|
|
252
|
+
walk_truncated = walk_truncated or extra_truncated
|
|
253
|
+
for f in extra_files:
|
|
254
|
+
files.append(str(extra_path / f))
|
|
255
|
+
except Exception as e:
|
|
256
|
+
logger.warning(f"Failed to enumerate extra dir {extra_dir}: {e}")
|
|
257
|
+
|
|
258
|
+
# Also get directories (for drilling down)
|
|
259
|
+
dirs = []
|
|
260
|
+
try:
|
|
261
|
+
for item in p.iterdir():
|
|
262
|
+
if item.is_dir() and not item.name.startswith('.') and item.name not in (
|
|
263
|
+
'node_modules', '__pycache__', 'venv', '.venv', 'dist', 'build', '.next', 'coverage'
|
|
264
|
+
):
|
|
265
|
+
dirs.append(item.name + '/')
|
|
266
|
+
except PermissionError:
|
|
267
|
+
pass
|
|
268
|
+
|
|
269
|
+
if walk_truncated:
|
|
270
|
+
logger.warning(
|
|
271
|
+
f"File enumeration hit its cap under {p} — returning a partial list"
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
"cwd": str(p),
|
|
276
|
+
"files": sorted(files)[:20000], # 20k files ≈ 1MB JSON
|
|
277
|
+
"directories": sorted(dirs),
|
|
278
|
+
"total": len(files),
|
|
279
|
+
# Either cap counts: the 20k response budget, or the walk's own
|
|
280
|
+
# 50k/15s ceiling. The latter used to cut the list off with no
|
|
281
|
+
# signal at all, so the client rendered a partial project as a
|
|
282
|
+
# complete one and an @-mention that found nothing looked like
|
|
283
|
+
# a missing file.
|
|
284
|
+
"truncated": len(files) > 20000 or walk_truncated,
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
except PermissionError:
|
|
288
|
+
raise HTTPException(status_code=403, detail="Permission denied")
|
|
289
|
+
except asyncio.TimeoutError:
|
|
290
|
+
raise HTTPException(status_code=504, detail="File listing timed out")
|
|
291
|
+
except Exception as e:
|
|
292
|
+
logger.error(f"Error listing files: {e}")
|
|
293
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
297
|
+
# File Read/Write
|
|
298
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
299
|
+
|
|
300
|
+
@router.get("/api/file")
|
|
301
|
+
async def read_file(path: str):
|
|
302
|
+
"""Read a file's contents (for file viewer)."""
|
|
303
|
+
try:
|
|
304
|
+
p = safe_resolve(path)
|
|
305
|
+
if not is_path_allowed_for_read(p):
|
|
306
|
+
raise HTTPException(status_code=403, detail=PATH_DENIED_DETAIL)
|
|
307
|
+
if not p.exists():
|
|
308
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
309
|
+
if not p.is_file():
|
|
310
|
+
raise HTTPException(status_code=400, detail="Not a file")
|
|
311
|
+
if p.stat().st_size > 1_000_000: # 1MB limit
|
|
312
|
+
raise HTTPException(status_code=400, detail="File too large")
|
|
313
|
+
|
|
314
|
+
return {"path": str(p), "content": p.read_text(encoding="utf-8", errors='replace'), "mtime": p.stat().st_mtime}
|
|
315
|
+
except PermissionError:
|
|
316
|
+
raise HTTPException(status_code=403, detail="Permission denied")
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@router.get("/api/file/stat")
|
|
320
|
+
async def file_stat(path: str):
|
|
321
|
+
"""Lightweight file stat (mtime + size) without reading content."""
|
|
322
|
+
try:
|
|
323
|
+
p = safe_resolve(path)
|
|
324
|
+
if not is_path_allowed_for_read(p):
|
|
325
|
+
raise HTTPException(status_code=403, detail=PATH_DENIED_DETAIL)
|
|
326
|
+
if not p.exists():
|
|
327
|
+
return {"path": str(p), "exists": False, "mtime": 0, "size": 0}
|
|
328
|
+
st = p.stat()
|
|
329
|
+
return {"path": str(p), "exists": True, "mtime": st.st_mtime, "size": st.st_size}
|
|
330
|
+
except PermissionError:
|
|
331
|
+
raise HTTPException(status_code=403, detail="Permission denied")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class WriteFileRequest(BaseModel):
|
|
335
|
+
path: str
|
|
336
|
+
content: str
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# How much of an existing file to sniff for its line-ending style. A file's
|
|
340
|
+
# convention is established in its first few hundred lines or not at all.
|
|
341
|
+
_EOL_SNIFF_BYTES = 65536
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _file_is_crlf(p: Path) -> bool:
|
|
345
|
+
"""Is this existing file written entirely with CRLF line endings?
|
|
346
|
+
|
|
347
|
+
True only when the sniffed prefix has at least one `\\r\\n` and no bare
|
|
348
|
+
`\\n` at all. Deliberately strict: see `write_file`.
|
|
349
|
+
"""
|
|
350
|
+
try:
|
|
351
|
+
with open(p, "rb") as fh:
|
|
352
|
+
head = fh.read(_EOL_SNIFF_BYTES)
|
|
353
|
+
except OSError:
|
|
354
|
+
return False
|
|
355
|
+
# A cut exactly between CR and LF would otherwise read as a bare CR
|
|
356
|
+
# followed (in the next chunk we never see) by a bare LF.
|
|
357
|
+
if head.endswith(b"\r"):
|
|
358
|
+
head = head[:-1]
|
|
359
|
+
lf = head.count(b"\n")
|
|
360
|
+
return lf > 0 and head.count(b"\r\n") == lf
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@router.post("/api/file/write")
|
|
364
|
+
async def write_file(request: WriteFileRequest):
|
|
365
|
+
"""Write content to a file (for scratch Save As, and every editor save).
|
|
366
|
+
|
|
367
|
+
Line endings: GET /api/file reads with universal newlines, which turns
|
|
368
|
+
a CRLF file into `\\n` before the client ever sees it, and this write
|
|
369
|
+
used `newline=""`, which suppresses the translation that would put them
|
|
370
|
+
back. So on a Windows server every save silently rewrote a CRLF file to
|
|
371
|
+
LF — and with `core.autocrlf=true`, git then reported the whole file as
|
|
372
|
+
modified after a one-character edit.
|
|
373
|
+
|
|
374
|
+
`newline=""` is still right (the content must go out byte-for-byte, and
|
|
375
|
+
on a Linux server editing a checked-out CRLF file the platform default
|
|
376
|
+
wouldn't help anyway); what was missing is that the endpoint has to
|
|
377
|
+
supply the endings itself. It takes them from the file already on disk,
|
|
378
|
+
which is the only place the information survives — the client's editor
|
|
379
|
+
normalizes to `\\n` in its document model regardless of what we send it.
|
|
380
|
+
|
|
381
|
+
A file that is *purely* CRLF gets CRLF back. Everything else — LF,
|
|
382
|
+
genuinely mixed, lone-CR, and any new file — is written verbatim. Mixed
|
|
383
|
+
is the interesting case and it is left alone on purpose: there is no
|
|
384
|
+
ending that is "the file's", so either choice rewrites lines the user
|
|
385
|
+
never touched, and silently normalizing a mixed file is a bigger
|
|
386
|
+
surprise (a whole-file diff) than leaving it as the editor produced it.
|
|
387
|
+
"""
|
|
388
|
+
try:
|
|
389
|
+
p = safe_resolve(request.path)
|
|
390
|
+
if not is_path_allowed(p):
|
|
391
|
+
raise HTTPException(status_code=403, detail=PATH_DENIED_DETAIL)
|
|
392
|
+
|
|
393
|
+
content = request.content
|
|
394
|
+
if p.is_file() and _file_is_crlf(p):
|
|
395
|
+
# Normalize first so a client that already sent CRLF doesn't
|
|
396
|
+
# come out with CRCRLF.
|
|
397
|
+
content = content.replace("\r\n", "\n").replace("\n", "\r\n")
|
|
398
|
+
|
|
399
|
+
# Create parent directories if needed
|
|
400
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
401
|
+
|
|
402
|
+
# Write the file
|
|
403
|
+
p.write_text(content, encoding="utf-8", newline="")
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
"path": str(p),
|
|
407
|
+
"size": p.stat().st_size,
|
|
408
|
+
"mtime": p.stat().st_mtime
|
|
409
|
+
}
|
|
410
|
+
except HTTPException:
|
|
411
|
+
raise
|
|
412
|
+
except PermissionError:
|
|
413
|
+
raise HTTPException(status_code=403, detail="Permission denied")
|
|
414
|
+
except Exception as e:
|
|
415
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
419
|
+
# Directory Operations
|
|
420
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
421
|
+
|
|
422
|
+
@router.get("/api/check-dir")
|
|
423
|
+
async def check_directory(path: str):
|
|
424
|
+
"""Check if a directory exists."""
|
|
425
|
+
try:
|
|
426
|
+
p = safe_resolve(path)
|
|
427
|
+
return {
|
|
428
|
+
"path": str(p),
|
|
429
|
+
"exists": p.exists(),
|
|
430
|
+
"is_dir": p.is_dir() if p.exists() else False
|
|
431
|
+
}
|
|
432
|
+
except Exception as e:
|
|
433
|
+
return {"path": path, "exists": False, "is_dir": False, "error": str(e)}
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
class MkdirRequest(BaseModel):
|
|
437
|
+
path: str
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
@router.post("/api/mkdir")
|
|
441
|
+
async def create_directory(req: MkdirRequest):
|
|
442
|
+
"""Create a directory if it doesn't exist.
|
|
443
|
+
|
|
444
|
+
Path rides in a JSON body (not a query param) so the request is
|
|
445
|
+
CORS-preflighted — a hostile page can't create dirs with the user's
|
|
446
|
+
ambient cookie via a simple cross-origin POST.
|
|
447
|
+
"""
|
|
448
|
+
path = req.path
|
|
449
|
+
try:
|
|
450
|
+
p = safe_resolve(path)
|
|
451
|
+
if not is_path_allowed(p):
|
|
452
|
+
raise HTTPException(status_code=403, detail=PATH_DENIED_DETAIL)
|
|
453
|
+
if p.exists():
|
|
454
|
+
if p.is_dir():
|
|
455
|
+
return {"path": str(p), "created": False, "message": "Directory already exists"}
|
|
456
|
+
else:
|
|
457
|
+
raise HTTPException(status_code=400, detail="Path exists but is not a directory")
|
|
458
|
+
|
|
459
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
460
|
+
return {"path": str(p), "created": True, "message": "Directory created"}
|
|
461
|
+
except HTTPException:
|
|
462
|
+
raise
|
|
463
|
+
except PermissionError:
|
|
464
|
+
raise HTTPException(status_code=403, detail="Permission denied")
|
|
465
|
+
except Exception as e:
|
|
466
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
470
|
+
# File Search
|
|
471
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
472
|
+
|
|
473
|
+
@router.get("/api/find-file")
|
|
474
|
+
async def find_file(name: str, cwd: str = None, hint: list[str] = Query(default=None)):
|
|
475
|
+
"""
|
|
476
|
+
Find a file by name within the project directory.
|
|
477
|
+
|
|
478
|
+
Resolution is context-aware: repeated `hint` query params carry
|
|
479
|
+
directory candidates from the caller's context (terminal lines around
|
|
480
|
+
the clicked filename, the shell's live cwd) and are tried before any
|
|
481
|
+
heuristic search. The fallback search ranks matches shallowest-first
|
|
482
|
+
(mtime breaks ties) and skips dependency/build trees like node_modules.
|
|
483
|
+
|
|
484
|
+
Args:
|
|
485
|
+
name: Filename to search for (e.g., "git-widget.js")
|
|
486
|
+
cwd: Base directory to search from
|
|
487
|
+
hint: Optional directory hints, most-relevant first
|
|
488
|
+
|
|
489
|
+
Returns:
|
|
490
|
+
- {found: true, path: "/full/path", is_dir: bool} if found
|
|
491
|
+
- {found: false} if not found
|
|
492
|
+
"""
|
|
493
|
+
try:
|
|
494
|
+
base_cwd = cwd or str(Path.cwd())
|
|
495
|
+
resolved = resolve_project_files([name], base_cwd, hint or ()).get(name)
|
|
496
|
+
if resolved:
|
|
497
|
+
return {"found": True, "path": resolved, "is_dir": False}
|
|
498
|
+
# Not a file anywhere — maybe it's a directory (e.g. a clicked
|
|
499
|
+
# `docs-ai/readme` in terminal output). Direct tiers only, no walk.
|
|
500
|
+
resolved_dir = resolve_project_dir(name, base_cwd, hint or ())
|
|
501
|
+
if resolved_dir:
|
|
502
|
+
return {"found": True, "path": resolved_dir, "is_dir": True}
|
|
503
|
+
return {"found": False}
|
|
504
|
+
|
|
505
|
+
except Exception as e:
|
|
506
|
+
return {"found": False, "error": str(e)}
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
@router.post("/api/verify-files")
|
|
510
|
+
async def verify_files(request: Request):
|
|
511
|
+
"""
|
|
512
|
+
Batch verify multiple file paths exist.
|
|
513
|
+
Used by frontend to validate file links after rendering.
|
|
514
|
+
|
|
515
|
+
Request body:
|
|
516
|
+
{
|
|
517
|
+
"files": ["git-panel.c", "server.py", "static/js/app.js"],
|
|
518
|
+
"cwd": "/home/user/dev/project"
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
Returns:
|
|
522
|
+
{
|
|
523
|
+
"results": {
|
|
524
|
+
"git-panel.c": null, // Not found
|
|
525
|
+
"server.py": "/full/path/server.py", // Found
|
|
526
|
+
"static/js/app.js": "/full/path/static/js/app.js" // Found
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
"""
|
|
530
|
+
try:
|
|
531
|
+
body = await request.json()
|
|
532
|
+
files = body.get("files", [])
|
|
533
|
+
cwd = body.get("cwd")
|
|
534
|
+
|
|
535
|
+
if not files:
|
|
536
|
+
return {"results": {}}
|
|
537
|
+
|
|
538
|
+
results = verify_file_paths(set(files), cwd or str(Path.cwd()))
|
|
539
|
+
return {"results": results}
|
|
540
|
+
|
|
541
|
+
except Exception as e:
|
|
542
|
+
return {"error": str(e), "results": {}}
|