qwen-sci 0.2.2__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.
- qwen_sci-0.2.2.dist-info/METADATA +492 -0
- qwen_sci-0.2.2.dist-info/RECORD +549 -0
- qwen_sci-0.2.2.dist-info/WHEEL +4 -0
- qwen_sci-0.2.2.dist-info/entry_points.txt +6 -0
- qwen_sci-0.2.2.dist-info/licenses/LICENSE +21 -0
- src/1.txt +1 -0
- src/__init__.py +1 -0
- src/__main__.py +5 -0
- src/agents/__init__.py +1 -0
- src/agents/experiment_design_agent/__init__.py +268 -0
- src/agents/experiment_design_agent/artifacts.py +747 -0
- src/agents/experiment_design_agent/cache.py +319 -0
- src/agents/experiment_design_agent/completeness.py +130 -0
- src/agents/experiment_design_agent/contracts.py +771 -0
- src/agents/experiment_design_agent/counterexample_analyzer.py +187 -0
- src/agents/experiment_design_agent/design_evidence_paper_screener.py +470 -0
- src/agents/experiment_design_agent/discipline_catalog.py +178 -0
- src/agents/experiment_design_agent/evidence_cards.py +338 -0
- src/agents/experiment_design_agent/evidence_planner.py +452 -0
- src/agents/experiment_design_agent/formal_reasoning_planner.py +884 -0
- src/agents/experiment_design_agent/fulltext_acquisition.py +863 -0
- src/agents/experiment_design_agent/idea_adapter.py +100 -0
- src/agents/experiment_design_agent/idea_intake.py +87 -0
- src/agents/experiment_design_agent/llm_json.py +328 -0
- src/agents/experiment_design_agent/orchestrator.py +1220 -0
- src/agents/experiment_design_agent/reasoning_context.py +183 -0
- src/agents/experiment_design_agent/reasoning_validation.py +546 -0
- src/agents/experiment_design_agent/run.py +481 -0
- src/agents/experiment_design_agent/run_logging.py +292 -0
- src/agents/experiment_design_agent/scope_gate.py +198 -0
- src/agents/experiment_design_agent/study_type_composer.py +1360 -0
- src/agents/experiment_design_agent/survey_evidence.py +1381 -0
- src/agents/experiment_design_agent/template_router.py +195 -0
- src/agents/experiment_design_agent/variable_claim_extractor.py +93 -0
- src/agents/idea_agent/__init__.py +1 -0
- src/agents/idea_agent/agent/__init__.py +6 -0
- src/agents/idea_agent/agent/artifacts.py +308 -0
- src/agents/idea_agent/agent/base.py +122 -0
- src/agents/idea_agent/agent/ligagent.py +488 -0
- src/agents/idea_agent/agent/mcts.py +2060 -0
- src/agents/idea_agent/agent/prompts/__init__.py +48 -0
- src/agents/idea_agent/agent/prompts/advanced_analysis.py +235 -0
- src/agents/idea_agent/agent/prompts/algorithm_alignment.py +55 -0
- src/agents/idea_agent/agent/prompts/algorithm_structuring.py +48 -0
- src/agents/idea_agent/agent/prompts/component_extraction.py +62 -0
- src/agents/idea_agent/agent/prompts/component_novelty_evaluation.py +54 -0
- src/agents/idea_agent/agent/prompts/experiment_findings_extraction.py +39 -0
- src/agents/idea_agent/agent/prompts/idea_fusion.py +295 -0
- src/agents/idea_agent/agent/prompts/idea_introduction.py +30 -0
- src/agents/idea_agent/agent/prompts/idea_result_alignment.py +39 -0
- src/agents/idea_agent/agent/prompts/input_interpreter.py +52 -0
- src/agents/idea_agent/agent/prompts/keynote_ops.py +78 -0
- src/agents/idea_agent/agent/prompts/mcts_evaluation.py +235 -0
- src/agents/idea_agent/agent/prompts/mcts_generation.py +140 -0
- src/agents/idea_agent/agent/prompts/mechanism_commit_query.py +99 -0
- src/agents/idea_agent/agent/prompts/memory_selection.py +0 -0
- src/agents/idea_agent/agent/prompts/prompt_modes.py +16 -0
- src/agents/idea_agent/agent/prompts/rag_query.py +34 -0
- src/agents/idea_agent/agent/prompts/re_analysis_replan.py +48 -0
- src/agents/idea_agent/agent/prompts/reference_grounding.py +34 -0
- src/agents/idea_agent/agent/prompts/root_domain_classification.py +32 -0
- src/agents/idea_agent/agent/prompts/scientific_debate.py +88 -0
- src/agents/idea_agent/agent/prompts/scientific_materialization.py +45 -0
- src/agents/idea_agent/agent/prompts/skill_instantiation.py +193 -0
- src/agents/idea_agent/agent/prompts/theory_transfer_query.py +93 -0
- src/agents/idea_agent/agent/prompts/topic_background.py +15 -0
- src/agents/idea_agent/agent/skills/DEFAULT_SKILL_TEMPLATES.json +263 -0
- src/agents/idea_agent/agent/skills/edit_operator_skills/alternative-path-contrast/SKILL.md +40 -0
- src/agents/idea_agent/agent/skills/edit_operator_skills/alternative-path-contrast/references/contrast_patterns.md +22 -0
- src/agents/idea_agent/agent/skills/edit_operator_skills/feedback-closed-loop/SKILL.md +40 -0
- src/agents/idea_agent/agent/skills/edit_operator_skills/feedback-closed-loop/references/mechanism_cards.md +24 -0
- src/agents/idea_agent/agent/skills/edit_operator_skills/hierarchical-decomposition/SKILL.md +39 -0
- src/agents/idea_agent/agent/skills/edit_operator_skills/hierarchical-decomposition/references/hierarchy_patterns.md +22 -0
- src/agents/idea_agent/agent/skills/edit_operator_skills/mechanism-commit-innovation/SKILL.md +39 -0
- src/agents/idea_agent/agent/skills/edit_operator_skills/mechanism-commit-innovation/references/mechanism_commit_patterns.md +24 -0
- src/agents/idea_agent/agent/skills/edit_operator_skills/multi-scale-coordinator/SKILL.md +39 -0
- src/agents/idea_agent/agent/skills/edit_operator_skills/multi-scale-coordinator/references/coordination_patterns.md +22 -0
- src/agents/idea_agent/agent/skills/edit_operator_skills/speculative-execution-with-repair/SKILL.md +41 -0
- src/agents/idea_agent/agent/skills/edit_operator_skills/speculative-execution-with-repair/references/speculation_patterns.md +22 -0
- src/agents/idea_agent/agent/skills/edit_operator_skills/surgical-modularity/SKILL.md +39 -0
- src/agents/idea_agent/agent/skills/edit_operator_skills/surgical-modularity/references/modularity_patterns.md +22 -0
- src/agents/idea_agent/agent/skills/edit_operator_skills/theory-transfer-injection/SKILL.md +40 -0
- src/agents/idea_agent/agent/skills/edit_operator_skills/theory-transfer-injection/references/transfer_patterns.md +24 -0
- src/agents/idea_agent/example/idea_result.json +900 -0
- src/agents/idea_agent/run.py +276 -0
- src/agents/idea_agent/tests/test_advanced_analysis_contract.py +90 -0
- src/agents/idea_agent/tests/test_gap_hypothesis_bridge.py +166 -0
- src/agents/idea_agent/tests/test_idea_debate.py +480 -0
- src/agents/idea_agent/tests/test_idea_result_v5.py +99 -0
- src/agents/idea_agent/tests/test_idea_synthesis.py +92 -0
- src/agents/idea_agent/tests/test_mature_idea_sources.py +27 -0
- src/agents/idea_agent/tests/test_multimodal_idea_integration.py +386 -0
- src/agents/idea_agent/tests/test_profile_materialization_and_scoring.py +219 -0
- src/agents/idea_agent/tests/test_profile_native_skill_rendering.py +122 -0
- src/agents/idea_agent/tests/test_result_persistence.py +183 -0
- src/agents/idea_agent/tests/test_scientific_intervention_profiles.py +241 -0
- src/agents/idea_agent/tests/test_scientific_rubric_and_prompts.py +559 -0
- src/agents/idea_agent/tests/test_strict_json_object_responses.py +48 -0
- src/agents/idea_agent/utils/core/__init__.py +1 -0
- src/agents/idea_agent/utils/core/ablation_inputs.py +60 -0
- src/agents/idea_agent/utils/core/chat_errors.py +95 -0
- src/agents/idea_agent/utils/core/chat_router.py +130 -0
- src/agents/idea_agent/utils/core/chat_transport.py +188 -0
- src/agents/idea_agent/utils/core/config_loader.py +166 -0
- src/agents/idea_agent/utils/core/json_utils.py +28 -0
- src/agents/idea_agent/utils/core/logger.py +287 -0
- src/agents/idea_agent/utils/core/progress.py +58 -0
- src/agents/idea_agent/utils/core/response_parsing.py +57 -0
- src/agents/idea_agent/utils/core/run_inputs.py +198 -0
- src/agents/idea_agent/utils/mcts/__init__.py +1 -0
- src/agents/idea_agent/utils/mcts/component_novelty.py +342 -0
- src/agents/idea_agent/utils/mcts/defect_registry.py +302 -0
- src/agents/idea_agent/utils/mcts/idea_routes.py +61 -0
- src/agents/idea_agent/utils/mcts/idea_taste_presets.py +312 -0
- src/agents/idea_agent/utils/mcts/mcts_helpers.py +738 -0
- src/agents/idea_agent/utils/mcts/mcts_runtime.py +3000 -0
- src/agents/idea_agent/utils/mcts/scientific_intervention_ontology.py +1561 -0
- src/agents/idea_agent/utils/mcts/scientific_rubric.py +211 -0
- src/agents/idea_agent/utils/mcts/skill_parsing.py +92 -0
- src/agents/idea_agent/utils/papers/__init__.py +1 -0
- src/agents/idea_agent/utils/papers/paper_graph_vector_store.py +499 -0
- src/agents/idea_agent/utils/papers/paper_repository.py +372 -0
- src/agents/idea_agent/utils/prompting/__init__.py +1 -0
- src/agents/idea_agent/utils/prompting/prompt_views.py +637 -0
- src/agents/idea_agent/utils/workflow/__init__.py +1 -0
- src/agents/idea_agent/utils/workflow/advanced_analysis_contract.py +244 -0
- src/agents/idea_agent/utils/workflow/gap_hypothesis_bridge.py +461 -0
- src/agents/idea_agent/utils/workflow/idea_contract.py +426 -0
- src/agents/idea_agent/utils/workflow/idea_debate.py +1238 -0
- src/agents/idea_agent/utils/workflow/idea_diversity.py +163 -0
- src/agents/idea_agent/utils/workflow/idea_fusion.py +889 -0
- src/agents/idea_agent/utils/workflow/idea_helpers.py +478 -0
- src/agents/idea_agent/utils/workflow/idea_portfolio.py +422 -0
- src/agents/idea_agent/utils/workflow/idea_synthesis.py +482 -0
- src/agents/idea_agent/utils/workflow/ligagent_flow.py +581 -0
- src/agents/idea_agent/utils/workflow/ligagent_handlers.py +1836 -0
- src/agents/idea_agent/utils/workflow/ligagent_helpers.py +1331 -0
- src/agents/idea_agent/utils/workflow/ligagent_utils.py +322 -0
- src/agents/idea_agent/utils/workflow/mature_idea_sources.py +648 -0
- src/agents/idea_agent/utils/workflow/multimodal_data_anchoring.py +578 -0
- src/agents/idea_agent/utils/workflow/result_persistence.py +774 -0
- src/agents/idea_agent/utils/workflow/stage_contract.py +76 -0
- src/agents/idea_agent/utils/workflow/workflow_runtime.py +288 -0
- src/agents/research_plan_author/__init__.py +50 -0
- src/agents/research_plan_author/artifacts.py +270 -0
- src/agents/research_plan_author/authoring_blueprint.py +1246 -0
- src/agents/research_plan_author/bibtex_renderer.py +252 -0
- src/agents/research_plan_author/contract_repair.py +131 -0
- src/agents/research_plan_author/contracts.py +745 -0
- src/agents/research_plan_author/cross_section_editor.py +232 -0
- src/agents/research_plan_author/document_quality.py +987 -0
- src/agents/research_plan_author/idea_evolution.py +223 -0
- src/agents/research_plan_author/input_loader.py +76 -0
- src/agents/research_plan_author/latex_compiler.py +260 -0
- src/agents/research_plan_author/latex_safety.py +159 -0
- src/agents/research_plan_author/llm_json.py +74 -0
- src/agents/research_plan_author/markdown_renderer.py +275 -0
- src/agents/research_plan_author/pdf_validator.py +149 -0
- src/agents/research_plan_author/render.py +166 -0
- src/agents/research_plan_author/render_artifacts.py +187 -0
- src/agents/research_plan_author/run.py +765 -0
- src/agents/research_plan_author/run_logging.py +209 -0
- src/agents/research_plan_author/section_cache.py +184 -0
- src/agents/research_plan_author/section_composer.py +1764 -0
- src/agents/research_plan_author/section_router.py +209 -0
- src/agents/research_plan_author/semantic_validator.py +254 -0
- src/agents/research_plan_author/source_bundle.py +46 -0
- src/agents/research_plan_author/source_registry.py +208 -0
- src/agents/research_plan_author/survey_source_loader.py +137 -0
- src/agents/research_plan_author/template_adapter.py +173 -0
- src/agents/research_plan_author/template_profile.py +204 -0
- src/agents/research_plan_author/tex_renderer.py +496 -0
- src/agents/research_plan_author/theory_presentation.py +134 -0
- src/agents/research_plan_author/theory_spine.py +703 -0
- src/agents/survey_agent/Dockerfile +54 -0
- src/agents/survey_agent/README.md +350 -0
- src/agents/survey_agent/config/deep_survey.yaml +217 -0
- src/agents/survey_agent/config/deep_survey_batch.yaml +176 -0
- src/agents/survey_agent/config/deep_survey_fast.yaml +183 -0
- src/agents/survey_agent/config/evaluate_baseline.yaml +197 -0
- src/agents/survey_agent/config/outcomeRAG.yaml +159 -0
- src/agents/survey_agent/env_report.md +348 -0
- src/agents/survey_agent/modules/__init__.py +0 -0
- src/agents/survey_agent/modules/code_collector.py +2194 -0
- src/agents/survey_agent/modules/code_report_generator.py +1282 -0
- src/agents/survey_agent/modules/data_manager.py +1713 -0
- src/agents/survey_agent/modules/database.py +167 -0
- src/agents/survey_agent/modules/fulltext_download_cache.py +462 -0
- src/agents/survey_agent/modules/fulltext_http.py +134 -0
- src/agents/survey_agent/modules/fulltext_resolution.py +749 -0
- src/agents/survey_agent/modules/judge.py +434 -0
- src/agents/survey_agent/modules/outcome_RAG.py +236 -0
- src/agents/survey_agent/modules/paper_graph_retriever.py +1075 -0
- src/agents/survey_agent/modules/pe.py +1729 -0
- src/agents/survey_agent/modules/pseudo_creator.py +782 -0
- src/agents/survey_agent/modules/pseudo_reviser.py +537 -0
- src/agents/survey_agent/modules/refine_agent.py +1952 -0
- src/agents/survey_agent/modules/survey_generator.py +5691 -0
- src/agents/survey_agent/modules/survey_visualizer.py +1817 -0
- src/agents/survey_agent/modules/visual_prompts.py +327 -0
- src/agents/survey_agent/modules/work_analyzer.py +2750 -0
- src/agents/survey_agent/modules/work_collector.py +5649 -0
- src/agents/survey_agent/scripts/baseline_evaluation.py +411 -0
- src/agents/survey_agent/scripts/run_deep_survey.py +224 -0
- src/agents/survey_agent/scripts/run_deep_survey_adapter.py +407 -0
- src/agents/survey_agent/scripts/run_deep_survey_adapter_with_input.py +409 -0
- src/agents/survey_agent/scripts/run_deep_survey_batch.py +228 -0
- src/agents/survey_agent/scripts/run_deep_survey_batch_arg.py +243 -0
- src/agents/survey_agent/scripts/run_deep_survey_batch_settings.py +218 -0
- src/agents/survey_agent/scripts/run_keynotes_gen.py +128 -0
- src/agents/survey_agent/scripts/run_work_clusters.py +140 -0
- src/agents/survey_agent/utils/__init__.py +0 -0
- src/agents/survey_agent/utils/ablation_utils.py +317 -0
- src/agents/survey_agent/utils/api_call.py +2518 -0
- src/agents/survey_agent/utils/chat_utils.py +82 -0
- src/agents/survey_agent/utils/config_utils.py +70 -0
- src/agents/survey_agent/utils/convert_to_md.py +29 -0
- src/agents/survey_agent/utils/err_info.py +24 -0
- src/agents/survey_agent/utils/file_utils.py +176 -0
- src/agents/survey_agent/utils/gpu_utils.py +145 -0
- src/agents/survey_agent/utils/graph_visualizer.py +279 -0
- src/agents/survey_agent/utils/html_utils.py +181 -0
- src/agents/survey_agent/utils/latex_generator_intra_cluster.py +529 -0
- src/agents/survey_agent/utils/latex_generator_relation_table.py +193 -0
- src/agents/survey_agent/utils/mineru_section_packer.py +334 -0
- src/agents/survey_agent/utils/mineru_utils.py +396 -0
- src/agents/survey_agent/utils/paper.py +144 -0
- src/agents/survey_agent/utils/read_yaml.py +15 -0
- src/agents/survey_agent/utils/repo_utils.py +167 -0
- src/agents/survey_agent/utils/rich_logger.py +77 -0
- src/agents/survey_agent/utils/step2v2.py +874 -0
- src/agents/survey_agent/utils/step2v2_extractor.py +856 -0
- src/agents/survey_agent/utils/topic_survey_storage.py +309 -0
- src/agents/survey_agent/utils/utils.py +118 -0
- src/agents/survey_agent/utils/work.py +2 -0
- src/cli.py +1611 -0
- src/config/__init__.py +213 -0
- src/config/default.yaml +1122 -0
- src/harness/LICENSE +21 -0
- src/harness/VENDORED.md +14 -0
- src/harness/src/__init__.py +0 -0
- src/harness/src/openharness/__init__.py +0 -0
- src/harness/src/openharness/__main__.py +6 -0
- src/harness/src/openharness/api/__init__.py +21 -0
- src/harness/src/openharness/api/client.py +281 -0
- src/harness/src/openharness/api/codex_client.py +407 -0
- src/harness/src/openharness/api/copilot_auth.py +240 -0
- src/harness/src/openharness/api/copilot_client.py +134 -0
- src/harness/src/openharness/api/errors.py +19 -0
- src/harness/src/openharness/api/openai_client.py +577 -0
- src/harness/src/openharness/api/provider.py +186 -0
- src/harness/src/openharness/api/registry.py +437 -0
- src/harness/src/openharness/api/usage.py +17 -0
- src/harness/src/openharness/auth/__init__.py +29 -0
- src/harness/src/openharness/auth/external.py +610 -0
- src/harness/src/openharness/auth/flows.py +189 -0
- src/harness/src/openharness/auth/manager.py +482 -0
- src/harness/src/openharness/auth/storage.py +269 -0
- src/harness/src/openharness/autopilot/__init__.py +23 -0
- src/harness/src/openharness/autopilot/service.py +2239 -0
- src/harness/src/openharness/autopilot/types.py +91 -0
- src/harness/src/openharness/bridge/__init__.py +20 -0
- src/harness/src/openharness/bridge/manager.py +106 -0
- src/harness/src/openharness/bridge/session_runner.py +46 -0
- src/harness/src/openharness/bridge/types.py +37 -0
- src/harness/src/openharness/bridge/work_secret.py +41 -0
- src/harness/src/openharness/channels/UPSTREAM +6 -0
- src/harness/src/openharness/channels/__init__.py +22 -0
- src/harness/src/openharness/channels/adapter.py +131 -0
- src/harness/src/openharness/channels/bus/__init__.py +6 -0
- src/harness/src/openharness/channels/bus/events.py +38 -0
- src/harness/src/openharness/channels/bus/queue.py +44 -0
- src/harness/src/openharness/channels/impl/__init__.py +6 -0
- src/harness/src/openharness/channels/impl/base.py +141 -0
- src/harness/src/openharness/channels/impl/dingtalk.py +445 -0
- src/harness/src/openharness/channels/impl/discord.py +311 -0
- src/harness/src/openharness/channels/impl/email.py +410 -0
- src/harness/src/openharness/channels/impl/feishu.py +1342 -0
- src/harness/src/openharness/channels/impl/manager.py +257 -0
- src/harness/src/openharness/channels/impl/matrix.py +700 -0
- src/harness/src/openharness/channels/impl/mochat.py +897 -0
- src/harness/src/openharness/channels/impl/qq.py +141 -0
- src/harness/src/openharness/channels/impl/slack.py +284 -0
- src/harness/src/openharness/channels/impl/telegram.py +525 -0
- src/harness/src/openharness/channels/impl/whatsapp.py +159 -0
- src/harness/src/openharness/cli.py +2551 -0
- src/harness/src/openharness/commands/__init__.py +21 -0
- src/harness/src/openharness/commands/registry.py +2783 -0
- src/harness/src/openharness/config/__init__.py +34 -0
- src/harness/src/openharness/config/paths.py +160 -0
- src/harness/src/openharness/config/schema.py +119 -0
- src/harness/src/openharness/config/settings.py +1104 -0
- src/harness/src/openharness/coordinator/__init__.py +12 -0
- src/harness/src/openharness/coordinator/agent_definitions.py +975 -0
- src/harness/src/openharness/coordinator/coordinator_mode.py +520 -0
- src/harness/src/openharness/engine/__init__.py +80 -0
- src/harness/src/openharness/engine/cost_tracker.py +24 -0
- src/harness/src/openharness/engine/messages.py +222 -0
- src/harness/src/openharness/engine/query.py +1188 -0
- src/harness/src/openharness/engine/query_engine.py +313 -0
- src/harness/src/openharness/engine/stream_events.py +90 -0
- src/harness/src/openharness/hooks/__init__.py +50 -0
- src/harness/src/openharness/hooks/events.py +20 -0
- src/harness/src/openharness/hooks/executor.py +242 -0
- src/harness/src/openharness/hooks/hot_reload.py +31 -0
- src/harness/src/openharness/hooks/loader.py +68 -0
- src/harness/src/openharness/hooks/schemas.py +66 -0
- src/harness/src/openharness/hooks/types.py +38 -0
- src/harness/src/openharness/keybindings/__init__.py +14 -0
- src/harness/src/openharness/keybindings/default_bindings.py +11 -0
- src/harness/src/openharness/keybindings/loader.py +22 -0
- src/harness/src/openharness/keybindings/parser.py +18 -0
- src/harness/src/openharness/keybindings/resolver.py +13 -0
- src/harness/src/openharness/mcp/__init__.py +79 -0
- src/harness/src/openharness/mcp/client.py +298 -0
- src/harness/src/openharness/mcp/config.py +16 -0
- src/harness/src/openharness/mcp/types.py +76 -0
- src/harness/src/openharness/memory/__init__.py +25 -0
- src/harness/src/openharness/memory/agent.py +91 -0
- src/harness/src/openharness/memory/manager.py +183 -0
- src/harness/src/openharness/memory/memdir.py +52 -0
- src/harness/src/openharness/memory/migrate.py +137 -0
- src/harness/src/openharness/memory/paths.py +22 -0
- src/harness/src/openharness/memory/relevance.py +145 -0
- src/harness/src/openharness/memory/scan.py +116 -0
- src/harness/src/openharness/memory/schema.py +443 -0
- src/harness/src/openharness/memory/search.py +71 -0
- src/harness/src/openharness/memory/team.py +90 -0
- src/harness/src/openharness/memory/types.py +33 -0
- src/harness/src/openharness/memory/usage.py +153 -0
- src/harness/src/openharness/output_styles/__init__.py +5 -0
- src/harness/src/openharness/output_styles/loader.py +42 -0
- src/harness/src/openharness/permissions/__init__.py +26 -0
- src/harness/src/openharness/permissions/checker.py +200 -0
- src/harness/src/openharness/permissions/modes.py +13 -0
- src/harness/src/openharness/personalization/__init__.py +6 -0
- src/harness/src/openharness/personalization/extractor.py +138 -0
- src/harness/src/openharness/personalization/rules.py +64 -0
- src/harness/src/openharness/personalization/session_hook.py +65 -0
- src/harness/src/openharness/platforms.py +86 -0
- src/harness/src/openharness/plugins/__init__.py +53 -0
- src/harness/src/openharness/plugins/bundled/__init__.py +0 -0
- src/harness/src/openharness/plugins/installer.py +39 -0
- src/harness/src/openharness/plugins/loader.py +730 -0
- src/harness/src/openharness/plugins/schemas.py +24 -0
- src/harness/src/openharness/plugins/types.py +59 -0
- src/harness/src/openharness/prompts/__init__.py +14 -0
- src/harness/src/openharness/prompts/claudemd.py +48 -0
- src/harness/src/openharness/prompts/context.py +187 -0
- src/harness/src/openharness/prompts/environment.py +135 -0
- src/harness/src/openharness/prompts/system_prompt.py +109 -0
- src/harness/src/openharness/sandbox/Dockerfile +6 -0
- src/harness/src/openharness/sandbox/__init__.py +33 -0
- src/harness/src/openharness/sandbox/adapter.py +148 -0
- src/harness/src/openharness/sandbox/docker_backend.py +232 -0
- src/harness/src/openharness/sandbox/docker_image.py +103 -0
- src/harness/src/openharness/sandbox/path_validator.py +37 -0
- src/harness/src/openharness/sandbox/session.py +63 -0
- src/harness/src/openharness/services/__init__.py +30 -0
- src/harness/src/openharness/services/autodream/__init__.py +34 -0
- src/harness/src/openharness/services/autodream/backup.py +104 -0
- src/harness/src/openharness/services/autodream/lock.py +138 -0
- src/harness/src/openharness/services/autodream/prompt.py +128 -0
- src/harness/src/openharness/services/autodream/service.py +313 -0
- src/harness/src/openharness/services/compact/__init__.py +1725 -0
- src/harness/src/openharness/services/cron.py +137 -0
- src/harness/src/openharness/services/cron_scheduler.py +595 -0
- src/harness/src/openharness/services/lsp/__init__.py +216 -0
- src/harness/src/openharness/services/memory_extract/__init__.py +261 -0
- src/harness/src/openharness/services/oauth/__init__.py +0 -0
- src/harness/src/openharness/services/session_backend.py +97 -0
- src/harness/src/openharness/services/session_memory/__init__.py +139 -0
- src/harness/src/openharness/services/session_storage.py +230 -0
- src/harness/src/openharness/services/token_estimation.py +15 -0
- src/harness/src/openharness/services/tool_outputs.py +55 -0
- src/harness/src/openharness/skills/__init__.py +44 -0
- src/harness/src/openharness/skills/_frontmatter.py +97 -0
- src/harness/src/openharness/skills/bundled/__init__.py +75 -0
- src/harness/src/openharness/skills/bundled/content/commit.md +25 -0
- src/harness/src/openharness/skills/bundled/content/debug.md +25 -0
- src/harness/src/openharness/skills/bundled/content/diagnose.md +35 -0
- src/harness/src/openharness/skills/bundled/content/plan.md +39 -0
- src/harness/src/openharness/skills/bundled/content/review.md +33 -0
- src/harness/src/openharness/skills/bundled/content/simplify.md +26 -0
- src/harness/src/openharness/skills/bundled/content/skill-creator.md +175 -0
- src/harness/src/openharness/skills/bundled/content/test.md +38 -0
- src/harness/src/openharness/skills/loader.py +229 -0
- src/harness/src/openharness/skills/registry.py +29 -0
- src/harness/src/openharness/skills/types.py +24 -0
- src/harness/src/openharness/state/__init__.py +6 -0
- src/harness/src/openharness/state/app_state.py +30 -0
- src/harness/src/openharness/state/store.py +40 -0
- src/harness/src/openharness/swarm/__init__.py +70 -0
- src/harness/src/openharness/swarm/in_process.py +693 -0
- src/harness/src/openharness/swarm/lockfile.py +24 -0
- src/harness/src/openharness/swarm/mailbox.py +522 -0
- src/harness/src/openharness/swarm/permission_sync.py +1168 -0
- src/harness/src/openharness/swarm/registry.py +410 -0
- src/harness/src/openharness/swarm/spawn_utils.py +229 -0
- src/harness/src/openharness/swarm/subprocess_backend.py +171 -0
- src/harness/src/openharness/swarm/team_lifecycle.py +910 -0
- src/harness/src/openharness/swarm/types.py +398 -0
- src/harness/src/openharness/swarm/worktree.py +315 -0
- src/harness/src/openharness/tasks/__init__.py +18 -0
- src/harness/src/openharness/tasks/local_agent_task.py +28 -0
- src/harness/src/openharness/tasks/local_shell_task.py +17 -0
- src/harness/src/openharness/tasks/manager.py +475 -0
- src/harness/src/openharness/tasks/stop_task.py +11 -0
- src/harness/src/openharness/tasks/types.py +32 -0
- src/harness/src/openharness/themes/__init__.py +21 -0
- src/harness/src/openharness/themes/builtin.py +89 -0
- src/harness/src/openharness/themes/loader.py +55 -0
- src/harness/src/openharness/themes/schema.py +54 -0
- src/harness/src/openharness/tools/__init__.py +107 -0
- src/harness/src/openharness/tools/agent_tool.py +141 -0
- src/harness/src/openharness/tools/ask_user_question_tool.py +46 -0
- src/harness/src/openharness/tools/base.py +80 -0
- src/harness/src/openharness/tools/bash_tool.py +259 -0
- src/harness/src/openharness/tools/brief_tool.py +33 -0
- src/harness/src/openharness/tools/config_tool.py +54 -0
- src/harness/src/openharness/tools/cron_create_tool.py +105 -0
- src/harness/src/openharness/tools/cron_delete_tool.py +32 -0
- src/harness/src/openharness/tools/cron_list_tool.py +69 -0
- src/harness/src/openharness/tools/cron_toggle_tool.py +37 -0
- src/harness/src/openharness/tools/enter_plan_mode_tool.py +28 -0
- src/harness/src/openharness/tools/enter_worktree_tool.py +80 -0
- src/harness/src/openharness/tools/exit_plan_mode_tool.py +28 -0
- src/harness/src/openharness/tools/exit_worktree_tool.py +42 -0
- src/harness/src/openharness/tools/file_edit_tool.py +95 -0
- src/harness/src/openharness/tools/file_read_tool.py +72 -0
- src/harness/src/openharness/tools/file_write_tool.py +87 -0
- src/harness/src/openharness/tools/glob_tool.py +175 -0
- src/harness/src/openharness/tools/grep_tool.py +374 -0
- src/harness/src/openharness/tools/image_generation_tool.py +447 -0
- src/harness/src/openharness/tools/image_to_text_tool.py +237 -0
- src/harness/src/openharness/tools/list_mcp_resources_tool.py +36 -0
- src/harness/src/openharness/tools/lsp_tool.py +154 -0
- src/harness/src/openharness/tools/mcp_auth_tool.py +71 -0
- src/harness/src/openharness/tools/mcp_tool.py +72 -0
- src/harness/src/openharness/tools/notebook_edit_tool.py +97 -0
- src/harness/src/openharness/tools/read_mcp_resource_tool.py +38 -0
- src/harness/src/openharness/tools/remote_trigger_tool.py +74 -0
- src/harness/src/openharness/tools/send_message_tool.py +58 -0
- src/harness/src/openharness/tools/skill_tool.py +43 -0
- src/harness/src/openharness/tools/sleep_tool.py +32 -0
- src/harness/src/openharness/tools/task_create_tool.py +56 -0
- src/harness/src/openharness/tools/task_get_tool.py +33 -0
- src/harness/src/openharness/tools/task_list_tool.py +35 -0
- src/harness/src/openharness/tools/task_output_tool.py +35 -0
- src/harness/src/openharness/tools/task_stop_tool.py +30 -0
- src/harness/src/openharness/tools/task_update_tool.py +50 -0
- src/harness/src/openharness/tools/team_create_tool.py +31 -0
- src/harness/src/openharness/tools/team_delete_tool.py +30 -0
- src/harness/src/openharness/tools/todo_write_tool.py +46 -0
- src/harness/src/openharness/tools/tool_search_tool.py +38 -0
- src/harness/src/openharness/tools/web_fetch_tool.py +117 -0
- src/harness/src/openharness/tools/web_search_tool.py +119 -0
- src/harness/src/openharness/ui/__init__.py +5 -0
- src/harness/src/openharness/ui/app.py +320 -0
- src/harness/src/openharness/ui/backend_host.py +941 -0
- src/harness/src/openharness/ui/coordinator_drain.py +198 -0
- src/harness/src/openharness/ui/input.py +32 -0
- src/harness/src/openharness/ui/output.py +265 -0
- src/harness/src/openharness/ui/permission_dialog.py +14 -0
- src/harness/src/openharness/ui/protocol.py +247 -0
- src/harness/src/openharness/ui/react_launcher.py +179 -0
- src/harness/src/openharness/ui/runtime.py +799 -0
- src/harness/src/openharness/ui/textual_app.py +495 -0
- src/harness/src/openharness/utils/__init__.py +0 -0
- src/harness/src/openharness/utils/file_lock.py +86 -0
- src/harness/src/openharness/utils/fs.py +98 -0
- src/harness/src/openharness/utils/helpers.py +77 -0
- src/harness/src/openharness/utils/network_guard.py +340 -0
- src/harness/src/openharness/utils/shell.py +147 -0
- src/harness/src/openharness/vim/__init__.py +5 -0
- src/harness/src/openharness/vim/transitions.py +8 -0
- src/harness/src/openharness/voice/__init__.py +7 -0
- src/harness/src/openharness/voice/keyterms.py +10 -0
- src/harness/src/openharness/voice/stream_stt.py +8 -0
- src/harness/src/openharness/voice/voice_mode.py +44 -0
- src/llm/__init__.py +29 -0
- src/llm/image_generation.py +402 -0
- src/llm/provider_registry.py +419 -0
- src/llm/runtime_env.py +64 -0
- src/llm/structured_output.py +131 -0
- src/llm/vision.py +198 -0
- src/memory/__init__.py +2 -0
- src/memory/api/__init__.py +1 -0
- src/memory/api/base_memory_system_api.py +98 -0
- src/memory/api/base_symbolic_memory_system_api.py +190 -0
- src/memory/api/base_vector_memory_system_api.py +116 -0
- src/memory/api/faiss_memory_system_api.py +228 -0
- src/memory/api/slot_process_api.py +827 -0
- src/memory/api/symbolic_memory_system_api.py +572 -0
- src/memory/memory_system/__init__.py +48 -0
- src/memory/memory_system/component_taxonomy.py +430 -0
- src/memory/memory_system/llm.py +437 -0
- src/memory/memory_system/models.py +168 -0
- src/memory/memory_system/user_prompt.py +418 -0
- src/memory/memory_system/utils.py +282 -0
- src/memory/memory_system/vectorstore.py +311 -0
- src/memory/memory_system/working_slot.py +99 -0
- src/pipeline/__init__.py +129 -0
- src/pipeline/contracts.py +531 -0
- src/pipeline/discipline_taxonomy.py +916 -0
- src/pipeline/evidence_coverage_ledger.py +1150 -0
- src/pipeline/evidence_refinement.py +620 -0
- src/pipeline/experiment_to_symbolic.py +137 -0
- src/pipeline/multimodal_evidence/__init__.py +28 -0
- src/pipeline/multimodal_evidence/capabilities.py +107 -0
- src/pipeline/multimodal_evidence/claims.py +135 -0
- src/pipeline/multimodal_evidence/contract.py +291 -0
- src/pipeline/multimodal_evidence/data_sh_compiler.py +505 -0
- src/pipeline/multimodal_evidence/handoff_binding.py +135 -0
- src/pipeline/multimodal_evidence/manifest.py +254 -0
- src/pipeline/multimodal_evidence/native_analysis.py +479 -0
- src/pipeline/multimodal_evidence/perception.py +277 -0
- src/pipeline/multimodal_evidence/query_binding.py +65 -0
- src/pipeline/multimodal_evidence/rendering.py +120 -0
- src/pipeline/multimodal_evidence/safety.py +42 -0
- src/pipeline/multimodal_evidence/sampling.py +69 -0
- src/pipeline/multimodal_evidence/service.py +148 -0
- src/pipeline/multimodal_evidence/survey_integration.py +460 -0
- src/pipeline/paper_identity.py +52 -0
- src/pipeline/research_design_inventory.py +338 -0
- src/pipeline/research_domain_resolution.py +669 -0
- src/pipeline/research_identity.py +548 -0
- src/pipeline/research_question_contract.py +646 -0
- src/pipeline/retrieval_lanes.py +889 -0
- src/pipeline/run_loop.py +862 -0
- src/pipeline/science_events.py +30 -0
- src/pipeline/science_manifests.py +602 -0
- src/pipeline/science_run.py +918 -0
- src/pipeline/science_stages.py +709 -0
- src/pipeline/science_workflow.py +807 -0
- src/pipeline/sh_cluster_projection.py +306 -0
- src/pipeline/sh_graph_provenance.py +648 -0
- src/pipeline/subhypothesis_decomposition.py +392 -0
- src/pipeline/subhypothesis_evidence.py +856 -0
- src/pipeline/survey_evidence_plan.py +858 -0
- src/pipeline/survey_gap_adjudication.py +647 -0
- src/pipeline/survey_gap_candidates.py +840 -0
- src/pipeline/survey_gap_ledger.py +683 -0
- src/pipeline/survey_gap_triage.py +401 -0
- src/pipeline/survey_handoff_persistence.py +481 -0
- src/pipeline/survey_handoff_projection.py +457 -0
- src/pipeline/survey_idea_handoff.py +1112 -0
- src/pipeline/survey_idea_loader.py +289 -0
- src/research_run_ids.py +11 -0
|
@@ -0,0 +1,2551 @@
|
|
|
1
|
+
"""CLI entry point using typer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
from contextlib import redirect_stderr
|
|
11
|
+
from io import StringIO
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
from urllib.parse import urlparse
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.9"
|
|
19
|
+
|
|
20
|
+
_PREVIEW_STOPWORDS = {
|
|
21
|
+
"a",
|
|
22
|
+
"an",
|
|
23
|
+
"and",
|
|
24
|
+
"bug",
|
|
25
|
+
"by",
|
|
26
|
+
"fix",
|
|
27
|
+
"for",
|
|
28
|
+
"get",
|
|
29
|
+
"help",
|
|
30
|
+
"in",
|
|
31
|
+
"of",
|
|
32
|
+
"on",
|
|
33
|
+
"or",
|
|
34
|
+
"please",
|
|
35
|
+
"show",
|
|
36
|
+
"test",
|
|
37
|
+
"that",
|
|
38
|
+
"the",
|
|
39
|
+
"this",
|
|
40
|
+
"to",
|
|
41
|
+
"with",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _safe_short(text: str, *, limit: int = 140) -> str:
|
|
46
|
+
normalized = " ".join(text.split())
|
|
47
|
+
if len(normalized) <= limit:
|
|
48
|
+
return normalized
|
|
49
|
+
return normalized[: limit - 3] + "..."
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _schema_argument_preview(tool_schema: dict[str, object]) -> dict[str, object]:
|
|
53
|
+
input_schema = tool_schema.get("input_schema")
|
|
54
|
+
if not isinstance(input_schema, dict):
|
|
55
|
+
return {"required_args": [], "optional_args": []}
|
|
56
|
+
properties = input_schema.get("properties")
|
|
57
|
+
if not isinstance(properties, dict):
|
|
58
|
+
return {"required_args": [], "optional_args": []}
|
|
59
|
+
required_raw = input_schema.get("required")
|
|
60
|
+
required = (
|
|
61
|
+
sorted(str(name) for name in required_raw if isinstance(name, str))
|
|
62
|
+
if isinstance(required_raw, list)
|
|
63
|
+
else []
|
|
64
|
+
)
|
|
65
|
+
optional = sorted(name for name in properties if name not in required)
|
|
66
|
+
return {"required_args": required, "optional_args": optional}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _mcp_transport_preview(config: object) -> dict[str, str]:
|
|
70
|
+
if hasattr(config, "type"):
|
|
71
|
+
transport = str(getattr(config, "type") or "unknown")
|
|
72
|
+
elif isinstance(config, dict):
|
|
73
|
+
transport = str(config.get("type") or "unknown")
|
|
74
|
+
else:
|
|
75
|
+
transport = "unknown"
|
|
76
|
+
|
|
77
|
+
if transport == "stdio":
|
|
78
|
+
command = getattr(config, "command", None) if not isinstance(config, dict) else config.get("command")
|
|
79
|
+
args = getattr(config, "args", None) if not isinstance(config, dict) else config.get("args")
|
|
80
|
+
rendered_args = " ".join(str(item) for item in args) if isinstance(args, list) and args else ""
|
|
81
|
+
target = " ".join(part for part in (str(command or "").strip(), rendered_args.strip()) if part).strip()
|
|
82
|
+
return {"transport": "stdio", "target": target or "configured"}
|
|
83
|
+
if transport in {"http", "ws"}:
|
|
84
|
+
url = getattr(config, "url", None) if not isinstance(config, dict) else config.get("url")
|
|
85
|
+
return {"transport": transport, "target": str(url or "").strip() or "configured"}
|
|
86
|
+
return {"transport": transport, "target": "configured"}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _validate_mcp_server(name: str, config: object) -> dict[str, object]:
|
|
90
|
+
preview = _mcp_transport_preview(config)
|
|
91
|
+
issues: list[str] = []
|
|
92
|
+
status = "ok"
|
|
93
|
+
transport = preview["transport"]
|
|
94
|
+
|
|
95
|
+
if transport == "stdio":
|
|
96
|
+
command = getattr(config, "command", None) if not isinstance(config, dict) else config.get("command")
|
|
97
|
+
raw_cwd = getattr(config, "cwd", None) if not isinstance(config, dict) else config.get("cwd")
|
|
98
|
+
command_text = str(command or "").strip()
|
|
99
|
+
if not command_text:
|
|
100
|
+
issues.append("missing command")
|
|
101
|
+
elif shutil.which(command_text) is None:
|
|
102
|
+
issues.append(f"command not found in PATH: {command_text}")
|
|
103
|
+
if raw_cwd:
|
|
104
|
+
resolved_cwd = Path(str(raw_cwd)).expanduser()
|
|
105
|
+
if not resolved_cwd.exists():
|
|
106
|
+
issues.append(f"cwd does not exist: {resolved_cwd}")
|
|
107
|
+
elif transport in {"http", "ws"}:
|
|
108
|
+
raw_url = getattr(config, "url", None) if not isinstance(config, dict) else config.get("url")
|
|
109
|
+
parsed = urlparse(str(raw_url or "").strip())
|
|
110
|
+
expected = {"http", "https"} if transport == "http" else {"ws", "wss"}
|
|
111
|
+
if parsed.scheme not in expected or not parsed.netloc:
|
|
112
|
+
issues.append(f"invalid {transport} url: {raw_url}")
|
|
113
|
+
|
|
114
|
+
if issues:
|
|
115
|
+
status = "error"
|
|
116
|
+
return {
|
|
117
|
+
"name": name,
|
|
118
|
+
**preview,
|
|
119
|
+
"status": status,
|
|
120
|
+
"issues": issues,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _dry_run_command_behavior(name: str) -> dict[str, str]:
|
|
125
|
+
read_only = {
|
|
126
|
+
"help",
|
|
127
|
+
"version",
|
|
128
|
+
"status",
|
|
129
|
+
"context",
|
|
130
|
+
"cost",
|
|
131
|
+
"usage",
|
|
132
|
+
"stats",
|
|
133
|
+
"hooks",
|
|
134
|
+
"onboarding",
|
|
135
|
+
"skills",
|
|
136
|
+
"mcp",
|
|
137
|
+
"doctor",
|
|
138
|
+
"diff",
|
|
139
|
+
"branch",
|
|
140
|
+
"privacy-settings",
|
|
141
|
+
"rate-limit-options",
|
|
142
|
+
"release-notes",
|
|
143
|
+
"upgrade",
|
|
144
|
+
"keybindings",
|
|
145
|
+
"files",
|
|
146
|
+
}
|
|
147
|
+
mutating = {
|
|
148
|
+
"clear",
|
|
149
|
+
"compact",
|
|
150
|
+
"resume",
|
|
151
|
+
"session",
|
|
152
|
+
"export",
|
|
153
|
+
"share",
|
|
154
|
+
"copy",
|
|
155
|
+
"tag",
|
|
156
|
+
"rewind",
|
|
157
|
+
"init",
|
|
158
|
+
"bridge",
|
|
159
|
+
"login",
|
|
160
|
+
"logout",
|
|
161
|
+
"feedback",
|
|
162
|
+
"config",
|
|
163
|
+
"plugin",
|
|
164
|
+
"reload-plugins",
|
|
165
|
+
"permissions",
|
|
166
|
+
"plan",
|
|
167
|
+
"fast",
|
|
168
|
+
"effort",
|
|
169
|
+
"passes",
|
|
170
|
+
"turns",
|
|
171
|
+
"continue",
|
|
172
|
+
"provider",
|
|
173
|
+
"model",
|
|
174
|
+
"theme",
|
|
175
|
+
"output-style",
|
|
176
|
+
"vim",
|
|
177
|
+
"voice",
|
|
178
|
+
"commit",
|
|
179
|
+
"issue",
|
|
180
|
+
"pr_comments",
|
|
181
|
+
"agents",
|
|
182
|
+
"subagents",
|
|
183
|
+
"tasks",
|
|
184
|
+
"autopilot",
|
|
185
|
+
"ship",
|
|
186
|
+
"memory",
|
|
187
|
+
}
|
|
188
|
+
if name in read_only:
|
|
189
|
+
return {
|
|
190
|
+
"kind": "read_only",
|
|
191
|
+
"detail": "This slash command mainly inspects current state and should not require a model turn.",
|
|
192
|
+
}
|
|
193
|
+
if name in mutating:
|
|
194
|
+
return {
|
|
195
|
+
"kind": "stateful",
|
|
196
|
+
"detail": "This slash command can mutate local state, queue work, or trigger follow-up execution depending on its arguments.",
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
"kind": "unknown",
|
|
200
|
+
"detail": "This slash command comes from a handler or plugin that dry-run cannot classify precisely.",
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _tokenize_preview_text(text: str) -> list[str]:
|
|
205
|
+
lowered = text.lower()
|
|
206
|
+
ascii_tokens = re.findall(r"[a-z0-9_/-]+", lowered)
|
|
207
|
+
cjk_tokens = [char for char in lowered if "\u4e00" <= char <= "\u9fff"]
|
|
208
|
+
seen: set[str] = set()
|
|
209
|
+
ordered: list[str] = []
|
|
210
|
+
for token in [*ascii_tokens, *cjk_tokens]:
|
|
211
|
+
normalized = token.strip("-_/")
|
|
212
|
+
if len(normalized) < 2 and normalized not in cjk_tokens:
|
|
213
|
+
continue
|
|
214
|
+
if normalized in _PREVIEW_STOPWORDS:
|
|
215
|
+
continue
|
|
216
|
+
if normalized and normalized not in seen:
|
|
217
|
+
seen.add(normalized)
|
|
218
|
+
ordered.append(normalized)
|
|
219
|
+
return ordered
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _score_candidate_match(prompt: str, *fields: str) -> tuple[int, list[str]]:
|
|
223
|
+
prompt_lower = prompt.lower()
|
|
224
|
+
prompt_tokens = _tokenize_preview_text(prompt)
|
|
225
|
+
haystack = " ".join(field.lower() for field in fields if field).strip()
|
|
226
|
+
if not haystack:
|
|
227
|
+
return 0, []
|
|
228
|
+
|
|
229
|
+
score = 0
|
|
230
|
+
reasons: list[str] = []
|
|
231
|
+
for token in prompt_tokens:
|
|
232
|
+
if token in haystack:
|
|
233
|
+
score += max(2, min(len(token), 8))
|
|
234
|
+
if len(reasons) < 3:
|
|
235
|
+
reasons.append(token)
|
|
236
|
+
primary_name = fields[0].lower() if fields and fields[0] else ""
|
|
237
|
+
if primary_name and primary_name in prompt_lower:
|
|
238
|
+
score += 10
|
|
239
|
+
if fields[0] not in reasons:
|
|
240
|
+
reasons.insert(0, fields[0])
|
|
241
|
+
return score, reasons[:3]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _candidate_entry(name: str, description: str, *, score: int, reasons: list[str]) -> dict[str, object]:
|
|
245
|
+
return {
|
|
246
|
+
"name": name,
|
|
247
|
+
"description": description,
|
|
248
|
+
"score": score,
|
|
249
|
+
"reasons": reasons,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _recommend_preview_candidates(
|
|
254
|
+
prompt: str | None,
|
|
255
|
+
*,
|
|
256
|
+
skills: list[object],
|
|
257
|
+
tool_schemas: list[dict[str, object]],
|
|
258
|
+
command_entries: list[dict[str, object]],
|
|
259
|
+
) -> dict[str, list[dict[str, object]]]:
|
|
260
|
+
if not prompt:
|
|
261
|
+
return {"skills": [], "tools": [], "commands": []}
|
|
262
|
+
stripped = prompt.strip()
|
|
263
|
+
if not stripped or stripped.startswith("/"):
|
|
264
|
+
return {"skills": [], "tools": [], "commands": []}
|
|
265
|
+
|
|
266
|
+
skill_matches: list[dict[str, object]] = []
|
|
267
|
+
for skill in skills:
|
|
268
|
+
score, reasons = _score_candidate_match(
|
|
269
|
+
stripped,
|
|
270
|
+
str(getattr(skill, "name", "")),
|
|
271
|
+
str(getattr(skill, "description", "")),
|
|
272
|
+
str(getattr(skill, "content", ""))[:800],
|
|
273
|
+
)
|
|
274
|
+
if score >= 4:
|
|
275
|
+
skill_matches.append(
|
|
276
|
+
_candidate_entry(
|
|
277
|
+
str(getattr(skill, "name", "")),
|
|
278
|
+
str(getattr(skill, "description", "")),
|
|
279
|
+
score=score,
|
|
280
|
+
reasons=reasons,
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
tool_matches: list[dict[str, object]] = []
|
|
285
|
+
for tool in tool_schemas:
|
|
286
|
+
optional = ", ".join(str(item) for item in tool.get("optional_args") or [])
|
|
287
|
+
required = ", ".join(str(item) for item in tool.get("required_args") or [])
|
|
288
|
+
score, reasons = _score_candidate_match(
|
|
289
|
+
stripped,
|
|
290
|
+
str(tool.get("name") or ""),
|
|
291
|
+
str(tool.get("description") or ""),
|
|
292
|
+
required,
|
|
293
|
+
optional,
|
|
294
|
+
)
|
|
295
|
+
if score >= 4:
|
|
296
|
+
tool_matches.append(
|
|
297
|
+
_candidate_entry(
|
|
298
|
+
str(tool.get("name") or ""),
|
|
299
|
+
str(tool.get("description") or ""),
|
|
300
|
+
score=score,
|
|
301
|
+
reasons=reasons,
|
|
302
|
+
)
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
command_matches: list[dict[str, object]] = []
|
|
306
|
+
for command in command_entries:
|
|
307
|
+
score, reasons = _score_candidate_match(
|
|
308
|
+
stripped,
|
|
309
|
+
str(command.get("name") or ""),
|
|
310
|
+
str(command.get("description") or ""),
|
|
311
|
+
str(command.get("behavior", {}).get("detail") or ""),
|
|
312
|
+
)
|
|
313
|
+
if score >= 8:
|
|
314
|
+
command_matches.append(
|
|
315
|
+
_candidate_entry(
|
|
316
|
+
str(command.get("name") or ""),
|
|
317
|
+
str(command.get("description") or ""),
|
|
318
|
+
score=score,
|
|
319
|
+
reasons=reasons,
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
skill_matches.sort(key=lambda entry: (-int(entry["score"]), str(entry["name"])))
|
|
324
|
+
tool_matches.sort(key=lambda entry: (-int(entry["score"]), str(entry["name"])))
|
|
325
|
+
command_matches.sort(key=lambda entry: (-int(entry["score"]), str(entry["name"])))
|
|
326
|
+
return {
|
|
327
|
+
"skills": skill_matches[:5],
|
|
328
|
+
"tools": tool_matches[:8],
|
|
329
|
+
"commands": command_matches[:5],
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _evaluate_dry_run_readiness(
|
|
334
|
+
*,
|
|
335
|
+
prompt: str | None,
|
|
336
|
+
entrypoint: dict[str, object],
|
|
337
|
+
validation: dict[str, object],
|
|
338
|
+
) -> dict[str, object]:
|
|
339
|
+
level = "ready"
|
|
340
|
+
reasons: list[str] = []
|
|
341
|
+
next_actions: list[str] = []
|
|
342
|
+
|
|
343
|
+
if entrypoint.get("kind") == "unknown_slash_command":
|
|
344
|
+
level = "blocked"
|
|
345
|
+
reasons.append("The prompt starts with '/' but does not match any registered slash command.")
|
|
346
|
+
next_actions.append("Check the command name and run `oh --dry-run -p \"/help\"` to inspect available slash commands.")
|
|
347
|
+
|
|
348
|
+
api_client = validation.get("api_client")
|
|
349
|
+
if isinstance(api_client, dict) and api_client.get("status") == "error":
|
|
350
|
+
if entrypoint.get("kind") == "model_prompt":
|
|
351
|
+
level = "blocked"
|
|
352
|
+
detail = str(api_client.get("detail") or "").strip()
|
|
353
|
+
reasons.append(detail or "Runtime client resolution failed for a prompt that would require a model call.")
|
|
354
|
+
next_actions.append("Fix authentication or provider profile configuration before running this prompt.")
|
|
355
|
+
elif level != "blocked":
|
|
356
|
+
level = "warning"
|
|
357
|
+
reasons.append("Runtime client resolution failed. Interactive commands may still work, but model execution would fail.")
|
|
358
|
+
next_actions.append("If you expect a model call later, fix authentication or provider profile configuration first.")
|
|
359
|
+
|
|
360
|
+
mcp_errors = int(validation.get("mcp_errors") or 0)
|
|
361
|
+
if mcp_errors > 0 and level != "blocked":
|
|
362
|
+
level = "warning"
|
|
363
|
+
reasons.append(f"{mcp_errors} configured MCP server(s) have obvious configuration errors.")
|
|
364
|
+
next_actions.append("Fix or disable the broken MCP server configuration before relying on MCP-backed tools.")
|
|
365
|
+
|
|
366
|
+
auth_status = str(validation.get("auth_status") or "")
|
|
367
|
+
if auth_status.startswith("missing") and entrypoint.get("kind") in {"interactive_session", "model_prompt"} and level != "blocked":
|
|
368
|
+
level = "warning"
|
|
369
|
+
reasons.append("Authentication is missing, so live model execution would not start successfully.")
|
|
370
|
+
next_actions.append("Run `oh auth login` or configure the active profile credentials before executing.")
|
|
371
|
+
|
|
372
|
+
if not prompt and level == "ready":
|
|
373
|
+
reasons.append("No prompt provided; dry-run only validated the session setup path.")
|
|
374
|
+
next_actions.append("Provide `-p/--print` for a single prompt preview, or start `oh` normally to enter an interactive session.")
|
|
375
|
+
elif level == "ready":
|
|
376
|
+
reasons.append("Resolved configuration, prompt assembly, and static discovery checks all look usable.")
|
|
377
|
+
if entrypoint.get("kind") == "slash_command":
|
|
378
|
+
next_actions.append(f"You can run `oh -p \"{prompt}\"` directly.")
|
|
379
|
+
elif entrypoint.get("kind") == "model_prompt":
|
|
380
|
+
next_actions.append("You can run this prompt directly with `oh -p '...'` or open the interactive UI with `oh`.")
|
|
381
|
+
else:
|
|
382
|
+
next_actions.append("You can run OpenHarness normally with the current configuration.")
|
|
383
|
+
|
|
384
|
+
deduped_actions: list[str] = []
|
|
385
|
+
seen_actions: set[str] = set()
|
|
386
|
+
for action in next_actions:
|
|
387
|
+
normalized = action.strip()
|
|
388
|
+
if not normalized or normalized in seen_actions:
|
|
389
|
+
continue
|
|
390
|
+
seen_actions.add(normalized)
|
|
391
|
+
deduped_actions.append(normalized)
|
|
392
|
+
|
|
393
|
+
return {"level": level, "reasons": reasons, "next_actions": deduped_actions}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _build_dry_run_preview(
|
|
397
|
+
*,
|
|
398
|
+
prompt: str | None,
|
|
399
|
+
cwd: str,
|
|
400
|
+
model: str | None,
|
|
401
|
+
max_turns: int | None,
|
|
402
|
+
base_url: str | None,
|
|
403
|
+
system_prompt: str | None,
|
|
404
|
+
append_system_prompt: str | None,
|
|
405
|
+
api_key: str | None,
|
|
406
|
+
api_format: str | None,
|
|
407
|
+
permission_mode: str | None,
|
|
408
|
+
effort: str | None = None,
|
|
409
|
+
) -> dict[str, object]:
|
|
410
|
+
from openharness.api.provider import auth_status, detect_provider
|
|
411
|
+
from openharness.commands import create_default_command_registry
|
|
412
|
+
from openharness.config import get_config_file_path, load_settings
|
|
413
|
+
from openharness.mcp.config import load_mcp_server_configs
|
|
414
|
+
from openharness.plugins import load_plugins
|
|
415
|
+
from openharness.prompts.context import build_runtime_system_prompt
|
|
416
|
+
from openharness.skills import load_skill_registry
|
|
417
|
+
from openharness.tools import create_default_tool_registry
|
|
418
|
+
from openharness.ui.runtime import _resolve_api_client_from_settings
|
|
419
|
+
|
|
420
|
+
resolved_cwd = str(Path(cwd).expanduser().resolve())
|
|
421
|
+
settings = load_settings().merge_cli_overrides(
|
|
422
|
+
model=model,
|
|
423
|
+
max_turns=max_turns,
|
|
424
|
+
base_url=base_url,
|
|
425
|
+
system_prompt=system_prompt,
|
|
426
|
+
api_key=api_key,
|
|
427
|
+
api_format=api_format,
|
|
428
|
+
permission_mode=permission_mode,
|
|
429
|
+
effort=effort,
|
|
430
|
+
)
|
|
431
|
+
provider = detect_provider(settings)
|
|
432
|
+
auth = auth_status(settings)
|
|
433
|
+
profile_name, profile = settings.resolve_profile()
|
|
434
|
+
|
|
435
|
+
plugins = load_plugins(settings, resolved_cwd)
|
|
436
|
+
plugin_commands = [
|
|
437
|
+
command
|
|
438
|
+
for plugin in plugins
|
|
439
|
+
if plugin.enabled
|
|
440
|
+
for command in plugin.commands
|
|
441
|
+
]
|
|
442
|
+
command_registry = create_default_command_registry(plugin_commands=plugin_commands)
|
|
443
|
+
command_match = command_registry.lookup(prompt) if prompt else None
|
|
444
|
+
skill_registry = load_skill_registry(resolved_cwd, settings=settings)
|
|
445
|
+
skills = skill_registry.list_skills()
|
|
446
|
+
mcp_servers = load_mcp_server_configs(settings, plugins)
|
|
447
|
+
tool_registry = create_default_tool_registry()
|
|
448
|
+
tool_schemas = []
|
|
449
|
+
for tool_schema in tool_registry.to_api_schema():
|
|
450
|
+
args_preview = _schema_argument_preview(tool_schema)
|
|
451
|
+
tool_schemas.append(
|
|
452
|
+
{
|
|
453
|
+
"name": str(tool_schema.get("name") or ""),
|
|
454
|
+
"description": str(tool_schema.get("description") or ""),
|
|
455
|
+
**args_preview,
|
|
456
|
+
}
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
client_validation = {"status": "ok", "detail": ""}
|
|
460
|
+
try:
|
|
461
|
+
with redirect_stderr(StringIO()):
|
|
462
|
+
_resolve_api_client_from_settings(settings)
|
|
463
|
+
except SystemExit:
|
|
464
|
+
client_validation = {"status": "error", "detail": "runtime client could not be resolved with current auth/config"}
|
|
465
|
+
except Exception as exc: # pragma: no cover - defensive diagnostic path
|
|
466
|
+
client_validation = {"status": "error", "detail": str(exc)}
|
|
467
|
+
|
|
468
|
+
preview_prompt = prompt.strip() if prompt else None
|
|
469
|
+
prompt_seed = preview_prompt
|
|
470
|
+
if append_system_prompt:
|
|
471
|
+
appended = append_system_prompt.strip()
|
|
472
|
+
if appended:
|
|
473
|
+
existing = settings.system_prompt or ""
|
|
474
|
+
settings = settings.model_copy(update={"system_prompt": f"{existing}\n\n{appended}".strip()})
|
|
475
|
+
system_prompt_text = build_runtime_system_prompt(
|
|
476
|
+
settings,
|
|
477
|
+
cwd=resolved_cwd,
|
|
478
|
+
latest_user_prompt=prompt_seed,
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
command_entries = []
|
|
482
|
+
for command in command_registry.list_commands():
|
|
483
|
+
behavior = _dry_run_command_behavior(command.name)
|
|
484
|
+
command_entries.append(
|
|
485
|
+
{
|
|
486
|
+
"name": command.name,
|
|
487
|
+
"description": command.description,
|
|
488
|
+
"remote_invocable": command.remote_invocable,
|
|
489
|
+
"remote_admin_opt_in": command.remote_admin_opt_in,
|
|
490
|
+
"behavior": behavior,
|
|
491
|
+
}
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
recommendations = _recommend_preview_candidates(
|
|
495
|
+
preview_prompt,
|
|
496
|
+
skills=skills,
|
|
497
|
+
tool_schemas=tool_schemas,
|
|
498
|
+
command_entries=command_entries,
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
if preview_prompt:
|
|
502
|
+
if preview_prompt.startswith("/") and command_match is not None:
|
|
503
|
+
matched_command = command_match[0]
|
|
504
|
+
behavior = _dry_run_command_behavior(matched_command.name)
|
|
505
|
+
entrypoint = {
|
|
506
|
+
"kind": "slash_command",
|
|
507
|
+
"command": matched_command.name,
|
|
508
|
+
"args": command_match[1],
|
|
509
|
+
"description": matched_command.description,
|
|
510
|
+
"remote_invocable": matched_command.remote_invocable,
|
|
511
|
+
"remote_admin_opt_in": matched_command.remote_admin_opt_in,
|
|
512
|
+
"behavior": behavior["kind"],
|
|
513
|
+
"detail": (
|
|
514
|
+
f"Input resolves to /{matched_command.name}. "
|
|
515
|
+
f"{behavior['detail']} Dry-run does not execute the command handler."
|
|
516
|
+
),
|
|
517
|
+
}
|
|
518
|
+
elif preview_prompt.startswith("/") and command_match is None:
|
|
519
|
+
entrypoint = {
|
|
520
|
+
"kind": "unknown_slash_command",
|
|
521
|
+
"detail": "Input starts with / but does not match a registered slash command.",
|
|
522
|
+
}
|
|
523
|
+
else:
|
|
524
|
+
entrypoint = {
|
|
525
|
+
"kind": "model_prompt",
|
|
526
|
+
"detail": (
|
|
527
|
+
"The first live step would be a model request. "
|
|
528
|
+
"Exact tool calls and parameters are decided by the model at runtime."
|
|
529
|
+
),
|
|
530
|
+
}
|
|
531
|
+
else:
|
|
532
|
+
entrypoint = {
|
|
533
|
+
"kind": "interactive_session",
|
|
534
|
+
"detail": "OpenHarness would start and wait for user input. No model or tool call happens until you submit one.",
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
preview = {
|
|
538
|
+
"mode": "dry-run",
|
|
539
|
+
"cwd": resolved_cwd,
|
|
540
|
+
"config_path": str(get_config_file_path()),
|
|
541
|
+
"prompt": preview_prompt,
|
|
542
|
+
"prompt_preview": _safe_short(preview_prompt or "", limit=220) if preview_prompt else "",
|
|
543
|
+
"settings": {
|
|
544
|
+
"active_profile": profile_name,
|
|
545
|
+
"profile_label": profile.label,
|
|
546
|
+
"provider": provider.name,
|
|
547
|
+
"api_format": settings.api_format,
|
|
548
|
+
"model": settings.model,
|
|
549
|
+
"base_url": settings.base_url or "",
|
|
550
|
+
"permission_mode": settings.permission.mode.value,
|
|
551
|
+
"max_turns": settings.max_turns,
|
|
552
|
+
"effort": settings.effort,
|
|
553
|
+
"passes": settings.passes,
|
|
554
|
+
},
|
|
555
|
+
"validation": {
|
|
556
|
+
"auth_status": auth,
|
|
557
|
+
"api_client": client_validation,
|
|
558
|
+
"system_prompt_chars": len(system_prompt_text),
|
|
559
|
+
"mcp_validation": "skipped in dry-run (configured only; external servers are not started)",
|
|
560
|
+
},
|
|
561
|
+
"entrypoint": entrypoint,
|
|
562
|
+
"commands": command_entries,
|
|
563
|
+
"skills": [
|
|
564
|
+
{
|
|
565
|
+
"name": skill.name,
|
|
566
|
+
"description": skill.description,
|
|
567
|
+
"source": skill.source,
|
|
568
|
+
}
|
|
569
|
+
for skill in skills
|
|
570
|
+
],
|
|
571
|
+
"tools": tool_schemas,
|
|
572
|
+
"recommendations": recommendations,
|
|
573
|
+
"plugins": [
|
|
574
|
+
{
|
|
575
|
+
"name": plugin.manifest.name,
|
|
576
|
+
"enabled": plugin.enabled,
|
|
577
|
+
"skills": len(plugin.skills),
|
|
578
|
+
"commands": len(plugin.commands),
|
|
579
|
+
"agents": len(plugin.agents),
|
|
580
|
+
"mcp_servers": len(plugin.mcp_servers),
|
|
581
|
+
}
|
|
582
|
+
for plugin in plugins
|
|
583
|
+
],
|
|
584
|
+
"mcp_servers": [
|
|
585
|
+
_validate_mcp_server(name, config)
|
|
586
|
+
for name, config in sorted(mcp_servers.items())
|
|
587
|
+
],
|
|
588
|
+
"system_prompt_preview": _safe_short(system_prompt_text, limit=600),
|
|
589
|
+
}
|
|
590
|
+
mcp_errors = sum(1 for entry in preview["mcp_servers"] if entry.get("status") == "error")
|
|
591
|
+
preview["validation"]["mcp_errors"] = mcp_errors
|
|
592
|
+
preview["readiness"] = _evaluate_dry_run_readiness(
|
|
593
|
+
prompt=preview_prompt,
|
|
594
|
+
entrypoint=preview["entrypoint"],
|
|
595
|
+
validation=preview["validation"],
|
|
596
|
+
)
|
|
597
|
+
return preview
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _format_dry_run_preview(preview: dict[str, object]) -> str:
|
|
601
|
+
settings = preview.get("settings") if isinstance(preview.get("settings"), dict) else {}
|
|
602
|
+
validation = preview.get("validation") if isinstance(preview.get("validation"), dict) else {}
|
|
603
|
+
entrypoint = preview.get("entrypoint") if isinstance(preview.get("entrypoint"), dict) else {}
|
|
604
|
+
readiness = preview.get("readiness") if isinstance(preview.get("readiness"), dict) else {}
|
|
605
|
+
recommendations = preview.get("recommendations") if isinstance(preview.get("recommendations"), dict) else {}
|
|
606
|
+
plugins = preview.get("plugins") if isinstance(preview.get("plugins"), list) else []
|
|
607
|
+
skills = preview.get("skills") if isinstance(preview.get("skills"), list) else []
|
|
608
|
+
commands = preview.get("commands") if isinstance(preview.get("commands"), list) else []
|
|
609
|
+
tools = preview.get("tools") if isinstance(preview.get("tools"), list) else []
|
|
610
|
+
mcp_servers = preview.get("mcp_servers") if isinstance(preview.get("mcp_servers"), list) else []
|
|
611
|
+
|
|
612
|
+
lines = [
|
|
613
|
+
"OpenHarness Dry Run",
|
|
614
|
+
"",
|
|
615
|
+
"Readiness",
|
|
616
|
+
f"- level: {readiness.get('level', 'unknown')}",
|
|
617
|
+
]
|
|
618
|
+
readiness_reasons = readiness.get("reasons")
|
|
619
|
+
if isinstance(readiness_reasons, list):
|
|
620
|
+
for reason in readiness_reasons[:4]:
|
|
621
|
+
lines.append(f"- {reason}")
|
|
622
|
+
readiness_actions = readiness.get("next_actions")
|
|
623
|
+
if isinstance(readiness_actions, list) and readiness_actions:
|
|
624
|
+
lines.append("- next actions:")
|
|
625
|
+
for action in readiness_actions[:4]:
|
|
626
|
+
lines.append(f" - {action}")
|
|
627
|
+
lines.extend(
|
|
628
|
+
[
|
|
629
|
+
"",
|
|
630
|
+
"Execution",
|
|
631
|
+
f"- cwd: {preview.get('cwd')}",
|
|
632
|
+
f"- prompt: {preview.get('prompt_preview') or '(none)'}",
|
|
633
|
+
f"- entrypoint: {entrypoint.get('kind', 'unknown')}",
|
|
634
|
+
f"- detail: {entrypoint.get('detail', '')}",
|
|
635
|
+
"",
|
|
636
|
+
"Resolved Settings",
|
|
637
|
+
f"- profile: {settings.get('active_profile')} ({settings.get('profile_label')})",
|
|
638
|
+
f"- provider: {settings.get('provider')}",
|
|
639
|
+
f"- api_format: {settings.get('api_format')}",
|
|
640
|
+
f"- model: {settings.get('model')}",
|
|
641
|
+
f"- base_url: {settings.get('base_url') or '(default)'}",
|
|
642
|
+
f"- permission_mode: {settings.get('permission_mode')}",
|
|
643
|
+
f"- max_turns: {settings.get('max_turns')}",
|
|
644
|
+
f"- effort: {settings.get('effort')} / passes={settings.get('passes')}",
|
|
645
|
+
"",
|
|
646
|
+
"Validation",
|
|
647
|
+
f"- auth: {validation.get('auth_status')}",
|
|
648
|
+
f"- api client: {validation.get('api_client', {}).get('status', 'unknown')}",
|
|
649
|
+
f"- system prompt chars: {validation.get('system_prompt_chars')}",
|
|
650
|
+
f"- mcp: {validation.get('mcp_validation')}",
|
|
651
|
+
f"- mcp config errors: {validation.get('mcp_errors', 0)}",
|
|
652
|
+
"",
|
|
653
|
+
"Discovery",
|
|
654
|
+
f"- plugins: {len(plugins)}",
|
|
655
|
+
f"- skills: {len(skills)}",
|
|
656
|
+
f"- slash commands: {len(commands)}",
|
|
657
|
+
f"- built-in tools: {len(tools)}",
|
|
658
|
+
f"- configured mcp servers: {len(mcp_servers)}",
|
|
659
|
+
]
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
if mcp_servers:
|
|
663
|
+
lines.extend(["", "Configured MCP"])
|
|
664
|
+
for entry in mcp_servers[:8]:
|
|
665
|
+
status = entry.get("status") or "unknown"
|
|
666
|
+
suffix = ""
|
|
667
|
+
issues = entry.get("issues")
|
|
668
|
+
if isinstance(issues, list) and issues:
|
|
669
|
+
suffix = f" [{'; '.join(str(item) for item in issues)}]"
|
|
670
|
+
lines.append(
|
|
671
|
+
f"- {entry.get('name')}: {entry.get('transport')} -> {entry.get('target')} ({status}){suffix}"
|
|
672
|
+
)
|
|
673
|
+
if len(mcp_servers) > 8:
|
|
674
|
+
lines.append(f"- ... (+{len(mcp_servers) - 8} more)")
|
|
675
|
+
|
|
676
|
+
if tools:
|
|
677
|
+
lines.extend(["", "Available Tools"])
|
|
678
|
+
for entry in tools[:12]:
|
|
679
|
+
required = entry.get("required_args") or []
|
|
680
|
+
optional = entry.get("optional_args") or []
|
|
681
|
+
signature_parts: list[str] = []
|
|
682
|
+
if required:
|
|
683
|
+
signature_parts.append("required: " + ", ".join(required))
|
|
684
|
+
if optional:
|
|
685
|
+
signature_parts.append("optional: " + ", ".join(optional[:4]))
|
|
686
|
+
suffix = f" ({'; '.join(signature_parts)})" if signature_parts else ""
|
|
687
|
+
lines.append(f"- {entry.get('name')}{suffix}")
|
|
688
|
+
if len(tools) > 12:
|
|
689
|
+
lines.append(f"- ... (+{len(tools) - 12} more)")
|
|
690
|
+
|
|
691
|
+
if skills:
|
|
692
|
+
lines.extend(["", "Available Skills"])
|
|
693
|
+
for entry in skills[:8]:
|
|
694
|
+
lines.append(f"- {entry.get('name')}: {_safe_short(str(entry.get('description') or ''), limit=100)}")
|
|
695
|
+
if len(skills) > 8:
|
|
696
|
+
lines.append(f"- ... (+{len(skills) - 8} more)")
|
|
697
|
+
|
|
698
|
+
recommended_skills = recommendations.get("skills") if isinstance(recommendations.get("skills"), list) else []
|
|
699
|
+
recommended_tools = recommendations.get("tools") if isinstance(recommendations.get("tools"), list) else []
|
|
700
|
+
recommended_commands = recommendations.get("commands") if isinstance(recommendations.get("commands"), list) else []
|
|
701
|
+
if recommended_skills or recommended_tools or recommended_commands:
|
|
702
|
+
lines.extend(["", "Likely Matches"])
|
|
703
|
+
if recommended_skills:
|
|
704
|
+
lines.append("- skills:")
|
|
705
|
+
for entry in recommended_skills[:4]:
|
|
706
|
+
reasons = ", ".join(str(item) for item in entry.get("reasons") or [])
|
|
707
|
+
suffix = f" [{reasons}]" if reasons else ""
|
|
708
|
+
lines.append(f" - {entry.get('name')} (score={entry.get('score')}){suffix}")
|
|
709
|
+
if recommended_tools:
|
|
710
|
+
lines.append("- tools:")
|
|
711
|
+
for entry in recommended_tools[:6]:
|
|
712
|
+
reasons = ", ".join(str(item) for item in entry.get("reasons") or [])
|
|
713
|
+
suffix = f" [{reasons}]" if reasons else ""
|
|
714
|
+
lines.append(f" - {entry.get('name')} (score={entry.get('score')}){suffix}")
|
|
715
|
+
if recommended_commands:
|
|
716
|
+
lines.append("- slash commands:")
|
|
717
|
+
for entry in recommended_commands[:4]:
|
|
718
|
+
reasons = ", ".join(str(item) for item in entry.get("reasons") or [])
|
|
719
|
+
suffix = f" [{reasons}]" if reasons else ""
|
|
720
|
+
lines.append(f" - /{entry.get('name')} (score={entry.get('score')}){suffix}")
|
|
721
|
+
|
|
722
|
+
if entrypoint.get("kind") == "slash_command":
|
|
723
|
+
lines.extend(
|
|
724
|
+
[
|
|
725
|
+
"",
|
|
726
|
+
"Slash Command Detail",
|
|
727
|
+
f"- command: /{entrypoint.get('command')}",
|
|
728
|
+
f"- description: {entrypoint.get('description')}",
|
|
729
|
+
f"- behavior: {entrypoint.get('behavior')}",
|
|
730
|
+
f"- remote_invocable: {entrypoint.get('remote_invocable')}",
|
|
731
|
+
f"- remote_admin_opt_in: {entrypoint.get('remote_admin_opt_in')}",
|
|
732
|
+
]
|
|
733
|
+
)
|
|
734
|
+
args = str(entrypoint.get("args") or "").strip()
|
|
735
|
+
if args:
|
|
736
|
+
lines.append(f"- args: {args}")
|
|
737
|
+
|
|
738
|
+
preview_text = str(preview.get("system_prompt_preview") or "").strip()
|
|
739
|
+
if preview_text:
|
|
740
|
+
lines.extend(["", "System Prompt Preview", preview_text])
|
|
741
|
+
|
|
742
|
+
return "\n".join(lines)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _version_callback(value: bool) -> None:
|
|
746
|
+
if value:
|
|
747
|
+
print(f"openharness {__version__}")
|
|
748
|
+
raise typer.Exit()
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
app = typer.Typer(
|
|
752
|
+
name="openharness",
|
|
753
|
+
help=(
|
|
754
|
+
"Oh my Harness! An AI-powered coding assistant.\n\n"
|
|
755
|
+
"Starts an interactive session by default, use -p/--print for non-interactive output."
|
|
756
|
+
),
|
|
757
|
+
add_completion=False,
|
|
758
|
+
rich_markup_mode="rich",
|
|
759
|
+
invoke_without_command=True,
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
# ---------------------------------------------------------------------------
|
|
764
|
+
# Subcommands
|
|
765
|
+
# ---------------------------------------------------------------------------
|
|
766
|
+
|
|
767
|
+
mcp_app = typer.Typer(name="mcp", help="Manage MCP servers")
|
|
768
|
+
plugin_app = typer.Typer(name="plugin", help="Manage plugins")
|
|
769
|
+
auth_app = typer.Typer(name="auth", help="Manage authentication")
|
|
770
|
+
provider_app = typer.Typer(name="provider", help="Manage provider profiles")
|
|
771
|
+
config_app = typer.Typer(name="config", help="Show or update settings")
|
|
772
|
+
cron_app = typer.Typer(name="cron", help="Manage cron scheduler and jobs")
|
|
773
|
+
autopilot_app = typer.Typer(name="autopilot", help="Manage repo autopilot")
|
|
774
|
+
|
|
775
|
+
app.add_typer(mcp_app)
|
|
776
|
+
app.add_typer(plugin_app)
|
|
777
|
+
app.add_typer(auth_app)
|
|
778
|
+
app.add_typer(provider_app)
|
|
779
|
+
app.add_typer(config_app)
|
|
780
|
+
app.add_typer(cron_app)
|
|
781
|
+
app.add_typer(autopilot_app)
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
# ---- mcp subcommands ----
|
|
785
|
+
|
|
786
|
+
@mcp_app.command("list")
|
|
787
|
+
def mcp_list() -> None:
|
|
788
|
+
"""List configured MCP servers."""
|
|
789
|
+
from openharness.config import load_settings
|
|
790
|
+
from openharness.mcp.config import load_mcp_server_configs
|
|
791
|
+
from openharness.plugins import load_plugins
|
|
792
|
+
|
|
793
|
+
settings = load_settings()
|
|
794
|
+
plugins = load_plugins(settings, str(Path.cwd()))
|
|
795
|
+
configs = load_mcp_server_configs(settings, plugins)
|
|
796
|
+
if not configs:
|
|
797
|
+
print("No MCP servers configured.")
|
|
798
|
+
return
|
|
799
|
+
for name, cfg in configs.items():
|
|
800
|
+
transport = cfg.get("transport", cfg.get("command", "unknown"))
|
|
801
|
+
print(f" {name}: {transport}")
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
@mcp_app.command("add")
|
|
805
|
+
def mcp_add(
|
|
806
|
+
name: str = typer.Argument(..., help="Server name"),
|
|
807
|
+
config_json: str = typer.Argument(..., help="Server config as JSON string"),
|
|
808
|
+
) -> None:
|
|
809
|
+
"""Add an MCP server configuration."""
|
|
810
|
+
from openharness.config import load_settings, save_settings
|
|
811
|
+
|
|
812
|
+
settings = load_settings()
|
|
813
|
+
try:
|
|
814
|
+
cfg = json.loads(config_json)
|
|
815
|
+
except json.JSONDecodeError as exc:
|
|
816
|
+
print(f"Invalid JSON: {exc}", file=sys.stderr)
|
|
817
|
+
raise typer.Exit(1)
|
|
818
|
+
if not isinstance(settings.mcp_servers, dict):
|
|
819
|
+
settings.mcp_servers = {}
|
|
820
|
+
settings.mcp_servers[name] = cfg
|
|
821
|
+
save_settings(settings)
|
|
822
|
+
print(f"Added MCP server: {name}")
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
@mcp_app.command("remove")
|
|
826
|
+
def mcp_remove(
|
|
827
|
+
name: str = typer.Argument(..., help="Server name to remove"),
|
|
828
|
+
) -> None:
|
|
829
|
+
"""Remove an MCP server configuration."""
|
|
830
|
+
from openharness.config import load_settings, save_settings
|
|
831
|
+
|
|
832
|
+
settings = load_settings()
|
|
833
|
+
if not isinstance(settings.mcp_servers, dict) or name not in settings.mcp_servers:
|
|
834
|
+
print(f"MCP server not found: {name}", file=sys.stderr)
|
|
835
|
+
raise typer.Exit(1)
|
|
836
|
+
del settings.mcp_servers[name]
|
|
837
|
+
save_settings(settings)
|
|
838
|
+
print(f"Removed MCP server: {name}")
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
# ---- plugin subcommands ----
|
|
842
|
+
|
|
843
|
+
@plugin_app.command("list")
|
|
844
|
+
def plugin_list() -> None:
|
|
845
|
+
"""List installed plugins."""
|
|
846
|
+
from openharness.config import load_settings
|
|
847
|
+
from openharness.plugins import load_plugins
|
|
848
|
+
|
|
849
|
+
settings = load_settings()
|
|
850
|
+
plugins = load_plugins(settings, str(Path.cwd()))
|
|
851
|
+
if not plugins:
|
|
852
|
+
print("No plugins installed.")
|
|
853
|
+
return
|
|
854
|
+
for plugin in plugins:
|
|
855
|
+
status = "enabled" if plugin.enabled else "disabled"
|
|
856
|
+
print(f" {plugin.name} [{status}] - {plugin.description or ''}")
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
@plugin_app.command("install")
|
|
860
|
+
def plugin_install(
|
|
861
|
+
source: str = typer.Argument(..., help="Plugin source (path or URL)"),
|
|
862
|
+
) -> None:
|
|
863
|
+
"""Install a plugin from a source path."""
|
|
864
|
+
from openharness.plugins.installer import install_plugin_from_path
|
|
865
|
+
|
|
866
|
+
result = install_plugin_from_path(source)
|
|
867
|
+
print(f"Installed plugin: {result}")
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
@plugin_app.command("uninstall")
|
|
871
|
+
def plugin_uninstall(
|
|
872
|
+
name: str = typer.Argument(..., help="Plugin name to uninstall"),
|
|
873
|
+
) -> None:
|
|
874
|
+
"""Uninstall a plugin."""
|
|
875
|
+
from openharness.plugins.installer import uninstall_plugin
|
|
876
|
+
|
|
877
|
+
try:
|
|
878
|
+
uninstall_plugin(name)
|
|
879
|
+
except ValueError as exc:
|
|
880
|
+
raise typer.BadParameter("invalid plugin name") from exc
|
|
881
|
+
print(f"Uninstalled plugin: {name}")
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
# ---- cron subcommands ----
|
|
885
|
+
|
|
886
|
+
@cron_app.command("start")
|
|
887
|
+
def cron_start() -> None:
|
|
888
|
+
"""Start the cron scheduler daemon."""
|
|
889
|
+
from openharness.services.cron_scheduler import is_scheduler_running, start_daemon
|
|
890
|
+
|
|
891
|
+
if is_scheduler_running():
|
|
892
|
+
print("Cron scheduler is already running.")
|
|
893
|
+
return
|
|
894
|
+
pid = start_daemon()
|
|
895
|
+
print(f"Cron scheduler started (pid={pid})")
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
@cron_app.command("stop")
|
|
899
|
+
def cron_stop() -> None:
|
|
900
|
+
"""Stop the cron scheduler daemon."""
|
|
901
|
+
from openharness.services.cron_scheduler import stop_scheduler
|
|
902
|
+
|
|
903
|
+
if stop_scheduler():
|
|
904
|
+
print("Cron scheduler stopped.")
|
|
905
|
+
else:
|
|
906
|
+
print("Cron scheduler is not running.")
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
@cron_app.command("status")
|
|
910
|
+
def cron_status_cmd() -> None:
|
|
911
|
+
"""Show cron scheduler status and job summary."""
|
|
912
|
+
from openharness.services.cron_scheduler import scheduler_status
|
|
913
|
+
|
|
914
|
+
status = scheduler_status()
|
|
915
|
+
state = "running" if status["running"] else "stopped"
|
|
916
|
+
print(f"Scheduler: {state}" + (f" (pid={status['pid']})" if status["pid"] else ""))
|
|
917
|
+
print(f"Jobs: {status['enabled_jobs']} enabled / {status['total_jobs']} total")
|
|
918
|
+
print(f"Log: {status['log_file']}")
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
@cron_app.command("list")
|
|
922
|
+
def cron_list_cmd() -> None:
|
|
923
|
+
"""List all registered cron jobs with schedule and status."""
|
|
924
|
+
from openharness.services.cron import load_cron_jobs
|
|
925
|
+
|
|
926
|
+
jobs = load_cron_jobs()
|
|
927
|
+
if not jobs:
|
|
928
|
+
print("No cron jobs configured.")
|
|
929
|
+
return
|
|
930
|
+
for job in jobs:
|
|
931
|
+
enabled = "on " if job.get("enabled", True) else "off"
|
|
932
|
+
last = job.get("last_run", "never")
|
|
933
|
+
if last != "never":
|
|
934
|
+
last = last[:19] # trim to readable datetime
|
|
935
|
+
last_status = job.get("last_status", "")
|
|
936
|
+
status_indicator = f" [{last_status}]" if last_status else ""
|
|
937
|
+
timezone = f" ({job['timezone']})" if job.get("timezone") else ""
|
|
938
|
+
print(f" [{enabled}] {job['name']} {job.get('schedule', '?')}{timezone}")
|
|
939
|
+
print(f" cmd: {job.get('command') or '(agent_turn)'}")
|
|
940
|
+
payload = job.get("payload")
|
|
941
|
+
if isinstance(payload, dict):
|
|
942
|
+
print(
|
|
943
|
+
f" payload: {payload.get('kind', 'agent_turn')} -> "
|
|
944
|
+
f"{payload.get('channel', '?')}:{payload.get('to', '?')}"
|
|
945
|
+
)
|
|
946
|
+
notify = job.get("notify")
|
|
947
|
+
if isinstance(notify, dict):
|
|
948
|
+
notify_type = notify.get("type", "?")
|
|
949
|
+
target = notify.get("user_open_id") or notify.get("open_id") or notify.get("chat_id") or "?"
|
|
950
|
+
print(f" notify: {notify_type} -> {target}")
|
|
951
|
+
print(f" last: {last}{status_indicator} next: {job.get('next_run', 'n/a')[:19]}")
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
@cron_app.command("toggle")
|
|
955
|
+
def cron_toggle_cmd(
|
|
956
|
+
name: str = typer.Argument(..., help="Cron job name"),
|
|
957
|
+
enabled: bool = typer.Argument(..., help="true to enable, false to disable"),
|
|
958
|
+
) -> None:
|
|
959
|
+
"""Enable or disable a cron job."""
|
|
960
|
+
from openharness.services.cron import set_job_enabled
|
|
961
|
+
|
|
962
|
+
if not set_job_enabled(name, enabled):
|
|
963
|
+
print(f"Cron job not found: {name}")
|
|
964
|
+
raise typer.Exit(1)
|
|
965
|
+
state = "enabled" if enabled else "disabled"
|
|
966
|
+
print(f"Cron job '{name}' is now {state}")
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
@cron_app.command("history")
|
|
970
|
+
def cron_history_cmd(
|
|
971
|
+
name: str | None = typer.Argument(None, help="Filter by job name"),
|
|
972
|
+
limit: int = typer.Option(20, "--limit", "-n", help="Number of entries"),
|
|
973
|
+
) -> None:
|
|
974
|
+
"""Show cron execution history."""
|
|
975
|
+
from openharness.services.cron_scheduler import load_history
|
|
976
|
+
|
|
977
|
+
entries = load_history(limit=limit, job_name=name)
|
|
978
|
+
if not entries:
|
|
979
|
+
print("No execution history.")
|
|
980
|
+
return
|
|
981
|
+
for entry in entries:
|
|
982
|
+
ts = entry.get("started_at", "?")[:19]
|
|
983
|
+
status = entry.get("status", "?")
|
|
984
|
+
rc = entry.get("returncode", "?")
|
|
985
|
+
print(f" {ts} {entry.get('name', '?')} {status} (rc={rc})")
|
|
986
|
+
stderr = entry.get("stderr", "").strip()
|
|
987
|
+
if stderr and status != "success":
|
|
988
|
+
for line in stderr.splitlines()[:3]:
|
|
989
|
+
print(f" stderr: {line}")
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
@cron_app.command("logs")
|
|
993
|
+
def cron_logs_cmd(
|
|
994
|
+
lines: int = typer.Option(30, "--lines", "-n", help="Number of lines to show"),
|
|
995
|
+
) -> None:
|
|
996
|
+
"""Show recent cron scheduler log output."""
|
|
997
|
+
from openharness.config.paths import get_logs_dir
|
|
998
|
+
|
|
999
|
+
log_path = get_logs_dir() / "cron_scheduler.log"
|
|
1000
|
+
if not log_path.exists():
|
|
1001
|
+
print("No scheduler log found. Start the scheduler with: oh cron start")
|
|
1002
|
+
return
|
|
1003
|
+
content = log_path.read_text(encoding="utf-8", errors="replace")
|
|
1004
|
+
tail = content.splitlines()[-lines:]
|
|
1005
|
+
for line in tail:
|
|
1006
|
+
print(line)
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
# ---- autopilot subcommands ----
|
|
1010
|
+
|
|
1011
|
+
@autopilot_app.command("status")
|
|
1012
|
+
def autopilot_status_cmd(
|
|
1013
|
+
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Repository root"),
|
|
1014
|
+
) -> None:
|
|
1015
|
+
"""Show repo autopilot queue status."""
|
|
1016
|
+
from openharness.autopilot import RepoAutopilotStore
|
|
1017
|
+
|
|
1018
|
+
store = RepoAutopilotStore(cwd)
|
|
1019
|
+
counts = store.stats()
|
|
1020
|
+
print("Autopilot queue status:")
|
|
1021
|
+
for status_name in (
|
|
1022
|
+
"queued",
|
|
1023
|
+
"accepted",
|
|
1024
|
+
"preparing",
|
|
1025
|
+
"running",
|
|
1026
|
+
"verifying",
|
|
1027
|
+
"pr_open",
|
|
1028
|
+
"waiting_ci",
|
|
1029
|
+
"repairing",
|
|
1030
|
+
"completed",
|
|
1031
|
+
"merged",
|
|
1032
|
+
"failed",
|
|
1033
|
+
"rejected",
|
|
1034
|
+
"superseded",
|
|
1035
|
+
):
|
|
1036
|
+
print(f" {status_name}: {counts.get(status_name, 0)}")
|
|
1037
|
+
next_card = store.pick_next_card()
|
|
1038
|
+
if next_card is not None:
|
|
1039
|
+
print(f" next: {next_card.id} {next_card.title} (score={next_card.score})")
|
|
1040
|
+
print(f" registry: {store.registry_path}")
|
|
1041
|
+
print(f" journal: {store.journal_path}")
|
|
1042
|
+
print(f" context: {store.context_path}")
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
@autopilot_app.command("list")
|
|
1046
|
+
def autopilot_list_cmd(
|
|
1047
|
+
status: str | None = typer.Argument(None, help="Optional status filter"),
|
|
1048
|
+
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Repository root"),
|
|
1049
|
+
) -> None:
|
|
1050
|
+
"""List repo autopilot cards."""
|
|
1051
|
+
from openharness.autopilot import RepoAutopilotStore
|
|
1052
|
+
|
|
1053
|
+
store = RepoAutopilotStore(cwd)
|
|
1054
|
+
cards = store.list_cards(status=status) if status else store.list_cards()
|
|
1055
|
+
if not cards:
|
|
1056
|
+
print("No autopilot cards.")
|
|
1057
|
+
return
|
|
1058
|
+
for card in cards[:20]:
|
|
1059
|
+
print(f"{card.id} [{card.status}] score={card.score} {card.title}")
|
|
1060
|
+
print(f" source={card.source_kind} ref={card.source_ref or '-'}")
|
|
1061
|
+
if card.body:
|
|
1062
|
+
print(f" {_safe_short(card.body)}")
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
@autopilot_app.command("add")
|
|
1066
|
+
def autopilot_add_cmd(
|
|
1067
|
+
source: str = typer.Argument("manual_idea", help="Source kind: idea, ohmo, issue, pr, claude"),
|
|
1068
|
+
title: str = typer.Argument(..., help="Task title"),
|
|
1069
|
+
body: str = typer.Option("", "--body", help="Task body/details"),
|
|
1070
|
+
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Repository root"),
|
|
1071
|
+
) -> None:
|
|
1072
|
+
"""Add one repo autopilot card."""
|
|
1073
|
+
from openharness.autopilot import RepoAutopilotStore
|
|
1074
|
+
|
|
1075
|
+
source_map = {
|
|
1076
|
+
"idea": "manual_idea",
|
|
1077
|
+
"manual": "manual_idea",
|
|
1078
|
+
"manual_idea": "manual_idea",
|
|
1079
|
+
"ohmo": "ohmo_request",
|
|
1080
|
+
"ohmo_request": "ohmo_request",
|
|
1081
|
+
"issue": "github_issue",
|
|
1082
|
+
"github_issue": "github_issue",
|
|
1083
|
+
"pr": "github_pr",
|
|
1084
|
+
"github_pr": "github_pr",
|
|
1085
|
+
"claude": "claude_code_candidate",
|
|
1086
|
+
"claude_code_candidate": "claude_code_candidate",
|
|
1087
|
+
}
|
|
1088
|
+
source_kind = source_map.get(source.lower())
|
|
1089
|
+
if source_kind is None:
|
|
1090
|
+
print(f"Unknown source kind: {source}", file=sys.stderr)
|
|
1091
|
+
raise typer.Exit(1)
|
|
1092
|
+
store = RepoAutopilotStore(cwd)
|
|
1093
|
+
card, created = store.enqueue_card(source_kind=source_kind, title=title, body=body)
|
|
1094
|
+
state = "Queued" if created else "Refreshed"
|
|
1095
|
+
print(f"{state} {card.id} (score={card.score}): {card.title}")
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
@autopilot_app.command("context")
|
|
1099
|
+
def autopilot_context_cmd(
|
|
1100
|
+
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Repository root"),
|
|
1101
|
+
) -> None:
|
|
1102
|
+
"""Print the synthesized active repo context."""
|
|
1103
|
+
from openharness.autopilot import RepoAutopilotStore
|
|
1104
|
+
|
|
1105
|
+
store = RepoAutopilotStore(cwd)
|
|
1106
|
+
print(store.load_active_context())
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
@autopilot_app.command("journal")
|
|
1110
|
+
def autopilot_journal_cmd(
|
|
1111
|
+
limit: int = typer.Option(12, "--limit", "-n", help="Number of entries"),
|
|
1112
|
+
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Repository root"),
|
|
1113
|
+
) -> None:
|
|
1114
|
+
"""Print the recent repo autopilot journal."""
|
|
1115
|
+
from openharness.autopilot import RepoAutopilotStore
|
|
1116
|
+
|
|
1117
|
+
store = RepoAutopilotStore(cwd)
|
|
1118
|
+
entries = store.load_journal(limit=limit)
|
|
1119
|
+
if not entries:
|
|
1120
|
+
print("Repo journal is empty.")
|
|
1121
|
+
return
|
|
1122
|
+
for entry in entries:
|
|
1123
|
+
print(f"{entry.kind} {entry.task_id or '-'} {entry.summary}")
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
@autopilot_app.command("scan")
|
|
1127
|
+
def autopilot_scan_cmd(
|
|
1128
|
+
target: str = typer.Argument(..., help="issues, prs, claude-code, or all"),
|
|
1129
|
+
limit: int = typer.Option(10, "--limit", "-n", help="Number of items"),
|
|
1130
|
+
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Repository root"),
|
|
1131
|
+
) -> None:
|
|
1132
|
+
"""Scan one or more autopilot intake sources."""
|
|
1133
|
+
from openharness.autopilot import RepoAutopilotStore
|
|
1134
|
+
|
|
1135
|
+
store = RepoAutopilotStore(cwd)
|
|
1136
|
+
if target == "issues":
|
|
1137
|
+
print(f"Scanned {len(store.scan_github_issues(limit=limit))} GitHub issues.")
|
|
1138
|
+
return
|
|
1139
|
+
if target == "prs":
|
|
1140
|
+
print(f"Scanned {len(store.scan_github_prs(limit=limit))} GitHub PRs.")
|
|
1141
|
+
return
|
|
1142
|
+
if target == "claude-code":
|
|
1143
|
+
print(f"Scanned {len(store.scan_claude_code_candidates(limit=limit))} claude-code candidates.")
|
|
1144
|
+
return
|
|
1145
|
+
if target == "all":
|
|
1146
|
+
print(json.dumps(store.scan_all_sources(issue_limit=limit, pr_limit=limit), ensure_ascii=False))
|
|
1147
|
+
return
|
|
1148
|
+
print(f"Unknown scan target: {target}", file=sys.stderr)
|
|
1149
|
+
raise typer.Exit(1)
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
@autopilot_app.command("run-next")
|
|
1153
|
+
def autopilot_run_next_cmd(
|
|
1154
|
+
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Repository root"),
|
|
1155
|
+
model: str | None = typer.Option(None, "--model", help="Override execution model"),
|
|
1156
|
+
max_turns: int | None = typer.Option(None, "--max-turns", help="Override execution max turns"),
|
|
1157
|
+
permission_mode: str | None = typer.Option(None, "--permission-mode", help="Override execution permission mode"),
|
|
1158
|
+
) -> None:
|
|
1159
|
+
"""Run the highest-priority queued autopilot card end-to-end."""
|
|
1160
|
+
import asyncio
|
|
1161
|
+
from openharness.autopilot import RepoAutopilotStore
|
|
1162
|
+
|
|
1163
|
+
try:
|
|
1164
|
+
result = asyncio.run(
|
|
1165
|
+
RepoAutopilotStore(cwd).run_next(
|
|
1166
|
+
model=model,
|
|
1167
|
+
max_turns=max_turns,
|
|
1168
|
+
permission_mode=permission_mode,
|
|
1169
|
+
)
|
|
1170
|
+
)
|
|
1171
|
+
except ValueError as exc:
|
|
1172
|
+
print(str(exc), file=sys.stderr)
|
|
1173
|
+
raise typer.Exit(1)
|
|
1174
|
+
print(f"{result.card_id} -> {result.status}")
|
|
1175
|
+
print(f"run report: {result.run_report_path}")
|
|
1176
|
+
print(f"verification report: {result.verification_report_path}")
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
@autopilot_app.command("tick")
|
|
1180
|
+
def autopilot_tick_cmd(
|
|
1181
|
+
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Repository root"),
|
|
1182
|
+
model: str | None = typer.Option(None, "--model", help="Override execution model"),
|
|
1183
|
+
max_turns: int | None = typer.Option(None, "--max-turns", help="Override execution max turns"),
|
|
1184
|
+
permission_mode: str | None = typer.Option(None, "--permission-mode", help="Override execution permission mode"),
|
|
1185
|
+
limit: int = typer.Option(10, "--limit", "-n", help="Scan limit for issues/PRs"),
|
|
1186
|
+
) -> None:
|
|
1187
|
+
"""Scan sources and, if idle, run the next queued autopilot task."""
|
|
1188
|
+
import asyncio
|
|
1189
|
+
from openharness.autopilot import RepoAutopilotStore
|
|
1190
|
+
|
|
1191
|
+
try:
|
|
1192
|
+
result = asyncio.run(
|
|
1193
|
+
RepoAutopilotStore(cwd).tick(
|
|
1194
|
+
model=model,
|
|
1195
|
+
max_turns=max_turns,
|
|
1196
|
+
permission_mode=permission_mode,
|
|
1197
|
+
issue_limit=limit,
|
|
1198
|
+
pr_limit=limit,
|
|
1199
|
+
)
|
|
1200
|
+
)
|
|
1201
|
+
except ValueError as exc:
|
|
1202
|
+
print(str(exc), file=sys.stderr)
|
|
1203
|
+
raise typer.Exit(1)
|
|
1204
|
+
if result is None:
|
|
1205
|
+
print("Autopilot tick completed with no execution.")
|
|
1206
|
+
return
|
|
1207
|
+
print(f"{result.card_id} -> {result.status}")
|
|
1208
|
+
print(f"run report: {result.run_report_path}")
|
|
1209
|
+
print(f"verification report: {result.verification_report_path}")
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
@autopilot_app.command("install-cron")
|
|
1213
|
+
def autopilot_install_cron_cmd(
|
|
1214
|
+
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Repository root"),
|
|
1215
|
+
) -> None:
|
|
1216
|
+
"""Install default cron jobs for repo autopilot scan/tick."""
|
|
1217
|
+
from openharness.autopilot import RepoAutopilotStore
|
|
1218
|
+
|
|
1219
|
+
names = RepoAutopilotStore(cwd).install_default_cron()
|
|
1220
|
+
print("Installed cron jobs: " + ", ".join(names))
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
@autopilot_app.command("export-dashboard")
|
|
1224
|
+
def autopilot_export_dashboard_cmd(
|
|
1225
|
+
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Repository root"),
|
|
1226
|
+
output: str | None = typer.Option(None, "--output", help="Dashboard output directory"),
|
|
1227
|
+
) -> None:
|
|
1228
|
+
"""Export a static autopilot kanban site for GitHub Pages."""
|
|
1229
|
+
from openharness.autopilot import RepoAutopilotStore
|
|
1230
|
+
|
|
1231
|
+
path = RepoAutopilotStore(cwd).export_dashboard(output)
|
|
1232
|
+
print(f"Exported autopilot dashboard: {path}")
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
# ---- auth subcommands ----
|
|
1236
|
+
|
|
1237
|
+
# Mapping from provider name to human-readable label for interactive prompts.
|
|
1238
|
+
_PROVIDER_LABELS: dict[str, str] = {
|
|
1239
|
+
"anthropic": "Anthropic (Claude API)",
|
|
1240
|
+
"anthropic_claude": "Claude subscription (Claude CLI)",
|
|
1241
|
+
"openai": "OpenAI / compatible",
|
|
1242
|
+
"openai_codex": "OpenAI Codex subscription (Codex CLI)",
|
|
1243
|
+
"copilot": "GitHub Copilot",
|
|
1244
|
+
"dashscope": "Alibaba DashScope",
|
|
1245
|
+
"bedrock": "AWS Bedrock",
|
|
1246
|
+
"vertex": "Google Vertex AI",
|
|
1247
|
+
"moonshot": "Moonshot (Kimi)",
|
|
1248
|
+
"gemini": "Google Gemini",
|
|
1249
|
+
"minimax": "MiniMax",
|
|
1250
|
+
"modelscope": "ModelScope",
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
_AUTH_SOURCE_LABELS: dict[str, str] = {
|
|
1254
|
+
"anthropic_api_key": "Anthropic API key",
|
|
1255
|
+
"openai_api_key": "OpenAI API key",
|
|
1256
|
+
"codex_subscription": "Codex subscription",
|
|
1257
|
+
"claude_subscription": "Claude subscription",
|
|
1258
|
+
"copilot_oauth": "GitHub Copilot OAuth",
|
|
1259
|
+
"dashscope_api_key": "DashScope API key",
|
|
1260
|
+
"bedrock_api_key": "Bedrock credentials",
|
|
1261
|
+
"vertex_api_key": "Vertex credentials",
|
|
1262
|
+
"moonshot_api_key": "Moonshot API key",
|
|
1263
|
+
"gemini_api_key": "Gemini API key",
|
|
1264
|
+
"minimax_api_key": "MiniMax API key",
|
|
1265
|
+
"modelscope_api_key": "ModelScope API key",
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def _can_use_questionary() -> bool:
|
|
1270
|
+
"""Return True when a real interactive terminal is available."""
|
|
1271
|
+
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
1272
|
+
return False
|
|
1273
|
+
if sys.stdin is not sys.__stdin__ or sys.stdout is not sys.__stdout__:
|
|
1274
|
+
return False
|
|
1275
|
+
try:
|
|
1276
|
+
import questionary # noqa: F401
|
|
1277
|
+
except ImportError:
|
|
1278
|
+
return False
|
|
1279
|
+
return True
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
def _select_with_questionary(
|
|
1283
|
+
title: str,
|
|
1284
|
+
options: list[tuple[str, str]],
|
|
1285
|
+
*,
|
|
1286
|
+
default_value: str | None = None,
|
|
1287
|
+
) -> str:
|
|
1288
|
+
import questionary
|
|
1289
|
+
|
|
1290
|
+
choices = [
|
|
1291
|
+
questionary.Choice(
|
|
1292
|
+
title=label,
|
|
1293
|
+
value=value,
|
|
1294
|
+
checked=(value == default_value),
|
|
1295
|
+
)
|
|
1296
|
+
for value, label in options
|
|
1297
|
+
]
|
|
1298
|
+
result = questionary.select(title, choices=choices, default=default_value).ask()
|
|
1299
|
+
if result is None:
|
|
1300
|
+
raise typer.Abort()
|
|
1301
|
+
return str(result)
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
def _text_prompt(message: str, *, default: str = "") -> str:
|
|
1305
|
+
"""Prompt for text input, preferring questionary in a real TTY."""
|
|
1306
|
+
if _can_use_questionary():
|
|
1307
|
+
import questionary
|
|
1308
|
+
|
|
1309
|
+
result = questionary.text(message, default=default).ask()
|
|
1310
|
+
if result is None:
|
|
1311
|
+
raise typer.Abort()
|
|
1312
|
+
return str(result)
|
|
1313
|
+
return typer.prompt(message, default=default)
|
|
1314
|
+
|
|
1315
|
+
|
|
1316
|
+
def _secret_prompt(message: str) -> str:
|
|
1317
|
+
"""Prompt for secret text, preferring questionary in a real TTY."""
|
|
1318
|
+
if _can_use_questionary():
|
|
1319
|
+
import questionary
|
|
1320
|
+
|
|
1321
|
+
result = questionary.password(message).ask()
|
|
1322
|
+
if result is None:
|
|
1323
|
+
raise typer.Abort()
|
|
1324
|
+
return str(result)
|
|
1325
|
+
return typer.prompt(message, hide_input=True)
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
def _confirm_prompt(message: str, *, default: bool = False) -> bool:
|
|
1329
|
+
"""Prompt for a yes/no confirmation, preferring questionary in a real TTY."""
|
|
1330
|
+
if _can_use_questionary():
|
|
1331
|
+
import questionary
|
|
1332
|
+
|
|
1333
|
+
result = questionary.confirm(message, default=default).ask()
|
|
1334
|
+
if result is None:
|
|
1335
|
+
raise typer.Abort()
|
|
1336
|
+
return bool(result)
|
|
1337
|
+
return bool(typer.confirm(message, default=default))
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
def _select_from_menu(
|
|
1341
|
+
title: str,
|
|
1342
|
+
options: list[tuple[str, str]],
|
|
1343
|
+
*,
|
|
1344
|
+
default_value: str | None = None,
|
|
1345
|
+
) -> str:
|
|
1346
|
+
"""Render a simple numbered picker and return the selected value."""
|
|
1347
|
+
if _can_use_questionary():
|
|
1348
|
+
return _select_with_questionary(title, options, default_value=default_value)
|
|
1349
|
+
print(title, flush=True)
|
|
1350
|
+
default_index = 1
|
|
1351
|
+
for index, (value, label) in enumerate(options, 1):
|
|
1352
|
+
marker = " (default)" if value == default_value else ""
|
|
1353
|
+
if value == default_value:
|
|
1354
|
+
default_index = index
|
|
1355
|
+
print(f" {index}. {label}{marker}", flush=True)
|
|
1356
|
+
raw = typer.prompt("Choose", default=str(default_index))
|
|
1357
|
+
try:
|
|
1358
|
+
selected = options[int(raw) - 1]
|
|
1359
|
+
except (ValueError, IndexError):
|
|
1360
|
+
raise typer.BadParameter(f"Invalid selection: {raw}") from None
|
|
1361
|
+
return selected[0]
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def _prompt_model_for_profile(profile) -> str:
|
|
1365
|
+
from openharness.config.settings import (
|
|
1366
|
+
CLAUDE_MODEL_ALIAS_OPTIONS,
|
|
1367
|
+
display_model_setting,
|
|
1368
|
+
is_claude_family_provider,
|
|
1369
|
+
)
|
|
1370
|
+
|
|
1371
|
+
current = display_model_setting(profile)
|
|
1372
|
+
if profile.allowed_models:
|
|
1373
|
+
if len(profile.allowed_models) == 1:
|
|
1374
|
+
return profile.allowed_models[0]
|
|
1375
|
+
options = [(value, value) for value in profile.allowed_models]
|
|
1376
|
+
return _select_from_menu("Choose a model setting:", options, default_value=current if current in profile.allowed_models else profile.allowed_models[0])
|
|
1377
|
+
if is_claude_family_provider(profile.provider):
|
|
1378
|
+
options = [(value, f"{label} - {description}") for value, label, description in CLAUDE_MODEL_ALIAS_OPTIONS]
|
|
1379
|
+
options.append(("__custom__", "Custom model ID"))
|
|
1380
|
+
selection = _select_from_menu(
|
|
1381
|
+
"Choose a model setting:",
|
|
1382
|
+
options,
|
|
1383
|
+
default_value=current if any(value == current for value, _, _ in CLAUDE_MODEL_ALIAS_OPTIONS) else "__custom__",
|
|
1384
|
+
)
|
|
1385
|
+
if selection != "__custom__":
|
|
1386
|
+
return selection
|
|
1387
|
+
return _text_prompt("Model", default=current).strip() or current
|
|
1388
|
+
|
|
1389
|
+
|
|
1390
|
+
def _format_profile_choice_label(info: dict[str, object]) -> str:
|
|
1391
|
+
"""Render a user-facing workflow label without leaking internal provider ids."""
|
|
1392
|
+
label = str(info["label"])
|
|
1393
|
+
state = "" if bool(info["configured"]) else f" ({info['auth_state']})"
|
|
1394
|
+
return f"{label}{state}"
|
|
1395
|
+
|
|
1396
|
+
|
|
1397
|
+
def _styled_missing_suffix(info: dict[str, object]) -> tuple[str, str] | None:
|
|
1398
|
+
"""Return a soft red missing-auth suffix for questionary titles."""
|
|
1399
|
+
if bool(info["configured"]):
|
|
1400
|
+
return None
|
|
1401
|
+
return (f" ({info['auth_state']})", "fg:#d3869b")
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
def _select_setup_workflow(
|
|
1405
|
+
statuses: dict[str, dict[str, object]],
|
|
1406
|
+
*,
|
|
1407
|
+
default_value: str | None = None,
|
|
1408
|
+
) -> str:
|
|
1409
|
+
"""Render the top-level `oh setup` workflow picker with richer hints."""
|
|
1410
|
+
hints = {
|
|
1411
|
+
"claude-api": ("Claude / Kimi / GLM / MiniMax", "fg:#7aa2f7"),
|
|
1412
|
+
"openai-compatible": ("OpenAI / OpenRouter", "fg:#9ece6a"),
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
if _can_use_questionary():
|
|
1416
|
+
import questionary
|
|
1417
|
+
|
|
1418
|
+
choices = []
|
|
1419
|
+
for name, info in statuses.items():
|
|
1420
|
+
label = str(info["label"])
|
|
1421
|
+
hint = hints.get(name)
|
|
1422
|
+
missing = _styled_missing_suffix(info)
|
|
1423
|
+
if hint is None:
|
|
1424
|
+
if missing is None:
|
|
1425
|
+
title = label
|
|
1426
|
+
else:
|
|
1427
|
+
suffix, suffix_style = missing
|
|
1428
|
+
title = [("", label), (suffix_style, suffix)]
|
|
1429
|
+
else:
|
|
1430
|
+
hint_text, hint_style = hint
|
|
1431
|
+
if missing is None:
|
|
1432
|
+
title = [
|
|
1433
|
+
("", f"{label} "),
|
|
1434
|
+
(hint_style, hint_text),
|
|
1435
|
+
]
|
|
1436
|
+
else:
|
|
1437
|
+
suffix, suffix_style = missing
|
|
1438
|
+
title = [
|
|
1439
|
+
("", f"{label} "),
|
|
1440
|
+
(hint_style, hint_text),
|
|
1441
|
+
("", " "),
|
|
1442
|
+
(suffix_style, suffix.strip()),
|
|
1443
|
+
]
|
|
1444
|
+
choices.append(questionary.Choice(title=title, value=name, checked=(name == default_value)))
|
|
1445
|
+
|
|
1446
|
+
result = questionary.select("Choose a provider workflow:", choices=choices, default=default_value).ask()
|
|
1447
|
+
if result is None:
|
|
1448
|
+
raise typer.Abort()
|
|
1449
|
+
return str(result)
|
|
1450
|
+
|
|
1451
|
+
options: list[tuple[str, str]] = []
|
|
1452
|
+
for name, info in statuses.items():
|
|
1453
|
+
label = _format_profile_choice_label(info)
|
|
1454
|
+
hint = hints.get(name)
|
|
1455
|
+
if hint is not None:
|
|
1456
|
+
label = f"{label} ({hint[0]})"
|
|
1457
|
+
options.append((name, label))
|
|
1458
|
+
return _select_from_menu("Choose a provider workflow:", options, default_value=default_value)
|
|
1459
|
+
|
|
1460
|
+
|
|
1461
|
+
def _default_credential_slot_for_profile(name: str, auth_source: str) -> str | None:
|
|
1462
|
+
from openharness.config.settings import auth_source_uses_api_key, builtin_provider_profile_names
|
|
1463
|
+
|
|
1464
|
+
if name in builtin_provider_profile_names():
|
|
1465
|
+
return None
|
|
1466
|
+
if not auth_source_uses_api_key(auth_source):
|
|
1467
|
+
return None
|
|
1468
|
+
return name
|
|
1469
|
+
|
|
1470
|
+
|
|
1471
|
+
def _prompt_api_key_for_profile(label: str) -> str:
|
|
1472
|
+
key = _secret_prompt(f"Enter API key for {label}").strip()
|
|
1473
|
+
if not key:
|
|
1474
|
+
raise typer.BadParameter("API key cannot be empty.")
|
|
1475
|
+
return key
|
|
1476
|
+
|
|
1477
|
+
|
|
1478
|
+
def _configure_custom_profile_via_setup(manager) -> str:
|
|
1479
|
+
from openharness.config.settings import ProviderProfile, default_auth_source_for_provider
|
|
1480
|
+
|
|
1481
|
+
family = _select_from_menu(
|
|
1482
|
+
"Choose a compatible API family:",
|
|
1483
|
+
[
|
|
1484
|
+
("anthropic", "Anthropic-compatible"),
|
|
1485
|
+
("openai", "OpenAI-compatible"),
|
|
1486
|
+
],
|
|
1487
|
+
default_value="anthropic",
|
|
1488
|
+
)
|
|
1489
|
+
default_name = f"custom-{family}"
|
|
1490
|
+
name = _text_prompt("Profile name", default=default_name).strip()
|
|
1491
|
+
if not name:
|
|
1492
|
+
raise typer.BadParameter("Profile name cannot be empty.")
|
|
1493
|
+
label = _text_prompt("Display label", default=name).strip() or name
|
|
1494
|
+
base_url = _text_prompt("Base URL", default="").strip()
|
|
1495
|
+
if not base_url:
|
|
1496
|
+
raise typer.BadParameter("Base URL cannot be empty.")
|
|
1497
|
+
|
|
1498
|
+
auth_source = default_auth_source_for_provider(family, family)
|
|
1499
|
+
model = _text_prompt("Default model", default="").strip()
|
|
1500
|
+
if not model:
|
|
1501
|
+
raise typer.BadParameter("Default model cannot be empty.")
|
|
1502
|
+
|
|
1503
|
+
profile = ProviderProfile(
|
|
1504
|
+
label=label,
|
|
1505
|
+
provider=family,
|
|
1506
|
+
api_format=family,
|
|
1507
|
+
auth_source=auth_source,
|
|
1508
|
+
default_model=model,
|
|
1509
|
+
last_model=model,
|
|
1510
|
+
base_url=base_url,
|
|
1511
|
+
credential_slot=_default_credential_slot_for_profile(name, auth_source),
|
|
1512
|
+
allowed_models=[model],
|
|
1513
|
+
)
|
|
1514
|
+
manager.upsert_profile(name, profile)
|
|
1515
|
+
manager.store_profile_credential(name, "api_key", _prompt_api_key_for_profile(label))
|
|
1516
|
+
return name
|
|
1517
|
+
|
|
1518
|
+
|
|
1519
|
+
def _ensure_preset_profile(
|
|
1520
|
+
manager,
|
|
1521
|
+
*,
|
|
1522
|
+
name: str,
|
|
1523
|
+
label: str,
|
|
1524
|
+
provider: str,
|
|
1525
|
+
api_format: str,
|
|
1526
|
+
auth_source: str,
|
|
1527
|
+
base_url: str | None,
|
|
1528
|
+
model: str,
|
|
1529
|
+
lock_model: bool,
|
|
1530
|
+
) -> str:
|
|
1531
|
+
from openharness.config.settings import ProviderProfile
|
|
1532
|
+
|
|
1533
|
+
existing = manager.list_profiles().get(name)
|
|
1534
|
+
profile = ProviderProfile(
|
|
1535
|
+
label=label,
|
|
1536
|
+
provider=provider,
|
|
1537
|
+
api_format=api_format,
|
|
1538
|
+
auth_source=auth_source,
|
|
1539
|
+
default_model=model,
|
|
1540
|
+
last_model=model,
|
|
1541
|
+
base_url=base_url,
|
|
1542
|
+
credential_slot=_default_credential_slot_for_profile(name, auth_source),
|
|
1543
|
+
allowed_models=[model] if lock_model else (existing.allowed_models if existing else []),
|
|
1544
|
+
)
|
|
1545
|
+
manager.upsert_profile(name, profile)
|
|
1546
|
+
return name
|
|
1547
|
+
|
|
1548
|
+
|
|
1549
|
+
def _specialize_setup_target(manager, target: str) -> str:
|
|
1550
|
+
"""Expand a top-level family choice into a concrete workflow profile."""
|
|
1551
|
+
from openharness.config.settings import default_auth_source_for_provider
|
|
1552
|
+
|
|
1553
|
+
if target == "claude-api":
|
|
1554
|
+
choice = _select_from_menu(
|
|
1555
|
+
"Choose an Anthropic-compatible provider:",
|
|
1556
|
+
[
|
|
1557
|
+
("claude-api", "Claude official"),
|
|
1558
|
+
("kimi-anthropic", "Moonshot Kimi"),
|
|
1559
|
+
("glm-anthropic", "Zhipu GLM"),
|
|
1560
|
+
("minimax-anthropic", "MiniMax"),
|
|
1561
|
+
],
|
|
1562
|
+
default_value="claude-api",
|
|
1563
|
+
)
|
|
1564
|
+
if choice == "claude-api":
|
|
1565
|
+
return choice
|
|
1566
|
+
defaults = {
|
|
1567
|
+
"kimi-anthropic": ("Kimi (Anthropic-compatible)", "https://api.moonshot.cn/anthropic", "kimi-k2.5"),
|
|
1568
|
+
"glm-anthropic": ("GLM (Anthropic-compatible)", "", "glm-4.5"),
|
|
1569
|
+
"minimax-anthropic": ("MiniMax (Anthropic-compatible)", "", "MiniMax-M2.7"),
|
|
1570
|
+
}
|
|
1571
|
+
label, suggested_base_url, suggested_model = defaults[choice]
|
|
1572
|
+
base_url = _text_prompt("Base URL", default=suggested_base_url).strip()
|
|
1573
|
+
if not base_url:
|
|
1574
|
+
raise typer.BadParameter("Base URL cannot be empty.")
|
|
1575
|
+
model = _text_prompt("Model", default=suggested_model).strip()
|
|
1576
|
+
if not model:
|
|
1577
|
+
raise typer.BadParameter("Model cannot be empty.")
|
|
1578
|
+
return _ensure_preset_profile(
|
|
1579
|
+
manager,
|
|
1580
|
+
name=choice,
|
|
1581
|
+
label=label,
|
|
1582
|
+
provider="anthropic",
|
|
1583
|
+
api_format="anthropic",
|
|
1584
|
+
auth_source=default_auth_source_for_provider("anthropic", "anthropic"),
|
|
1585
|
+
base_url=base_url,
|
|
1586
|
+
model=model,
|
|
1587
|
+
lock_model=True,
|
|
1588
|
+
)
|
|
1589
|
+
|
|
1590
|
+
if target == "openai-compatible":
|
|
1591
|
+
choice = _select_from_menu(
|
|
1592
|
+
"Choose an OpenAI-compatible provider:",
|
|
1593
|
+
[
|
|
1594
|
+
("openai-compatible", "OpenAI official"),
|
|
1595
|
+
("openrouter", "OpenRouter"),
|
|
1596
|
+
],
|
|
1597
|
+
default_value="openai-compatible",
|
|
1598
|
+
)
|
|
1599
|
+
if choice == "openai-compatible":
|
|
1600
|
+
return choice
|
|
1601
|
+
base_url = _text_prompt("Base URL", default="https://openrouter.ai/api/v1").strip()
|
|
1602
|
+
if not base_url:
|
|
1603
|
+
raise typer.BadParameter("Base URL cannot be empty.")
|
|
1604
|
+
model = _text_prompt("Default model", default="").strip()
|
|
1605
|
+
if not model:
|
|
1606
|
+
raise typer.BadParameter("Default model cannot be empty.")
|
|
1607
|
+
return _ensure_preset_profile(
|
|
1608
|
+
manager,
|
|
1609
|
+
name="openrouter",
|
|
1610
|
+
label="OpenRouter",
|
|
1611
|
+
provider="openai",
|
|
1612
|
+
api_format="openai",
|
|
1613
|
+
auth_source=default_auth_source_for_provider("openai", "openai"),
|
|
1614
|
+
base_url=base_url,
|
|
1615
|
+
model=model,
|
|
1616
|
+
lock_model=False,
|
|
1617
|
+
)
|
|
1618
|
+
|
|
1619
|
+
return target
|
|
1620
|
+
|
|
1621
|
+
|
|
1622
|
+
def _ensure_profile_auth(manager, profile_name: str) -> None:
|
|
1623
|
+
from openharness.auth.flows import ApiKeyFlow
|
|
1624
|
+
from openharness.config.settings import auth_source_provider_name, auth_source_uses_api_key
|
|
1625
|
+
|
|
1626
|
+
profile = manager.list_profiles()[profile_name]
|
|
1627
|
+
if not auth_source_uses_api_key(profile.auth_source):
|
|
1628
|
+
_login_provider(auth_source_provider_name(profile.auth_source))
|
|
1629
|
+
return
|
|
1630
|
+
|
|
1631
|
+
flow = ApiKeyFlow(
|
|
1632
|
+
provider=profile.provider,
|
|
1633
|
+
prompt_text=f"Enter API key for {profile.label}",
|
|
1634
|
+
)
|
|
1635
|
+
try:
|
|
1636
|
+
key = flow.run()
|
|
1637
|
+
except ValueError as exc:
|
|
1638
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
1639
|
+
raise typer.Exit(1)
|
|
1640
|
+
manager.store_profile_credential(profile_name, "api_key", key)
|
|
1641
|
+
print(f"{profile.label} API key saved.", flush=True)
|
|
1642
|
+
|
|
1643
|
+
|
|
1644
|
+
def _maybe_update_profile_auth(manager, profile_name: str) -> bool:
|
|
1645
|
+
"""Ask whether to replace an already configured profile API key."""
|
|
1646
|
+
from openharness.config.settings import auth_source_uses_api_key
|
|
1647
|
+
|
|
1648
|
+
profile = manager.list_profiles()[profile_name]
|
|
1649
|
+
if not auth_source_uses_api_key(profile.auth_source):
|
|
1650
|
+
return False
|
|
1651
|
+
if not _confirm_prompt(f"Update API key for {profile.label}?", default=False):
|
|
1652
|
+
return False
|
|
1653
|
+
_ensure_profile_auth(manager, profile_name)
|
|
1654
|
+
return True
|
|
1655
|
+
|
|
1656
|
+
|
|
1657
|
+
def _maybe_update_default_model_for_provider(provider: str) -> None:
|
|
1658
|
+
"""Keep the active model in-family after switching auth providers."""
|
|
1659
|
+
from openharness.auth.manager import AuthManager
|
|
1660
|
+
|
|
1661
|
+
manager = AuthManager()
|
|
1662
|
+
profile_name = {
|
|
1663
|
+
"openai_codex": "codex",
|
|
1664
|
+
"anthropic_claude": "claude-subscription",
|
|
1665
|
+
}.get(provider)
|
|
1666
|
+
if profile_name is None:
|
|
1667
|
+
return
|
|
1668
|
+
profile = manager.list_profiles()[profile_name]
|
|
1669
|
+
model = profile.resolved_model.lower()
|
|
1670
|
+
target_model = None
|
|
1671
|
+
if provider == "openai_codex" and not model.startswith(("gpt-", "o1", "o3", "o4")):
|
|
1672
|
+
target_model = "gpt-5.4"
|
|
1673
|
+
elif provider == "anthropic_claude" and not model.startswith("claude-"):
|
|
1674
|
+
target_model = "sonnet"
|
|
1675
|
+
if not target_model:
|
|
1676
|
+
return
|
|
1677
|
+
manager.update_profile(profile_name, default_model=target_model, last_model=target_model)
|
|
1678
|
+
|
|
1679
|
+
|
|
1680
|
+
def _bind_external_provider(provider: str) -> None:
|
|
1681
|
+
"""Bind a provider to credentials managed by an external CLI."""
|
|
1682
|
+
from openharness.auth.external import default_binding_for_provider, load_external_credential
|
|
1683
|
+
from openharness.auth.storage import store_external_binding
|
|
1684
|
+
|
|
1685
|
+
binding = default_binding_for_provider(provider)
|
|
1686
|
+
try:
|
|
1687
|
+
credential = load_external_credential(
|
|
1688
|
+
binding,
|
|
1689
|
+
refresh_if_needed=(provider == "anthropic_claude"),
|
|
1690
|
+
)
|
|
1691
|
+
except ValueError as exc:
|
|
1692
|
+
print(f"Error: {exc}", file=sys.stderr, flush=True)
|
|
1693
|
+
raise typer.Exit(1)
|
|
1694
|
+
|
|
1695
|
+
profile_label = credential.profile_label or binding.profile_label
|
|
1696
|
+
store_external_binding(
|
|
1697
|
+
binding.__class__(
|
|
1698
|
+
provider=binding.provider,
|
|
1699
|
+
source_path=binding.source_path,
|
|
1700
|
+
source_kind=binding.source_kind,
|
|
1701
|
+
managed_by=binding.managed_by,
|
|
1702
|
+
profile_label=profile_label,
|
|
1703
|
+
)
|
|
1704
|
+
)
|
|
1705
|
+
|
|
1706
|
+
_maybe_update_default_model_for_provider(provider)
|
|
1707
|
+
label = _PROVIDER_LABELS.get(provider, provider)
|
|
1708
|
+
profile_name = {
|
|
1709
|
+
"openai_codex": "codex",
|
|
1710
|
+
"anthropic_claude": "claude-subscription",
|
|
1711
|
+
}[provider]
|
|
1712
|
+
print(f"{label} bound from {credential.source_path}.", flush=True)
|
|
1713
|
+
print(f"Use `oh provider use {profile_name}` to activate it.", flush=True)
|
|
1714
|
+
|
|
1715
|
+
|
|
1716
|
+
def _login_provider(provider: str) -> None:
|
|
1717
|
+
"""Authenticate or bind the given provider."""
|
|
1718
|
+
from openharness.auth.flows import ApiKeyFlow
|
|
1719
|
+
from openharness.auth.manager import AuthManager
|
|
1720
|
+
from openharness.auth.storage import store_credential
|
|
1721
|
+
|
|
1722
|
+
manager = AuthManager()
|
|
1723
|
+
|
|
1724
|
+
if provider == "copilot":
|
|
1725
|
+
_run_copilot_login()
|
|
1726
|
+
return
|
|
1727
|
+
|
|
1728
|
+
if provider in ("openai_codex", "anthropic_claude"):
|
|
1729
|
+
_bind_external_provider(provider)
|
|
1730
|
+
return
|
|
1731
|
+
|
|
1732
|
+
if provider in ("anthropic", "openai", "dashscope", "bedrock", "vertex", "moonshot", "gemini", "minimax", "modelscope"):
|
|
1733
|
+
label = _PROVIDER_LABELS.get(provider, provider)
|
|
1734
|
+
flow = ApiKeyFlow(provider=provider, prompt_text=f"Enter your {label} API key")
|
|
1735
|
+
try:
|
|
1736
|
+
key = flow.run()
|
|
1737
|
+
except ValueError as exc:
|
|
1738
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
1739
|
+
raise typer.Exit(1)
|
|
1740
|
+
store_credential(provider, "api_key", key)
|
|
1741
|
+
try:
|
|
1742
|
+
manager.store_credential(provider, "api_key", key)
|
|
1743
|
+
except Exception:
|
|
1744
|
+
pass
|
|
1745
|
+
print(f"{label} API key saved.", flush=True)
|
|
1746
|
+
return
|
|
1747
|
+
|
|
1748
|
+
print(f"Unknown provider: {provider!r}. Known: {', '.join(_PROVIDER_LABELS)}", file=sys.stderr)
|
|
1749
|
+
raise typer.Exit(1)
|
|
1750
|
+
|
|
1751
|
+
|
|
1752
|
+
@app.command("setup")
|
|
1753
|
+
def setup_cmd(
|
|
1754
|
+
profile: str | None = typer.Argument(None, help="Provider profile name to configure"),
|
|
1755
|
+
) -> None:
|
|
1756
|
+
"""Unified setup flow: choose workflow, authenticate if needed, then set the model."""
|
|
1757
|
+
from openharness.auth.manager import AuthManager
|
|
1758
|
+
from openharness.config.settings import display_model_setting
|
|
1759
|
+
|
|
1760
|
+
manager = AuthManager()
|
|
1761
|
+
statuses = manager.get_profile_statuses()
|
|
1762
|
+
if not statuses:
|
|
1763
|
+
print("No provider profiles available.", file=sys.stderr)
|
|
1764
|
+
raise typer.Exit(1)
|
|
1765
|
+
|
|
1766
|
+
target = profile
|
|
1767
|
+
if target is None:
|
|
1768
|
+
target = _select_setup_workflow(
|
|
1769
|
+
statuses,
|
|
1770
|
+
default_value=manager.get_active_profile(),
|
|
1771
|
+
)
|
|
1772
|
+
|
|
1773
|
+
target = _specialize_setup_target(manager, target)
|
|
1774
|
+
manager = AuthManager()
|
|
1775
|
+
statuses = manager.get_profile_statuses()
|
|
1776
|
+
|
|
1777
|
+
if target not in statuses:
|
|
1778
|
+
print(f"Unknown provider profile: {target!r}", file=sys.stderr)
|
|
1779
|
+
raise typer.Exit(1)
|
|
1780
|
+
|
|
1781
|
+
info = statuses[target]
|
|
1782
|
+
if not info["configured"]:
|
|
1783
|
+
source_label = _AUTH_SOURCE_LABELS.get(info["auth_source"], info["auth_source"])
|
|
1784
|
+
print(f"{info['label']} requires {source_label}.", flush=True)
|
|
1785
|
+
_ensure_profile_auth(manager, target)
|
|
1786
|
+
manager = AuthManager()
|
|
1787
|
+
else:
|
|
1788
|
+
if _maybe_update_profile_auth(manager, target):
|
|
1789
|
+
manager = AuthManager()
|
|
1790
|
+
|
|
1791
|
+
profile_obj = manager.list_profiles()[target]
|
|
1792
|
+
model_setting = _prompt_model_for_profile(profile_obj)
|
|
1793
|
+
if model_setting.lower() == "default":
|
|
1794
|
+
manager.update_profile(target, last_model="")
|
|
1795
|
+
else:
|
|
1796
|
+
manager.update_profile(target, last_model=model_setting)
|
|
1797
|
+
manager.use_profile(target)
|
|
1798
|
+
|
|
1799
|
+
updated = manager.list_profiles()[target]
|
|
1800
|
+
print(
|
|
1801
|
+
"Setup complete:\n"
|
|
1802
|
+
f"- profile: {target}\n"
|
|
1803
|
+
f"- provider: {updated.provider}\n"
|
|
1804
|
+
f"- auth_source: {updated.auth_source}\n"
|
|
1805
|
+
f"- model: {display_model_setting(updated)}",
|
|
1806
|
+
flush=True,
|
|
1807
|
+
)
|
|
1808
|
+
|
|
1809
|
+
|
|
1810
|
+
@auth_app.command("login")
|
|
1811
|
+
def auth_login(
|
|
1812
|
+
provider: Optional[str] = typer.Argument(None, help="Provider name (anthropic, openai, copilot, …)"),
|
|
1813
|
+
) -> None:
|
|
1814
|
+
"""Interactively authenticate with a provider.
|
|
1815
|
+
|
|
1816
|
+
Run without arguments to choose a provider from a menu.
|
|
1817
|
+
Supported providers: anthropic, anthropic_claude, openai, openai_codex, copilot, dashscope, bedrock, vertex, moonshot, minimax, modelscope.
|
|
1818
|
+
"""
|
|
1819
|
+
if provider is None:
|
|
1820
|
+
print("Select a provider to authenticate:", flush=True)
|
|
1821
|
+
labels = list(_PROVIDER_LABELS.items())
|
|
1822
|
+
for i, (name, label) in enumerate(labels, 1):
|
|
1823
|
+
print(f" {i}. {label} [{name}]", flush=True)
|
|
1824
|
+
raw = typer.prompt("Enter number or provider name", default="1")
|
|
1825
|
+
try:
|
|
1826
|
+
idx = int(raw.strip()) - 1
|
|
1827
|
+
if 0 <= idx < len(labels):
|
|
1828
|
+
provider = labels[idx][0]
|
|
1829
|
+
else:
|
|
1830
|
+
print("Invalid selection.", file=sys.stderr)
|
|
1831
|
+
raise typer.Exit(1)
|
|
1832
|
+
except ValueError:
|
|
1833
|
+
provider = raw.strip()
|
|
1834
|
+
|
|
1835
|
+
provider = provider.lower()
|
|
1836
|
+
_login_provider(provider)
|
|
1837
|
+
|
|
1838
|
+
|
|
1839
|
+
@auth_app.command("status")
|
|
1840
|
+
def auth_status_cmd() -> None:
|
|
1841
|
+
"""Show authentication source and provider profile status."""
|
|
1842
|
+
from openharness.auth.manager import AuthManager
|
|
1843
|
+
|
|
1844
|
+
manager = AuthManager()
|
|
1845
|
+
auth_sources = manager.get_auth_source_statuses()
|
|
1846
|
+
profiles = manager.get_profile_statuses()
|
|
1847
|
+
|
|
1848
|
+
print("Auth sources:")
|
|
1849
|
+
print(f"{'Source':<24} {'State':<14} {'Origin':<10} Active")
|
|
1850
|
+
print("-" * 60)
|
|
1851
|
+
for name, info in auth_sources.items():
|
|
1852
|
+
label = _AUTH_SOURCE_LABELS.get(name, name)
|
|
1853
|
+
active_str = "<-- active" if info["active"] else ""
|
|
1854
|
+
print(f"{label:<24} {info['state']:<14} {info['source']:<10} {active_str}")
|
|
1855
|
+
if info.get("detail"):
|
|
1856
|
+
print(f" detail: {info['detail']}")
|
|
1857
|
+
|
|
1858
|
+
print()
|
|
1859
|
+
print("Provider profiles:")
|
|
1860
|
+
print(f"{'Profile':<20} {'Provider':<18} {'Auth source':<22} {'State':<12} Active")
|
|
1861
|
+
print("-" * 92)
|
|
1862
|
+
for name, info in profiles.items():
|
|
1863
|
+
status_str = "ready" if info["configured"] else info.get("auth_state", "missing auth")
|
|
1864
|
+
active_str = "<-- active" if info["active"] else ""
|
|
1865
|
+
print(f"{name:<20} {info['provider']:<18} {info['auth_source']:<22} {status_str:<12} {active_str}")
|
|
1866
|
+
|
|
1867
|
+
|
|
1868
|
+
@auth_app.command("logout")
|
|
1869
|
+
def auth_logout(
|
|
1870
|
+
provider: Optional[str] = typer.Argument(None, help="Provider to log out (default: active provider)"),
|
|
1871
|
+
) -> None:
|
|
1872
|
+
"""Clear stored authentication for a provider."""
|
|
1873
|
+
from openharness.auth.manager import AuthManager
|
|
1874
|
+
|
|
1875
|
+
manager = AuthManager()
|
|
1876
|
+
if provider is None:
|
|
1877
|
+
target = manager.get_active_profile()
|
|
1878
|
+
manager.clear_profile_credential(target)
|
|
1879
|
+
print(f"Authentication cleared for profile: {target}", flush=True)
|
|
1880
|
+
return
|
|
1881
|
+
manager.clear_credential(provider)
|
|
1882
|
+
print(f"Authentication cleared for provider: {provider}", flush=True)
|
|
1883
|
+
|
|
1884
|
+
|
|
1885
|
+
@auth_app.command("switch")
|
|
1886
|
+
def auth_switch(
|
|
1887
|
+
provider: str = typer.Argument(..., help="Auth source or profile to activate"),
|
|
1888
|
+
) -> None:
|
|
1889
|
+
"""Switch the auth source for the active profile, or use a profile by name."""
|
|
1890
|
+
from openharness.auth.manager import AuthManager
|
|
1891
|
+
|
|
1892
|
+
manager = AuthManager()
|
|
1893
|
+
try:
|
|
1894
|
+
manager.switch_provider(provider)
|
|
1895
|
+
except ValueError as exc:
|
|
1896
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
1897
|
+
raise typer.Exit(1)
|
|
1898
|
+
print(f"Switched auth/profile to: {provider}", flush=True)
|
|
1899
|
+
|
|
1900
|
+
|
|
1901
|
+
# ---------------------------------------------------------------------------
|
|
1902
|
+
# Copilot login helper (kept as a named function for reuse and backward compat)
|
|
1903
|
+
# ---------------------------------------------------------------------------
|
|
1904
|
+
|
|
1905
|
+
|
|
1906
|
+
def _run_copilot_login() -> None:
|
|
1907
|
+
"""Run the GitHub Copilot device-code flow and persist the result."""
|
|
1908
|
+
from openharness.api.copilot_auth import save_copilot_auth
|
|
1909
|
+
from openharness.auth.flows import DeviceCodeFlow
|
|
1910
|
+
|
|
1911
|
+
print("Select GitHub deployment type:", flush=True)
|
|
1912
|
+
print(" 1. GitHub.com (public)", flush=True)
|
|
1913
|
+
print(" 2. GitHub Enterprise (data residency / self-hosted)", flush=True)
|
|
1914
|
+
choice = typer.prompt("Enter choice", default="1")
|
|
1915
|
+
|
|
1916
|
+
enterprise_url: str | None = None
|
|
1917
|
+
github_domain = "github.com"
|
|
1918
|
+
|
|
1919
|
+
if choice.strip() == "2":
|
|
1920
|
+
raw_url = typer.prompt("Enter your GitHub Enterprise URL or domain (e.g. company.ghe.com)")
|
|
1921
|
+
domain = raw_url.replace("https://", "").replace("http://", "").rstrip("/")
|
|
1922
|
+
if not domain:
|
|
1923
|
+
print("Error: domain cannot be empty.", file=sys.stderr, flush=True)
|
|
1924
|
+
raise typer.Exit(1)
|
|
1925
|
+
enterprise_url = domain
|
|
1926
|
+
github_domain = domain
|
|
1927
|
+
|
|
1928
|
+
print(flush=True)
|
|
1929
|
+
flow = DeviceCodeFlow(github_domain=github_domain, enterprise_url=enterprise_url)
|
|
1930
|
+
try:
|
|
1931
|
+
token = flow.run()
|
|
1932
|
+
except RuntimeError as exc:
|
|
1933
|
+
print(f"Error: {exc}", file=sys.stderr, flush=True)
|
|
1934
|
+
raise typer.Exit(1)
|
|
1935
|
+
|
|
1936
|
+
save_copilot_auth(token, enterprise_url=enterprise_url)
|
|
1937
|
+
print("GitHub Copilot authenticated successfully.", flush=True)
|
|
1938
|
+
if enterprise_url:
|
|
1939
|
+
print(f" Enterprise domain: {enterprise_url}", flush=True)
|
|
1940
|
+
print(flush=True)
|
|
1941
|
+
print("To use Copilot as the provider, run:", flush=True)
|
|
1942
|
+
print(" oh provider use copilot", flush=True)
|
|
1943
|
+
|
|
1944
|
+
|
|
1945
|
+
@auth_app.command("copilot-login")
|
|
1946
|
+
def auth_copilot_login() -> None:
|
|
1947
|
+
"""Authenticate with GitHub Copilot via device flow (alias for 'oh auth login copilot')."""
|
|
1948
|
+
_run_copilot_login()
|
|
1949
|
+
|
|
1950
|
+
|
|
1951
|
+
@auth_app.command("codex-login")
|
|
1952
|
+
def auth_codex_login() -> None:
|
|
1953
|
+
"""Bind OpenHarness to a local Codex CLI subscription session."""
|
|
1954
|
+
_bind_external_provider("openai_codex")
|
|
1955
|
+
|
|
1956
|
+
|
|
1957
|
+
@auth_app.command("claude-login")
|
|
1958
|
+
def auth_claude_login() -> None:
|
|
1959
|
+
"""Bind OpenHarness to a local Claude CLI subscription session."""
|
|
1960
|
+
_bind_external_provider("anthropic_claude")
|
|
1961
|
+
|
|
1962
|
+
|
|
1963
|
+
@auth_app.command("copilot-logout")
|
|
1964
|
+
def auth_copilot_logout() -> None:
|
|
1965
|
+
"""Remove stored GitHub Copilot authentication."""
|
|
1966
|
+
from openharness.api.copilot_auth import clear_github_token
|
|
1967
|
+
|
|
1968
|
+
clear_github_token()
|
|
1969
|
+
print("Copilot authentication cleared.")
|
|
1970
|
+
|
|
1971
|
+
|
|
1972
|
+
# ---- config subcommands ----
|
|
1973
|
+
|
|
1974
|
+
|
|
1975
|
+
def _config_resolve_target(settings: object, key: str) -> tuple[object, str]:
|
|
1976
|
+
target = settings
|
|
1977
|
+
parts = key.split(".")
|
|
1978
|
+
for part in parts[:-1]:
|
|
1979
|
+
if not hasattr(target, part):
|
|
1980
|
+
raise KeyError(key)
|
|
1981
|
+
target = getattr(target, part)
|
|
1982
|
+
leaf = parts[-1]
|
|
1983
|
+
if not hasattr(target, leaf):
|
|
1984
|
+
raise KeyError(key)
|
|
1985
|
+
return target, leaf
|
|
1986
|
+
|
|
1987
|
+
|
|
1988
|
+
def _config_coerce_value(current: object, raw: str) -> object:
|
|
1989
|
+
if isinstance(current, bool):
|
|
1990
|
+
lowered = raw.strip().lower()
|
|
1991
|
+
if lowered in {"1", "true", "yes", "on"}:
|
|
1992
|
+
return True
|
|
1993
|
+
if lowered in {"0", "false", "no", "off"}:
|
|
1994
|
+
return False
|
|
1995
|
+
raise ValueError(f"Invalid boolean value: {raw}")
|
|
1996
|
+
if isinstance(current, int) and not isinstance(current, bool):
|
|
1997
|
+
return int(raw)
|
|
1998
|
+
if isinstance(current, float):
|
|
1999
|
+
return float(raw)
|
|
2000
|
+
if isinstance(current, list):
|
|
2001
|
+
return [entry.strip() for entry in raw.split(",") if entry.strip()]
|
|
2002
|
+
return raw
|
|
2003
|
+
|
|
2004
|
+
|
|
2005
|
+
@config_app.command("show")
|
|
2006
|
+
def config_show() -> None:
|
|
2007
|
+
"""Print the resolved settings JSON."""
|
|
2008
|
+
from openharness.commands.registry import _settings_json_for_display
|
|
2009
|
+
from openharness.config.settings import load_settings
|
|
2010
|
+
|
|
2011
|
+
print(_settings_json_for_display(load_settings()), flush=True)
|
|
2012
|
+
|
|
2013
|
+
|
|
2014
|
+
@config_app.command("set")
|
|
2015
|
+
def config_set(
|
|
2016
|
+
key: str = typer.Argument(..., help="Setting key, including dotted nested keys"),
|
|
2017
|
+
value: str = typer.Argument(..., help="Value to store"),
|
|
2018
|
+
) -> None:
|
|
2019
|
+
"""Persist one setting in ~/.openharness/settings.json."""
|
|
2020
|
+
from openharness.config.settings import load_settings, save_settings
|
|
2021
|
+
|
|
2022
|
+
settings = load_settings()
|
|
2023
|
+
try:
|
|
2024
|
+
target, leaf = _config_resolve_target(settings, key)
|
|
2025
|
+
except KeyError:
|
|
2026
|
+
print(f"Unknown config key: {key}", file=sys.stderr)
|
|
2027
|
+
raise typer.Exit(1)
|
|
2028
|
+
try:
|
|
2029
|
+
coerced = _config_coerce_value(getattr(target, leaf), value)
|
|
2030
|
+
except ValueError as exc:
|
|
2031
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
2032
|
+
raise typer.Exit(1)
|
|
2033
|
+
setattr(target, leaf, coerced)
|
|
2034
|
+
save_settings(settings)
|
|
2035
|
+
print(f"Updated {key}", flush=True)
|
|
2036
|
+
|
|
2037
|
+
|
|
2038
|
+
# ---- provider subcommands ----
|
|
2039
|
+
|
|
2040
|
+
|
|
2041
|
+
@provider_app.command("list")
|
|
2042
|
+
def provider_list() -> None:
|
|
2043
|
+
"""List configured provider profiles."""
|
|
2044
|
+
from openharness.auth.manager import AuthManager
|
|
2045
|
+
|
|
2046
|
+
statuses = AuthManager().get_profile_statuses()
|
|
2047
|
+
for name, info in statuses.items():
|
|
2048
|
+
marker = "*" if info["active"] else " "
|
|
2049
|
+
configured = "ready" if info["configured"] else "missing auth"
|
|
2050
|
+
base = info["base_url"] or "(default)"
|
|
2051
|
+
print(f"{marker} {name}: {info['label']} [{configured}]")
|
|
2052
|
+
print(f" auth={info['auth_source']} model={info['model']} base_url={base}")
|
|
2053
|
+
|
|
2054
|
+
|
|
2055
|
+
@provider_app.command("use")
|
|
2056
|
+
def provider_use(
|
|
2057
|
+
name: str = typer.Argument(..., help="Provider profile name"),
|
|
2058
|
+
) -> None:
|
|
2059
|
+
"""Activate a provider profile."""
|
|
2060
|
+
from openharness.auth.manager import AuthManager
|
|
2061
|
+
|
|
2062
|
+
manager = AuthManager()
|
|
2063
|
+
try:
|
|
2064
|
+
manager.use_profile(name)
|
|
2065
|
+
except ValueError as exc:
|
|
2066
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
2067
|
+
raise typer.Exit(1)
|
|
2068
|
+
print(f"Activated provider profile: {name}", flush=True)
|
|
2069
|
+
|
|
2070
|
+
|
|
2071
|
+
@provider_app.command("add")
|
|
2072
|
+
def provider_add(
|
|
2073
|
+
name: str = typer.Argument(..., help="Provider profile name"),
|
|
2074
|
+
label: str = typer.Option(..., "--label", help="Display label"),
|
|
2075
|
+
provider: str = typer.Option(..., "--provider", help="Runtime provider id"),
|
|
2076
|
+
api_format: str = typer.Option(..., "--api-format", help="API format"),
|
|
2077
|
+
auth_source: str = typer.Option(..., "--auth-source", help="Auth source name"),
|
|
2078
|
+
model: str = typer.Option(..., "--model", help="Default model"),
|
|
2079
|
+
base_url: str | None = typer.Option(None, "--base-url", help="Optional base URL"),
|
|
2080
|
+
credential_slot: str | None = typer.Option(None, "--credential-slot", help="Optional profile-specific credential slot"),
|
|
2081
|
+
api_key: str | None = typer.Option(None, "--api-key", help="Set the profile API key"),
|
|
2082
|
+
allowed_models: list[str] | None = typer.Option(None, "--allowed-model", help="Allowed model values for this profile"),
|
|
2083
|
+
context_window_tokens: int | None = typer.Option(None, "--context-window-tokens", help="Optional context window override for auto-compact"),
|
|
2084
|
+
auto_compact_threshold_tokens: int | None = typer.Option(None, "--auto-compact-threshold-tokens", help="Optional explicit auto-compact threshold override"),
|
|
2085
|
+
) -> None:
|
|
2086
|
+
"""Create a provider profile."""
|
|
2087
|
+
from openharness.auth.manager import AuthManager
|
|
2088
|
+
from openharness.config.settings import ProviderProfile
|
|
2089
|
+
|
|
2090
|
+
manager = AuthManager()
|
|
2091
|
+
manager.upsert_profile(
|
|
2092
|
+
name,
|
|
2093
|
+
ProviderProfile(
|
|
2094
|
+
label=label,
|
|
2095
|
+
provider=provider,
|
|
2096
|
+
api_format=api_format,
|
|
2097
|
+
auth_source=auth_source,
|
|
2098
|
+
default_model=model,
|
|
2099
|
+
last_model=model,
|
|
2100
|
+
base_url=base_url,
|
|
2101
|
+
credential_slot=credential_slot or _default_credential_slot_for_profile(name, auth_source),
|
|
2102
|
+
allowed_models=allowed_models or ([model] if credential_slot or _default_credential_slot_for_profile(name, auth_source) else []),
|
|
2103
|
+
context_window_tokens=context_window_tokens,
|
|
2104
|
+
auto_compact_threshold_tokens=auto_compact_threshold_tokens,
|
|
2105
|
+
),
|
|
2106
|
+
)
|
|
2107
|
+
if api_key is not None:
|
|
2108
|
+
manager = AuthManager()
|
|
2109
|
+
manager.store_profile_credential(name, "api_key", api_key)
|
|
2110
|
+
print(f"Saved provider profile: {name} (API key set)", flush=True)
|
|
2111
|
+
else:
|
|
2112
|
+
print(f"Saved provider profile: {name}", flush=True)
|
|
2113
|
+
|
|
2114
|
+
|
|
2115
|
+
@provider_app.command("edit")
|
|
2116
|
+
def provider_edit(
|
|
2117
|
+
name: str = typer.Argument(..., help="Provider profile name"),
|
|
2118
|
+
label: str | None = typer.Option(None, "--label", help="Display label"),
|
|
2119
|
+
provider: str | None = typer.Option(None, "--provider", help="Runtime provider id"),
|
|
2120
|
+
api_format: str | None = typer.Option(None, "--api-format", help="API format"),
|
|
2121
|
+
auth_source: str | None = typer.Option(None, "--auth-source", help="Auth source name"),
|
|
2122
|
+
model: str | None = typer.Option(None, "--model", help="Default model"),
|
|
2123
|
+
base_url: str | None = typer.Option(None, "--base-url", help="Optional base URL"),
|
|
2124
|
+
credential_slot: str | None = typer.Option(None, "--credential-slot", help="Optional profile-specific credential slot"),
|
|
2125
|
+
api_key: str | None = typer.Option(None, "--api-key", help="Replace the profile API key"),
|
|
2126
|
+
allowed_models: list[str] | None = typer.Option(None, "--allowed-model", help="Allowed model values for this profile"),
|
|
2127
|
+
context_window_tokens: int | None = typer.Option(None, "--context-window-tokens", help="Optional context window override for auto-compact"),
|
|
2128
|
+
auto_compact_threshold_tokens: int | None = typer.Option(None, "--auto-compact-threshold-tokens", help="Optional explicit auto-compact threshold override"),
|
|
2129
|
+
) -> None:
|
|
2130
|
+
"""Edit a provider profile."""
|
|
2131
|
+
from openharness.auth.manager import AuthManager
|
|
2132
|
+
|
|
2133
|
+
manager = AuthManager()
|
|
2134
|
+
try:
|
|
2135
|
+
manager.update_profile(
|
|
2136
|
+
name,
|
|
2137
|
+
label=label,
|
|
2138
|
+
provider=provider,
|
|
2139
|
+
api_format=api_format,
|
|
2140
|
+
auth_source=auth_source,
|
|
2141
|
+
default_model=model,
|
|
2142
|
+
last_model=model,
|
|
2143
|
+
base_url=base_url,
|
|
2144
|
+
credential_slot=credential_slot,
|
|
2145
|
+
allowed_models=allowed_models,
|
|
2146
|
+
context_window_tokens=context_window_tokens,
|
|
2147
|
+
auto_compact_threshold_tokens=auto_compact_threshold_tokens,
|
|
2148
|
+
)
|
|
2149
|
+
if api_key is not None:
|
|
2150
|
+
manager = AuthManager()
|
|
2151
|
+
manager.store_profile_credential(name, "api_key", api_key)
|
|
2152
|
+
except ValueError as exc:
|
|
2153
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
2154
|
+
raise typer.Exit(1)
|
|
2155
|
+
if api_key is not None:
|
|
2156
|
+
print(f"Updated provider profile: {name} (API key replaced)", flush=True)
|
|
2157
|
+
else:
|
|
2158
|
+
print(f"Updated provider profile: {name}", flush=True)
|
|
2159
|
+
|
|
2160
|
+
|
|
2161
|
+
@provider_app.command("remove")
|
|
2162
|
+
def provider_remove(
|
|
2163
|
+
name: str = typer.Argument(..., help="Provider profile name"),
|
|
2164
|
+
) -> None:
|
|
2165
|
+
"""Remove a provider profile."""
|
|
2166
|
+
from openharness.auth.manager import AuthManager
|
|
2167
|
+
|
|
2168
|
+
manager = AuthManager()
|
|
2169
|
+
try:
|
|
2170
|
+
manager.remove_profile(name)
|
|
2171
|
+
except ValueError as exc:
|
|
2172
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
2173
|
+
raise typer.Exit(1)
|
|
2174
|
+
print(f"Removed provider profile: {name}", flush=True)
|
|
2175
|
+
|
|
2176
|
+
# ---------------------------------------------------------------------------
|
|
2177
|
+
# Main command
|
|
2178
|
+
# ---------------------------------------------------------------------------
|
|
2179
|
+
|
|
2180
|
+
@app.callback(invoke_without_command=True)
|
|
2181
|
+
def main(
|
|
2182
|
+
ctx: typer.Context,
|
|
2183
|
+
version: bool = typer.Option(
|
|
2184
|
+
False,
|
|
2185
|
+
"--version",
|
|
2186
|
+
"-v",
|
|
2187
|
+
help="Show version and exit",
|
|
2188
|
+
callback=_version_callback,
|
|
2189
|
+
is_eager=True,
|
|
2190
|
+
),
|
|
2191
|
+
# --- Session ---
|
|
2192
|
+
continue_session: bool = typer.Option(
|
|
2193
|
+
False,
|
|
2194
|
+
"--continue",
|
|
2195
|
+
"-c",
|
|
2196
|
+
help="Continue the most recent conversation in the current directory",
|
|
2197
|
+
rich_help_panel="Session",
|
|
2198
|
+
),
|
|
2199
|
+
resume: str | None = typer.Option(
|
|
2200
|
+
None,
|
|
2201
|
+
"--resume",
|
|
2202
|
+
"-r",
|
|
2203
|
+
help="Resume a conversation by session ID, or open picker",
|
|
2204
|
+
rich_help_panel="Session",
|
|
2205
|
+
),
|
|
2206
|
+
name: str | None = typer.Option(
|
|
2207
|
+
None,
|
|
2208
|
+
"--name",
|
|
2209
|
+
"-n",
|
|
2210
|
+
help="Set a display name for this session",
|
|
2211
|
+
rich_help_panel="Session",
|
|
2212
|
+
),
|
|
2213
|
+
# --- Model & Effort ---
|
|
2214
|
+
model: str | None = typer.Option(
|
|
2215
|
+
None,
|
|
2216
|
+
"--model",
|
|
2217
|
+
"-m",
|
|
2218
|
+
help="Model alias (e.g. 'sonnet', 'opus') or full model ID",
|
|
2219
|
+
rich_help_panel="Model & Effort",
|
|
2220
|
+
),
|
|
2221
|
+
effort: str | None = typer.Option(
|
|
2222
|
+
None,
|
|
2223
|
+
"--effort",
|
|
2224
|
+
help="Effort level for the session (low, medium, high, xhigh/max)",
|
|
2225
|
+
rich_help_panel="Model & Effort",
|
|
2226
|
+
),
|
|
2227
|
+
verbose: bool = typer.Option(
|
|
2228
|
+
False,
|
|
2229
|
+
"--verbose",
|
|
2230
|
+
help="Override verbose mode setting from config",
|
|
2231
|
+
rich_help_panel="Model & Effort",
|
|
2232
|
+
),
|
|
2233
|
+
max_turns: int | None = typer.Option(
|
|
2234
|
+
None,
|
|
2235
|
+
"--max-turns",
|
|
2236
|
+
help="Maximum number of agentic turns (enforced by default in --print; optional cap for interactive mode)",
|
|
2237
|
+
rich_help_panel="Model & Effort",
|
|
2238
|
+
),
|
|
2239
|
+
# --- Output ---
|
|
2240
|
+
print_mode: str | None = typer.Option(
|
|
2241
|
+
None,
|
|
2242
|
+
"--print",
|
|
2243
|
+
"-p",
|
|
2244
|
+
help="Print response and exit. Pass your prompt as the value: -p 'your prompt'",
|
|
2245
|
+
rich_help_panel="Output",
|
|
2246
|
+
),
|
|
2247
|
+
output_format: str | None = typer.Option(
|
|
2248
|
+
None,
|
|
2249
|
+
"--output-format",
|
|
2250
|
+
help="Output format with --print: text (default), json, or stream-json",
|
|
2251
|
+
rich_help_panel="Output",
|
|
2252
|
+
),
|
|
2253
|
+
dry_run: bool = typer.Option(
|
|
2254
|
+
False,
|
|
2255
|
+
"--dry-run",
|
|
2256
|
+
help="Preview resolved runtime config, skills, commands, and tools without executing the model or tools",
|
|
2257
|
+
rich_help_panel="Output",
|
|
2258
|
+
),
|
|
2259
|
+
# --- Permissions ---
|
|
2260
|
+
permission_mode: str | None = typer.Option(
|
|
2261
|
+
None,
|
|
2262
|
+
"--permission-mode",
|
|
2263
|
+
help="Permission mode: default, plan, or full_auto",
|
|
2264
|
+
rich_help_panel="Permissions",
|
|
2265
|
+
),
|
|
2266
|
+
dangerously_skip_permissions: bool = typer.Option(
|
|
2267
|
+
False,
|
|
2268
|
+
"--dangerously-skip-permissions",
|
|
2269
|
+
help="Bypass all permission checks (only for sandboxed environments)",
|
|
2270
|
+
rich_help_panel="Permissions",
|
|
2271
|
+
),
|
|
2272
|
+
allowed_tools: Optional[list[str]] = typer.Option(
|
|
2273
|
+
None,
|
|
2274
|
+
"--allowed-tools",
|
|
2275
|
+
help="Comma or space-separated list of tool names to allow",
|
|
2276
|
+
rich_help_panel="Permissions",
|
|
2277
|
+
),
|
|
2278
|
+
disallowed_tools: Optional[list[str]] = typer.Option(
|
|
2279
|
+
None,
|
|
2280
|
+
"--disallowed-tools",
|
|
2281
|
+
help="Comma or space-separated list of tool names to deny",
|
|
2282
|
+
rich_help_panel="Permissions",
|
|
2283
|
+
),
|
|
2284
|
+
# --- System & Context ---
|
|
2285
|
+
system_prompt: str | None = typer.Option(
|
|
2286
|
+
None,
|
|
2287
|
+
"--system-prompt",
|
|
2288
|
+
"-s",
|
|
2289
|
+
help="Override the default system prompt",
|
|
2290
|
+
rich_help_panel="System & Context",
|
|
2291
|
+
),
|
|
2292
|
+
append_system_prompt: str | None = typer.Option(
|
|
2293
|
+
None,
|
|
2294
|
+
"--append-system-prompt",
|
|
2295
|
+
help="Append text to the default system prompt",
|
|
2296
|
+
rich_help_panel="System & Context",
|
|
2297
|
+
),
|
|
2298
|
+
settings_file: str | None = typer.Option(
|
|
2299
|
+
None,
|
|
2300
|
+
"--settings",
|
|
2301
|
+
help="Path to a JSON settings file or inline JSON string",
|
|
2302
|
+
rich_help_panel="System & Context",
|
|
2303
|
+
),
|
|
2304
|
+
base_url: str | None = typer.Option(
|
|
2305
|
+
None,
|
|
2306
|
+
"--base-url",
|
|
2307
|
+
help="Anthropic-compatible API base URL",
|
|
2308
|
+
rich_help_panel="System & Context",
|
|
2309
|
+
),
|
|
2310
|
+
api_key: str | None = typer.Option(
|
|
2311
|
+
None,
|
|
2312
|
+
"--api-key",
|
|
2313
|
+
"-k",
|
|
2314
|
+
help="API key (overrides config and environment)",
|
|
2315
|
+
rich_help_panel="System & Context",
|
|
2316
|
+
),
|
|
2317
|
+
bare: bool = typer.Option(
|
|
2318
|
+
False,
|
|
2319
|
+
"--bare",
|
|
2320
|
+
help="Minimal mode: skip hooks, plugins, MCP, and auto-discovery",
|
|
2321
|
+
rich_help_panel="System & Context",
|
|
2322
|
+
),
|
|
2323
|
+
api_format: str | None = typer.Option(
|
|
2324
|
+
None,
|
|
2325
|
+
"--api-format",
|
|
2326
|
+
help="API format: 'anthropic' (default), 'openai' (DashScope, GitHub Models, etc.), or 'copilot' (GitHub Copilot)",
|
|
2327
|
+
rich_help_panel="System & Context",
|
|
2328
|
+
),
|
|
2329
|
+
theme: str | None = typer.Option(
|
|
2330
|
+
None,
|
|
2331
|
+
"--theme",
|
|
2332
|
+
help="TUI theme: default, dark, minimal, cyberpunk, solarized, or custom name",
|
|
2333
|
+
rich_help_panel="System & Context",
|
|
2334
|
+
),
|
|
2335
|
+
# --- Advanced ---
|
|
2336
|
+
debug: bool = typer.Option(
|
|
2337
|
+
False,
|
|
2338
|
+
"--debug",
|
|
2339
|
+
"-d",
|
|
2340
|
+
help="Enable debug logging",
|
|
2341
|
+
rich_help_panel="Advanced",
|
|
2342
|
+
),
|
|
2343
|
+
mcp_config: Optional[list[str]] = typer.Option(
|
|
2344
|
+
None,
|
|
2345
|
+
"--mcp-config",
|
|
2346
|
+
help="Load MCP servers from JSON files or strings",
|
|
2347
|
+
rich_help_panel="Advanced",
|
|
2348
|
+
),
|
|
2349
|
+
cwd: str = typer.Option(
|
|
2350
|
+
str(Path.cwd()),
|
|
2351
|
+
"--cwd",
|
|
2352
|
+
help="Working directory for the session",
|
|
2353
|
+
hidden=True,
|
|
2354
|
+
),
|
|
2355
|
+
backend_only: bool = typer.Option(
|
|
2356
|
+
False,
|
|
2357
|
+
"--backend-only",
|
|
2358
|
+
help="Run the structured backend host for the React terminal UI",
|
|
2359
|
+
hidden=True,
|
|
2360
|
+
),
|
|
2361
|
+
task_worker: bool = typer.Option(
|
|
2362
|
+
False,
|
|
2363
|
+
"--task-worker",
|
|
2364
|
+
help="Run the stdin-driven headless worker loop used for background agent tasks",
|
|
2365
|
+
hidden=True,
|
|
2366
|
+
),
|
|
2367
|
+
) -> None:
|
|
2368
|
+
"""Start an interactive session or run a single prompt."""
|
|
2369
|
+
if ctx.invoked_subcommand is not None:
|
|
2370
|
+
return
|
|
2371
|
+
|
|
2372
|
+
import asyncio
|
|
2373
|
+
import logging
|
|
2374
|
+
|
|
2375
|
+
if debug:
|
|
2376
|
+
logging.basicConfig(
|
|
2377
|
+
level=logging.DEBUG,
|
|
2378
|
+
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
|
|
2379
|
+
stream=sys.stderr,
|
|
2380
|
+
)
|
|
2381
|
+
logging.getLogger("openharness").setLevel(logging.DEBUG)
|
|
2382
|
+
elif os.environ.get("OPENHARNESS_LOG_LEVEL"):
|
|
2383
|
+
lvl = getattr(logging, os.environ["OPENHARNESS_LOG_LEVEL"].upper(), logging.WARNING)
|
|
2384
|
+
logging.basicConfig(level=lvl, format="%(asctime)s [%(name)s] %(levelname)s %(message)s", stream=sys.stderr)
|
|
2385
|
+
|
|
2386
|
+
if dangerously_skip_permissions:
|
|
2387
|
+
permission_mode = "full_auto"
|
|
2388
|
+
|
|
2389
|
+
# Apply --theme override to settings
|
|
2390
|
+
if theme:
|
|
2391
|
+
from openharness.config.settings import load_settings, save_settings
|
|
2392
|
+
|
|
2393
|
+
settings = load_settings()
|
|
2394
|
+
settings.theme = theme
|
|
2395
|
+
save_settings(settings)
|
|
2396
|
+
|
|
2397
|
+
from openharness.ui.app import run_print_mode, run_repl, run_task_worker
|
|
2398
|
+
|
|
2399
|
+
if dry_run and (continue_session or resume is not None):
|
|
2400
|
+
print("Error: --dry-run does not support --continue/--resume yet.", file=sys.stderr)
|
|
2401
|
+
raise typer.Exit(1)
|
|
2402
|
+
|
|
2403
|
+
if dry_run:
|
|
2404
|
+
prompt = print_mode.strip() if print_mode is not None else None
|
|
2405
|
+
if print_mode is not None and not prompt:
|
|
2406
|
+
print("Error: -p/--print requires a prompt value, e.g. -p 'your prompt'", file=sys.stderr)
|
|
2407
|
+
raise typer.Exit(1)
|
|
2408
|
+
preview = _build_dry_run_preview(
|
|
2409
|
+
prompt=prompt,
|
|
2410
|
+
cwd=cwd,
|
|
2411
|
+
model=model,
|
|
2412
|
+
max_turns=max_turns,
|
|
2413
|
+
base_url=base_url,
|
|
2414
|
+
system_prompt=system_prompt,
|
|
2415
|
+
append_system_prompt=append_system_prompt,
|
|
2416
|
+
api_key=api_key,
|
|
2417
|
+
api_format=api_format,
|
|
2418
|
+
permission_mode=permission_mode,
|
|
2419
|
+
effort=effort,
|
|
2420
|
+
)
|
|
2421
|
+
effective_output_format = output_format or "text"
|
|
2422
|
+
if effective_output_format == "text":
|
|
2423
|
+
print(_format_dry_run_preview(preview))
|
|
2424
|
+
elif effective_output_format == "json":
|
|
2425
|
+
print(json.dumps(preview, ensure_ascii=False, indent=2))
|
|
2426
|
+
elif effective_output_format == "stream-json":
|
|
2427
|
+
print(json.dumps(preview, ensure_ascii=False))
|
|
2428
|
+
else:
|
|
2429
|
+
print(
|
|
2430
|
+
"Error: --dry-run only supports --output-format text, json, or stream-json",
|
|
2431
|
+
file=sys.stderr,
|
|
2432
|
+
)
|
|
2433
|
+
raise typer.Exit(1)
|
|
2434
|
+
return
|
|
2435
|
+
|
|
2436
|
+
# Handle --continue and --resume flags
|
|
2437
|
+
if continue_session or resume is not None:
|
|
2438
|
+
from openharness.services.session_storage import (
|
|
2439
|
+
list_session_snapshots,
|
|
2440
|
+
load_session_by_id,
|
|
2441
|
+
load_session_snapshot,
|
|
2442
|
+
)
|
|
2443
|
+
|
|
2444
|
+
session_data = None
|
|
2445
|
+
if continue_session:
|
|
2446
|
+
session_data = load_session_snapshot(cwd)
|
|
2447
|
+
if session_data is None:
|
|
2448
|
+
print("No previous session found in this directory.", file=sys.stderr)
|
|
2449
|
+
raise typer.Exit(1)
|
|
2450
|
+
print(f"Continuing session: {session_data.get('summary', '(untitled)')[:60]}")
|
|
2451
|
+
elif resume == "" or resume is None:
|
|
2452
|
+
# --resume with no value: show session picker
|
|
2453
|
+
sessions = list_session_snapshots(cwd, limit=10)
|
|
2454
|
+
if not sessions:
|
|
2455
|
+
print("No saved sessions found.", file=sys.stderr)
|
|
2456
|
+
raise typer.Exit(1)
|
|
2457
|
+
print("Saved sessions:")
|
|
2458
|
+
for i, s in enumerate(sessions, 1):
|
|
2459
|
+
print(f" {i}. [{s['session_id']}] {s.get('summary', '?')[:50]} ({s['message_count']} msgs)")
|
|
2460
|
+
choice = typer.prompt("Enter session number or ID")
|
|
2461
|
+
try:
|
|
2462
|
+
idx = int(choice) - 1
|
|
2463
|
+
if 0 <= idx < len(sessions):
|
|
2464
|
+
session_data = load_session_by_id(cwd, sessions[idx]["session_id"])
|
|
2465
|
+
else:
|
|
2466
|
+
print("Invalid selection.", file=sys.stderr)
|
|
2467
|
+
raise typer.Exit(1)
|
|
2468
|
+
except ValueError:
|
|
2469
|
+
session_data = load_session_by_id(cwd, choice)
|
|
2470
|
+
if session_data is None:
|
|
2471
|
+
print(f"Session not found: {choice}", file=sys.stderr)
|
|
2472
|
+
raise typer.Exit(1)
|
|
2473
|
+
else:
|
|
2474
|
+
session_data = load_session_by_id(cwd, resume)
|
|
2475
|
+
if session_data is None:
|
|
2476
|
+
print(f"Session not found: {resume}", file=sys.stderr)
|
|
2477
|
+
raise typer.Exit(1)
|
|
2478
|
+
|
|
2479
|
+
# Pass restored session to the REPL
|
|
2480
|
+
asyncio.run(
|
|
2481
|
+
run_repl(
|
|
2482
|
+
prompt=None,
|
|
2483
|
+
cwd=cwd,
|
|
2484
|
+
model=session_data.get("model") or model,
|
|
2485
|
+
backend_only=backend_only,
|
|
2486
|
+
base_url=base_url,
|
|
2487
|
+
system_prompt=system_prompt,
|
|
2488
|
+
api_key=api_key,
|
|
2489
|
+
restore_messages=session_data.get("messages"),
|
|
2490
|
+
restore_tool_metadata=session_data.get("tool_metadata"),
|
|
2491
|
+
permission_mode=permission_mode,
|
|
2492
|
+
api_format=api_format,
|
|
2493
|
+
effort=effort,
|
|
2494
|
+
)
|
|
2495
|
+
)
|
|
2496
|
+
return
|
|
2497
|
+
|
|
2498
|
+
if print_mode is not None:
|
|
2499
|
+
prompt = print_mode.strip()
|
|
2500
|
+
if not prompt:
|
|
2501
|
+
print("Error: -p/--print requires a prompt value, e.g. -p 'your prompt'", file=sys.stderr)
|
|
2502
|
+
raise typer.Exit(1)
|
|
2503
|
+
asyncio.run(
|
|
2504
|
+
run_print_mode(
|
|
2505
|
+
prompt=prompt,
|
|
2506
|
+
output_format=output_format or "text",
|
|
2507
|
+
cwd=cwd,
|
|
2508
|
+
model=model,
|
|
2509
|
+
base_url=base_url,
|
|
2510
|
+
system_prompt=system_prompt,
|
|
2511
|
+
append_system_prompt=append_system_prompt,
|
|
2512
|
+
api_key=api_key,
|
|
2513
|
+
api_format=api_format,
|
|
2514
|
+
permission_mode=permission_mode,
|
|
2515
|
+
max_turns=max_turns,
|
|
2516
|
+
effort=effort,
|
|
2517
|
+
)
|
|
2518
|
+
)
|
|
2519
|
+
return
|
|
2520
|
+
|
|
2521
|
+
if task_worker:
|
|
2522
|
+
asyncio.run(
|
|
2523
|
+
run_task_worker(
|
|
2524
|
+
cwd=cwd,
|
|
2525
|
+
model=model,
|
|
2526
|
+
max_turns=max_turns,
|
|
2527
|
+
base_url=base_url,
|
|
2528
|
+
system_prompt=system_prompt,
|
|
2529
|
+
api_key=api_key,
|
|
2530
|
+
api_format=api_format,
|
|
2531
|
+
permission_mode=permission_mode,
|
|
2532
|
+
effort=effort,
|
|
2533
|
+
)
|
|
2534
|
+
)
|
|
2535
|
+
return
|
|
2536
|
+
|
|
2537
|
+
asyncio.run(
|
|
2538
|
+
run_repl(
|
|
2539
|
+
prompt=None,
|
|
2540
|
+
cwd=cwd,
|
|
2541
|
+
model=model,
|
|
2542
|
+
max_turns=max_turns,
|
|
2543
|
+
backend_only=backend_only,
|
|
2544
|
+
base_url=base_url,
|
|
2545
|
+
system_prompt=system_prompt,
|
|
2546
|
+
api_key=api_key,
|
|
2547
|
+
api_format=api_format,
|
|
2548
|
+
permission_mode=permission_mode,
|
|
2549
|
+
effort=effort,
|
|
2550
|
+
)
|
|
2551
|
+
)
|