aru-code 0.56.0__tar.gz → 0.58.0__tar.gz
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.
- {aru_code-0.56.0/aru_code.egg-info → aru_code-0.58.0}/PKG-INFO +1 -1
- aru_code-0.58.0/aru/__init__.py +1 -0
- aru_code-0.58.0/aru/auth.py +93 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/commands.py +385 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/providers.py +84 -1
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/plan_mode.py +13 -1
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/app.py +117 -5
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/screens/text_input.py +3 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/slash_bridge.py +11 -1
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/ui.py +53 -1
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/widgets/completer.py +1 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/ui.py +13 -2
- {aru_code-0.56.0 → aru_code-0.58.0/aru_code.egg-info}/PKG-INFO +1 -1
- {aru_code-0.56.0 → aru_code-0.58.0}/aru_code.egg-info/SOURCES.txt +4 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/pyproject.toml +1 -1
- aru_code-0.58.0/tests/test_auth_store.py +143 -0
- aru_code-0.58.0/tests/test_connect_command.py +256 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_plan_mode_refactor.py +54 -0
- aru_code-0.58.0/tests/test_tui_connect_wiring.py +42 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_permission_flow.py +111 -0
- aru_code-0.56.0/aru/__init__.py +0 -1
- {aru_code-0.56.0 → aru_code-0.58.0}/LICENSE +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/README.md +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/_debug/__init__.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/_debug/analyze_trace.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/_debug/loop_tracer.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/agent_factory.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/agents/__init__.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/agents/base.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/agents/catalog.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/agents/planner.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/cache_patch.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/checkpoints.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/cli.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/completers.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/config.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/context.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/display.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/doom_loop.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/events.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/format/__init__.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/format/manager.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/format/runner.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/history_blocks.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/lsp/__init__.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/lsp/client.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/lsp/manager.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/lsp/protocol.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/memory/__init__.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/memory/extractor.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/memory/loader.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/memory/store.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/permissions.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/plugin_cache.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/plugins/__init__.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/plugins/custom_tools.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/plugins/hooks.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/plugins/manager.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/plugins/tool_api.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/runner.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/runtime.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/select.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/session.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/sinks.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/streaming.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tool_policy.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/__init__.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/_diff.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/_shared.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/apply_patch.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/apply_patch_prompt.txt +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/ast_tools.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/codebase.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/delegate.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/delegate_prompt.txt +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/file_ops.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/gitignore.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/lsp.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/mcp_client.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/memory_tool.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/ranker.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/registry.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/search.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/shell.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/skill.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/tasklist.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/web.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/worktree.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/__init__.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/log_bridge.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/notifications.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/sanitize.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/screens/__init__.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/screens/choice.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/screens/confirm.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/screens/keymap.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/screens/search.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/screens/session_picker.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/sinks.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/themes.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/widgets/__init__.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/widgets/chat.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/widgets/context_pane.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/widgets/file_link.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/widgets/header.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/widgets/inline_choice.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/widgets/loaded_pane.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/widgets/prompt_area.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/widgets/prompt_queue.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/widgets/status.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/widgets/subagent_panel.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/widgets/tasklist_panel.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/widgets/thinking.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/widgets/tools.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru_code.egg-info/dependency_links.txt +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru_code.egg-info/entry_points.txt +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru_code.egg-info/requires.txt +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/aru_code.egg-info/top_level.txt +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/setup.cfg +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_agents_base.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_agents_md_coverage.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_apply_patch.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_async_tool_permission.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_cache_patch_metrics.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_cache_patch_stop_reason.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_catalog.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_chat_scrollable.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_checkpoints.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_cli.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_cli_advanced.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_cli_base.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_cli_completers.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_cli_new.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_cli_run_cli.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_cli_session.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_cli_shell.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_codebase.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_confabulation_regression.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_config.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_context.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_context_pane.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_cwd_awareness.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_delegate.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_doom_loop.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_events_backward_compat.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_events_schema.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_fork_ctx_concurrency.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_format.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_gitignore.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_guardrails_scenarios.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_invoke_skill.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_invoked_skills.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_loaded_pane_path.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_lsp.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_lsp_rename.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_main.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_markdown_to_text.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_mcp_client.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_mcp_health.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_memory.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_memory_tool.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_microcompact.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_permission_timeout_suspension.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_permissions.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_plugin_cache.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_plugin_errors.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_plugin_hooks_v2.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_plugins.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_providers.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_ranker.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_reasoning.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_runner_interrupt.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_runner_recovery.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_runtime.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_select.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_session_free_cost.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_skill_disallowed_tools.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_status_breakdown.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_status_cost.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_streaming_sink.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_subagent_tool_events.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tasklist.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_thread_tool_timeout.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tool_policy.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_truncation_marker.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_app_boot.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_bindings.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_bus_flow.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_chat.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_chat_adversarial.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_completer.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_completer_dynamic.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_copy.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_error_display.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_file_link.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_input_behaviour.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_layer12_recovery.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_layer13_recovery.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_mention_expand.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_modals.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_mode_cycle.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_native_selection.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_plan_task_render.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_prompt_queue.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_shell_bang.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_sidebar_toggle.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_slash_bridge.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_slash_model.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_snapshot_smoke.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_subagent_panel.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_theme.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_thinking_and_boot.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_widgets_visual.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_ui_adapter.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_worktree.py +0 -0
- {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_worktree_session_restore.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.58.0"
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Credential store for provider API keys (OpenCode-parity ``auth.json``).
|
|
2
|
+
|
|
3
|
+
``/connect`` writes here so users no longer have to hand-edit ``aru.json``
|
|
4
|
+
to wire up a provider. Mirrors OpenCode's ``auth.json``: a flat
|
|
5
|
+
``{ "<provider>": <info> }`` map persisted under the user's home, written
|
|
6
|
+
with ``0600`` permissions so the key isn't world-readable.
|
|
7
|
+
|
|
8
|
+
Schema (``info``) — a tagged union on ``type`` so future auth methods
|
|
9
|
+
(OAuth, well-known) can slot in beside the current API-key path:
|
|
10
|
+
|
|
11
|
+
{"type": "api", "key": "sk-...", # built-in provider
|
|
12
|
+
"base_url": "...", "name": "...", # extra fields for a
|
|
13
|
+
"provider_type": "openai", "default_model": "...", # custom provider
|
|
14
|
+
"context_limit": 128000}
|
|
15
|
+
{"type": "local", "base_url": "http://..."} # keyless (e.g. Ollama)
|
|
16
|
+
|
|
17
|
+
Consumption lives in :func:`aru.providers.apply_stored_credentials`, which
|
|
18
|
+
layers these onto the in-memory provider registry at startup (and again
|
|
19
|
+
right after ``/connect``) so a stored key takes precedence over the
|
|
20
|
+
provider's ``api_key_env``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import os
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger("aru.auth")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def auth_path() -> Path:
|
|
35
|
+
"""Absolute path to the credential file (``~/.aru/auth.json``)."""
|
|
36
|
+
return Path.home() / ".aru" / "auth.json"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_auth() -> dict[str, dict[str, Any]]:
|
|
40
|
+
"""Return the full credential map, or ``{}`` when missing/unreadable."""
|
|
41
|
+
path = auth_path()
|
|
42
|
+
if not path.is_file():
|
|
43
|
+
return {}
|
|
44
|
+
try:
|
|
45
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
46
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
47
|
+
logger.warning("Failed to read %s: %s", path, exc)
|
|
48
|
+
return {}
|
|
49
|
+
if not isinstance(data, dict):
|
|
50
|
+
return {}
|
|
51
|
+
# Drop any malformed (non-dict) entries defensively.
|
|
52
|
+
return {k: v for k, v in data.items() if isinstance(v, dict)}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_credential(provider_key: str) -> dict[str, Any] | None:
|
|
56
|
+
"""Return the stored credential for ``provider_key`` or ``None``."""
|
|
57
|
+
return load_auth().get(provider_key)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _write_auth(data: dict[str, dict[str, Any]]) -> None:
|
|
61
|
+
"""Write the credential map atomically-ish with ``0600`` perms."""
|
|
62
|
+
path = auth_path()
|
|
63
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
tmp = path.with_suffix(".json.tmp")
|
|
65
|
+
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
66
|
+
# Lock down before the rename so the secret is never briefly group/other
|
|
67
|
+
# readable. chmod is a partial no-op on Windows but harmless.
|
|
68
|
+
try:
|
|
69
|
+
os.chmod(tmp, 0o600)
|
|
70
|
+
except OSError:
|
|
71
|
+
pass
|
|
72
|
+
os.replace(tmp, path)
|
|
73
|
+
try:
|
|
74
|
+
os.chmod(path, 0o600)
|
|
75
|
+
except OSError:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def set_credential(provider_key: str, info: dict[str, Any]) -> None:
|
|
80
|
+
"""Store (or replace) the credential for ``provider_key``."""
|
|
81
|
+
data = load_auth()
|
|
82
|
+
data[provider_key] = info
|
|
83
|
+
_write_auth(data)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def remove_credential(provider_key: str) -> bool:
|
|
87
|
+
"""Delete the credential for ``provider_key``. Returns ``True`` if removed."""
|
|
88
|
+
data = load_auth()
|
|
89
|
+
if provider_key not in data:
|
|
90
|
+
return False
|
|
91
|
+
del data[provider_key]
|
|
92
|
+
_write_auth(data)
|
|
93
|
+
return True
|
|
@@ -15,6 +15,7 @@ from aru.display import console
|
|
|
15
15
|
SLASH_COMMANDS = [
|
|
16
16
|
("/help", "Show help and available commands", "/help"),
|
|
17
17
|
("/plan", "Create an implementation plan", "/plan <task>"),
|
|
18
|
+
("/connect", "Connect a provider — store an API key", "/connect [provider|list|logout]"),
|
|
18
19
|
("/model", "Switch model/provider", "/model [provider/model]"),
|
|
19
20
|
("/reasoning", "Set reasoning effort for this session", "/reasoning [low|medium|high|max|off|clear]"),
|
|
20
21
|
("/sessions", "List recent sessions", "/sessions"),
|
|
@@ -79,6 +80,389 @@ def ask_yes_no(prompt: str) -> bool:
|
|
|
79
80
|
return False
|
|
80
81
|
|
|
81
82
|
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# /connect — interactive provider connection (OpenCode `auth login` parity)
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
# Where to grab an API key, shown as a hint before the key prompt. Keyed by
|
|
88
|
+
# built-in provider id. Providers absent here just skip the hint.
|
|
89
|
+
_PROVIDER_KEY_HINTS: dict[str, str] = {
|
|
90
|
+
"anthropic": "Create a key at https://console.anthropic.com/settings/keys",
|
|
91
|
+
"openai": "Create a key at https://platform.openai.com/api-keys",
|
|
92
|
+
"openrouter": "Create a key at https://openrouter.ai/keys",
|
|
93
|
+
"groq": "Create a key at https://console.groq.com/keys",
|
|
94
|
+
"deepseek": "Create a key at https://platform.deepseek.com/api_keys",
|
|
95
|
+
"ollama": "Local provider — no API key needed.",
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
# Display order for the provider menu (most common first); anything not
|
|
99
|
+
# listed is appended afterwards in registry order.
|
|
100
|
+
_PROVIDER_MENU_ORDER = ["anthropic", "openai", "openrouter", "groq", "deepseek", "ollama"]
|
|
101
|
+
|
|
102
|
+
_OTHER_SENTINEL = "__other__"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _resolve_connect_ui():
|
|
106
|
+
"""Return the active ``ctx.ui`` adapter, falling back to a REPL one."""
|
|
107
|
+
from aru.runtime import get_ctx
|
|
108
|
+
try:
|
|
109
|
+
ctx = get_ctx()
|
|
110
|
+
except LookupError:
|
|
111
|
+
ctx = None
|
|
112
|
+
if ctx is not None:
|
|
113
|
+
from aru.permissions import _resolve_ui
|
|
114
|
+
return _resolve_ui(ctx)
|
|
115
|
+
from aru.ui import ReplUI
|
|
116
|
+
return ReplUI()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _mask_key(key: str) -> str:
|
|
120
|
+
"""Mask an API key for display: keep a short head/tail, hide the middle."""
|
|
121
|
+
if not key:
|
|
122
|
+
return "(empty)"
|
|
123
|
+
if len(key) <= 8:
|
|
124
|
+
return "****"
|
|
125
|
+
return f"{key[:4]}…{key[-4:]}"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def handle_connect_command(args: str, session=None):
|
|
129
|
+
"""``/connect`` — connect to an LLM provider by storing an API key.
|
|
130
|
+
|
|
131
|
+
OpenCode-parity ``auth login`` flow: pick a provider, paste a key, then
|
|
132
|
+
pick a model — all in one go. The credential is persisted to
|
|
133
|
+
``~/.aru/auth.json`` and live immediately (no ``aru.json`` editing) and
|
|
134
|
+
the chosen model becomes the session model. All interaction goes through
|
|
135
|
+
``ctx.ui`` so the same handler drives both the TUI (modals) and the REPL.
|
|
136
|
+
|
|
137
|
+
Subcommands:
|
|
138
|
+
``/connect`` Interactive: select a provider, enter a key.
|
|
139
|
+
``/connect <provider>`` Skip selection; connect that provider.
|
|
140
|
+
``/connect list`` Show stored credentials + active env vars.
|
|
141
|
+
``/connect logout [p]`` Remove a stored credential.
|
|
142
|
+
|
|
143
|
+
Returns the new ``model_ref`` string when the user opts to switch models
|
|
144
|
+
after connecting (so the caller can sync the UI), else ``None``.
|
|
145
|
+
"""
|
|
146
|
+
ui = _resolve_connect_ui()
|
|
147
|
+
arg = (args or "").strip()
|
|
148
|
+
parts = arg.split(None, 1)
|
|
149
|
+
sub = parts[0].lower() if parts else ""
|
|
150
|
+
rest = parts[1].strip() if len(parts) > 1 else ""
|
|
151
|
+
|
|
152
|
+
if sub == "list":
|
|
153
|
+
_connect_list(ui)
|
|
154
|
+
return None
|
|
155
|
+
if sub in ("logout", "disconnect", "remove"):
|
|
156
|
+
_connect_logout(ui, rest)
|
|
157
|
+
return None
|
|
158
|
+
return _connect_login(ui, arg, session)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _provider_menu() -> list[tuple[str, str]]:
|
|
162
|
+
"""Build ``(key, label)`` provider menu entries in display order."""
|
|
163
|
+
from aru.providers import list_providers
|
|
164
|
+
|
|
165
|
+
providers = list_providers()
|
|
166
|
+
ordered_keys = [k for k in _PROVIDER_MENU_ORDER if k in providers]
|
|
167
|
+
ordered_keys += [k for k in providers if k not in ordered_keys]
|
|
168
|
+
entries: list[tuple[str, str]] = []
|
|
169
|
+
for k in ordered_keys:
|
|
170
|
+
p = providers[k]
|
|
171
|
+
entries.append((k, f"{p.name} ({k})"))
|
|
172
|
+
return entries
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _connect_login(ui, preselect: str, session):
|
|
176
|
+
"""Provider selection → key entry → store → optional model switch."""
|
|
177
|
+
from aru import auth
|
|
178
|
+
from aru.providers import apply_stored_credentials, get_provider, list_providers
|
|
179
|
+
|
|
180
|
+
entries = _provider_menu()
|
|
181
|
+
|
|
182
|
+
# ── 1. Resolve which provider to connect ─────────────────────────────
|
|
183
|
+
provider_key: str | None = None
|
|
184
|
+
if preselect:
|
|
185
|
+
low = preselect.lower()
|
|
186
|
+
if low in ("other", "custom"):
|
|
187
|
+
provider_key = _OTHER_SENTINEL
|
|
188
|
+
else:
|
|
189
|
+
providers = list_providers()
|
|
190
|
+
if low in providers:
|
|
191
|
+
provider_key = low
|
|
192
|
+
else:
|
|
193
|
+
# Match by display name (case-insensitive).
|
|
194
|
+
for k, p in providers.items():
|
|
195
|
+
if p.name.lower() == low:
|
|
196
|
+
provider_key = k
|
|
197
|
+
break
|
|
198
|
+
if provider_key is None:
|
|
199
|
+
ui.notify(
|
|
200
|
+
f"Unknown provider '{preselect}'. Run /connect with no "
|
|
201
|
+
"argument to pick from the list.",
|
|
202
|
+
"warn",
|
|
203
|
+
)
|
|
204
|
+
return None
|
|
205
|
+
else:
|
|
206
|
+
labels = [label for _, label in entries]
|
|
207
|
+
labels.append("Other (custom OpenAI-compatible endpoint)")
|
|
208
|
+
idx = ui.ask_choice(
|
|
209
|
+
labels,
|
|
210
|
+
title="Connect a provider — select one",
|
|
211
|
+
cancel_value=None,
|
|
212
|
+
)
|
|
213
|
+
if idx is None:
|
|
214
|
+
ui.notify("Connect cancelled.", "warn")
|
|
215
|
+
return None
|
|
216
|
+
provider_key = _OTHER_SENTINEL if idx == len(entries) else entries[idx][0]
|
|
217
|
+
|
|
218
|
+
# ── 2. Gather provider details + the credential ──────────────────────
|
|
219
|
+
if provider_key == _OTHER_SENTINEL:
|
|
220
|
+
return _connect_custom(ui, session)
|
|
221
|
+
|
|
222
|
+
provider = get_provider(provider_key)
|
|
223
|
+
display_name = provider.name if provider else provider_key
|
|
224
|
+
hint = _PROVIDER_KEY_HINTS.get(provider_key)
|
|
225
|
+
if hint:
|
|
226
|
+
ui.print(hint)
|
|
227
|
+
|
|
228
|
+
# Keyless local providers (Ollama) — store a base URL instead of a key.
|
|
229
|
+
if provider is not None and not provider.api_key_env:
|
|
230
|
+
default_url = provider.base_url or "http://localhost:11434"
|
|
231
|
+
base_url = ui.ask_text(
|
|
232
|
+
f"Base URL for {display_name}:", default=default_url
|
|
233
|
+
).strip()
|
|
234
|
+
if not base_url:
|
|
235
|
+
base_url = default_url
|
|
236
|
+
auth.set_credential(provider_key, {"type": "local", "base_url": base_url})
|
|
237
|
+
apply_stored_credentials()
|
|
238
|
+
ui.print(f"Connected {display_name} at {base_url}.")
|
|
239
|
+
else:
|
|
240
|
+
key = ui.ask_text(
|
|
241
|
+
f"Enter your API key for {display_name}:", password=True
|
|
242
|
+
).strip()
|
|
243
|
+
if not key:
|
|
244
|
+
ui.notify("Connect cancelled — no key entered.", "warn")
|
|
245
|
+
return None
|
|
246
|
+
auth.set_credential(provider_key, {"type": "api", "key": key})
|
|
247
|
+
apply_stored_credentials()
|
|
248
|
+
ui.print(f"Connected {display_name} — key stored in {auth.auth_path()}.")
|
|
249
|
+
|
|
250
|
+
return _select_model(ui, provider_key, session)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _connect_custom(ui, session):
|
|
254
|
+
"""Connect a custom OpenAI-compatible provider (the 'Other' path)."""
|
|
255
|
+
import re
|
|
256
|
+
|
|
257
|
+
from aru import auth
|
|
258
|
+
from aru.providers import apply_stored_credentials
|
|
259
|
+
|
|
260
|
+
pid = ui.ask_text(
|
|
261
|
+
"Provider id (lowercase letters, digits, hyphens):"
|
|
262
|
+
).strip().lower()
|
|
263
|
+
if not pid:
|
|
264
|
+
ui.notify("Connect cancelled.", "warn")
|
|
265
|
+
return None
|
|
266
|
+
if not re.fullmatch(r"[a-z0-9-]+", pid):
|
|
267
|
+
ui.notify("Invalid id — use only a-z, 0-9 and hyphens.", "error")
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
base_url = ui.ask_text(
|
|
271
|
+
"Base URL (OpenAI-compatible, e.g. https://api.example.com/v1):"
|
|
272
|
+
).strip()
|
|
273
|
+
if not base_url:
|
|
274
|
+
ui.notify("Connect cancelled — a base URL is required.", "warn")
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
name = ui.ask_text("Display name (optional):", default=pid).strip() or pid
|
|
278
|
+
default_model = ui.ask_text("Default model id (optional):").strip()
|
|
279
|
+
key = ui.ask_text(f"Enter your API key for {name}:", password=True).strip()
|
|
280
|
+
if not key:
|
|
281
|
+
ui.notify("Connect cancelled — no key entered.", "warn")
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
info = {
|
|
285
|
+
"type": "api",
|
|
286
|
+
"key": key,
|
|
287
|
+
"base_url": base_url,
|
|
288
|
+
"name": name,
|
|
289
|
+
"provider_type": "openai",
|
|
290
|
+
}
|
|
291
|
+
if default_model:
|
|
292
|
+
info["default_model"] = default_model
|
|
293
|
+
auth.set_credential(pid, info)
|
|
294
|
+
apply_stored_credentials()
|
|
295
|
+
ui.print(f"Connected {name} ({pid}) — key stored in {auth.auth_path()}.")
|
|
296
|
+
# The custom setup already asked for a model id — honour it directly
|
|
297
|
+
# instead of re-prompting. Otherwise offer the (free-text) selector.
|
|
298
|
+
if default_model and session is not None:
|
|
299
|
+
session.model_ref = f"{pid}/{default_model}"
|
|
300
|
+
ui.print(f"Model set to {pid}/{default_model}.")
|
|
301
|
+
return f"{pid}/{default_model}"
|
|
302
|
+
return _select_model(ui, pid, session)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
_CUSTOM_MODEL_LABEL = "Enter a model id manually…"
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _list_provider_models(provider) -> list[str]:
|
|
309
|
+
"""Return the provider's model names, deduped by underlying API id.
|
|
310
|
+
|
|
311
|
+
The registry carries both clean aliases and dated ids that map to the
|
|
312
|
+
same model (e.g. ``claude-sonnet-4-5`` and ``claude-sonnet-4-5-20250929``).
|
|
313
|
+
Keep the first occurrence — the clean alias — so the picker stays tidy.
|
|
314
|
+
"""
|
|
315
|
+
seen_ids: set[str] = set()
|
|
316
|
+
out: list[str] = []
|
|
317
|
+
for name, cfg in provider.models.items():
|
|
318
|
+
mid = cfg.get("id", name) if isinstance(cfg, dict) else name
|
|
319
|
+
if mid in seen_ids:
|
|
320
|
+
continue
|
|
321
|
+
seen_ids.add(mid)
|
|
322
|
+
out.append(name)
|
|
323
|
+
return out
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _model_id_for(provider, name: str) -> str:
|
|
327
|
+
cfg = provider.models.get(name)
|
|
328
|
+
return cfg.get("id", name) if isinstance(cfg, dict) else name
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _select_model(ui, provider_key: str, session):
|
|
332
|
+
"""Pick a model for the just-connected provider (OpenCode-style).
|
|
333
|
+
|
|
334
|
+
Shows a menu of the provider's models with its default pre-highlighted,
|
|
335
|
+
plus an escape hatch for a model id not in the registry. Providers
|
|
336
|
+
without a static model list (Ollama, OpenRouter, custom) fall back to a
|
|
337
|
+
free-text id prompt. Esc keeps the current model. Returns the new
|
|
338
|
+
``model_ref`` when changed, else ``None``.
|
|
339
|
+
"""
|
|
340
|
+
if session is None:
|
|
341
|
+
return None
|
|
342
|
+
from aru.providers import get_provider
|
|
343
|
+
|
|
344
|
+
provider = get_provider(provider_key)
|
|
345
|
+
if provider is None:
|
|
346
|
+
return None
|
|
347
|
+
|
|
348
|
+
model_names = _list_provider_models(provider)
|
|
349
|
+
chosen: str | None = None
|
|
350
|
+
|
|
351
|
+
if model_names:
|
|
352
|
+
# Pre-highlight whichever name resolves to the provider's default id.
|
|
353
|
+
default_idx = 0
|
|
354
|
+
if provider.default_model:
|
|
355
|
+
default_id = _model_id_for(provider, provider.default_model)
|
|
356
|
+
for i, n in enumerate(model_names):
|
|
357
|
+
if _model_id_for(provider, n) == default_id:
|
|
358
|
+
default_idx = i
|
|
359
|
+
break
|
|
360
|
+
labels = [*model_names, _CUSTOM_MODEL_LABEL]
|
|
361
|
+
idx = ui.ask_choice(
|
|
362
|
+
labels,
|
|
363
|
+
title=f"Select a model for {provider.name}",
|
|
364
|
+
default=default_idx,
|
|
365
|
+
cancel_value=None,
|
|
366
|
+
)
|
|
367
|
+
if idx is None:
|
|
368
|
+
return None # keep current model
|
|
369
|
+
if idx == len(model_names):
|
|
370
|
+
chosen = ui.ask_text(
|
|
371
|
+
"Model id:", default=provider.default_model or ""
|
|
372
|
+
).strip()
|
|
373
|
+
else:
|
|
374
|
+
chosen = model_names[idx]
|
|
375
|
+
else:
|
|
376
|
+
chosen = ui.ask_text(
|
|
377
|
+
f"Model id for {provider.name}:",
|
|
378
|
+
default=provider.default_model or "",
|
|
379
|
+
).strip()
|
|
380
|
+
|
|
381
|
+
if not chosen:
|
|
382
|
+
return None
|
|
383
|
+
new_ref = f"{provider_key}/{chosen}"
|
|
384
|
+
session.model_ref = new_ref
|
|
385
|
+
ui.print(f"Model set to {new_ref}.")
|
|
386
|
+
return new_ref
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _connect_list(ui) -> None:
|
|
390
|
+
"""Show stored credentials and any active provider env vars."""
|
|
391
|
+
import os as _os
|
|
392
|
+
|
|
393
|
+
from aru import auth
|
|
394
|
+
from aru.providers import get_provider, list_providers
|
|
395
|
+
|
|
396
|
+
lines: list[str] = []
|
|
397
|
+
stored = auth.load_auth()
|
|
398
|
+
lines.append(f"Stored credentials ({auth.auth_path()}):")
|
|
399
|
+
if stored:
|
|
400
|
+
for key, info in sorted(stored.items()):
|
|
401
|
+
provider = get_provider(key)
|
|
402
|
+
name = provider.name if provider else key
|
|
403
|
+
itype = info.get("type", "api")
|
|
404
|
+
if itype == "local":
|
|
405
|
+
detail = info.get("base_url", "")
|
|
406
|
+
else:
|
|
407
|
+
detail = _mask_key(info.get("key", ""))
|
|
408
|
+
lines.append(f" • {name} ({key}) [{itype}] {detail}")
|
|
409
|
+
else:
|
|
410
|
+
lines.append(" (none — run /connect to add one)")
|
|
411
|
+
|
|
412
|
+
# Active env vars that back a provider (these still work as a fallback).
|
|
413
|
+
active: list[str] = []
|
|
414
|
+
for key, provider in list_providers().items():
|
|
415
|
+
env = provider.api_key_env
|
|
416
|
+
if env and _os.environ.get(env):
|
|
417
|
+
active.append(f" • {provider.name} ({key}) {env}")
|
|
418
|
+
if active:
|
|
419
|
+
lines.append("")
|
|
420
|
+
lines.append("Active environment variables:")
|
|
421
|
+
lines.extend(active)
|
|
422
|
+
|
|
423
|
+
ui.print("\n".join(lines))
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _connect_logout(ui, arg: str) -> None:
|
|
427
|
+
"""Remove a stored credential (interactive picker when no arg given)."""
|
|
428
|
+
from aru import auth
|
|
429
|
+
from aru.providers import forget_credential, get_provider
|
|
430
|
+
|
|
431
|
+
stored = auth.load_auth()
|
|
432
|
+
if not stored:
|
|
433
|
+
ui.notify("No stored credentials to remove.", "warn")
|
|
434
|
+
return
|
|
435
|
+
|
|
436
|
+
keys = sorted(stored.keys())
|
|
437
|
+
if arg:
|
|
438
|
+
target = arg.lower()
|
|
439
|
+
if target not in stored:
|
|
440
|
+
ui.notify(f"No stored credential for '{arg}'.", "warn")
|
|
441
|
+
return
|
|
442
|
+
else:
|
|
443
|
+
labels = []
|
|
444
|
+
for k in keys:
|
|
445
|
+
provider = get_provider(k)
|
|
446
|
+
name = provider.name if provider else k
|
|
447
|
+
labels.append(f"{name} ({k}) [{stored[k].get('type', 'api')}]")
|
|
448
|
+
idx = ui.ask_choice(
|
|
449
|
+
labels, title="Remove which credential?", cancel_value=None
|
|
450
|
+
)
|
|
451
|
+
if idx is None:
|
|
452
|
+
ui.notify("Logout cancelled.", "warn")
|
|
453
|
+
return
|
|
454
|
+
target = keys[idx]
|
|
455
|
+
|
|
456
|
+
auth.remove_credential(target)
|
|
457
|
+
forget_credential(target)
|
|
458
|
+
provider = get_provider(target)
|
|
459
|
+
name = provider.name if provider else target
|
|
460
|
+
note = ""
|
|
461
|
+
if provider and provider.api_key_env:
|
|
462
|
+
note = f" Falls back to ${provider.api_key_env} if set."
|
|
463
|
+
ui.print(f"Disconnected {name}.{note}")
|
|
464
|
+
|
|
465
|
+
|
|
82
466
|
def handle_subagents_command(session) -> None:
|
|
83
467
|
"""Render the session's sub-agent trace tree (`/subagents`).
|
|
84
468
|
|
|
@@ -685,6 +1069,7 @@ def _show_help(config) -> None:
|
|
|
685
1069
|
table.add_column("Description", style="dim")
|
|
686
1070
|
|
|
687
1071
|
table.add_row("/plan <task>", "Create detailed implementation plan")
|
|
1072
|
+
table.add_row("/connect [provider]", "Connect a provider — store API key (list/logout)")
|
|
688
1073
|
table.add_row("/model [provider/model]", "Switch models (e.g., ollama/llama3.1, openai/gpt-4o)")
|
|
689
1074
|
table.add_row("/sessions", "List recent sessions")
|
|
690
1075
|
table.add_row("/commands", "List custom commands")
|
|
@@ -41,6 +41,11 @@ class ProviderConfig:
|
|
|
41
41
|
models: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
42
42
|
options: dict[str, Any] = field(default_factory=dict)
|
|
43
43
|
reasoning_effort: str | None = None # provider-level default effort
|
|
44
|
+
# Resolved API key injected by `/connect` (via apply_stored_credentials).
|
|
45
|
+
# Takes precedence over `api_key_env` so a credential the user stored
|
|
46
|
+
# interactively wins over a stale shell env var. Left None for providers
|
|
47
|
+
# configured only through env vars (the legacy path).
|
|
48
|
+
api_key: str | None = None
|
|
44
49
|
|
|
45
50
|
|
|
46
51
|
# Built-in providers with sensible defaults
|
|
@@ -857,12 +862,90 @@ def _create_provider_model(
|
|
|
857
862
|
|
|
858
863
|
|
|
859
864
|
def _resolve_api_key(provider: ProviderConfig) -> str | None:
|
|
860
|
-
"""Resolve API key
|
|
865
|
+
"""Resolve the API key for a provider.
|
|
866
|
+
|
|
867
|
+
Priority: a key stored via ``/connect`` (``provider.api_key``, populated
|
|
868
|
+
by :func:`apply_stored_credentials`) wins over the provider's
|
|
869
|
+
``api_key_env`` environment variable. Existing env-only setups are
|
|
870
|
+
unaffected — ``api_key`` stays ``None`` until the user connects.
|
|
871
|
+
"""
|
|
872
|
+
if provider.api_key:
|
|
873
|
+
return provider.api_key
|
|
861
874
|
if provider.api_key_env:
|
|
862
875
|
return os.environ.get(provider.api_key_env)
|
|
863
876
|
return None
|
|
864
877
|
|
|
865
878
|
|
|
879
|
+
def apply_stored_credentials() -> None:
|
|
880
|
+
"""Layer credentials from ``~/.aru/auth.json`` onto the provider registry.
|
|
881
|
+
|
|
882
|
+
Called at startup (after config load) and again right after ``/connect``
|
|
883
|
+
so the in-memory registry reflects stored keys without a restart. For a
|
|
884
|
+
built-in provider this sets ``api_key`` (and any ``base_url`` override);
|
|
885
|
+
for an unknown provider id it registers a fresh OpenAI-compatible
|
|
886
|
+
``ProviderConfig`` so custom endpoints connected via ``/connect`` work
|
|
887
|
+
with no ``aru.json`` editing. Best-effort: a missing/garbled file is a
|
|
888
|
+
no-op.
|
|
889
|
+
"""
|
|
890
|
+
try:
|
|
891
|
+
from aru import auth
|
|
892
|
+
data = auth.load_auth()
|
|
893
|
+
except Exception as exc: # pragma: no cover — never fail startup over auth
|
|
894
|
+
logger.warning("apply_stored_credentials: %s", exc)
|
|
895
|
+
return
|
|
896
|
+
if not data:
|
|
897
|
+
return
|
|
898
|
+
|
|
899
|
+
from aru.context import MODEL_CONTEXT_LIMITS
|
|
900
|
+
|
|
901
|
+
for key, info in data.items():
|
|
902
|
+
if not isinstance(info, dict):
|
|
903
|
+
continue
|
|
904
|
+
api_key = info.get("key")
|
|
905
|
+
base_url = info.get("base_url")
|
|
906
|
+
default_model = info.get("default_model") or None
|
|
907
|
+
|
|
908
|
+
existing = _providers.get(key)
|
|
909
|
+
if existing is not None:
|
|
910
|
+
if api_key:
|
|
911
|
+
existing.api_key = api_key
|
|
912
|
+
# Populate the env var too (without clobbering an explicit
|
|
913
|
+
# one) so any code path that reads it directly still works.
|
|
914
|
+
if existing.api_key_env:
|
|
915
|
+
os.environ.setdefault(existing.api_key_env, api_key)
|
|
916
|
+
if base_url:
|
|
917
|
+
existing.base_url = base_url
|
|
918
|
+
if default_model:
|
|
919
|
+
existing.default_model = default_model
|
|
920
|
+
else:
|
|
921
|
+
provider_type = info.get("provider_type", "openai")
|
|
922
|
+
_providers[key] = ProviderConfig(
|
|
923
|
+
name=info.get("name", key),
|
|
924
|
+
api_key_env=info.get("api_key_env"),
|
|
925
|
+
base_url=base_url,
|
|
926
|
+
default_model=default_model,
|
|
927
|
+
models={},
|
|
928
|
+
options={"_provider_type": provider_type},
|
|
929
|
+
api_key=api_key,
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
# Honour an explicit context window for the default model.
|
|
933
|
+
cl = info.get("context_limit")
|
|
934
|
+
if isinstance(cl, int) and cl > 0 and default_model:
|
|
935
|
+
MODEL_CONTEXT_LIMITS.setdefault(default_model, cl)
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def forget_credential(provider_key: str) -> None:
|
|
939
|
+
"""Clear an in-memory stored key so the provider falls back to its env var.
|
|
940
|
+
|
|
941
|
+
Paired with ``auth.remove_credential`` on ``/connect logout`` — the file
|
|
942
|
+
write removes persistence, this drops the live override.
|
|
943
|
+
"""
|
|
944
|
+
provider = _providers.get(provider_key)
|
|
945
|
+
if provider is not None:
|
|
946
|
+
provider.api_key = None
|
|
947
|
+
|
|
948
|
+
|
|
866
949
|
# ---------------------------------------------------------------------------
|
|
867
950
|
# Convenience: list available models for display
|
|
868
951
|
# ---------------------------------------------------------------------------
|
|
@@ -24,6 +24,7 @@ specialized read-only tool set and instructions.
|
|
|
24
24
|
|
|
25
25
|
from __future__ import annotations
|
|
26
26
|
|
|
27
|
+
import asyncio
|
|
27
28
|
import sys
|
|
28
29
|
|
|
29
30
|
from rich.panel import Panel
|
|
@@ -197,7 +198,18 @@ async def exit_plan_mode(plan: str) -> str:
|
|
|
197
198
|
session.set_plan(task=task_label, plan_content=plan_text)
|
|
198
199
|
n_steps = len(session.plan_steps)
|
|
199
200
|
|
|
200
|
-
|
|
201
|
+
# ``exit_plan_mode`` is an async tool, so ``agent_factory`` awaits it
|
|
202
|
+
# directly on the event-loop thread (no ``_thread_tool`` worker hop like
|
|
203
|
+
# sync tools get). ``_prompt_plan_approval`` is synchronous and in TUI
|
|
204
|
+
# mode invokes ``TuiUI.ask_choice`` → ``App.call_from_thread``, which
|
|
205
|
+
# raises "must run in a different thread from the app" when called from
|
|
206
|
+
# the loop thread it targets. Hop to a worker thread first so the modal
|
|
207
|
+
# dispatch path matches the sync-tool and runner-auto-exit paths
|
|
208
|
+
# (``runner.py`` does the same). ``asyncio.to_thread`` copies the
|
|
209
|
+
# contextvars snapshot, so ``get_ctx()`` inside still resolves.
|
|
210
|
+
approved, feedback = await asyncio.to_thread(
|
|
211
|
+
_prompt_plan_approval, session.plan_steps, n_steps
|
|
212
|
+
)
|
|
201
213
|
|
|
202
214
|
# The approval prompt already rendered the plan panel inline, so suppress
|
|
203
215
|
# the runner's coalesced end-of-batch render to avoid a duplicate.
|