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
painapple_code/paths.py
ADDED
|
@@ -0,0 +1,1514 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Paths - Centralized path management for ~/.painapple-code/
|
|
3
|
+
|
|
4
|
+
All Painapple Code metadata is stored in ~/.painapple-code/:
|
|
5
|
+
~/.painapple-code/
|
|
6
|
+
├── config.json # Global settings
|
|
7
|
+
└── projects/
|
|
8
|
+
└── {project-hash}/ # SHA256(absolute path)[:12]
|
|
9
|
+
├── path # Original path for reverse lookup
|
|
10
|
+
├── config.json # Project-specific overrides
|
|
11
|
+
├── shadow-git/ # File recovery repo
|
|
12
|
+
├── sessions/ # Session data
|
|
13
|
+
└── cache/ # Cached data
|
|
14
|
+
|
|
15
|
+
Identity model: one project per local working-copy path. Two clones of the
|
|
16
|
+
same repo at different paths are intentionally separate identities — the
|
|
17
|
+
shadow-git and DuckDB rows for one are not entangled with the other. A
|
|
18
|
+
future widget will let users manually merge/connect related projects.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
import os
|
|
25
|
+
import stat
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Optional
|
|
31
|
+
|
|
32
|
+
from painapple_code import PACKAGE_DIR
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger("painapple-code.paths")
|
|
35
|
+
|
|
36
|
+
# Allow override via environment variable
|
|
37
|
+
DATA_HOME = Path(os.environ.get('PAINAPPLE_CODE_HOME', Path.home() / '.painapple-code'))
|
|
38
|
+
|
|
39
|
+
# Credentials/config home (XDG-style). Holds auth config and OAuth tokens.
|
|
40
|
+
CONFIG_HOME = Path(os.environ.get('PAINAPPLE_CODE_CONFIG', Path.home() / '.config' / 'painapple-code'))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def detect_environment() -> str:
|
|
44
|
+
"""Best-effort detection of the runtime environment.
|
|
45
|
+
|
|
46
|
+
Returns one of: 'codespaces', 'devcontainer', 'kubernetes', 'docker',
|
|
47
|
+
'podman', 'container', 'local'. Used by the login page to show an
|
|
48
|
+
environment-appropriate "reveal password" command.
|
|
49
|
+
|
|
50
|
+
Detection is env-var-only on a normal host. The filesystem probes that tell
|
|
51
|
+
docker from podman are gated behind PAINAPPLE_IN_CONTAINER=1, the marker
|
|
52
|
+
baked into our own image — so on a user's bare-metal machine we never
|
|
53
|
+
stat() /.dockerenv or /run/.containerenv. No surprising filesystem access;
|
|
54
|
+
we only probe once our own marker already says we're containerized.
|
|
55
|
+
|
|
56
|
+
Ordering:
|
|
57
|
+
- Codespaces / dev containers / Kubernetes match first via their definitive
|
|
58
|
+
env vars (they *are* containers; the probes below would swallow them).
|
|
59
|
+
- Then, only inside our image, /.dockerenv vs /run/.containerenv pick the
|
|
60
|
+
engine. They're written at *runtime*, so they distinguish docker from
|
|
61
|
+
podman even though the same image runs on both — a baked ENV cannot.
|
|
62
|
+
Neither present (an exotic runtime) falls back to a generic 'container'.
|
|
63
|
+
- A foreign container without our marker reports 'local' on purpose: the
|
|
64
|
+
bare `awk` we then show works in that container's own shell, whereas a
|
|
65
|
+
host-side `docker exec painapple-code …` would assume our image's name.
|
|
66
|
+
- cgroup parsing (/proc/1/cgroup) is avoided: on cgroup v2 it is just
|
|
67
|
+
"0::/", unreliable on modern/rootless hosts.
|
|
68
|
+
"""
|
|
69
|
+
env = os.environ
|
|
70
|
+
if env.get('CODESPACES') == 'true' or env.get('CODESPACE_NAME'):
|
|
71
|
+
return 'codespaces'
|
|
72
|
+
if env.get('REMOTE_CONTAINERS') == 'true' or env.get('DEVCONTAINER') == 'true':
|
|
73
|
+
return 'devcontainer'
|
|
74
|
+
if env.get('KUBERNETES_SERVICE_HOST'):
|
|
75
|
+
return 'kubernetes'
|
|
76
|
+
# Filesystem probes only when our own image says we're containerized.
|
|
77
|
+
if env.get('PAINAPPLE_IN_CONTAINER') == '1':
|
|
78
|
+
if Path('/.dockerenv').exists():
|
|
79
|
+
return 'docker'
|
|
80
|
+
if Path('/run/.containerenv').exists():
|
|
81
|
+
return 'podman'
|
|
82
|
+
return 'container'
|
|
83
|
+
return 'local'
|
|
84
|
+
|
|
85
|
+
# Per-tier suffix for UI-state files (tab-state, shortcuts, presets, favorites,
|
|
86
|
+
# global config). Empty for the default (stable) tier. Co-located tiers — dev,
|
|
87
|
+
# test — run under the same OS user and $HOME, so without a suffix they'd share
|
|
88
|
+
# all of this state. Set once at startup via the server's --state-suffix flag,
|
|
89
|
+
# mirroring how --shadow-db / --log-dir already isolate the DuckDB and logs.
|
|
90
|
+
# Per-project state (projects/{hash}/...: sessions + shadow-git) deliberately
|
|
91
|
+
# stays shared and is NOT suffixed.
|
|
92
|
+
STATE_SUFFIX = ""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def init_state_suffix(suffix: Optional[str]) -> None:
|
|
96
|
+
"""Set the per-tier UI-state suffix. Call once at startup, before any
|
|
97
|
+
state file is read or written.
|
|
98
|
+
|
|
99
|
+
A leading separator is optional: 'dev' and '-dev' both yield
|
|
100
|
+
tab-state-dev.json. Accepting the bare 'dev' form lets callers write
|
|
101
|
+
`--state-suffix dev` instead of `--state-suffix=-dev` — a plain `-dev`
|
|
102
|
+
value trips argparse, which reads the leading dash as a new option."""
|
|
103
|
+
global STATE_SUFFIX
|
|
104
|
+
if not suffix:
|
|
105
|
+
STATE_SUFFIX = ""
|
|
106
|
+
elif suffix[0] in "-_.":
|
|
107
|
+
STATE_SUFFIX = suffix
|
|
108
|
+
else:
|
|
109
|
+
STATE_SUFFIX = "-" + suffix
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _state_file(stem: str, ext: str = "") -> Path:
|
|
113
|
+
"""DATA_HOME / f'{stem}{STATE_SUFFIX}{ext}' — applies the per-tier suffix
|
|
114
|
+
to a UI-state file or directory name."""
|
|
115
|
+
return DATA_HOME / f"{stem}{STATE_SUFFIX}{ext}"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _path_hash(path: str) -> str:
|
|
119
|
+
"""First 12 hex chars of SHA256 of the resolved absolute path. 48 bits
|
|
120
|
+
of entropy — collisions are not a concern at single-user/single-host
|
|
121
|
+
scale, and short ids stay readable in paths and DB rows."""
|
|
122
|
+
abs_path = str(Path(path).resolve())
|
|
123
|
+
return hashlib.sha256(abs_path.encode()).hexdigest()[:12]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def get_project_hash(project_path: str) -> str:
|
|
127
|
+
"""SHA256 of the resolved absolute project path. Stable per-host."""
|
|
128
|
+
return _path_hash(project_path)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def get_git_repo_hash(project_path: str) -> Optional[str]:
|
|
132
|
+
"""Stable hash identifying the git repository, resolving worktrees.
|
|
133
|
+
|
|
134
|
+
All worktrees of the same repo return the same hash. Non-git directories
|
|
135
|
+
return None.
|
|
136
|
+
"""
|
|
137
|
+
try:
|
|
138
|
+
result = subprocess.run(
|
|
139
|
+
["git", "rev-parse", "--git-common-dir"],
|
|
140
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace", cwd=str(project_path), timeout=5
|
|
141
|
+
)
|
|
142
|
+
if result.returncode != 0:
|
|
143
|
+
return None
|
|
144
|
+
common = (Path(project_path) / result.stdout.strip()).resolve()
|
|
145
|
+
except Exception:
|
|
146
|
+
return None
|
|
147
|
+
return _path_hash(str(common))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def get_project_dir(project_path: str) -> Path:
|
|
151
|
+
"""
|
|
152
|
+
Get the ~/.painapple-code/projects/{hash}/ directory for a project.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
project_path: Project directory path
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Path to project's data directory
|
|
159
|
+
"""
|
|
160
|
+
return DATA_HOME / "projects" / get_project_hash(project_path)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def get_project_dir_by_hash(project_hash: str) -> Path:
|
|
164
|
+
"""
|
|
165
|
+
Get the ~/.painapple-code/projects/{hash}/ directory by hash directly.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
project_hash: SHA256 of the resolved project path
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
Path to project's data directory
|
|
172
|
+
"""
|
|
173
|
+
return DATA_HOME / "projects" / project_hash
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def get_sessions_dir(project_path: str) -> Path:
|
|
177
|
+
"""
|
|
178
|
+
Get sessions directory for a project.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
project_path: Project directory path
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Path to ~/.painapple-code/projects/{hash}/sessions/
|
|
185
|
+
"""
|
|
186
|
+
return get_project_dir(project_path) / "sessions"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def get_shadow_git_dir(project_path: str) -> Path:
|
|
190
|
+
"""
|
|
191
|
+
Get shadow-git directory for a project.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
project_path: Project directory path
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
Path to ~/.painapple-code/projects/{hash}/shadow-git/
|
|
198
|
+
"""
|
|
199
|
+
return get_project_dir(project_path) / "shadow-git"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def get_project_config_path(project_path: str) -> Path:
|
|
203
|
+
"""
|
|
204
|
+
Get project-specific config file path.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
project_path: Project directory path
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
Path to ~/.painapple-code/projects/{hash}/config.json
|
|
211
|
+
"""
|
|
212
|
+
return get_project_dir(project_path) / "config.json"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def get_global_config_path() -> Path:
|
|
216
|
+
"""
|
|
217
|
+
Get global config file path.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
Path to ~/.painapple-code/config{suffix}.json (per-tier)
|
|
221
|
+
"""
|
|
222
|
+
return _state_file("config", ".json")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def get_tab_state_path() -> Path:
|
|
226
|
+
"""Return the per-tier tab-state file path (open tabs + active tab)."""
|
|
227
|
+
return _state_file("tab-state", ".json")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def get_shortcuts_path() -> Path:
|
|
231
|
+
"""Return the per-tier keyboard-shortcut overrides file path."""
|
|
232
|
+
return _state_file("shortcuts", ".json")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def get_presets_dir() -> Path:
|
|
236
|
+
"""Return the per-tier ~/.painapple-code/presets{suffix}/ directory path."""
|
|
237
|
+
return _state_file("presets")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _load_default_presets() -> dict:
|
|
241
|
+
"""Load default presets from presets.defaults.json (ships with the project)."""
|
|
242
|
+
defaults_file = PACKAGE_DIR / "data" / "presets.defaults.json"
|
|
243
|
+
try:
|
|
244
|
+
return json.loads(defaults_file.read_text(encoding="utf-8"))
|
|
245
|
+
except Exception as e:
|
|
246
|
+
logger.error(f"Failed to load {defaults_file}: {e}")
|
|
247
|
+
return {}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def ensure_presets_dir() -> Path:
|
|
251
|
+
"""Ensure ~/.painapple-code/presets/ exists, seed missing defaults from presets.defaults.json."""
|
|
252
|
+
presets_dir = get_presets_dir()
|
|
253
|
+
presets_dir.mkdir(parents=True, exist_ok=True)
|
|
254
|
+
# Seed any missing default presets
|
|
255
|
+
defaults = _load_default_presets()
|
|
256
|
+
seeded = 0
|
|
257
|
+
for preset_id, preset in defaults.items():
|
|
258
|
+
path = presets_dir / f"{preset_id}.json"
|
|
259
|
+
if not path.exists():
|
|
260
|
+
path.write_text(json.dumps(preset, indent=2) + "\n", encoding="utf-8")
|
|
261
|
+
seeded += 1
|
|
262
|
+
if seeded:
|
|
263
|
+
logger.info(f"Seeded {seeded} new default presets in {presets_dir}")
|
|
264
|
+
return presets_dir
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def load_all_presets() -> dict:
|
|
268
|
+
"""Load all presets from ~/.painapple-code/presets/*.json"""
|
|
269
|
+
presets_dir = ensure_presets_dir()
|
|
270
|
+
presets = {}
|
|
271
|
+
for path in sorted(presets_dir.glob("*.json")):
|
|
272
|
+
try:
|
|
273
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
274
|
+
preset_id = path.stem
|
|
275
|
+
presets[preset_id] = data
|
|
276
|
+
except Exception as e:
|
|
277
|
+
logger.warning(f"Failed to load preset {path.name}: {e}")
|
|
278
|
+
return presets
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _preset_file(preset_id: str) -> Path:
|
|
282
|
+
"""Resolve a preset id to its JSON file, contained to the presets dir.
|
|
283
|
+
|
|
284
|
+
`preset_id` reaches the route as a single-segment FastAPI path param, so it
|
|
285
|
+
can't hold a `/` — but that is a POSIX-only guarantee: on Windows a
|
|
286
|
+
backslash survives the `[^/]+` convertor, so `..\\..\\evil` would land
|
|
287
|
+
`<traversal>.json` outside the presets dir on a `write_text`/`unlink`. This
|
|
288
|
+
is the one preset sink the WP-08 audit found with no explicit guard.
|
|
289
|
+
|
|
290
|
+
Validate lexically (reject any separator, traversal, or NUL — checked for
|
|
291
|
+
BOTH separators regardless of host OS, so the POSIX CI catches a
|
|
292
|
+
Windows-only escape) and then confirm containment with `is_relative_to`
|
|
293
|
+
after resolve() as the platform-correct backstop. Raises ValueError on a
|
|
294
|
+
bad id; callers surface it as 400.
|
|
295
|
+
"""
|
|
296
|
+
if (not preset_id
|
|
297
|
+
or preset_id in (".", "..")
|
|
298
|
+
or "/" in preset_id
|
|
299
|
+
or "\\" in preset_id
|
|
300
|
+
or "\x00" in preset_id
|
|
301
|
+
or preset_id != Path(preset_id).name):
|
|
302
|
+
raise ValueError(f"Invalid preset id: {preset_id!r}")
|
|
303
|
+
presets_dir = get_presets_dir()
|
|
304
|
+
resolved = (presets_dir / f"{preset_id}.json").resolve()
|
|
305
|
+
if not resolved.is_relative_to(presets_dir.resolve()):
|
|
306
|
+
raise ValueError(f"Preset id escapes the presets directory: {preset_id!r}")
|
|
307
|
+
return resolved
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def save_preset(preset_id: str, data: dict):
|
|
311
|
+
"""Save a single preset to ~/.painapple-code/presets/{id}.json"""
|
|
312
|
+
ensure_presets_dir()
|
|
313
|
+
path = _preset_file(preset_id)
|
|
314
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def delete_preset(preset_id: str) -> bool:
|
|
318
|
+
"""Delete a preset file. Returns True if deleted."""
|
|
319
|
+
path = _preset_file(preset_id)
|
|
320
|
+
if path.exists():
|
|
321
|
+
path.unlink()
|
|
322
|
+
return True
|
|
323
|
+
return False
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def ensure_data_home() -> Path:
|
|
327
|
+
"""
|
|
328
|
+
Ensure ~/.painapple-code/ exists.
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
Path to ~/.painapple-code/
|
|
332
|
+
"""
|
|
333
|
+
DATA_HOME.mkdir(parents=True, exist_ok=True)
|
|
334
|
+
return DATA_HOME
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def ensure_project_dir(project_path: str) -> Path:
|
|
338
|
+
"""
|
|
339
|
+
Create project directory structure and write path file for reverse lookup.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
project_path: Project directory path
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
Path to the created/existing project directory
|
|
346
|
+
"""
|
|
347
|
+
project_dir = get_project_dir(project_path)
|
|
348
|
+
project_dir.mkdir(parents=True, exist_ok=True)
|
|
349
|
+
|
|
350
|
+
# Write reverse lookup file
|
|
351
|
+
path_file = project_dir / "path"
|
|
352
|
+
abs_path = str(Path(project_path).resolve())
|
|
353
|
+
|
|
354
|
+
if not path_file.exists():
|
|
355
|
+
path_file.write_text(abs_path, encoding="utf-8")
|
|
356
|
+
logger.debug(f"Created project dir: {project_dir} -> {abs_path}")
|
|
357
|
+
elif path_file.read_text(encoding="utf-8").strip() != abs_path:
|
|
358
|
+
# Path changed (shouldn't happen with hash, but handle gracefully)
|
|
359
|
+
logger.warning(f"Project path mismatch: {path_file.read_text(encoding="utf-8").strip()} vs {abs_path}")
|
|
360
|
+
path_file.write_text(abs_path, encoding="utf-8")
|
|
361
|
+
|
|
362
|
+
return project_dir
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def get_project_path_from_hash(project_hash: str) -> Optional[str]:
|
|
366
|
+
"""
|
|
367
|
+
Reverse lookup: get original project path from hash.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
project_hash: SHA256 of the resolved project path
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
Original project path or None if not found
|
|
374
|
+
"""
|
|
375
|
+
path_file = DATA_HOME / "projects" / project_hash / "path"
|
|
376
|
+
if path_file.exists():
|
|
377
|
+
return path_file.read_text(encoding="utf-8").strip()
|
|
378
|
+
return None
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def list_projects(include_unreachable: bool = False) -> list[dict]:
|
|
382
|
+
"""
|
|
383
|
+
List all tracked projects.
|
|
384
|
+
|
|
385
|
+
By default, projects whose original `path` is not a directory on the
|
|
386
|
+
current host are skipped. This matters in Docker: ~/.painapple-code/
|
|
387
|
+
survives across runs but the host paths it references may not be
|
|
388
|
+
mounted into the current container.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
include_unreachable: If True, include projects whose path no longer
|
|
392
|
+
exists. Useful for archaeology/cleanup; the result dict's
|
|
393
|
+
`reachable` field tells callers which ones are missing.
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
List of dicts with: hash, path, sessions_dir, shadow_git_dir,
|
|
397
|
+
session_count, has_sessions, has_shadow_git, reachable.
|
|
398
|
+
"""
|
|
399
|
+
projects_dir = DATA_HOME / "projects"
|
|
400
|
+
if not projects_dir.exists():
|
|
401
|
+
return []
|
|
402
|
+
|
|
403
|
+
result = []
|
|
404
|
+
for hash_dir in projects_dir.iterdir():
|
|
405
|
+
if not hash_dir.is_dir():
|
|
406
|
+
continue
|
|
407
|
+
path_file = hash_dir / "path"
|
|
408
|
+
if not path_file.exists():
|
|
409
|
+
continue
|
|
410
|
+
|
|
411
|
+
project_path = path_file.read_text(encoding="utf-8").strip()
|
|
412
|
+
reachable = Path(project_path).is_dir()
|
|
413
|
+
|
|
414
|
+
if not reachable and not include_unreachable:
|
|
415
|
+
continue
|
|
416
|
+
|
|
417
|
+
sessions_dir = hash_dir / "sessions"
|
|
418
|
+
shadow_git_dir = hash_dir / "shadow-git"
|
|
419
|
+
|
|
420
|
+
# Count sessions
|
|
421
|
+
session_count = 0
|
|
422
|
+
if sessions_dir.exists():
|
|
423
|
+
session_count = sum(1 for d in sessions_dir.iterdir()
|
|
424
|
+
if d.is_dir() and (d / "meta.json").exists())
|
|
425
|
+
|
|
426
|
+
# Custom color, if the user assigned one (read straight from the
|
|
427
|
+
# project's config.json — no global merge needed for a display field).
|
|
428
|
+
custom_color = None
|
|
429
|
+
cfg_file = hash_dir / "config.json"
|
|
430
|
+
if cfg_file.exists():
|
|
431
|
+
try:
|
|
432
|
+
cfg = json.loads(cfg_file.read_text(encoding="utf-8"))
|
|
433
|
+
custom_color = _normalize_project_color(
|
|
434
|
+
cfg.get("display", {}).get("color")
|
|
435
|
+
)
|
|
436
|
+
except Exception:
|
|
437
|
+
pass
|
|
438
|
+
|
|
439
|
+
result.append({
|
|
440
|
+
"hash": hash_dir.name,
|
|
441
|
+
"path": project_path,
|
|
442
|
+
"sessions_dir": str(sessions_dir),
|
|
443
|
+
"shadow_git_dir": str(shadow_git_dir),
|
|
444
|
+
"session_count": session_count,
|
|
445
|
+
"has_sessions": sessions_dir.exists(),
|
|
446
|
+
"has_shadow_git": shadow_git_dir.exists(),
|
|
447
|
+
"reachable": reachable,
|
|
448
|
+
"color": custom_color,
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
return result
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
# Don't auto-scan these dangerous parents — they're almost never what the user
|
|
455
|
+
# means by "workspace folder" and would surface huge noisy lists.
|
|
456
|
+
_WORKSPACE_ROOT_DENYLIST = {"/", "/home", "/Users", "/root", "/mnt", "/srv"}
|
|
457
|
+
|
|
458
|
+
# Windows equivalents. The exact-string set above can't cover these: a
|
|
459
|
+
# drive root is "C:\\" (any letter) and the user container is
|
|
460
|
+
# "C:\\Users" — so a `--workspace C:\` would have enumerated every
|
|
461
|
+
# top-level directory into the welcome screen, exactly what this guard
|
|
462
|
+
# exists to prevent.
|
|
463
|
+
_WIN_DENY_NAMES = {"users", "windows", "program files", "program files (x86)",
|
|
464
|
+
"programdata"}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _is_denied_workspace_root(root_path: Path) -> bool:
|
|
468
|
+
if str(root_path) in _WORKSPACE_ROOT_DENYLIST:
|
|
469
|
+
return True
|
|
470
|
+
if sys.platform != "win32":
|
|
471
|
+
return False
|
|
472
|
+
# A drive or UNC root is its own parent.
|
|
473
|
+
if root_path.parent == root_path:
|
|
474
|
+
return True
|
|
475
|
+
return (root_path.parent == Path(root_path.anchor)
|
|
476
|
+
and root_path.name.lower() in _WIN_DENY_NAMES)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
_PROJECT_MARKERS = (
|
|
480
|
+
".git", "package.json", "pyproject.toml", "Cargo.toml", "go.mod",
|
|
481
|
+
"setup.py", "requirements.txt", "Gemfile", "pom.xml", "build.gradle",
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _looks_like_project(child: Path) -> bool:
|
|
486
|
+
"""Heuristic: does this dir look like the root of a project?
|
|
487
|
+
|
|
488
|
+
Cheap signal — any common manifest at the top level. .git matches both
|
|
489
|
+
a normal repo (dir) and a git worktree (file pointing at the main repo).
|
|
490
|
+
"""
|
|
491
|
+
for marker in _PROJECT_MARKERS:
|
|
492
|
+
if (child / marker).exists():
|
|
493
|
+
return True
|
|
494
|
+
return False
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def list_workspace_dirs(root: str, exclude_paths: Optional[list[str]] = None,
|
|
498
|
+
limit: int = 100) -> list[dict]:
|
|
499
|
+
"""List immediate sub-directories of a workspace root, excluding ones
|
|
500
|
+
that are already tracked projects.
|
|
501
|
+
|
|
502
|
+
Used by the welcome screen to surface sibling folders the user hasn't
|
|
503
|
+
started a session in yet. Sorted with project-looking dirs (anything
|
|
504
|
+
with a top-level .git, package.json, pyproject.toml, …) first, then
|
|
505
|
+
by directory mtime descending so recently touched folders bubble up.
|
|
506
|
+
|
|
507
|
+
Hidden directories (leading dot) and symlinks are skipped. The
|
|
508
|
+
`_WORKSPACE_ROOT_DENYLIST` short-circuits dangerous roots like `/` so
|
|
509
|
+
a misconfigured `--workspace` can't surface every top-level dir.
|
|
510
|
+
|
|
511
|
+
Args:
|
|
512
|
+
root: Absolute path to scan.
|
|
513
|
+
exclude_paths: Resolved absolute paths to skip (e.g. existing
|
|
514
|
+
project paths from list_projects()).
|
|
515
|
+
limit: Max entries to return.
|
|
516
|
+
|
|
517
|
+
Returns:
|
|
518
|
+
List of {path, name, mtime, looks_like_project} sorted with
|
|
519
|
+
project-looking dirs first, then by mtime descending.
|
|
520
|
+
"""
|
|
521
|
+
try:
|
|
522
|
+
root_path = Path(root).expanduser().resolve()
|
|
523
|
+
except (OSError, RuntimeError):
|
|
524
|
+
return []
|
|
525
|
+
|
|
526
|
+
if not root_path.is_dir() or _is_denied_workspace_root(root_path):
|
|
527
|
+
return []
|
|
528
|
+
|
|
529
|
+
exclude_set: set[str] = set()
|
|
530
|
+
for p in exclude_paths or []:
|
|
531
|
+
try:
|
|
532
|
+
exclude_set.add(str(Path(p).expanduser().resolve()))
|
|
533
|
+
except (OSError, RuntimeError):
|
|
534
|
+
continue
|
|
535
|
+
|
|
536
|
+
candidates = []
|
|
537
|
+
try:
|
|
538
|
+
entries = list(root_path.iterdir())
|
|
539
|
+
except (OSError, PermissionError):
|
|
540
|
+
return []
|
|
541
|
+
|
|
542
|
+
for child in entries:
|
|
543
|
+
if child.name.startswith("."):
|
|
544
|
+
continue
|
|
545
|
+
# is_dir() follows symlinks; is_symlink() lets us reject those first.
|
|
546
|
+
if child.is_symlink() or not child.is_dir():
|
|
547
|
+
continue
|
|
548
|
+
try:
|
|
549
|
+
resolved = str(child.resolve())
|
|
550
|
+
except (OSError, RuntimeError):
|
|
551
|
+
continue
|
|
552
|
+
if resolved in exclude_set:
|
|
553
|
+
continue
|
|
554
|
+
try:
|
|
555
|
+
mtime = child.stat().st_mtime
|
|
556
|
+
except OSError:
|
|
557
|
+
continue
|
|
558
|
+
candidates.append({
|
|
559
|
+
"path": resolved,
|
|
560
|
+
"name": child.name,
|
|
561
|
+
"mtime": mtime,
|
|
562
|
+
"looks_like_project": _looks_like_project(child),
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
# Project-looking dirs first (True > False), then most-recently-touched.
|
|
566
|
+
candidates.sort(key=lambda x: (x["looks_like_project"], x["mtime"]), reverse=True)
|
|
567
|
+
return candidates[:limit]
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def load_global_config() -> dict:
|
|
571
|
+
"""
|
|
572
|
+
Load global config from ~/.painapple-code/config.json.
|
|
573
|
+
|
|
574
|
+
Returns:
|
|
575
|
+
Config dict (empty dict if file doesn't exist)
|
|
576
|
+
"""
|
|
577
|
+
config_path = get_global_config_path()
|
|
578
|
+
if config_path.exists():
|
|
579
|
+
try:
|
|
580
|
+
return json.loads(config_path.read_text(encoding="utf-8"))
|
|
581
|
+
except Exception as e:
|
|
582
|
+
logger.error(f"Failed to load global config: {e}")
|
|
583
|
+
return {}
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def save_global_config(config: dict):
|
|
587
|
+
"""
|
|
588
|
+
Save global config to ~/.painapple-code/config.json.
|
|
589
|
+
|
|
590
|
+
Args:
|
|
591
|
+
config: Config dict to save
|
|
592
|
+
"""
|
|
593
|
+
ensure_data_home()
|
|
594
|
+
config_path = get_global_config_path()
|
|
595
|
+
try:
|
|
596
|
+
config_path.write_text(json.dumps(config, indent=2), encoding="utf-8")
|
|
597
|
+
except Exception as e:
|
|
598
|
+
logger.error(f"Failed to save global config: {e}")
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
# ─── Shadow-git-helper subagent model ─────────────────────────────────────────
|
|
602
|
+
|
|
603
|
+
# The Claude Code subagent `model:` frontmatter field accepts "inherit" (reuse
|
|
604
|
+
# the main thread's model) or a full model ID — the real `claude -p` CLI honors
|
|
605
|
+
# full IDs (verified: a subagent pinned to claude-opus-4-8 ran on opus while the
|
|
606
|
+
# main thread ran Fable). So we offer the same picks as the main model selector
|
|
607
|
+
# (models.yaml `selectable`) plus "inherit", rather than the tier aliases —
|
|
608
|
+
# that's the only way to reach models like Fable, which has no `fable` alias.
|
|
609
|
+
# Persisted in the global config so it survives helper reinstalls
|
|
610
|
+
# (install-helpers.sh cp -f's the bundled default over the top; we re-apply this).
|
|
611
|
+
HELPER_AGENT_INHERIT = "inherit"
|
|
612
|
+
DEFAULT_HELPER_AGENT_MODEL = "claude-sonnet-5"
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def get_helper_agent_options() -> list[str]:
|
|
616
|
+
"""Valid subagent-model picks: 'inherit' + the selectable model IDs."""
|
|
617
|
+
ids = [m.get("id") for m in get_selectable_models() if m.get("id")]
|
|
618
|
+
return [HELPER_AGENT_INHERIT] + ids
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def get_helper_agent_model() -> str:
|
|
622
|
+
"""Return the configured shadow-git-helper subagent model (full ID or 'inherit').
|
|
623
|
+
|
|
624
|
+
Reads leniently — a value stored before a model was removed from the
|
|
625
|
+
selectable list is still honored; only writes are validated.
|
|
626
|
+
"""
|
|
627
|
+
cfg = load_global_config()
|
|
628
|
+
val = str(cfg.get("helper_agent_model", "") or "").strip()
|
|
629
|
+
return val or DEFAULT_HELPER_AGENT_MODEL
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def set_helper_agent_model(model: str) -> str:
|
|
633
|
+
"""Persist the shadow-git-helper subagent model.
|
|
634
|
+
|
|
635
|
+
Accepts 'inherit' or a model ID present in the selectable list. Returns the
|
|
636
|
+
normalized value. Raises ValueError for anything else.
|
|
637
|
+
"""
|
|
638
|
+
val = str(model or "").strip()
|
|
639
|
+
options = get_helper_agent_options()
|
|
640
|
+
if val not in options:
|
|
641
|
+
raise ValueError(
|
|
642
|
+
f"Invalid helper agent model {model!r}; expected one of {options}"
|
|
643
|
+
)
|
|
644
|
+
cfg = load_global_config()
|
|
645
|
+
cfg["helper_agent_model"] = val
|
|
646
|
+
save_global_config(cfg)
|
|
647
|
+
return val
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
# ─── Models config (models.yaml) ──────────────────────────────────────────────
|
|
651
|
+
|
|
652
|
+
_models_cache: dict = {}
|
|
653
|
+
_models_mtime: float = 0.0
|
|
654
|
+
|
|
655
|
+
def load_models_config() -> dict:
|
|
656
|
+
"""Load models.yaml from package data. Cached by file mtime."""
|
|
657
|
+
global _models_cache, _models_mtime
|
|
658
|
+
yaml_path = PACKAGE_DIR / "data" / "models.yaml"
|
|
659
|
+
if not yaml_path.exists():
|
|
660
|
+
return {"selectable": [], "summary_model": "claude-haiku-4-5"}
|
|
661
|
+
mtime = yaml_path.stat().st_mtime
|
|
662
|
+
if mtime == _models_mtime and _models_cache:
|
|
663
|
+
return _models_cache
|
|
664
|
+
import yaml
|
|
665
|
+
with open(yaml_path, encoding="utf-8") as f:
|
|
666
|
+
_models_cache = yaml.safe_load(f) or {}
|
|
667
|
+
_models_mtime = mtime
|
|
668
|
+
return _models_cache
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def get_summary_model() -> str:
|
|
672
|
+
"""Model ID for shadow-git summary / auto-journal forks (Claude → Haiku)."""
|
|
673
|
+
cfg = load_models_config()
|
|
674
|
+
# Back-compat: the models.yaml key was `haiku:` before the rename.
|
|
675
|
+
return cfg.get("summary_model") or cfg.get("haiku") or "claude-haiku-4-5"
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def get_selectable_models() -> list:
|
|
679
|
+
"""Get the list of user-selectable models."""
|
|
680
|
+
return load_models_config().get("selectable", [])
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def get_default_models_config() -> dict:
|
|
684
|
+
"""Load the pristine default model list from models.defaults.yaml."""
|
|
685
|
+
yaml_path = PACKAGE_DIR / "data" / "models.defaults.yaml"
|
|
686
|
+
if not yaml_path.exists():
|
|
687
|
+
return {"selectable": [], "summary_model": "claude-haiku-4-5"}
|
|
688
|
+
import yaml
|
|
689
|
+
with open(yaml_path, encoding="utf-8") as f:
|
|
690
|
+
return yaml.safe_load(f) or {}
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def save_models_config(config: dict) -> None:
|
|
694
|
+
"""Write models.yaml with the given config. Invalidates the cache."""
|
|
695
|
+
global _models_cache, _models_mtime
|
|
696
|
+
import yaml
|
|
697
|
+
yaml_path = PACKAGE_DIR / "data" / "models.yaml"
|
|
698
|
+
header = (
|
|
699
|
+
"# models.yaml - Available Claude models (single source of truth)\n"
|
|
700
|
+
"# Server serves this via GET /api/app/models\n"
|
|
701
|
+
"# Edit via Settings → Engines (Claude tab), or by hand (both work).\n\n"
|
|
702
|
+
)
|
|
703
|
+
body = yaml.safe_dump(config, sort_keys=False, allow_unicode=True)
|
|
704
|
+
yaml_path.write_text(header + body, encoding="utf-8")
|
|
705
|
+
# Invalidate cache so next read picks up the change
|
|
706
|
+
_models_cache = {}
|
|
707
|
+
_models_mtime = 0.0
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
# ─── Per-engine new-session defaults ──────────────────────────────────────────
|
|
711
|
+
#
|
|
712
|
+
# Session defaults are per ENGINE: each map is keyed by the provider's
|
|
713
|
+
# `models_key` namespace (driver pairs share — same convention as
|
|
714
|
+
# `models_disabled`). The flat legacy keys (`default_model`, `default_effort`,
|
|
715
|
+
# `default_token_profile`) remain as read fallbacks for configs written before
|
|
716
|
+
# the split; the engine-defaults PUT migrates them into the maps on first
|
|
717
|
+
# write and drops them.
|
|
718
|
+
|
|
719
|
+
def _engine_scoped_default(map_key: str, legacy_key: str, provider):
|
|
720
|
+
cfg = load_global_config()
|
|
721
|
+
scoped_map = cfg.get(map_key)
|
|
722
|
+
ns = getattr(provider, "models_key", None) or provider.name
|
|
723
|
+
scoped = scoped_map.get(ns) if isinstance(scoped_map, dict) else None
|
|
724
|
+
if isinstance(scoped, str) and scoped:
|
|
725
|
+
return scoped
|
|
726
|
+
legacy = cfg.get(legacy_key)
|
|
727
|
+
return legacy if isinstance(legacy, str) and legacy else None
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def engine_default_model(provider):
|
|
731
|
+
"""The engine's default new-session model — always CONCRETE when the
|
|
732
|
+
engine has a catalog.
|
|
733
|
+
|
|
734
|
+
Resolution: per-engine map → legacy app-wide flat → **the engine's top
|
|
735
|
+
enabled catalog model** (priority-first). The app owns the default; we no
|
|
736
|
+
longer defer to the engine's own CLI config (the "Default" placeholder is
|
|
737
|
+
gone). A configured value the engine can't serve — a legacy Claude flat
|
|
738
|
+
key read by Codex, or a Settings-hidden id — is treated as unset and
|
|
739
|
+
falls through to the top catalog model, so every engine with a catalog
|
|
740
|
+
presents a real, in-catalog default.
|
|
741
|
+
|
|
742
|
+
Membership is prefix-matched against `enabled_models()` (same as
|
|
743
|
+
`preferred_model_survives`; catalog ids are the short canonical form,
|
|
744
|
+
stored ids may carry date suffixes). Returns None only for an engine with
|
|
745
|
+
NO catalog and no configured value — then the launch path lets the CLI
|
|
746
|
+
use its own configured model (e.g. Codex with no models cache)."""
|
|
747
|
+
value = _engine_scoped_default("default_models", "default_model", provider)
|
|
748
|
+
enabled_ids = [m.get("id") for m in provider.enabled_models() if m.get("id")]
|
|
749
|
+
if not enabled_ids:
|
|
750
|
+
# No catalog to validate against — trust the value; launch decides.
|
|
751
|
+
return value or None
|
|
752
|
+
if value and any(value.startswith(mid) for mid in enabled_ids):
|
|
753
|
+
return value
|
|
754
|
+
# Unset / foreign / hidden → the engine's top (highest-priority) model.
|
|
755
|
+
return enabled_ids[0]
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def engine_default_effort(provider):
|
|
759
|
+
"""The engine's configured default effort, gated to its own vocabulary —
|
|
760
|
+
a legacy `max` must not leak into an engine that caps at `high`."""
|
|
761
|
+
value = _engine_scoped_default("default_efforts", "default_effort", provider)
|
|
762
|
+
levels = provider.effort_levels() or []
|
|
763
|
+
if value and levels and value not in levels:
|
|
764
|
+
return None
|
|
765
|
+
return value
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def engine_default_token_profile(provider):
|
|
769
|
+
"""The engine's configured default account/token profile. None for
|
|
770
|
+
engines without selectable accounts (profiles are meaningless there)."""
|
|
771
|
+
if not provider.accounts():
|
|
772
|
+
return None
|
|
773
|
+
return _engine_scoped_default(
|
|
774
|
+
"default_token_profiles", "default_token_profile", provider)
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def get_shadow_git_defaults(global_config: dict = None) -> dict:
|
|
778
|
+
"""
|
|
779
|
+
Get shadow git defaults from global config.
|
|
780
|
+
|
|
781
|
+
Args:
|
|
782
|
+
global_config: Optional pre-loaded global config
|
|
783
|
+
|
|
784
|
+
Returns:
|
|
785
|
+
Dict with enabled and rich_commits defaults
|
|
786
|
+
"""
|
|
787
|
+
if global_config is None:
|
|
788
|
+
global_config = load_global_config()
|
|
789
|
+
|
|
790
|
+
defaults = global_config.get("shadow_git_defaults", {})
|
|
791
|
+
return {
|
|
792
|
+
"enabled": defaults.get("enabled", True),
|
|
793
|
+
"rich_commits": defaults.get("rich_commits", True),
|
|
794
|
+
# Files larger than this (MB) are never committed to shadow git;
|
|
795
|
+
# 0 disables the cap. Overridable per project via shadow_git config.
|
|
796
|
+
"max_file_size_mb": defaults.get("max_file_size_mb", 50),
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def load_project_config(project_path: str) -> dict:
|
|
801
|
+
"""
|
|
802
|
+
Load project-specific config, merged with global config.
|
|
803
|
+
Project config overrides global config.
|
|
804
|
+
|
|
805
|
+
For shadow_git settings, applies defaults from shadow_git_defaults
|
|
806
|
+
in global config, then project-specific overrides.
|
|
807
|
+
|
|
808
|
+
Args:
|
|
809
|
+
project_path: Project directory path
|
|
810
|
+
|
|
811
|
+
Returns:
|
|
812
|
+
Merged config dict with shadow_git settings resolved
|
|
813
|
+
"""
|
|
814
|
+
global_config = load_global_config()
|
|
815
|
+
|
|
816
|
+
# Start with global config (excluding shadow_git_defaults which is meta)
|
|
817
|
+
result = {k: v for k, v in global_config.items() if k != "shadow_git_defaults"}
|
|
818
|
+
|
|
819
|
+
# Apply shadow_git defaults
|
|
820
|
+
sg_defaults = get_shadow_git_defaults(global_config)
|
|
821
|
+
result["shadow_git"] = sg_defaults.copy()
|
|
822
|
+
|
|
823
|
+
# Load project-specific config
|
|
824
|
+
project_config_path = get_project_config_path(project_path)
|
|
825
|
+
if project_config_path.exists():
|
|
826
|
+
try:
|
|
827
|
+
project_config = json.loads(project_config_path.read_text(encoding="utf-8"))
|
|
828
|
+
|
|
829
|
+
# Deep merge shadow_git if present
|
|
830
|
+
if "shadow_git" in project_config:
|
|
831
|
+
result["shadow_git"].update(project_config["shadow_git"])
|
|
832
|
+
|
|
833
|
+
# Merge other project config (overrides global)
|
|
834
|
+
for k, v in project_config.items():
|
|
835
|
+
if k != "shadow_git":
|
|
836
|
+
result[k] = v
|
|
837
|
+
|
|
838
|
+
except Exception as e:
|
|
839
|
+
logger.error(f"Failed to load project config: {e}")
|
|
840
|
+
|
|
841
|
+
return result
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def save_project_config(project_path: str, config: dict):
|
|
845
|
+
"""
|
|
846
|
+
Save project-specific config.
|
|
847
|
+
|
|
848
|
+
Args:
|
|
849
|
+
project_path: Project directory path
|
|
850
|
+
config: Config dict to save
|
|
851
|
+
"""
|
|
852
|
+
ensure_project_dir(project_path)
|
|
853
|
+
config_path = get_project_config_path(project_path)
|
|
854
|
+
try:
|
|
855
|
+
config_path.write_text(json.dumps(config, indent=2), encoding="utf-8")
|
|
856
|
+
except Exception as e:
|
|
857
|
+
logger.error(f"Failed to save project config: {e}")
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def get_project_display_name(project_path: str) -> str:
|
|
861
|
+
"""
|
|
862
|
+
Get human-friendly display name for a project.
|
|
863
|
+
|
|
864
|
+
Returns custom name from config if set, otherwise directory name.
|
|
865
|
+
|
|
866
|
+
Args:
|
|
867
|
+
project_path: Project directory path
|
|
868
|
+
|
|
869
|
+
Returns:
|
|
870
|
+
Display name string
|
|
871
|
+
"""
|
|
872
|
+
config = load_project_config(project_path)
|
|
873
|
+
custom_name = config.get("display", {}).get("name")
|
|
874
|
+
if custom_name:
|
|
875
|
+
return custom_name
|
|
876
|
+
return Path(project_path).name
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def set_project_display_name(project_path: str, name: str):
|
|
880
|
+
"""
|
|
881
|
+
Set human-friendly display name for a project.
|
|
882
|
+
|
|
883
|
+
Args:
|
|
884
|
+
project_path: Project directory path
|
|
885
|
+
name: Display name (empty string to clear)
|
|
886
|
+
"""
|
|
887
|
+
config_path = get_project_config_path(project_path)
|
|
888
|
+
|
|
889
|
+
# Load existing config
|
|
890
|
+
existing = {}
|
|
891
|
+
if config_path.exists():
|
|
892
|
+
try:
|
|
893
|
+
existing = json.loads(config_path.read_text(encoding="utf-8"))
|
|
894
|
+
except Exception:
|
|
895
|
+
pass
|
|
896
|
+
|
|
897
|
+
# Update display.name
|
|
898
|
+
if "display" not in existing:
|
|
899
|
+
existing["display"] = {}
|
|
900
|
+
|
|
901
|
+
if name:
|
|
902
|
+
existing["display"]["name"] = name
|
|
903
|
+
else:
|
|
904
|
+
existing["display"].pop("name", None)
|
|
905
|
+
# Clean up empty display dict
|
|
906
|
+
if not existing["display"]:
|
|
907
|
+
existing.pop("display", None)
|
|
908
|
+
|
|
909
|
+
save_project_config(project_path, existing)
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def _normalize_project_color(color: str) -> Optional[str]:
|
|
913
|
+
"""Validate + normalize a project color to a #rrggbb hex string.
|
|
914
|
+
|
|
915
|
+
Accepts 3- or 6-digit hex with or without a leading '#'. Returns the
|
|
916
|
+
lowercased #rrggbb form, or None if the value is empty/invalid (so
|
|
917
|
+
callers can treat "invalid" the same as "clear").
|
|
918
|
+
"""
|
|
919
|
+
if not color:
|
|
920
|
+
return None
|
|
921
|
+
c = str(color).strip().lstrip("#").lower()
|
|
922
|
+
if len(c) == 3 and all(ch in "0123456789abcdef" for ch in c):
|
|
923
|
+
c = "".join(ch * 2 for ch in c)
|
|
924
|
+
if len(c) == 6 and all(ch in "0123456789abcdef" for ch in c):
|
|
925
|
+
return f"#{c}"
|
|
926
|
+
return None
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def get_project_color(project_path: str) -> Optional[str]:
|
|
930
|
+
"""Get the user-assigned custom color for a project.
|
|
931
|
+
|
|
932
|
+
Returns a normalized #rrggbb hex string, or None when no custom color
|
|
933
|
+
is set (callers fall back to the deterministic client-side hash color).
|
|
934
|
+
"""
|
|
935
|
+
config = load_project_config(project_path)
|
|
936
|
+
return _normalize_project_color(config.get("display", {}).get("color"))
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def set_project_color(project_path: str, color: str) -> Optional[str]:
|
|
940
|
+
"""Set (or clear) the custom color for a project.
|
|
941
|
+
|
|
942
|
+
Stores under ``display.color`` to mirror ``display.name``. Pass an empty
|
|
943
|
+
string / invalid value to clear the override. Returns the normalized
|
|
944
|
+
color that was stored, or None when cleared.
|
|
945
|
+
"""
|
|
946
|
+
config_path = get_project_config_path(project_path)
|
|
947
|
+
|
|
948
|
+
existing = {}
|
|
949
|
+
if config_path.exists():
|
|
950
|
+
try:
|
|
951
|
+
existing = json.loads(config_path.read_text(encoding="utf-8"))
|
|
952
|
+
except Exception:
|
|
953
|
+
pass
|
|
954
|
+
|
|
955
|
+
if "display" not in existing:
|
|
956
|
+
existing["display"] = {}
|
|
957
|
+
|
|
958
|
+
normalized = _normalize_project_color(color)
|
|
959
|
+
if normalized:
|
|
960
|
+
existing["display"]["color"] = normalized
|
|
961
|
+
else:
|
|
962
|
+
existing["display"].pop("color", None)
|
|
963
|
+
|
|
964
|
+
# Clean up an empty display dict so config stays tidy.
|
|
965
|
+
if not existing.get("display"):
|
|
966
|
+
existing.pop("display", None)
|
|
967
|
+
|
|
968
|
+
save_project_config(project_path, existing)
|
|
969
|
+
return normalized
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def list_project_colors() -> dict:
|
|
973
|
+
"""Map of project path → custom color for every project that has one set.
|
|
974
|
+
|
|
975
|
+
Powers a single client-side fetch so the synchronous ``getProjectColor``
|
|
976
|
+
lookups across the UI can consult overrides without per-project requests.
|
|
977
|
+
"""
|
|
978
|
+
return {
|
|
979
|
+
p["path"]: p["color"]
|
|
980
|
+
for p in list_projects(include_unreachable=True)
|
|
981
|
+
if p.get("color")
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
986
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
987
|
+
# Favorites System
|
|
988
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
989
|
+
|
|
990
|
+
def get_favorites_path() -> Path:
|
|
991
|
+
"""
|
|
992
|
+
Get favorites file path.
|
|
993
|
+
|
|
994
|
+
Returns:
|
|
995
|
+
Path to ~/.painapple-code/favorites{suffix}.json (per-tier)
|
|
996
|
+
"""
|
|
997
|
+
return _state_file("favorites", ".json")
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def get_drafts_path() -> Path:
|
|
1001
|
+
"""
|
|
1002
|
+
Get prompt-drafts file path (explicit "save for later" prompts).
|
|
1003
|
+
|
|
1004
|
+
Returns:
|
|
1005
|
+
Path to ~/.painapple-code/drafts{suffix}.json (per-tier)
|
|
1006
|
+
"""
|
|
1007
|
+
return _state_file("drafts", ".json")
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def load_drafts() -> dict:
|
|
1011
|
+
"""
|
|
1012
|
+
Load prompt drafts from ~/.painapple-code/drafts.json.
|
|
1013
|
+
|
|
1014
|
+
Returns:
|
|
1015
|
+
Dict with version and drafts list (newest first)
|
|
1016
|
+
"""
|
|
1017
|
+
drafts_path = get_drafts_path()
|
|
1018
|
+
if drafts_path.exists():
|
|
1019
|
+
try:
|
|
1020
|
+
return json.loads(drafts_path.read_text(encoding="utf-8"))
|
|
1021
|
+
except Exception as e:
|
|
1022
|
+
logger.error(f"Failed to load drafts: {e}")
|
|
1023
|
+
return {"version": 1, "drafts": []}
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def save_drafts(data: dict) -> bool:
|
|
1027
|
+
"""
|
|
1028
|
+
Save prompt drafts to ~/.painapple-code/drafts.json.
|
|
1029
|
+
|
|
1030
|
+
Args:
|
|
1031
|
+
data: Drafts dict with version and drafts list
|
|
1032
|
+
|
|
1033
|
+
Returns:
|
|
1034
|
+
True on success (drafts are user data — callers should surface failure)
|
|
1035
|
+
"""
|
|
1036
|
+
ensure_data_home()
|
|
1037
|
+
drafts_path = get_drafts_path()
|
|
1038
|
+
try:
|
|
1039
|
+
drafts_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
1040
|
+
return True
|
|
1041
|
+
except Exception as e:
|
|
1042
|
+
logger.error(f"Failed to save drafts: {e}")
|
|
1043
|
+
return False
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def load_favorites() -> dict:
|
|
1047
|
+
"""
|
|
1048
|
+
Load favorites from ~/.painapple-code/favorites.json.
|
|
1049
|
+
|
|
1050
|
+
Returns:
|
|
1051
|
+
Dict with version and favorites list
|
|
1052
|
+
"""
|
|
1053
|
+
fav_path = get_favorites_path()
|
|
1054
|
+
if fav_path.exists():
|
|
1055
|
+
try:
|
|
1056
|
+
return json.loads(fav_path.read_text(encoding="utf-8"))
|
|
1057
|
+
except Exception as e:
|
|
1058
|
+
logger.error(f"Failed to load favorites: {e}")
|
|
1059
|
+
return {"version": 1, "favorites": []}
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def save_favorites(data: dict):
|
|
1063
|
+
"""
|
|
1064
|
+
Save favorites to ~/.painapple-code/favorites.json.
|
|
1065
|
+
|
|
1066
|
+
Args:
|
|
1067
|
+
data: Favorites dict with version and favorites list
|
|
1068
|
+
"""
|
|
1069
|
+
ensure_data_home()
|
|
1070
|
+
fav_path = get_favorites_path()
|
|
1071
|
+
try:
|
|
1072
|
+
fav_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
1073
|
+
except Exception as e:
|
|
1074
|
+
logger.error(f"Failed to save favorites: {e}")
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def add_favorite(session_id: str, project_hash: str, note: str = None) -> bool:
|
|
1078
|
+
"""
|
|
1079
|
+
Add a session to favorites.
|
|
1080
|
+
|
|
1081
|
+
Args:
|
|
1082
|
+
session_id: Session ID to favorite
|
|
1083
|
+
project_hash: Project hash the session belongs to
|
|
1084
|
+
note: Optional note explaining why it's favorited
|
|
1085
|
+
|
|
1086
|
+
Returns:
|
|
1087
|
+
True if added, False if already exists
|
|
1088
|
+
"""
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
data = load_favorites()
|
|
1092
|
+
favorites = data.get("favorites", [])
|
|
1093
|
+
|
|
1094
|
+
# Check if already favorited
|
|
1095
|
+
for fav in favorites:
|
|
1096
|
+
if fav["session_id"] == session_id:
|
|
1097
|
+
return False
|
|
1098
|
+
|
|
1099
|
+
# Add new favorite
|
|
1100
|
+
favorites.append({
|
|
1101
|
+
"session_id": session_id,
|
|
1102
|
+
"project_hash": project_hash,
|
|
1103
|
+
"note": note,
|
|
1104
|
+
"added_at": datetime.now(timezone.utc).isoformat(),
|
|
1105
|
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
1106
|
+
})
|
|
1107
|
+
|
|
1108
|
+
data["favorites"] = favorites
|
|
1109
|
+
save_favorites(data)
|
|
1110
|
+
return True
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
def remove_favorite(session_id: str) -> bool:
|
|
1114
|
+
"""
|
|
1115
|
+
Remove a session from favorites.
|
|
1116
|
+
|
|
1117
|
+
Args:
|
|
1118
|
+
session_id: Session ID to unfavorite
|
|
1119
|
+
|
|
1120
|
+
Returns:
|
|
1121
|
+
True if removed, False if not found
|
|
1122
|
+
"""
|
|
1123
|
+
data = load_favorites()
|
|
1124
|
+
favorites = data.get("favorites", [])
|
|
1125
|
+
|
|
1126
|
+
original_len = len(favorites)
|
|
1127
|
+
favorites = [f for f in favorites if f["session_id"] != session_id]
|
|
1128
|
+
|
|
1129
|
+
if len(favorites) == original_len:
|
|
1130
|
+
return False
|
|
1131
|
+
|
|
1132
|
+
data["favorites"] = favorites
|
|
1133
|
+
save_favorites(data)
|
|
1134
|
+
return True
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def update_favorite_note(session_id: str, note: str) -> bool:
|
|
1138
|
+
"""
|
|
1139
|
+
Update the note on a favorite.
|
|
1140
|
+
|
|
1141
|
+
Args:
|
|
1142
|
+
session_id: Session ID to update
|
|
1143
|
+
note: New note (can be empty string to clear)
|
|
1144
|
+
|
|
1145
|
+
Returns:
|
|
1146
|
+
True if updated, False if not found
|
|
1147
|
+
"""
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
data = load_favorites()
|
|
1151
|
+
favorites = data.get("favorites", [])
|
|
1152
|
+
|
|
1153
|
+
for fav in favorites:
|
|
1154
|
+
if fav["session_id"] == session_id:
|
|
1155
|
+
fav["note"] = note
|
|
1156
|
+
fav["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
1157
|
+
data["favorites"] = favorites
|
|
1158
|
+
save_favorites(data)
|
|
1159
|
+
return True
|
|
1160
|
+
|
|
1161
|
+
return False
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
def is_favorite(session_id: str) -> bool:
|
|
1165
|
+
"""
|
|
1166
|
+
Check if a session is favorited.
|
|
1167
|
+
|
|
1168
|
+
Args:
|
|
1169
|
+
session_id: Session ID to check
|
|
1170
|
+
|
|
1171
|
+
Returns:
|
|
1172
|
+
True if favorited, False otherwise
|
|
1173
|
+
"""
|
|
1174
|
+
data = load_favorites()
|
|
1175
|
+
favorites = data.get("favorites", [])
|
|
1176
|
+
return any(f["session_id"] == session_id for f in favorites)
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def get_favorite(session_id: str) -> Optional[dict]:
|
|
1180
|
+
"""
|
|
1181
|
+
Get favorite data for a session.
|
|
1182
|
+
|
|
1183
|
+
Args:
|
|
1184
|
+
session_id: Session ID to look up
|
|
1185
|
+
|
|
1186
|
+
Returns:
|
|
1187
|
+
Favorite dict if found, None otherwise
|
|
1188
|
+
"""
|
|
1189
|
+
data = load_favorites()
|
|
1190
|
+
favorites = data.get("favorites", [])
|
|
1191
|
+
for fav in favorites:
|
|
1192
|
+
if fav["session_id"] == session_id:
|
|
1193
|
+
return fav
|
|
1194
|
+
return None
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
1198
|
+
# Prompt Favorites System
|
|
1199
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
1200
|
+
|
|
1201
|
+
def get_prompt_favorites_path() -> Path:
|
|
1202
|
+
"""
|
|
1203
|
+
Get prompt favorites file path.
|
|
1204
|
+
|
|
1205
|
+
Returns:
|
|
1206
|
+
Path to ~/.painapple-code/prompt-favorites{suffix}.json (per-tier)
|
|
1207
|
+
"""
|
|
1208
|
+
return _state_file("prompt-favorites", ".json")
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def load_prompt_favorites() -> dict:
|
|
1212
|
+
"""
|
|
1213
|
+
Load prompt favorites from ~/.painapple-code/prompt-favorites.json.
|
|
1214
|
+
|
|
1215
|
+
Returns:
|
|
1216
|
+
Dict with version and prompts dict (prompt_id -> metadata)
|
|
1217
|
+
"""
|
|
1218
|
+
fav_path = get_prompt_favorites_path()
|
|
1219
|
+
if fav_path.exists():
|
|
1220
|
+
try:
|
|
1221
|
+
return json.loads(fav_path.read_text(encoding="utf-8"))
|
|
1222
|
+
except Exception as e:
|
|
1223
|
+
logger.error(f"Failed to load prompt favorites: {e}")
|
|
1224
|
+
return {"version": 1, "prompts": {}}
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
def save_prompt_favorites(data: dict):
|
|
1228
|
+
"""
|
|
1229
|
+
Save prompt favorites to ~/.painapple-code/prompt-favorites.json.
|
|
1230
|
+
|
|
1231
|
+
Args:
|
|
1232
|
+
data: Favorites dict with version and prompts
|
|
1233
|
+
"""
|
|
1234
|
+
ensure_data_home()
|
|
1235
|
+
fav_path = get_prompt_favorites_path()
|
|
1236
|
+
try:
|
|
1237
|
+
fav_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
1238
|
+
except Exception as e:
|
|
1239
|
+
logger.error(f"Failed to save prompt favorites: {e}")
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
def add_prompt_favorite(prompt_id: str, content_preview: str = "", note: str = "") -> bool:
|
|
1243
|
+
"""
|
|
1244
|
+
Add a prompt to favorites.
|
|
1245
|
+
|
|
1246
|
+
Args:
|
|
1247
|
+
prompt_id: Prompt ID (format: session_id:line_number)
|
|
1248
|
+
content_preview: First ~100 chars of prompt for display
|
|
1249
|
+
note: Optional user note
|
|
1250
|
+
|
|
1251
|
+
Returns:
|
|
1252
|
+
True if added, False if already exists
|
|
1253
|
+
"""
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
data = load_prompt_favorites()
|
|
1257
|
+
prompts = data.get("prompts", {})
|
|
1258
|
+
|
|
1259
|
+
if prompt_id in prompts:
|
|
1260
|
+
return False
|
|
1261
|
+
|
|
1262
|
+
prompts[prompt_id] = {
|
|
1263
|
+
"content_preview": content_preview[:100] if content_preview else "",
|
|
1264
|
+
"note": note,
|
|
1265
|
+
"added_at": datetime.now(timezone.utc).isoformat(),
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
data["prompts"] = prompts
|
|
1269
|
+
save_prompt_favorites(data)
|
|
1270
|
+
return True
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
def remove_prompt_favorite(prompt_id: str) -> bool:
|
|
1274
|
+
"""
|
|
1275
|
+
Remove a prompt from favorites.
|
|
1276
|
+
|
|
1277
|
+
Args:
|
|
1278
|
+
prompt_id: Prompt ID to unfavorite
|
|
1279
|
+
|
|
1280
|
+
Returns:
|
|
1281
|
+
True if removed, False if not found
|
|
1282
|
+
"""
|
|
1283
|
+
data = load_prompt_favorites()
|
|
1284
|
+
prompts = data.get("prompts", {})
|
|
1285
|
+
|
|
1286
|
+
if prompt_id not in prompts:
|
|
1287
|
+
return False
|
|
1288
|
+
|
|
1289
|
+
del prompts[prompt_id]
|
|
1290
|
+
data["prompts"] = prompts
|
|
1291
|
+
save_prompt_favorites(data)
|
|
1292
|
+
return True
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
def is_prompt_favorite(prompt_id: str) -> bool:
|
|
1296
|
+
"""
|
|
1297
|
+
Check if a prompt is favorited.
|
|
1298
|
+
|
|
1299
|
+
Args:
|
|
1300
|
+
prompt_id: Prompt ID to check
|
|
1301
|
+
|
|
1302
|
+
Returns:
|
|
1303
|
+
True if favorited, False otherwise
|
|
1304
|
+
"""
|
|
1305
|
+
data = load_prompt_favorites()
|
|
1306
|
+
return prompt_id in data.get("prompts", {})
|
|
1307
|
+
|
|
1308
|
+
|
|
1309
|
+
def get_prompt_favorite(prompt_id: str) -> Optional[dict]:
|
|
1310
|
+
"""
|
|
1311
|
+
Get favorite metadata for a prompt.
|
|
1312
|
+
|
|
1313
|
+
Args:
|
|
1314
|
+
prompt_id: Prompt ID to look up
|
|
1315
|
+
|
|
1316
|
+
Returns:
|
|
1317
|
+
Favorite metadata dict if found, None otherwise
|
|
1318
|
+
"""
|
|
1319
|
+
data = load_prompt_favorites()
|
|
1320
|
+
return data.get("prompts", {}).get(prompt_id)
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
def get_all_prompt_favorites() -> dict:
|
|
1324
|
+
"""
|
|
1325
|
+
Get all favorited prompt IDs.
|
|
1326
|
+
|
|
1327
|
+
Returns:
|
|
1328
|
+
Dict of prompt_id -> metadata
|
|
1329
|
+
"""
|
|
1330
|
+
data = load_prompt_favorites()
|
|
1331
|
+
return data.get("prompts", {})
|
|
1332
|
+
|
|
1333
|
+
|
|
1334
|
+
# One warning per path per process — a failed ACL tightening is worth
|
|
1335
|
+
# saying loudly once, not on every ensure_config_file() call.
|
|
1336
|
+
_ACL_WARNED: set = set()
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
def _current_user_sid() -> Optional[str]:
|
|
1340
|
+
"""The calling process's user SID (``S-1-5-21-…``), or None if unreadable.
|
|
1341
|
+
|
|
1342
|
+
icacls names a principal either by account name or, with a ``*`` prefix,
|
|
1343
|
+
by SID. Account names are the wrong currency here. The obvious spelling,
|
|
1344
|
+
``%USERDOMAIN%\\%USERNAME%``, is actively wrong on a workgroup machine:
|
|
1345
|
+
USERDOMAIN is the literal string "WORKGROUP", which maps to no SID at
|
|
1346
|
+
all, so icacls fails with 1332 ("No mapping between account names and
|
|
1347
|
+
security IDs was done") and the hardening silently degrades to a warning.
|
|
1348
|
+
A bare username usually resolves, but not on a localized install where
|
|
1349
|
+
the account was renamed, and it can be ambiguous when a local and a
|
|
1350
|
+
domain account share a name.
|
|
1351
|
+
|
|
1352
|
+
The SID sidesteps all of it: we read it straight off our own process
|
|
1353
|
+
token, so there is no name to resolve and no locale to get wrong.
|
|
1354
|
+
"""
|
|
1355
|
+
import ctypes
|
|
1356
|
+
from ctypes import wintypes
|
|
1357
|
+
|
|
1358
|
+
TOKEN_QUERY = 0x0008
|
|
1359
|
+
TOKEN_USER_CLASS = 1
|
|
1360
|
+
|
|
1361
|
+
try:
|
|
1362
|
+
advapi32 = ctypes.WinDLL("advapi32", use_last_error=True)
|
|
1363
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
1364
|
+
|
|
1365
|
+
# Declare signatures explicitly: ctypes defaults every return type to
|
|
1366
|
+
# C int, which truncates HANDLEs and pointers on 64-bit Windows.
|
|
1367
|
+
advapi32.OpenProcessToken.argtypes = [
|
|
1368
|
+
wintypes.HANDLE, wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE)]
|
|
1369
|
+
advapi32.OpenProcessToken.restype = wintypes.BOOL
|
|
1370
|
+
advapi32.GetTokenInformation.argtypes = [
|
|
1371
|
+
wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD,
|
|
1372
|
+
ctypes.POINTER(wintypes.DWORD)]
|
|
1373
|
+
advapi32.GetTokenInformation.restype = wintypes.BOOL
|
|
1374
|
+
advapi32.ConvertSidToStringSidW.argtypes = [
|
|
1375
|
+
ctypes.c_void_p, ctypes.POINTER(ctypes.c_wchar_p)]
|
|
1376
|
+
advapi32.ConvertSidToStringSidW.restype = wintypes.BOOL
|
|
1377
|
+
kernel32.GetCurrentProcess.restype = wintypes.HANDLE
|
|
1378
|
+
kernel32.LocalFree.argtypes = [ctypes.c_void_p]
|
|
1379
|
+
kernel32.LocalFree.restype = ctypes.c_void_p
|
|
1380
|
+
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
1381
|
+
kernel32.CloseHandle.restype = wintypes.BOOL
|
|
1382
|
+
|
|
1383
|
+
token = wintypes.HANDLE()
|
|
1384
|
+
if not advapi32.OpenProcessToken(
|
|
1385
|
+
kernel32.GetCurrentProcess(), TOKEN_QUERY, ctypes.byref(token)):
|
|
1386
|
+
return None
|
|
1387
|
+
try:
|
|
1388
|
+
# First call sizes the buffer; it's expected to "fail" with
|
|
1389
|
+
# ERROR_INSUFFICIENT_BUFFER, so only the second return matters.
|
|
1390
|
+
size = wintypes.DWORD()
|
|
1391
|
+
advapi32.GetTokenInformation(
|
|
1392
|
+
token, TOKEN_USER_CLASS, None, 0, ctypes.byref(size))
|
|
1393
|
+
if not size.value:
|
|
1394
|
+
return None
|
|
1395
|
+
buf = ctypes.create_string_buffer(size.value)
|
|
1396
|
+
if not advapi32.GetTokenInformation(
|
|
1397
|
+
token, TOKEN_USER_CLASS, buf, size, ctypes.byref(size)):
|
|
1398
|
+
return None
|
|
1399
|
+
# TOKEN_USER is a SID_AND_ATTRIBUTES, whose first pointer-sized
|
|
1400
|
+
# field is the PSID itself (the SID lives past the struct).
|
|
1401
|
+
sid = ctypes.cast(buf, ctypes.POINTER(ctypes.c_void_p)).contents
|
|
1402
|
+
out = ctypes.c_wchar_p()
|
|
1403
|
+
if not advapi32.ConvertSidToStringSidW(sid, ctypes.byref(out)):
|
|
1404
|
+
return None
|
|
1405
|
+
try:
|
|
1406
|
+
return out.value
|
|
1407
|
+
finally:
|
|
1408
|
+
kernel32.LocalFree(out)
|
|
1409
|
+
finally:
|
|
1410
|
+
kernel32.CloseHandle(token)
|
|
1411
|
+
except (OSError, AttributeError, ValueError):
|
|
1412
|
+
return None
|
|
1413
|
+
|
|
1414
|
+
|
|
1415
|
+
def _acl_principal() -> Optional[str]:
|
|
1416
|
+
"""The icacls principal for the current user — SID first, name as backstop."""
|
|
1417
|
+
sid = _current_user_sid()
|
|
1418
|
+
if sid:
|
|
1419
|
+
return f"*{sid}"
|
|
1420
|
+
# Deliberately the BARE username, never %USERDOMAIN%\%USERNAME%: on a
|
|
1421
|
+
# workgroup machine that domain part is unresolvable (see above).
|
|
1422
|
+
return os.environ.get("USERNAME") or None
|
|
1423
|
+
|
|
1424
|
+
|
|
1425
|
+
def _lock_mode_windows(path: Path, mode: int) -> None:
|
|
1426
|
+
"""Owner-only DACL via icacls — the NTFS stand-in for chmod 0600/0700.
|
|
1427
|
+
|
|
1428
|
+
On Windows `os.chmod` only toggles FILE_ATTRIBUTE_READONLY and
|
|
1429
|
+
RETURNS SUCCESS, so the POSIX path here was a silent no-op: the
|
|
1430
|
+
server password and the TLS private key were protected by nothing but
|
|
1431
|
+
whatever the user's profile happened to inherit, and the `except
|
|
1432
|
+
OSError` warning could never fire to say so. (Perversely, chmod 0600
|
|
1433
|
+
on an already-read-only file LOOSENS it by clearing that attribute.)
|
|
1434
|
+
|
|
1435
|
+
icacls ships in every Windows install, so this needs no pywin32:
|
|
1436
|
+
/inheritance:r drop inherited ACEs (the whole point — otherwise
|
|
1437
|
+
a permissive parent keeps granting access)
|
|
1438
|
+
/grant:r USER:F full control for just this user, replacing any
|
|
1439
|
+
existing grant for them
|
|
1440
|
+
Modes with any group/other bits set (0o644) aren't "owner only" and
|
|
1441
|
+
are left alone rather than silently over-tightened.
|
|
1442
|
+
"""
|
|
1443
|
+
if mode & 0o077:
|
|
1444
|
+
return
|
|
1445
|
+
key = str(path)
|
|
1446
|
+
principal = _acl_principal()
|
|
1447
|
+
if not principal:
|
|
1448
|
+
if key not in _ACL_WARNED:
|
|
1449
|
+
_ACL_WARNED.add(key)
|
|
1450
|
+
logger.warning(
|
|
1451
|
+
f"Cannot restrict {path}: the current user's SID and USERNAME "
|
|
1452
|
+
"are both unreadable, so no owner-only ACL was applied. This "
|
|
1453
|
+
"file is readable by any account that inherits access to its "
|
|
1454
|
+
"parent directory."
|
|
1455
|
+
)
|
|
1456
|
+
return
|
|
1457
|
+
try:
|
|
1458
|
+
result = subprocess.run(
|
|
1459
|
+
["icacls", str(path), "/inheritance:r", "/grant:r", f"{principal}:F"],
|
|
1460
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
1461
|
+
timeout=15,
|
|
1462
|
+
)
|
|
1463
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
1464
|
+
result = None
|
|
1465
|
+
detail = f"{type(e).__name__}: {e}"
|
|
1466
|
+
else:
|
|
1467
|
+
detail = (result.stderr or result.stdout or "").strip()[:200]
|
|
1468
|
+
if result is None or result.returncode != 0:
|
|
1469
|
+
if key not in _ACL_WARNED:
|
|
1470
|
+
_ACL_WARNED.add(key)
|
|
1471
|
+
logger.warning(
|
|
1472
|
+
f"Could not restrict {path} to {principal} only ({detail}). "
|
|
1473
|
+
"Continuing — check it isn't readable by other accounts on "
|
|
1474
|
+
"this machine."
|
|
1475
|
+
)
|
|
1476
|
+
|
|
1477
|
+
|
|
1478
|
+
def lock_mode(path, mode: int) -> None:
|
|
1479
|
+
"""Tighten `path` to `mode`, tolerating a filesystem that won't have it.
|
|
1480
|
+
|
|
1481
|
+
chmod needs OWNERSHIP, not write permission — it fails with EPERM,
|
|
1482
|
+
not EACCES, when you merely have write access. So a config dir
|
|
1483
|
+
bind-mounted from the host into a container raises here whenever the
|
|
1484
|
+
container's user isn't its owner, which used to take the whole server
|
|
1485
|
+
down on boot before it had served a single request.
|
|
1486
|
+
|
|
1487
|
+
These modes are hardening on files the host user already controls and
|
|
1488
|
+
can chmod themselves, so a refusal is worth a loud warning rather
|
|
1489
|
+
than a dead server. The no-op case (already correct, by far the most
|
|
1490
|
+
common) doesn't call chmod at all, so it can't fail there either.
|
|
1491
|
+
|
|
1492
|
+
On Windows the POSIX bits are meaningless, so this delegates to an
|
|
1493
|
+
icacls DACL — see _lock_mode_windows for why a no-op there was worse
|
|
1494
|
+
than it looks.
|
|
1495
|
+
"""
|
|
1496
|
+
path = Path(path)
|
|
1497
|
+
if sys.platform == "win32":
|
|
1498
|
+
_lock_mode_windows(path, mode)
|
|
1499
|
+
return
|
|
1500
|
+
try:
|
|
1501
|
+
current = stat.S_IMODE(path.stat().st_mode)
|
|
1502
|
+
except OSError:
|
|
1503
|
+
current = None
|
|
1504
|
+
if current == mode:
|
|
1505
|
+
return
|
|
1506
|
+
try:
|
|
1507
|
+
os.chmod(path, mode)
|
|
1508
|
+
except OSError as e:
|
|
1509
|
+
logger.warning(
|
|
1510
|
+
f"Could not set permissions on {path} to {mode:#o} "
|
|
1511
|
+
f"({type(e).__name__}: {e.strerror})"
|
|
1512
|
+
+ (f"; it is {current:#o}" if current is not None else "")
|
|
1513
|
+
+ ". Continuing — check it isn't readable by other users."
|
|
1514
|
+
)
|