meltygui 0.1.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.
- meltygui/__init__.py +107 -0
- meltygui/accounts/__init__.py +0 -0
- meltygui/accounts/internet_accounts.py +1355 -0
- meltygui/chat/__init__.py +91 -0
- meltygui/chat/activity.py +75 -0
- meltygui/chat/backends.py +36 -0
- meltygui/chat/chat_interface.py +732 -0
- meltygui/chat/chat_proxy.py +352 -0
- meltygui/chat/codex_proxy.py +592 -0
- meltygui/chat/codex_settings.py +100 -0
- meltygui/chat/codex_transport.py +60 -0
- meltygui/chat/command_parser.py +204 -0
- meltygui/chat/images.py +227 -0
- meltygui/chat/messages.py +417 -0
- meltygui/chat/metadata.py +139 -0
- meltygui/chat/writer_locks.py +64 -0
- meltygui/code/__init__.py +0 -0
- meltygui/code/basic_converters.py +533 -0
- meltygui/code/chain_converters.py +2111 -0
- meltygui/code/code_checks.py +2209 -0
- meltygui/code/core_syntax.py +1430 -0
- meltygui/code/file_converters.py +1933 -0
- meltygui/code/fileref.py +702 -0
- meltygui/code/hotswap_guard.py +144 -0
- meltygui/code/libcst_conversion.py +9724 -0
- meltygui/code/live_instrument.py +392 -0
- meltygui/code/live_view.py +2490 -0
- meltygui/code/melty_scan.py +2684 -0
- meltygui/code/new_codecs.py +1255 -0
- meltygui/code/new_converters.py +3017 -0
- meltygui/code/project_code.py +278 -0
- meltygui/code/source_context.py +63 -0
- meltygui/code/symbol_roster.py +1588 -0
- meltygui/code/syntax_check.py +34 -0
- meltygui/code/syntax_check_worker.py +114 -0
- meltygui/completion/__init__.py +0 -0
- meltygui/completion/fim.py +1232 -0
- meltygui/completion/fim_context.py +481 -0
- meltygui/completion/providers/__init__.py +0 -0
- meltygui/completion/providers/anthropic_oauth.py +446 -0
- meltygui/completion/providers/anthropic_requests.py +69 -0
- meltygui/completion/providers/claude.py +203 -0
- meltygui/completion/providers/claude_usage.py +499 -0
- meltygui/completion/providers/codex_accounts.py +180 -0
- meltygui/completion/providers/copilot.py +617 -0
- meltygui/completion/providers/oauth_popup.py +220 -0
- meltygui/completion/providers/ollama.py +320 -0
- meltygui/completion/providers/profiles.py +23 -0
- meltygui/core/README.md +88 -0
- meltygui/core/__init__.py +1 -0
- meltygui/core/automation/__init__.py +1 -0
- meltygui/core/automation/action_core.py +153 -0
- meltygui/core/automation/collection_action.py +45 -0
- meltygui/core/automation/mcp_eval.py +167 -0
- meltygui/core/automation/mcp_hotswap.py +107 -0
- meltygui/core/automation/mcp_query.py +552 -0
- meltygui/core/automation/mcp_server.py +657 -0
- meltygui/core/automation/orchestration_core.py +2636 -0
- meltygui/core/automation/query_core.py +124 -0
- meltygui/core/automation/search_core.py +26 -0
- meltygui/core/automation/selector_core.py +340 -0
- meltygui/core/automation/value_core.py +1752 -0
- meltygui/core/cache/__init__.py +1 -0
- meltygui/core/cache/cache_diagnostics.py +0 -0
- meltygui/core/cache/invalidation_decoration.py +153 -0
- meltygui/core/cache/invalidation_tracker.py +43 -0
- meltygui/core/cache/tile_cache.py +5968 -0
- meltygui/core/conversion/__init__.py +1 -0
- meltygui/core/conversion/bubbling.py +599 -0
- meltygui/core/conversion/cache_tree.py +188 -0
- meltygui/core/conversion/chain.py +113 -0
- meltygui/core/conversion/converter_register.py +145 -0
- meltygui/core/conversion/data_decoration.py +67 -0
- meltygui/core/conversion/dict_conversion.py +1815 -0
- meltygui/core/conversion/dict_conversion_util.py +177 -0
- meltygui/core/conversion/dynamic_obj.py +89 -0
- meltygui/core/conversion/graph_compare.py +183 -0
- meltygui/core/conversion/load_save_v2.py +1032 -0
- meltygui/core/conversion/missing_saved_class.py +39 -0
- meltygui/core/conversion/path_finder.py +606 -0
- meltygui/core/conversion/render_host.py +1083 -0
- meltygui/core/core_render.py +6537 -0
- meltygui/core/definition_hotswap.py +231 -0
- meltygui/core/diagnostics/__init__.py +1 -0
- meltygui/core/diagnostics/attribute_churn.py +38 -0
- meltygui/core/diagnostics/fps_counter.py +39 -0
- meltygui/core/diagnostics/gpu_frame_timer.py +103 -0
- meltygui/core/diagnostics/inspection_core.py +169 -0
- meltygui/core/diagnostics/monitor_core.py +105 -0
- meltygui/core/diagnostics/notifications.py +706 -0
- meltygui/core/diagnostics/perf_trace.py +281 -0
- meltygui/core/diagnostics/profile_decoration.py +88 -0
- meltygui/core/diagnostics/resize_trace.py +62 -0
- meltygui/core/diagnostics/screenshot_core.py +247 -0
- meltygui/core/diagnostics/session_status.py +98 -0
- meltygui/core/diagnostics/trace_core.py +445 -0
- meltygui/core/files/__init__.py +1 -0
- meltygui/core/files/file_core.py +208 -0
- meltygui/core/files/file_explorer_core.py +104 -0
- meltygui/core/files/file_tree_core.py +198 -0
- meltygui/core/files/file_watch_core.py +43 -0
- meltygui/core/files/import_graph_core.py +43 -0
- meltygui/core/files/metadata_core.py +51 -0
- meltygui/core/graphics/__init__.py +1 -0
- meltygui/core/graphics/cuda_context_core.py +166 -0
- meltygui/core/graphics/cuda_interop_core.py +136 -0
- meltygui/core/graphics/cuda_kernel_core.py +91 -0
- meltygui/core/graphics/framebuffer_recorder.py +337 -0
- meltygui/core/graphics/gl_state.py +658 -0
- meltygui/core/graphics/lut_core.py +52 -0
- meltygui/core/graphics/overlay_renderer.py +984 -0
- meltygui/core/graphics/scene_target.py +180 -0
- meltygui/core/graphics/screenshot.py +439 -0
- meltygui/core/graphics/shader_func.py +478 -0
- meltygui/core/graphics/tensor_core.py +45 -0
- meltygui/core/graphics/text_texture.py +329 -0
- meltygui/core/graphics/wayland_color.py +635 -0
- meltygui/core/input/__init__.py +1 -0
- meltygui/core/input/collision.py +165 -0
- meltygui/core/input/drag_drop_core.py +1525 -0
- meltygui/core/input/hypr_left_drag.py +323 -0
- meltygui/core/input/input_core.py +245 -0
- meltygui/core/input/input_handler.py +1101 -0
- meltygui/core/input/mouse_cursor.py +355 -0
- meltygui/core/input/pynput_backend.py +1054 -0
- meltygui/core/input/space_mouse.py +338 -0
- meltygui/core/input/touchpad_backend.py +393 -0
- meltygui/core/input/view_selection.py +177 -0
- meltygui/core/layout/__init__.py +1 -0
- meltygui/core/layout/column_core.py +2153 -0
- meltygui/core/layout/cursor_core.py +161 -0
- meltygui/core/layout/dropdown_core.py +278 -0
- meltygui/core/layout/edge_constraints.py +155 -0
- meltygui/core/layout/grid_core.py +117 -0
- meltygui/core/layout/header_core.py +23 -0
- meltygui/core/layout/header_runtime.py +67 -0
- meltygui/core/layout/layout_core.py +87 -0
- meltygui/core/layout/tile_manager_core.py +591 -0
- meltygui/core/melty.py +6726 -0
- meltygui/core/module_map.json +896 -0
- meltygui/core/module_names.py +19 -0
- meltygui/core/rendering/__init__.py +1 -0
- meltygui/core/rendering/core_decoration.py +431 -0
- meltygui/core/rendering/core_render_helpers.py +328 -0
- meltygui/core/rendering/func_metadata.py +398 -0
- meltygui/core/rendering/mode.py +818 -0
- meltygui/core/rendering/mode_defaults.py +41 -0
- meltygui/core/rendering/modes.py +136 -0
- meltygui/core/rendering/parameter_core.py +1665 -0
- meltygui/core/rendering/render_dispatch.py +1891 -0
- meltygui/core/rendering/render_funcs.py +273 -0
- meltygui/core/rendering/shaped.py +312 -0
- meltygui/core/rendering/window_decoration.py +25 -0
- meltygui/core/runtime/__init__.py +1 -0
- meltygui/core/runtime/app.py +735 -0
- meltygui/core/runtime/app_session.py +140 -0
- meltygui/core/runtime/background.py +564 -0
- meltygui/core/runtime/extensions.py +53 -0
- meltygui/core/runtime/gc_manager.py +1163 -0
- meltygui/core/runtime/lifecycle.py +21 -0
- meltygui/core/runtime/paths.py +27 -0
- meltygui/core/runtime/settings.py +14 -0
- meltygui/core/runtime/singleton.py +16 -0
- meltygui/core/runtime/thread_safe_bool.py +24 -0
- meltygui/core/runtime/thread_signal.py +30 -0
- meltygui/core/runtime/toggles.py +3115 -0
- meltygui/core/services/__init__.py +1 -0
- meltygui/core/services/account_core.py +10 -0
- meltygui/core/services/chat_core.py +19 -0
- meltygui/core/services/claude_terminal_core.py +346 -0
- meltygui/core/services/terminal_core.py +458 -0
- meltygui/core/services/terminal_runtime.py +78 -0
- meltygui/core/styling/__init__.py +1 -0
- meltygui/core/styling/color_core.py +46 -0
- meltygui/core/styling/fonts.py +639 -0
- meltygui/core/styling/global_style.py +338 -0
- meltygui/core/styling/style.py +198 -0
- meltygui/core/styling/style_core.py +522 -0
- meltygui/core/styling/warm_start.py +149 -0
- meltygui/core/windowing/__init__.py +1 -0
- meltygui/core/windowing/backends/PYIMGUI_LICENSE +28 -0
- meltygui/core/windowing/backends/__init__.py +1 -0
- meltygui/core/windowing/backends/imgui_renderer.py +138 -0
- meltygui/core/windowing/backends/native_wayland.py +850 -0
- meltygui/core/windowing/backends/protocols/xdg-decoration-unstable-v1.xml +156 -0
- meltygui/core/windowing/backends/protocols/xdg-shell.xml +1420 -0
- meltygui/core/windowing/backends/wayland_protocol.py +101 -0
- meltygui/core/windowing/dock_core.py +182 -0
- meltygui/core/windowing/frame_geometry.py +42 -0
- meltygui/core/windowing/geometry_feed.py +864 -0
- meltygui/core/windowing/glfw_utils.py +1343 -0
- meltygui/core/windowing/os_frame.py +1552 -0
- meltygui/core/windowing/surface.py +643 -0
- meltygui/core/windowing/titlebar.py +1560 -0
- meltygui/core/windowing/titlebar_buttons.py +281 -0
- meltygui/core/windowing/wayland_move.py +932 -0
- meltygui/core/windowing/window_api.py +62 -0
- meltygui/core/windowing/window_constants.py +339 -0
- meltygui/core/windowing/window_visibility.py +162 -0
- meltygui/debug/__init__.py +0 -0
- meltygui/debug/app_view_utils.py +9 -0
- meltygui/editor/__init__.py +0 -0
- meltygui/editor/bash_syntax.py +30 -0
- meltygui/editor/code_line_fast.py +152 -0
- meltygui/editor/diff.py +139 -0
- meltygui/editor/external_changes.py +159 -0
- meltygui/editor/file_header.py +41 -0
- meltygui/editor/live_usage.py +169 -0
- meltygui/editor/live_view_views.py +1803 -0
- meltygui/editor/pending_save.py +1351 -0
- meltygui/editor/roster_tints.py +547 -0
- meltygui/editor/source_preview.py +16 -0
- meltygui/editor/source_tools.py +10 -0
- meltygui/editor/source_ui.py +70 -0
- meltygui/editor/spell_check.py +77 -0
- meltygui/editor/text_editor.py +9076 -0
- meltygui/editor/usage_picker.py +538 -0
- meltygui/events/__init__.py +0 -0
- meltygui/events/example.py +118 -0
- meltygui/examples/__init__.py +0 -0
- meltygui/examples/columns_demo.py +33 -0
- meltygui/examples/columns_window_demo.py +60 -0
- meltygui/examples/context_menu_demo.py +24 -0
- meltygui/examples/context_menu_window_demo.py +49 -0
- meltygui/examples/gui_playground.py +127 -0
- meltygui/examples/live_view_playground.py +256 -0
- meltygui/examples/lora.py +43 -0
- meltygui/examples/lora_data.py +59 -0
- meltygui/examples/lora_policies.py +67 -0
- meltygui/examples/mode_demo.py +49 -0
- meltygui/examples/modifies_demo.py +119 -0
- meltygui/examples/scalar_policies.py +60 -0
- meltygui/examples/style_layouts.py +149 -0
- meltygui/examples/tile_manager_demo.py +74 -0
- meltygui/examples/tint_demo.py +160 -0
- meltygui/examples/tint_functions.py +56 -0
- meltygui/examples/trace_demo.py +88 -0
- meltygui/examples/two_windows.py +36 -0
- meltygui/files/__init__.py +0 -0
- meltygui/files/fast_file_explorer.py +439 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/extension.js +237 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/lsd-window-geometry@latent-descent.iml +9 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/metadata.json +7 -0
- meltygui/graphics/__init__.py +6 -0
- meltygui/graphics/base.py +85 -0
- meltygui/graphics/examples.py +507 -0
- meltygui/graphics/executor.py +520 -0
- meltygui/graphics/filter.py +667 -0
- meltygui/graphics/filter.pyi +856 -0
- meltygui/graphics/generate_stubs.py +22 -0
- meltygui/graphics/registry.py +203 -0
- meltygui/graphics/shader_compiler.py +155 -0
- meltygui/graphics/shaders.py +1302 -0
- meltygui/graphics/stub_generator.py +250 -0
- meltygui/graphics/texture_manager.py +170 -0
- meltygui/graphics/texture_min_max.py +295 -0
- meltygui/hdr_color.py +757 -0
- meltygui/image_load.py +308 -0
- meltygui/model/__init__.py +1 -0
- meltygui/model/account_model.py +142 -0
- meltygui/model/camera_model.py +159 -0
- meltygui/model/chat_model.py +86 -0
- meltygui/model/code_model.py +62 -0
- meltygui/model/code_proxy_model.py +876 -0
- meltygui/model/collection_model.py +41 -0
- meltygui/model/color_model.py +127 -0
- meltygui/model/cuda_tensor_model.py +36 -0
- meltygui/model/cuda_texture_model.py +149 -0
- meltygui/model/dropdown_model.py +137 -0
- meltygui/model/file_metadata_model.py +73 -0
- meltygui/model/file_model.py +289 -0
- meltygui/model/format_model.py +390 -0
- meltygui/model/graph_model.py +98 -0
- meltygui/model/icon_model.py +1024 -0
- meltygui/model/import_graph_model.py +468 -0
- meltygui/model/layout_model.py +15 -0
- meltygui/model/lut_model.py +266 -0
- meltygui/model/search_model.py +173 -0
- meltygui/model/tensor_model.py +402 -0
- meltygui/model/terminal_model.py +329 -0
- meltygui/model/texture_model.py +146 -0
- meltygui/model/tile_model.py +24 -0
- meltygui/model/trace_model.py +198 -0
- meltygui/model/trace_report_model.py +146 -0
- meltygui/models/__init__.py +0 -0
- meltygui/models/file_meta.py +613 -0
- meltygui/models/function_console.py +184 -0
- meltygui/models/orchestration.py +48 -0
- meltygui/pbr.py +1576 -0
- meltygui/png_unfilter.c +49 -0
- meltygui/resources/JetBrainsMono-Regular.ttf +0 -0
- meltygui/resources/THIRD_PARTY_NOTICES.md +21 -0
- meltygui/resources/dejavu/DejaVuSans-Bold.ttf +0 -0
- meltygui/resources/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
- meltygui/resources/dejavu/DejaVuSans.ttf +0 -0
- meltygui/resources/dejavu/LICENSE.txt +78 -0
- meltygui/resources/fontawesome-LICENSE.txt +121 -0
- meltygui/resources/fontawesome-webfont.ttf +0 -0
- meltygui/resources/hdri/studio_small_09_1k.hdr +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Bold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraBold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraLight.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Light.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Medium.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-SemiBold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Thin.ttf +0 -0
- meltygui/resources/jetbrains-weights/OFL.txt +93 -0
- meltygui/resources/jetbrains-weights/README.md +2 -0
- meltygui/state/__init__.py +0 -0
- meltygui/state/account_state.py +22 -0
- meltygui/state/animation_state.py +97 -0
- meltygui/state/annotation_state.py +22 -0
- meltygui/state/chat_state.py +36 -0
- meltygui/state/code_state.py +10 -0
- meltygui/state/core_enums.py +59 -0
- meltygui/state/core_markers.py +115 -0
- meltygui/state/core_undo.py +1075 -0
- meltygui/state/file_state.py +75 -0
- meltygui/state/graph_state.py +26 -0
- meltygui/state/inspection_state.py +47 -0
- meltygui/state/menu_state.py +10 -0
- meltygui/state/model_enums.py +11 -0
- meltygui/state/new_core_model.py +2394 -0
- meltygui/state/orchestration_state.py +14 -0
- meltygui/state/query_state.py +24 -0
- meltygui/state/tensor_state.py +10 -0
- meltygui/state/terminal_state.py +23 -0
- meltygui/state/trace_state.py +32 -0
- meltygui/state/voxel_state.py +12 -0
- meltygui/text_index.py +816 -0
- meltygui/utils/__init__.py +0 -0
- meltygui/utils/jump_to_code.py +344 -0
- meltygui/utils/pkl_inspect.py +90 -0
- meltygui/utils/render_utils.py +1419 -0
- meltygui/view/__init__.py +1 -0
- meltygui/view/account_view.py +524 -0
- meltygui/view/action_view.py +73 -0
- meltygui/view/chat_decoration_view.py +112 -0
- meltygui/view/chat_view.py +1703 -0
- meltygui/view/code_view.py +2677 -0
- meltygui/view/collection_view.py +1185 -0
- meltygui/view/color_view.py +824 -0
- meltygui/view/control_view.py +559 -0
- meltygui/view/decoration_view.py +439 -0
- meltygui/view/diagnostic_view.py +177 -0
- meltygui/view/dropdown_view.py +1082 -0
- meltygui/view/file_view.py +1599 -0
- meltygui/view/graph_cuda_view.py +105 -0
- meltygui/view/graph_view.py +882 -0
- meltygui/view/header_view.py +848 -0
- meltygui/view/input_view.py +238 -0
- meltygui/view/inspection_view.py +1783 -0
- meltygui/view/layout_view.py +596 -0
- meltygui/view/lut_view.py +45 -0
- meltygui/view/menu_view.py +212 -0
- meltygui/view/orchestration_view.py +658 -0
- meltygui/view/query_view.py +118 -0
- meltygui/view/search_view.py +342 -0
- meltygui/view/tab_view.py +258 -0
- meltygui/view/tensor_view.py +430 -0
- meltygui/view/terminal_view.py +355 -0
- meltygui/view/text_view.py +8031 -0
- meltygui/view/texture_view.py +480 -0
- meltygui/view/tile_view.py +38 -0
- meltygui/view/trace_view.py +923 -0
- meltygui/view/voxel_cuda_view.py +824 -0
- meltygui/view/voxel_view.py +1860 -0
- meltygui/view/window_view.py +111 -0
- meltygui-0.1.0.dist-info/METADATA +143 -0
- meltygui-0.1.0.dist-info/RECORD +372 -0
- meltygui-0.1.0.dist-info/WHEEL +4 -0
- meltygui-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
"""Claude subscription usage — the Max / Pro rate-limit windows (session,
|
|
2
|
+
weekly all-models, weekly per-model such as Fable) and the extra-usage
|
|
3
|
+
spend, for the Internet Accounts window's "Claude subscription" row.
|
|
4
|
+
|
|
5
|
+
Source: the claude.ai OAuth login Claude Code keeps in
|
|
6
|
+
`~/.claude/.credentials.json` (`claudeAiOauth.accessToken`). The studio
|
|
7
|
+
only READS that file — it never refreshes the token and never sends it
|
|
8
|
+
for inference; the numbers come from GET /api/oauth/usage, the call
|
|
9
|
+
Claude Code's own `/usage` makes. An API-org token (the studio's Console
|
|
10
|
+
sign-in, anthropic_oauth.py) is refused by that endpoint ("Usage limits
|
|
11
|
+
are not applicable to API organizations"), which is why the subscription
|
|
12
|
+
is its own account kind. A stale token is Claude Code's to refresh: run
|
|
13
|
+
`claude` once and the file is rewritten.
|
|
14
|
+
|
|
15
|
+
`parse_usage` normalises the payload to rows the window paints as bars:
|
|
16
|
+
the `limits` list (kind / percent / severity / resets_at / scope — the
|
|
17
|
+
general form, one entry per window incl. per-model ones), with the
|
|
18
|
+
legacy `five_hour` / `seven_day*` blocks as the fallback, plus `spend`
|
|
19
|
+
(or `extra_usage`) as an "Extra usage" row.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import collections
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import shutil
|
|
28
|
+
import subprocess
|
|
29
|
+
import threading
|
|
30
|
+
import time
|
|
31
|
+
import urllib.error
|
|
32
|
+
import urllib.request
|
|
33
|
+
from datetime import datetime, timezone
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
USAGE_URL = "https://api.anthropic.com/api/oauth/usage"
|
|
37
|
+
|
|
38
|
+
# Every usage call this process ran: (wall time, outcome) - printed on a
|
|
39
|
+
# 429 so the log shows the real cadence behind a rate limit.
|
|
40
|
+
recent_requests = collections.deque(maxlen=40)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
44
|
+
# Claude Code's login file
|
|
45
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
def read_login(path):
|
|
48
|
+
"""Claude Code's claude.ai login: {"token", "expires_at" (unix s or
|
|
49
|
+
None), "expired", "plan" ("Max 20x" / "Pro" / …), "scopes", "email",
|
|
50
|
+
"organization", "path"} — or None when the file / login is missing.
|
|
51
|
+
`email` / `organization` come from Claude Code's identity file next to
|
|
52
|
+
it (`read_identity`); they say WHOSE usage the file yields, so two
|
|
53
|
+
Internet Accounts rows sharing one file don't both paint it. No
|
|
54
|
+
network."""
|
|
55
|
+
path = Path(path).expanduser()
|
|
56
|
+
try:
|
|
57
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
58
|
+
except (OSError, ValueError):
|
|
59
|
+
return None
|
|
60
|
+
oauth = data.get("claudeAiOauth") if isinstance(data, dict) else None
|
|
61
|
+
if not isinstance(oauth, dict) or not oauth.get("accessToken"):
|
|
62
|
+
return None
|
|
63
|
+
expires_at = oauth.get("expiresAt")
|
|
64
|
+
try:
|
|
65
|
+
expires_at = float(expires_at) / 1000.0 if expires_at is not None else None
|
|
66
|
+
except (TypeError, ValueError):
|
|
67
|
+
expires_at = None
|
|
68
|
+
identity = read_identity(path)
|
|
69
|
+
return {"token": oauth["accessToken"],
|
|
70
|
+
"expires_at": expires_at,
|
|
71
|
+
"expired": expires_at is not None and expires_at <= time.time(),
|
|
72
|
+
"plan": plan_label(oauth.get("subscriptionType"), oauth.get("rateLimitTier")),
|
|
73
|
+
"scopes": list(oauth.get("scopes") or []),
|
|
74
|
+
"email": (identity.get("emailAddress") or "").strip(),
|
|
75
|
+
"organization": (identity.get("organizationName") or "").strip(),
|
|
76
|
+
"path": path}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_login_cache = {} # credentials path → (file signature, read_login result); see cached_login
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cached_login(path):
|
|
83
|
+
"""read_login(path), re-read only when the credentials file or the
|
|
84
|
+
identity file beside it changed (mtime + size) — cheap enough for a
|
|
85
|
+
repaint, and the way a Claude Code login switch (`claude auth login`,
|
|
86
|
+
the Use-in-Claude-Code button) reaches every row on its next draw."""
|
|
87
|
+
path = Path(path).expanduser()
|
|
88
|
+
signature = tuple(_file_signature(candidate) for candidate in (
|
|
89
|
+
path, path.parent / ".claude.json", path.parent.parent / ".claude.json"))
|
|
90
|
+
hit = _login_cache.get(path)
|
|
91
|
+
if hit is not None and hit[0] == signature:
|
|
92
|
+
return hit[1]
|
|
93
|
+
login = read_login(path)
|
|
94
|
+
_login_cache[path] = (signature, login)
|
|
95
|
+
return login
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _file_signature(path):
|
|
99
|
+
try:
|
|
100
|
+
info = os.stat(path)
|
|
101
|
+
except OSError:
|
|
102
|
+
return None
|
|
103
|
+
return (info.st_mtime_ns, info.st_size)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def read_identity(credentials_path) -> dict:
|
|
107
|
+
"""The `oauthAccount` block of Claude Code's `.claude.json` (no secrets:
|
|
108
|
+
emailAddress, organizationName, displayName, …) for a credentials
|
|
109
|
+
file: `<CLAUDE_CONFIG_DIR>/.claude.json` beside it, else the default
|
|
110
|
+
layout's `~/.claude.json` one level up from `~/.claude/`. {} when
|
|
111
|
+
absent."""
|
|
112
|
+
credentials_path = Path(credentials_path).expanduser()
|
|
113
|
+
for candidate in (credentials_path.parent / ".claude.json",
|
|
114
|
+
credentials_path.parent.parent / ".claude.json"):
|
|
115
|
+
try:
|
|
116
|
+
data = json.loads(candidate.read_text(encoding="utf-8"))
|
|
117
|
+
except (OSError, ValueError):
|
|
118
|
+
# Claude Code rewrites this file constantly (stats, sessions); a
|
|
119
|
+
# read that lands mid-write parses as nothing. Keep the last good
|
|
120
|
+
# identity for this path rather than reporting "nobody" for a
|
|
121
|
+
# frame - a flicker to "nobody" reads as an account switch.
|
|
122
|
+
if candidate in _identity_cache:
|
|
123
|
+
return _identity_cache[candidate]
|
|
124
|
+
continue
|
|
125
|
+
account = data.get("oauthAccount") if isinstance(data, dict) else None
|
|
126
|
+
if isinstance(account, dict):
|
|
127
|
+
_identity_cache[candidate] = account
|
|
128
|
+
return account
|
|
129
|
+
return {}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
_identity_cache = {} # identity file path → last successfully parsed oauthAccount
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def plan_label(subscription_type, rate_limit_tier) -> str:
|
|
136
|
+
""""Max 20x" from subscriptionType "max" + rateLimitTier
|
|
137
|
+
"default_claude_max_20x"; "Pro"; or whatever the type says."""
|
|
138
|
+
name = (subscription_type or "").strip()
|
|
139
|
+
name = name[:1].upper() + name[1:] if name else "Claude"
|
|
140
|
+
multiplier = re.search(r"_(\d+)x$", rate_limit_tier or "")
|
|
141
|
+
if multiplier:
|
|
142
|
+
name += f" {multiplier.group(1)}x"
|
|
143
|
+
return name
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
147
|
+
# The usage endpoint
|
|
148
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
class UsageRateLimited(RuntimeError):
|
|
151
|
+
"""The usage endpoint answered 429; `retry_after` is its Retry-After in
|
|
152
|
+
seconds (None when it sent none)."""
|
|
153
|
+
|
|
154
|
+
def __init__(self, message, retry_after=None):
|
|
155
|
+
super().__init__(message)
|
|
156
|
+
self.retry_after = retry_after
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def fetch_usage(token: str, url: str = USAGE_URL, timeout_s: float = 15.0) -> dict:
|
|
160
|
+
"""GET the usage payload with the claude.ai OAuth token (Bearer + the
|
|
161
|
+
oauth beta header). RuntimeError with the status on failure
|
|
162
|
+
(UsageRateLimited on 429)."""
|
|
163
|
+
from meltygui.completion.providers.anthropic_requests import notify_request
|
|
164
|
+
notify_request("GET /api/oauth/usage")
|
|
165
|
+
request = urllib.request.Request(
|
|
166
|
+
url, headers={"Authorization": f"Bearer {token}", "anthropic-beta": "oauth-2025-04-20",
|
|
167
|
+
"Accept": "application/json", "User-Agent": "latent-descent"})
|
|
168
|
+
started = time.time()
|
|
169
|
+
try:
|
|
170
|
+
with urllib.request.urlopen(request, timeout=timeout_s) as response:
|
|
171
|
+
raw = response.read()
|
|
172
|
+
recent_requests.append((started, "ok"))
|
|
173
|
+
except urllib.error.HTTPError as error:
|
|
174
|
+
recent_requests.append((started, f"http {error.code}"))
|
|
175
|
+
detail = error.read().decode("utf-8", "replace")
|
|
176
|
+
try:
|
|
177
|
+
detail = json.loads(detail)["error"]["message"]
|
|
178
|
+
except (ValueError, KeyError, TypeError):
|
|
179
|
+
detail = detail.strip()[:160]
|
|
180
|
+
if error.code == 401:
|
|
181
|
+
detail = "token rejected — open Claude Code to refresh its login"
|
|
182
|
+
if error.code == 429:
|
|
183
|
+
retry_after = None
|
|
184
|
+
try:
|
|
185
|
+
retry_after = float(error.headers.get("retry-after")) if error.headers else None
|
|
186
|
+
except (TypeError, ValueError):
|
|
187
|
+
retry_after = None
|
|
188
|
+
cadence = ", ".join(time.strftime("%H:%M:%S", time.localtime(when)) + f" {outcome}"
|
|
189
|
+
for when, outcome in recent_requests)
|
|
190
|
+
print(f"[claude_usage] 429 from {url} (retry-after={retry_after}); requests this process: {cadence}")
|
|
191
|
+
raise UsageRateLimited(f"usage endpoint rate limited: {detail}", retry_after) from None
|
|
192
|
+
raise RuntimeError(f"usage endpoint returned {error.code}: {detail}") from None
|
|
193
|
+
except urllib.error.URLError as error:
|
|
194
|
+
recent_requests.append((started, "unreachable"))
|
|
195
|
+
raise RuntimeError(f"usage endpoint unreachable: {error.reason}") from None
|
|
196
|
+
try:
|
|
197
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
198
|
+
except ValueError:
|
|
199
|
+
raise RuntimeError("usage endpoint returned a non-JSON response") from None
|
|
200
|
+
if not isinstance(payload, dict):
|
|
201
|
+
raise RuntimeError("usage endpoint returned an unexpected payload")
|
|
202
|
+
return payload
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
206
|
+
# Normalisation
|
|
207
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
def parse_usage(payload: dict) -> list[dict]:
|
|
210
|
+
"""Rows for the window: {"key", "label", "percent", "severity"
|
|
211
|
+
("normal" | "warning" | "critical" | "exceeded" | …), "resets_at" (unix
|
|
212
|
+
s or None), "active", "detail"}. Limits first (the `limits` list, or
|
|
213
|
+
the legacy blocks), the spend row last."""
|
|
214
|
+
rows = []
|
|
215
|
+
limits = payload.get("limits")
|
|
216
|
+
if isinstance(limits, list) and limits:
|
|
217
|
+
for entry in limits:
|
|
218
|
+
if not isinstance(entry, dict):
|
|
219
|
+
continue
|
|
220
|
+
rows.append({"key": _limit_key(entry),
|
|
221
|
+
"label": _limit_label(entry),
|
|
222
|
+
"percent": _percent(entry.get("percent")),
|
|
223
|
+
"severity": entry.get("severity") or "normal",
|
|
224
|
+
"resets_at": parse_iso(entry.get("resets_at")),
|
|
225
|
+
"active": bool(entry.get("is_active")),
|
|
226
|
+
"detail": ""})
|
|
227
|
+
else:
|
|
228
|
+
for key, label in (("five_hour", "Session (5 h)"), ("seven_day", "Week · all models")):
|
|
229
|
+
block = payload.get(key)
|
|
230
|
+
if isinstance(block, dict):
|
|
231
|
+
rows.append(_legacy_row(key, label, block))
|
|
232
|
+
for key, block in payload.items():
|
|
233
|
+
if (key.startswith("seven_day_") and isinstance(block, dict)
|
|
234
|
+
and key not in ("seven_day",)):
|
|
235
|
+
rows.append(_legacy_row(key, "Week · " + key[len("seven_day_"):].replace("_", " ").title(), block))
|
|
236
|
+
|
|
237
|
+
spend = payload.get("spend")
|
|
238
|
+
if isinstance(spend, dict) and (spend.get("used") or spend.get("limit")):
|
|
239
|
+
used = _money(spend.get("used"))
|
|
240
|
+
limit = _money(spend.get("limit"))
|
|
241
|
+
detail = f"{used} of {limit}" if limit else used
|
|
242
|
+
if not spend.get("enabled"):
|
|
243
|
+
reason = (spend.get("disabled_reason") or "off").replace("_", " ")
|
|
244
|
+
detail += f" · off ({reason})"
|
|
245
|
+
rows.append({"key": "spend", "label": "Extra usage",
|
|
246
|
+
"percent": _percent(spend.get("percent")),
|
|
247
|
+
"severity": spend.get("severity") or "normal",
|
|
248
|
+
"resets_at": None, "active": bool(spend.get("enabled")), "detail": detail})
|
|
249
|
+
else:
|
|
250
|
+
extra = payload.get("extra_usage")
|
|
251
|
+
if isinstance(extra, dict) and extra.get("monthly_limit") is not None:
|
|
252
|
+
places = int(extra.get("decimal_places") or 2)
|
|
253
|
+
scale = 10 ** places
|
|
254
|
+
currency = extra.get("currency") or "USD"
|
|
255
|
+
used = _format_money(float(extra.get("used_credits") or 0) / scale, currency, places)
|
|
256
|
+
limit = _format_money(float(extra["monthly_limit"]) / scale, currency, places)
|
|
257
|
+
detail = f"{used} of {limit}"
|
|
258
|
+
if not extra.get("is_enabled"):
|
|
259
|
+
detail += f" · off ({(extra.get('disabled_reason') or 'off').replace('_', ' ')})"
|
|
260
|
+
rows.append({"key": "spend", "label": "Extra usage",
|
|
261
|
+
"percent": _percent(extra.get("utilization")),
|
|
262
|
+
"severity": "normal", "resets_at": None,
|
|
263
|
+
"active": bool(extra.get("is_enabled")), "detail": detail})
|
|
264
|
+
return rows
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def summary(rows) -> str:
|
|
268
|
+
""""session 13% · week 46% · Fable 88%" — the account row's status text."""
|
|
269
|
+
parts = []
|
|
270
|
+
for row in rows:
|
|
271
|
+
if row["key"] == "spend":
|
|
272
|
+
continue
|
|
273
|
+
parts.append(f"{_short_label(row)} {row['percent']:.0f}%")
|
|
274
|
+
return " · ".join(parts)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def reset_text(resets_at, now=None) -> str:
|
|
278
|
+
""""resets in 3d 2h" / "2h 13m" / "14m" / "resetting…"; "" for None."""
|
|
279
|
+
if resets_at is None:
|
|
280
|
+
return ""
|
|
281
|
+
now = time.time() if now is None else now
|
|
282
|
+
remaining = int(resets_at - now)
|
|
283
|
+
if remaining <= 0:
|
|
284
|
+
return "resetting…"
|
|
285
|
+
days, rest = divmod(remaining, 86400)
|
|
286
|
+
hours, rest = divmod(rest, 3600)
|
|
287
|
+
minutes = rest // 60
|
|
288
|
+
if days:
|
|
289
|
+
return f"resets in {days}d {hours}h"
|
|
290
|
+
if hours:
|
|
291
|
+
return f"resets in {hours}h {minutes:02d}m"
|
|
292
|
+
return f"resets in {max(1, minutes)}m"
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def parse_iso(text):
|
|
296
|
+
"""ISO-8601 (with offset) → unix seconds, None when absent/unparseable."""
|
|
297
|
+
if not text:
|
|
298
|
+
return None
|
|
299
|
+
try:
|
|
300
|
+
parsed = datetime.fromisoformat(str(text).replace("Z", "+00:00"))
|
|
301
|
+
except ValueError:
|
|
302
|
+
return None
|
|
303
|
+
if parsed.tzinfo is None:
|
|
304
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
305
|
+
return parsed.timestamp()
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# -- helpers ---------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
def _percent(value) -> float:
|
|
311
|
+
try:
|
|
312
|
+
return max(0.0, float(value or 0.0))
|
|
313
|
+
except (TypeError, ValueError):
|
|
314
|
+
return 0.0
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _scope_name(entry) -> str:
|
|
318
|
+
scope = entry.get("scope") or {}
|
|
319
|
+
model = scope.get("model") or {}
|
|
320
|
+
return (model.get("display_name") or model.get("id") or scope.get("surface") or "").strip()
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _limit_key(entry) -> str:
|
|
324
|
+
kind = entry.get("kind") or "limit"
|
|
325
|
+
scope = _scope_name(entry)
|
|
326
|
+
return f"{kind}:{scope}" if scope else kind
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _limit_label(entry) -> str:
|
|
330
|
+
kind = entry.get("kind") or ""
|
|
331
|
+
scope = _scope_name(entry)
|
|
332
|
+
if kind == "session":
|
|
333
|
+
return "Session (5 h)"
|
|
334
|
+
if kind == "weekly_all":
|
|
335
|
+
return "Week · all models"
|
|
336
|
+
if kind.startswith("weekly"):
|
|
337
|
+
return f"Week · {scope}" if scope else "Week"
|
|
338
|
+
label = kind.replace("_", " ") or "limit"
|
|
339
|
+
return f"{label} · {scope}" if scope else label
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _short_label(row) -> str:
|
|
343
|
+
key = row["key"]
|
|
344
|
+
if key == "session":
|
|
345
|
+
return "session"
|
|
346
|
+
if key == "weekly_all":
|
|
347
|
+
return "week"
|
|
348
|
+
if ":" in key:
|
|
349
|
+
return key.split(":", 1)[1]
|
|
350
|
+
return row["label"].lower()
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _legacy_row(key, label, block) -> dict:
|
|
354
|
+
percent = _percent(block.get("utilization"))
|
|
355
|
+
severity = "exceeded" if percent >= 100 else "critical" if percent >= 90 else "warning" if percent >= 75 else "normal"
|
|
356
|
+
return {"key": key, "label": label, "percent": percent, "severity": severity,
|
|
357
|
+
"resets_at": parse_iso(block.get("resets_at")), "active": False, "detail": ""}
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _money(block) -> str:
|
|
361
|
+
if not isinstance(block, dict) or block.get("amount_minor") is None:
|
|
362
|
+
return ""
|
|
363
|
+
exponent = int(block.get("exponent") or 2)
|
|
364
|
+
return _format_money(float(block["amount_minor"]) / (10 ** exponent),
|
|
365
|
+
block.get("currency") or "USD", exponent)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _format_money(amount: float, currency: str, places: int) -> str:
|
|
369
|
+
symbol = {"USD": "$", "EUR": "€", "GBP": "£"}.get(currency, f"{currency} ")
|
|
370
|
+
return f"{symbol}{amount:,.{places}f}"
|
|
371
|
+
|
|
372
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
373
|
+
# Switching Claude Code's login
|
|
374
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
def find_claude(explicit=""):
|
|
377
|
+
"""The Claude Code executable: an explicit path, else PATH, else the
|
|
378
|
+
usual install spots (the studio's PATH may lack ~/.local/bin)."""
|
|
379
|
+
if explicit:
|
|
380
|
+
candidate = Path(explicit).expanduser()
|
|
381
|
+
return str(candidate) if candidate.is_file() else None
|
|
382
|
+
found = shutil.which("claude")
|
|
383
|
+
if found:
|
|
384
|
+
return found
|
|
385
|
+
for candidate in (Path.home() / ".local" / "bin" / "claude",
|
|
386
|
+
Path.home() / ".claude" / "local" / "claude",
|
|
387
|
+
Path("/usr/local/bin/claude")):
|
|
388
|
+
if candidate.is_file():
|
|
389
|
+
return str(candidate)
|
|
390
|
+
return None
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def strip_terminal_codes(text: str) -> str:
|
|
394
|
+
"""Drop OSC (hyperlinks) and CSI sequences from Claude Code's output."""
|
|
395
|
+
text = re.sub(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)", "", text)
|
|
396
|
+
return re.sub(r"\x1b\[[0-9;?]*[ -/]*[@-~]", "", text)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class ClaudeCodeLogin:
|
|
400
|
+
"""`claude auth login --email <email>` driven from the studio (verified
|
|
401
|
+
without a TTY): Claude Code opens the browser itself with a LOOPBACK
|
|
402
|
+
redirect, so the login normally completes on its own; it also prints the
|
|
403
|
+
paste-a-code fallback URL and reads a code from stdin, which `url` /
|
|
404
|
+
`open_in_browser` / `submit_code` expose for a browser that can't reach
|
|
405
|
+
the callback. Exit 0 = Claude Code rewrote its credentials + identity
|
|
406
|
+
files (cached_login notices on the next draw). `config_dir` targets a
|
|
407
|
+
non-default CLAUDE_CONFIG_DIR (a row pointed at its own login file).
|
|
408
|
+
Spawned with close_fds=False (posix_spawn — never fork the studio)."""
|
|
409
|
+
|
|
410
|
+
def __init__(self, email, executable=None, config_dir=None, on_change=None):
|
|
411
|
+
self.email = email
|
|
412
|
+
self.executable = executable
|
|
413
|
+
self.config_dir = config_dir
|
|
414
|
+
self.on_change = on_change
|
|
415
|
+
self.url = None
|
|
416
|
+
self.output = []
|
|
417
|
+
self.done = False
|
|
418
|
+
self.ok = False
|
|
419
|
+
self.error = None
|
|
420
|
+
self._process = None
|
|
421
|
+
|
|
422
|
+
def start(self):
|
|
423
|
+
executable = self.executable or find_claude()
|
|
424
|
+
if not executable:
|
|
425
|
+
raise RuntimeError("claude (Claude Code) not found — install it or set Toggles.InternetAccounts.claude_code_bin")
|
|
426
|
+
from meltygui.completion.providers.anthropic_requests import notify_request
|
|
427
|
+
import meltygui.completion.providers.oauth_popup as oauth_popup
|
|
428
|
+
notify_request("claude auth login", f"Claude Code's own requests · {self.email}")
|
|
429
|
+
# Claude Code opens the browser itself (xdg-open) - the shim on its
|
|
430
|
+
# PATH turns that into the placed popup, then place_async parks it.
|
|
431
|
+
env = oauth_popup.shim_env(os.environ)
|
|
432
|
+
if self.config_dir:
|
|
433
|
+
env["CLAUDE_CONFIG_DIR"] = str(self.config_dir)
|
|
434
|
+
self._process = subprocess.Popen(
|
|
435
|
+
[executable, "auth", "login", "--email", self.email],
|
|
436
|
+
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
437
|
+
close_fds=False, env=env)
|
|
438
|
+
if oauth_popup.popup_available():
|
|
439
|
+
oauth_popup.place_async()
|
|
440
|
+
threading.Thread(target=self._pump, daemon=True, name="claude-auth-login").start()
|
|
441
|
+
|
|
442
|
+
def _pump(self):
|
|
443
|
+
process = self._process
|
|
444
|
+
try:
|
|
445
|
+
for raw in iter(process.stdout.readline, b""):
|
|
446
|
+
line = strip_terminal_codes(raw.decode("utf-8", "replace")).strip()
|
|
447
|
+
if line:
|
|
448
|
+
self.output.append(line)
|
|
449
|
+
if self.url is None:
|
|
450
|
+
match = re.search(r"https://[^\s]+?/oauth/authorize\?[^\s]+", line)
|
|
451
|
+
if match:
|
|
452
|
+
self.url = match.group(0)
|
|
453
|
+
self._notify()
|
|
454
|
+
except Exception as error:
|
|
455
|
+
self.output.append(f"output pump failed: {error}")
|
|
456
|
+
code = process.wait()
|
|
457
|
+
self.ok = code == 0
|
|
458
|
+
if not self.ok:
|
|
459
|
+
failures = [line for line in self.output if "fail" in line.lower() or "error" in line.lower()]
|
|
460
|
+
self.error = (failures[-1] if failures else (self.output[-1] if self.output else f"claude exited with {code}"))
|
|
461
|
+
self.error = self.error.replace("Paste code here if prompted >", "").strip() or f"claude exited with {code}"
|
|
462
|
+
self.done = True
|
|
463
|
+
import meltygui.completion.providers.oauth_popup as oauth_popup
|
|
464
|
+
if oauth_popup.popup_available():
|
|
465
|
+
oauth_popup.close_popups() # the shim's popup isn't our child - close by class
|
|
466
|
+
self._notify()
|
|
467
|
+
|
|
468
|
+
def submit_code(self, code: str):
|
|
469
|
+
"""The paste-a-code fallback: what the Console page showed, into
|
|
470
|
+
Claude Code's stdin."""
|
|
471
|
+
if self._process is None or self._process.poll() is not None:
|
|
472
|
+
return False
|
|
473
|
+
try:
|
|
474
|
+
self._process.stdin.write((code.strip() + "\n").encode())
|
|
475
|
+
self._process.stdin.flush()
|
|
476
|
+
return True
|
|
477
|
+
except (OSError, ValueError):
|
|
478
|
+
return False
|
|
479
|
+
|
|
480
|
+
def open_in_browser(self) -> bool:
|
|
481
|
+
if not self.url:
|
|
482
|
+
return False
|
|
483
|
+
import meltygui.completion.providers.oauth_popup as oauth_popup
|
|
484
|
+
oauth_popup.open_auth_popup(self.url) # falls back to xdg-open otherwise
|
|
485
|
+
return True
|
|
486
|
+
|
|
487
|
+
def cancel(self):
|
|
488
|
+
if self._process is not None and self._process.poll() is None:
|
|
489
|
+
try:
|
|
490
|
+
self._process.terminate()
|
|
491
|
+
except OSError:
|
|
492
|
+
pass
|
|
493
|
+
|
|
494
|
+
def _notify(self):
|
|
495
|
+
if self.on_change is not None:
|
|
496
|
+
try:
|
|
497
|
+
self.on_change(self)
|
|
498
|
+
except Exception:
|
|
499
|
+
pass
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Codex account RPC over stdio. No model requests and no GUI dependencies.
|
|
2
|
+
|
|
3
|
+
The default row shares the native Codex home; additional rows are isolated.
|
|
4
|
+
Codex manages OAuth and token refresh; accounts.json never receives tokens.
|
|
5
|
+
"""
|
|
6
|
+
from collections import deque
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import queue
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from urllib.parse import quote
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AppServer:
|
|
20
|
+
def __init__(self, home, executable="", timeout=30.0):
|
|
21
|
+
executable = shutil.which(executable or "codex")
|
|
22
|
+
if not executable:
|
|
23
|
+
raise RuntimeError("Codex not found — install Codex CLI or set the Codex executable")
|
|
24
|
+
home = Path(home).expanduser().resolve()
|
|
25
|
+
home.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
26
|
+
environment = dict(os.environ, CODEX_HOME=str(home))
|
|
27
|
+
# These can otherwise select an unrelated account in the child.
|
|
28
|
+
for key in ("OPENAI_API_KEY", "CODEX_API_KEY"):
|
|
29
|
+
environment.pop(key, None)
|
|
30
|
+
self.timeout = timeout
|
|
31
|
+
self.cancelled = threading.Event()
|
|
32
|
+
self.messages = queue.Queue()
|
|
33
|
+
self.notifications = deque(maxlen=64)
|
|
34
|
+
self.sequence = 0
|
|
35
|
+
command = [os.path.abspath(executable), "app-server"]
|
|
36
|
+
if home != native_home():
|
|
37
|
+
command += ["-c", 'cli_auth_credentials_store="file"']
|
|
38
|
+
self.process = subprocess.Popen(
|
|
39
|
+
command,
|
|
40
|
+
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
41
|
+
text=True, encoding="utf-8", bufsize=1, env=environment, close_fds=False)
|
|
42
|
+
self.reader = threading.Thread(target=self._read, daemon=True, name="codex-account-rpc")
|
|
43
|
+
self.reader.start()
|
|
44
|
+
try:
|
|
45
|
+
self.request("initialize", {"clientInfo": {
|
|
46
|
+
"name": "meltygui", "title": "Melty", "version": "0.1.0"}})
|
|
47
|
+
self._send({"method": "initialized", "params": {}})
|
|
48
|
+
except Exception:
|
|
49
|
+
self.close()
|
|
50
|
+
raise
|
|
51
|
+
|
|
52
|
+
def _read(self):
|
|
53
|
+
try:
|
|
54
|
+
for line in self.process.stdout:
|
|
55
|
+
self.messages.put(json.loads(line))
|
|
56
|
+
except (ValueError, OSError):
|
|
57
|
+
pass
|
|
58
|
+
finally:
|
|
59
|
+
self.messages.put(None)
|
|
60
|
+
|
|
61
|
+
def _send(self, message):
|
|
62
|
+
self.process.stdin.write(json.dumps(message) + "\n")
|
|
63
|
+
self.process.stdin.flush()
|
|
64
|
+
|
|
65
|
+
def _receive(self, deadline):
|
|
66
|
+
remaining = deadline - time.monotonic()
|
|
67
|
+
if remaining <= 0:
|
|
68
|
+
raise TimeoutError("Codex account request timed out")
|
|
69
|
+
try:
|
|
70
|
+
message = self.messages.get(timeout=min(remaining, 0.2))
|
|
71
|
+
except queue.Empty:
|
|
72
|
+
if self.process.poll() is not None:
|
|
73
|
+
raise RuntimeError("Codex app-server stopped; try Refresh")
|
|
74
|
+
return {}
|
|
75
|
+
if message is None:
|
|
76
|
+
raise RuntimeError("Codex app-server disconnected; try Refresh")
|
|
77
|
+
# No tools are exposed by this account-only client.
|
|
78
|
+
if "method" in message and "id" in message:
|
|
79
|
+
self._send({"id": message["id"], "error": {
|
|
80
|
+
"code": -32601, "message": "Unsupported by Melty accounts"}})
|
|
81
|
+
return {}
|
|
82
|
+
return message
|
|
83
|
+
|
|
84
|
+
def request(self, method, params=None):
|
|
85
|
+
"""Single worker per client; notifications may precede the reply."""
|
|
86
|
+
self.sequence += 1
|
|
87
|
+
identifier = self.sequence
|
|
88
|
+
self._send({"method": method, "id": identifier, "params": params or {}})
|
|
89
|
+
deadline = time.monotonic() + self.timeout
|
|
90
|
+
while True:
|
|
91
|
+
message = self._receive(deadline)
|
|
92
|
+
if message.get("id") == identifier:
|
|
93
|
+
if "error" in message:
|
|
94
|
+
raise RuntimeError(message["error"].get("message", "Codex request failed"))
|
|
95
|
+
return message.get("result") or {}
|
|
96
|
+
if message.get("method"):
|
|
97
|
+
self.notifications.append(message)
|
|
98
|
+
|
|
99
|
+
def wait_login(self, login_id, timeout):
|
|
100
|
+
deadline = time.monotonic() + timeout
|
|
101
|
+
try:
|
|
102
|
+
while not self.cancelled.is_set():
|
|
103
|
+
message = (self.notifications.popleft() if self.notifications
|
|
104
|
+
else self._receive(deadline))
|
|
105
|
+
params = message.get("params") or {}
|
|
106
|
+
if (message.get("method") == "account/login/completed"
|
|
107
|
+
and params.get("loginId") == login_id):
|
|
108
|
+
if not params.get("success"):
|
|
109
|
+
raise RuntimeError(params.get("error") or "Codex sign-in failed")
|
|
110
|
+
return True
|
|
111
|
+
return False
|
|
112
|
+
finally:
|
|
113
|
+
if self.cancelled.is_set() or time.monotonic() >= deadline:
|
|
114
|
+
self.request("account/login/cancel", {"loginId": login_id})
|
|
115
|
+
|
|
116
|
+
def close(self):
|
|
117
|
+
if self.process.poll() is None:
|
|
118
|
+
self.process.terminate()
|
|
119
|
+
try:
|
|
120
|
+
self.process.wait(timeout=1)
|
|
121
|
+
except subprocess.TimeoutExpired:
|
|
122
|
+
self.process.kill()
|
|
123
|
+
self.process.wait(timeout=1)
|
|
124
|
+
self.reader.join(timeout=1)
|
|
125
|
+
self.process.stdin.close()
|
|
126
|
+
self.process.stdout.close()
|
|
127
|
+
|
|
128
|
+
def __enter__(self):
|
|
129
|
+
return self
|
|
130
|
+
|
|
131
|
+
def __exit__(self, *_):
|
|
132
|
+
self.close()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def native_home():
|
|
136
|
+
return Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex").expanduser().resolve()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def account_home(account_id):
|
|
140
|
+
if account_id == "codex":
|
|
141
|
+
return native_home()
|
|
142
|
+
return Path.home() / ".lsd" / "codex" / ("account-" + quote(account_id, safe=""))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def usage_rows(payload):
|
|
146
|
+
"""Adapt all returned limit buckets to the Internet Accounts bar schema.
|
|
147
|
+
|
|
148
|
+
Missing windows/percentages mean unavailable, never zero usage.
|
|
149
|
+
"""
|
|
150
|
+
buckets = payload.get("rateLimitsByLimitId")
|
|
151
|
+
if not isinstance(buckets, dict) or not buckets:
|
|
152
|
+
legacy = payload.get("rateLimits")
|
|
153
|
+
buckets = {legacy.get("limitId") or "codex": legacy} if legacy else {}
|
|
154
|
+
rows = []
|
|
155
|
+
for bucket_id, bucket in buckets.items():
|
|
156
|
+
if not isinstance(bucket, dict):
|
|
157
|
+
continue
|
|
158
|
+
for slot in ("primary", "secondary"):
|
|
159
|
+
window = bucket.get(slot)
|
|
160
|
+
if not isinstance(window, dict):
|
|
161
|
+
continue
|
|
162
|
+
percent = window.get("usedPercent")
|
|
163
|
+
if not isinstance(percent, (int, float)) or not math.isfinite(percent):
|
|
164
|
+
continue
|
|
165
|
+
percent = max(0.0, min(100.0, percent))
|
|
166
|
+
minutes = window.get("windowDurationMins")
|
|
167
|
+
label = slot.title()
|
|
168
|
+
if isinstance(minutes, (int, float)) and minutes > 0:
|
|
169
|
+
label = (f"{minutes / 1440:g}d" if minutes % 1440 == 0 else
|
|
170
|
+
f"{minutes / 60:g}h" if minutes % 60 == 0 else f"{minutes:g}m")
|
|
171
|
+
if len(buckets) > 1 or bucket_id != "codex":
|
|
172
|
+
label = f"{bucket.get('limitName') or bucket_id} · {label}"
|
|
173
|
+
severity = ("exceeded" if percent >= 100 else "critical" if percent >= 90
|
|
174
|
+
else "warning" if percent >= 75 else "normal")
|
|
175
|
+
resets = window.get("resetsAt")
|
|
176
|
+
rows.append({"key": f"{bucket_id}:{slot}", "label": label,
|
|
177
|
+
"percent": percent, "severity": severity,
|
|
178
|
+
"resets_at": resets if isinstance(resets, (int, float)) else None,
|
|
179
|
+
"active": True, "detail": ""})
|
|
180
|
+
return rows
|