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.
Files changed (549) hide show
  1. qwen_sci-0.2.2.dist-info/METADATA +492 -0
  2. qwen_sci-0.2.2.dist-info/RECORD +549 -0
  3. qwen_sci-0.2.2.dist-info/WHEEL +4 -0
  4. qwen_sci-0.2.2.dist-info/entry_points.txt +6 -0
  5. qwen_sci-0.2.2.dist-info/licenses/LICENSE +21 -0
  6. src/1.txt +1 -0
  7. src/__init__.py +1 -0
  8. src/__main__.py +5 -0
  9. src/agents/__init__.py +1 -0
  10. src/agents/experiment_design_agent/__init__.py +268 -0
  11. src/agents/experiment_design_agent/artifacts.py +747 -0
  12. src/agents/experiment_design_agent/cache.py +319 -0
  13. src/agents/experiment_design_agent/completeness.py +130 -0
  14. src/agents/experiment_design_agent/contracts.py +771 -0
  15. src/agents/experiment_design_agent/counterexample_analyzer.py +187 -0
  16. src/agents/experiment_design_agent/design_evidence_paper_screener.py +470 -0
  17. src/agents/experiment_design_agent/discipline_catalog.py +178 -0
  18. src/agents/experiment_design_agent/evidence_cards.py +338 -0
  19. src/agents/experiment_design_agent/evidence_planner.py +452 -0
  20. src/agents/experiment_design_agent/formal_reasoning_planner.py +884 -0
  21. src/agents/experiment_design_agent/fulltext_acquisition.py +863 -0
  22. src/agents/experiment_design_agent/idea_adapter.py +100 -0
  23. src/agents/experiment_design_agent/idea_intake.py +87 -0
  24. src/agents/experiment_design_agent/llm_json.py +328 -0
  25. src/agents/experiment_design_agent/orchestrator.py +1220 -0
  26. src/agents/experiment_design_agent/reasoning_context.py +183 -0
  27. src/agents/experiment_design_agent/reasoning_validation.py +546 -0
  28. src/agents/experiment_design_agent/run.py +481 -0
  29. src/agents/experiment_design_agent/run_logging.py +292 -0
  30. src/agents/experiment_design_agent/scope_gate.py +198 -0
  31. src/agents/experiment_design_agent/study_type_composer.py +1360 -0
  32. src/agents/experiment_design_agent/survey_evidence.py +1381 -0
  33. src/agents/experiment_design_agent/template_router.py +195 -0
  34. src/agents/experiment_design_agent/variable_claim_extractor.py +93 -0
  35. src/agents/idea_agent/__init__.py +1 -0
  36. src/agents/idea_agent/agent/__init__.py +6 -0
  37. src/agents/idea_agent/agent/artifacts.py +308 -0
  38. src/agents/idea_agent/agent/base.py +122 -0
  39. src/agents/idea_agent/agent/ligagent.py +488 -0
  40. src/agents/idea_agent/agent/mcts.py +2060 -0
  41. src/agents/idea_agent/agent/prompts/__init__.py +48 -0
  42. src/agents/idea_agent/agent/prompts/advanced_analysis.py +235 -0
  43. src/agents/idea_agent/agent/prompts/algorithm_alignment.py +55 -0
  44. src/agents/idea_agent/agent/prompts/algorithm_structuring.py +48 -0
  45. src/agents/idea_agent/agent/prompts/component_extraction.py +62 -0
  46. src/agents/idea_agent/agent/prompts/component_novelty_evaluation.py +54 -0
  47. src/agents/idea_agent/agent/prompts/experiment_findings_extraction.py +39 -0
  48. src/agents/idea_agent/agent/prompts/idea_fusion.py +295 -0
  49. src/agents/idea_agent/agent/prompts/idea_introduction.py +30 -0
  50. src/agents/idea_agent/agent/prompts/idea_result_alignment.py +39 -0
  51. src/agents/idea_agent/agent/prompts/input_interpreter.py +52 -0
  52. src/agents/idea_agent/agent/prompts/keynote_ops.py +78 -0
  53. src/agents/idea_agent/agent/prompts/mcts_evaluation.py +235 -0
  54. src/agents/idea_agent/agent/prompts/mcts_generation.py +140 -0
  55. src/agents/idea_agent/agent/prompts/mechanism_commit_query.py +99 -0
  56. src/agents/idea_agent/agent/prompts/memory_selection.py +0 -0
  57. src/agents/idea_agent/agent/prompts/prompt_modes.py +16 -0
  58. src/agents/idea_agent/agent/prompts/rag_query.py +34 -0
  59. src/agents/idea_agent/agent/prompts/re_analysis_replan.py +48 -0
  60. src/agents/idea_agent/agent/prompts/reference_grounding.py +34 -0
  61. src/agents/idea_agent/agent/prompts/root_domain_classification.py +32 -0
  62. src/agents/idea_agent/agent/prompts/scientific_debate.py +88 -0
  63. src/agents/idea_agent/agent/prompts/scientific_materialization.py +45 -0
  64. src/agents/idea_agent/agent/prompts/skill_instantiation.py +193 -0
  65. src/agents/idea_agent/agent/prompts/theory_transfer_query.py +93 -0
  66. src/agents/idea_agent/agent/prompts/topic_background.py +15 -0
  67. src/agents/idea_agent/agent/skills/DEFAULT_SKILL_TEMPLATES.json +263 -0
  68. src/agents/idea_agent/agent/skills/edit_operator_skills/alternative-path-contrast/SKILL.md +40 -0
  69. src/agents/idea_agent/agent/skills/edit_operator_skills/alternative-path-contrast/references/contrast_patterns.md +22 -0
  70. src/agents/idea_agent/agent/skills/edit_operator_skills/feedback-closed-loop/SKILL.md +40 -0
  71. src/agents/idea_agent/agent/skills/edit_operator_skills/feedback-closed-loop/references/mechanism_cards.md +24 -0
  72. src/agents/idea_agent/agent/skills/edit_operator_skills/hierarchical-decomposition/SKILL.md +39 -0
  73. src/agents/idea_agent/agent/skills/edit_operator_skills/hierarchical-decomposition/references/hierarchy_patterns.md +22 -0
  74. src/agents/idea_agent/agent/skills/edit_operator_skills/mechanism-commit-innovation/SKILL.md +39 -0
  75. src/agents/idea_agent/agent/skills/edit_operator_skills/mechanism-commit-innovation/references/mechanism_commit_patterns.md +24 -0
  76. src/agents/idea_agent/agent/skills/edit_operator_skills/multi-scale-coordinator/SKILL.md +39 -0
  77. src/agents/idea_agent/agent/skills/edit_operator_skills/multi-scale-coordinator/references/coordination_patterns.md +22 -0
  78. src/agents/idea_agent/agent/skills/edit_operator_skills/speculative-execution-with-repair/SKILL.md +41 -0
  79. src/agents/idea_agent/agent/skills/edit_operator_skills/speculative-execution-with-repair/references/speculation_patterns.md +22 -0
  80. src/agents/idea_agent/agent/skills/edit_operator_skills/surgical-modularity/SKILL.md +39 -0
  81. src/agents/idea_agent/agent/skills/edit_operator_skills/surgical-modularity/references/modularity_patterns.md +22 -0
  82. src/agents/idea_agent/agent/skills/edit_operator_skills/theory-transfer-injection/SKILL.md +40 -0
  83. src/agents/idea_agent/agent/skills/edit_operator_skills/theory-transfer-injection/references/transfer_patterns.md +24 -0
  84. src/agents/idea_agent/example/idea_result.json +900 -0
  85. src/agents/idea_agent/run.py +276 -0
  86. src/agents/idea_agent/tests/test_advanced_analysis_contract.py +90 -0
  87. src/agents/idea_agent/tests/test_gap_hypothesis_bridge.py +166 -0
  88. src/agents/idea_agent/tests/test_idea_debate.py +480 -0
  89. src/agents/idea_agent/tests/test_idea_result_v5.py +99 -0
  90. src/agents/idea_agent/tests/test_idea_synthesis.py +92 -0
  91. src/agents/idea_agent/tests/test_mature_idea_sources.py +27 -0
  92. src/agents/idea_agent/tests/test_multimodal_idea_integration.py +386 -0
  93. src/agents/idea_agent/tests/test_profile_materialization_and_scoring.py +219 -0
  94. src/agents/idea_agent/tests/test_profile_native_skill_rendering.py +122 -0
  95. src/agents/idea_agent/tests/test_result_persistence.py +183 -0
  96. src/agents/idea_agent/tests/test_scientific_intervention_profiles.py +241 -0
  97. src/agents/idea_agent/tests/test_scientific_rubric_and_prompts.py +559 -0
  98. src/agents/idea_agent/tests/test_strict_json_object_responses.py +48 -0
  99. src/agents/idea_agent/utils/core/__init__.py +1 -0
  100. src/agents/idea_agent/utils/core/ablation_inputs.py +60 -0
  101. src/agents/idea_agent/utils/core/chat_errors.py +95 -0
  102. src/agents/idea_agent/utils/core/chat_router.py +130 -0
  103. src/agents/idea_agent/utils/core/chat_transport.py +188 -0
  104. src/agents/idea_agent/utils/core/config_loader.py +166 -0
  105. src/agents/idea_agent/utils/core/json_utils.py +28 -0
  106. src/agents/idea_agent/utils/core/logger.py +287 -0
  107. src/agents/idea_agent/utils/core/progress.py +58 -0
  108. src/agents/idea_agent/utils/core/response_parsing.py +57 -0
  109. src/agents/idea_agent/utils/core/run_inputs.py +198 -0
  110. src/agents/idea_agent/utils/mcts/__init__.py +1 -0
  111. src/agents/idea_agent/utils/mcts/component_novelty.py +342 -0
  112. src/agents/idea_agent/utils/mcts/defect_registry.py +302 -0
  113. src/agents/idea_agent/utils/mcts/idea_routes.py +61 -0
  114. src/agents/idea_agent/utils/mcts/idea_taste_presets.py +312 -0
  115. src/agents/idea_agent/utils/mcts/mcts_helpers.py +738 -0
  116. src/agents/idea_agent/utils/mcts/mcts_runtime.py +3000 -0
  117. src/agents/idea_agent/utils/mcts/scientific_intervention_ontology.py +1561 -0
  118. src/agents/idea_agent/utils/mcts/scientific_rubric.py +211 -0
  119. src/agents/idea_agent/utils/mcts/skill_parsing.py +92 -0
  120. src/agents/idea_agent/utils/papers/__init__.py +1 -0
  121. src/agents/idea_agent/utils/papers/paper_graph_vector_store.py +499 -0
  122. src/agents/idea_agent/utils/papers/paper_repository.py +372 -0
  123. src/agents/idea_agent/utils/prompting/__init__.py +1 -0
  124. src/agents/idea_agent/utils/prompting/prompt_views.py +637 -0
  125. src/agents/idea_agent/utils/workflow/__init__.py +1 -0
  126. src/agents/idea_agent/utils/workflow/advanced_analysis_contract.py +244 -0
  127. src/agents/idea_agent/utils/workflow/gap_hypothesis_bridge.py +461 -0
  128. src/agents/idea_agent/utils/workflow/idea_contract.py +426 -0
  129. src/agents/idea_agent/utils/workflow/idea_debate.py +1238 -0
  130. src/agents/idea_agent/utils/workflow/idea_diversity.py +163 -0
  131. src/agents/idea_agent/utils/workflow/idea_fusion.py +889 -0
  132. src/agents/idea_agent/utils/workflow/idea_helpers.py +478 -0
  133. src/agents/idea_agent/utils/workflow/idea_portfolio.py +422 -0
  134. src/agents/idea_agent/utils/workflow/idea_synthesis.py +482 -0
  135. src/agents/idea_agent/utils/workflow/ligagent_flow.py +581 -0
  136. src/agents/idea_agent/utils/workflow/ligagent_handlers.py +1836 -0
  137. src/agents/idea_agent/utils/workflow/ligagent_helpers.py +1331 -0
  138. src/agents/idea_agent/utils/workflow/ligagent_utils.py +322 -0
  139. src/agents/idea_agent/utils/workflow/mature_idea_sources.py +648 -0
  140. src/agents/idea_agent/utils/workflow/multimodal_data_anchoring.py +578 -0
  141. src/agents/idea_agent/utils/workflow/result_persistence.py +774 -0
  142. src/agents/idea_agent/utils/workflow/stage_contract.py +76 -0
  143. src/agents/idea_agent/utils/workflow/workflow_runtime.py +288 -0
  144. src/agents/research_plan_author/__init__.py +50 -0
  145. src/agents/research_plan_author/artifacts.py +270 -0
  146. src/agents/research_plan_author/authoring_blueprint.py +1246 -0
  147. src/agents/research_plan_author/bibtex_renderer.py +252 -0
  148. src/agents/research_plan_author/contract_repair.py +131 -0
  149. src/agents/research_plan_author/contracts.py +745 -0
  150. src/agents/research_plan_author/cross_section_editor.py +232 -0
  151. src/agents/research_plan_author/document_quality.py +987 -0
  152. src/agents/research_plan_author/idea_evolution.py +223 -0
  153. src/agents/research_plan_author/input_loader.py +76 -0
  154. src/agents/research_plan_author/latex_compiler.py +260 -0
  155. src/agents/research_plan_author/latex_safety.py +159 -0
  156. src/agents/research_plan_author/llm_json.py +74 -0
  157. src/agents/research_plan_author/markdown_renderer.py +275 -0
  158. src/agents/research_plan_author/pdf_validator.py +149 -0
  159. src/agents/research_plan_author/render.py +166 -0
  160. src/agents/research_plan_author/render_artifacts.py +187 -0
  161. src/agents/research_plan_author/run.py +765 -0
  162. src/agents/research_plan_author/run_logging.py +209 -0
  163. src/agents/research_plan_author/section_cache.py +184 -0
  164. src/agents/research_plan_author/section_composer.py +1764 -0
  165. src/agents/research_plan_author/section_router.py +209 -0
  166. src/agents/research_plan_author/semantic_validator.py +254 -0
  167. src/agents/research_plan_author/source_bundle.py +46 -0
  168. src/agents/research_plan_author/source_registry.py +208 -0
  169. src/agents/research_plan_author/survey_source_loader.py +137 -0
  170. src/agents/research_plan_author/template_adapter.py +173 -0
  171. src/agents/research_plan_author/template_profile.py +204 -0
  172. src/agents/research_plan_author/tex_renderer.py +496 -0
  173. src/agents/research_plan_author/theory_presentation.py +134 -0
  174. src/agents/research_plan_author/theory_spine.py +703 -0
  175. src/agents/survey_agent/Dockerfile +54 -0
  176. src/agents/survey_agent/README.md +350 -0
  177. src/agents/survey_agent/config/deep_survey.yaml +217 -0
  178. src/agents/survey_agent/config/deep_survey_batch.yaml +176 -0
  179. src/agents/survey_agent/config/deep_survey_fast.yaml +183 -0
  180. src/agents/survey_agent/config/evaluate_baseline.yaml +197 -0
  181. src/agents/survey_agent/config/outcomeRAG.yaml +159 -0
  182. src/agents/survey_agent/env_report.md +348 -0
  183. src/agents/survey_agent/modules/__init__.py +0 -0
  184. src/agents/survey_agent/modules/code_collector.py +2194 -0
  185. src/agents/survey_agent/modules/code_report_generator.py +1282 -0
  186. src/agents/survey_agent/modules/data_manager.py +1713 -0
  187. src/agents/survey_agent/modules/database.py +167 -0
  188. src/agents/survey_agent/modules/fulltext_download_cache.py +462 -0
  189. src/agents/survey_agent/modules/fulltext_http.py +134 -0
  190. src/agents/survey_agent/modules/fulltext_resolution.py +749 -0
  191. src/agents/survey_agent/modules/judge.py +434 -0
  192. src/agents/survey_agent/modules/outcome_RAG.py +236 -0
  193. src/agents/survey_agent/modules/paper_graph_retriever.py +1075 -0
  194. src/agents/survey_agent/modules/pe.py +1729 -0
  195. src/agents/survey_agent/modules/pseudo_creator.py +782 -0
  196. src/agents/survey_agent/modules/pseudo_reviser.py +537 -0
  197. src/agents/survey_agent/modules/refine_agent.py +1952 -0
  198. src/agents/survey_agent/modules/survey_generator.py +5691 -0
  199. src/agents/survey_agent/modules/survey_visualizer.py +1817 -0
  200. src/agents/survey_agent/modules/visual_prompts.py +327 -0
  201. src/agents/survey_agent/modules/work_analyzer.py +2750 -0
  202. src/agents/survey_agent/modules/work_collector.py +5649 -0
  203. src/agents/survey_agent/scripts/baseline_evaluation.py +411 -0
  204. src/agents/survey_agent/scripts/run_deep_survey.py +224 -0
  205. src/agents/survey_agent/scripts/run_deep_survey_adapter.py +407 -0
  206. src/agents/survey_agent/scripts/run_deep_survey_adapter_with_input.py +409 -0
  207. src/agents/survey_agent/scripts/run_deep_survey_batch.py +228 -0
  208. src/agents/survey_agent/scripts/run_deep_survey_batch_arg.py +243 -0
  209. src/agents/survey_agent/scripts/run_deep_survey_batch_settings.py +218 -0
  210. src/agents/survey_agent/scripts/run_keynotes_gen.py +128 -0
  211. src/agents/survey_agent/scripts/run_work_clusters.py +140 -0
  212. src/agents/survey_agent/utils/__init__.py +0 -0
  213. src/agents/survey_agent/utils/ablation_utils.py +317 -0
  214. src/agents/survey_agent/utils/api_call.py +2518 -0
  215. src/agents/survey_agent/utils/chat_utils.py +82 -0
  216. src/agents/survey_agent/utils/config_utils.py +70 -0
  217. src/agents/survey_agent/utils/convert_to_md.py +29 -0
  218. src/agents/survey_agent/utils/err_info.py +24 -0
  219. src/agents/survey_agent/utils/file_utils.py +176 -0
  220. src/agents/survey_agent/utils/gpu_utils.py +145 -0
  221. src/agents/survey_agent/utils/graph_visualizer.py +279 -0
  222. src/agents/survey_agent/utils/html_utils.py +181 -0
  223. src/agents/survey_agent/utils/latex_generator_intra_cluster.py +529 -0
  224. src/agents/survey_agent/utils/latex_generator_relation_table.py +193 -0
  225. src/agents/survey_agent/utils/mineru_section_packer.py +334 -0
  226. src/agents/survey_agent/utils/mineru_utils.py +396 -0
  227. src/agents/survey_agent/utils/paper.py +144 -0
  228. src/agents/survey_agent/utils/read_yaml.py +15 -0
  229. src/agents/survey_agent/utils/repo_utils.py +167 -0
  230. src/agents/survey_agent/utils/rich_logger.py +77 -0
  231. src/agents/survey_agent/utils/step2v2.py +874 -0
  232. src/agents/survey_agent/utils/step2v2_extractor.py +856 -0
  233. src/agents/survey_agent/utils/topic_survey_storage.py +309 -0
  234. src/agents/survey_agent/utils/utils.py +118 -0
  235. src/agents/survey_agent/utils/work.py +2 -0
  236. src/cli.py +1611 -0
  237. src/config/__init__.py +213 -0
  238. src/config/default.yaml +1122 -0
  239. src/harness/LICENSE +21 -0
  240. src/harness/VENDORED.md +14 -0
  241. src/harness/src/__init__.py +0 -0
  242. src/harness/src/openharness/__init__.py +0 -0
  243. src/harness/src/openharness/__main__.py +6 -0
  244. src/harness/src/openharness/api/__init__.py +21 -0
  245. src/harness/src/openharness/api/client.py +281 -0
  246. src/harness/src/openharness/api/codex_client.py +407 -0
  247. src/harness/src/openharness/api/copilot_auth.py +240 -0
  248. src/harness/src/openharness/api/copilot_client.py +134 -0
  249. src/harness/src/openharness/api/errors.py +19 -0
  250. src/harness/src/openharness/api/openai_client.py +577 -0
  251. src/harness/src/openharness/api/provider.py +186 -0
  252. src/harness/src/openharness/api/registry.py +437 -0
  253. src/harness/src/openharness/api/usage.py +17 -0
  254. src/harness/src/openharness/auth/__init__.py +29 -0
  255. src/harness/src/openharness/auth/external.py +610 -0
  256. src/harness/src/openharness/auth/flows.py +189 -0
  257. src/harness/src/openharness/auth/manager.py +482 -0
  258. src/harness/src/openharness/auth/storage.py +269 -0
  259. src/harness/src/openharness/autopilot/__init__.py +23 -0
  260. src/harness/src/openharness/autopilot/service.py +2239 -0
  261. src/harness/src/openharness/autopilot/types.py +91 -0
  262. src/harness/src/openharness/bridge/__init__.py +20 -0
  263. src/harness/src/openharness/bridge/manager.py +106 -0
  264. src/harness/src/openharness/bridge/session_runner.py +46 -0
  265. src/harness/src/openharness/bridge/types.py +37 -0
  266. src/harness/src/openharness/bridge/work_secret.py +41 -0
  267. src/harness/src/openharness/channels/UPSTREAM +6 -0
  268. src/harness/src/openharness/channels/__init__.py +22 -0
  269. src/harness/src/openharness/channels/adapter.py +131 -0
  270. src/harness/src/openharness/channels/bus/__init__.py +6 -0
  271. src/harness/src/openharness/channels/bus/events.py +38 -0
  272. src/harness/src/openharness/channels/bus/queue.py +44 -0
  273. src/harness/src/openharness/channels/impl/__init__.py +6 -0
  274. src/harness/src/openharness/channels/impl/base.py +141 -0
  275. src/harness/src/openharness/channels/impl/dingtalk.py +445 -0
  276. src/harness/src/openharness/channels/impl/discord.py +311 -0
  277. src/harness/src/openharness/channels/impl/email.py +410 -0
  278. src/harness/src/openharness/channels/impl/feishu.py +1342 -0
  279. src/harness/src/openharness/channels/impl/manager.py +257 -0
  280. src/harness/src/openharness/channels/impl/matrix.py +700 -0
  281. src/harness/src/openharness/channels/impl/mochat.py +897 -0
  282. src/harness/src/openharness/channels/impl/qq.py +141 -0
  283. src/harness/src/openharness/channels/impl/slack.py +284 -0
  284. src/harness/src/openharness/channels/impl/telegram.py +525 -0
  285. src/harness/src/openharness/channels/impl/whatsapp.py +159 -0
  286. src/harness/src/openharness/cli.py +2551 -0
  287. src/harness/src/openharness/commands/__init__.py +21 -0
  288. src/harness/src/openharness/commands/registry.py +2783 -0
  289. src/harness/src/openharness/config/__init__.py +34 -0
  290. src/harness/src/openharness/config/paths.py +160 -0
  291. src/harness/src/openharness/config/schema.py +119 -0
  292. src/harness/src/openharness/config/settings.py +1104 -0
  293. src/harness/src/openharness/coordinator/__init__.py +12 -0
  294. src/harness/src/openharness/coordinator/agent_definitions.py +975 -0
  295. src/harness/src/openharness/coordinator/coordinator_mode.py +520 -0
  296. src/harness/src/openharness/engine/__init__.py +80 -0
  297. src/harness/src/openharness/engine/cost_tracker.py +24 -0
  298. src/harness/src/openharness/engine/messages.py +222 -0
  299. src/harness/src/openharness/engine/query.py +1188 -0
  300. src/harness/src/openharness/engine/query_engine.py +313 -0
  301. src/harness/src/openharness/engine/stream_events.py +90 -0
  302. src/harness/src/openharness/hooks/__init__.py +50 -0
  303. src/harness/src/openharness/hooks/events.py +20 -0
  304. src/harness/src/openharness/hooks/executor.py +242 -0
  305. src/harness/src/openharness/hooks/hot_reload.py +31 -0
  306. src/harness/src/openharness/hooks/loader.py +68 -0
  307. src/harness/src/openharness/hooks/schemas.py +66 -0
  308. src/harness/src/openharness/hooks/types.py +38 -0
  309. src/harness/src/openharness/keybindings/__init__.py +14 -0
  310. src/harness/src/openharness/keybindings/default_bindings.py +11 -0
  311. src/harness/src/openharness/keybindings/loader.py +22 -0
  312. src/harness/src/openharness/keybindings/parser.py +18 -0
  313. src/harness/src/openharness/keybindings/resolver.py +13 -0
  314. src/harness/src/openharness/mcp/__init__.py +79 -0
  315. src/harness/src/openharness/mcp/client.py +298 -0
  316. src/harness/src/openharness/mcp/config.py +16 -0
  317. src/harness/src/openharness/mcp/types.py +76 -0
  318. src/harness/src/openharness/memory/__init__.py +25 -0
  319. src/harness/src/openharness/memory/agent.py +91 -0
  320. src/harness/src/openharness/memory/manager.py +183 -0
  321. src/harness/src/openharness/memory/memdir.py +52 -0
  322. src/harness/src/openharness/memory/migrate.py +137 -0
  323. src/harness/src/openharness/memory/paths.py +22 -0
  324. src/harness/src/openharness/memory/relevance.py +145 -0
  325. src/harness/src/openharness/memory/scan.py +116 -0
  326. src/harness/src/openharness/memory/schema.py +443 -0
  327. src/harness/src/openharness/memory/search.py +71 -0
  328. src/harness/src/openharness/memory/team.py +90 -0
  329. src/harness/src/openharness/memory/types.py +33 -0
  330. src/harness/src/openharness/memory/usage.py +153 -0
  331. src/harness/src/openharness/output_styles/__init__.py +5 -0
  332. src/harness/src/openharness/output_styles/loader.py +42 -0
  333. src/harness/src/openharness/permissions/__init__.py +26 -0
  334. src/harness/src/openharness/permissions/checker.py +200 -0
  335. src/harness/src/openharness/permissions/modes.py +13 -0
  336. src/harness/src/openharness/personalization/__init__.py +6 -0
  337. src/harness/src/openharness/personalization/extractor.py +138 -0
  338. src/harness/src/openharness/personalization/rules.py +64 -0
  339. src/harness/src/openharness/personalization/session_hook.py +65 -0
  340. src/harness/src/openharness/platforms.py +86 -0
  341. src/harness/src/openharness/plugins/__init__.py +53 -0
  342. src/harness/src/openharness/plugins/bundled/__init__.py +0 -0
  343. src/harness/src/openharness/plugins/installer.py +39 -0
  344. src/harness/src/openharness/plugins/loader.py +730 -0
  345. src/harness/src/openharness/plugins/schemas.py +24 -0
  346. src/harness/src/openharness/plugins/types.py +59 -0
  347. src/harness/src/openharness/prompts/__init__.py +14 -0
  348. src/harness/src/openharness/prompts/claudemd.py +48 -0
  349. src/harness/src/openharness/prompts/context.py +187 -0
  350. src/harness/src/openharness/prompts/environment.py +135 -0
  351. src/harness/src/openharness/prompts/system_prompt.py +109 -0
  352. src/harness/src/openharness/sandbox/Dockerfile +6 -0
  353. src/harness/src/openharness/sandbox/__init__.py +33 -0
  354. src/harness/src/openharness/sandbox/adapter.py +148 -0
  355. src/harness/src/openharness/sandbox/docker_backend.py +232 -0
  356. src/harness/src/openharness/sandbox/docker_image.py +103 -0
  357. src/harness/src/openharness/sandbox/path_validator.py +37 -0
  358. src/harness/src/openharness/sandbox/session.py +63 -0
  359. src/harness/src/openharness/services/__init__.py +30 -0
  360. src/harness/src/openharness/services/autodream/__init__.py +34 -0
  361. src/harness/src/openharness/services/autodream/backup.py +104 -0
  362. src/harness/src/openharness/services/autodream/lock.py +138 -0
  363. src/harness/src/openharness/services/autodream/prompt.py +128 -0
  364. src/harness/src/openharness/services/autodream/service.py +313 -0
  365. src/harness/src/openharness/services/compact/__init__.py +1725 -0
  366. src/harness/src/openharness/services/cron.py +137 -0
  367. src/harness/src/openharness/services/cron_scheduler.py +595 -0
  368. src/harness/src/openharness/services/lsp/__init__.py +216 -0
  369. src/harness/src/openharness/services/memory_extract/__init__.py +261 -0
  370. src/harness/src/openharness/services/oauth/__init__.py +0 -0
  371. src/harness/src/openharness/services/session_backend.py +97 -0
  372. src/harness/src/openharness/services/session_memory/__init__.py +139 -0
  373. src/harness/src/openharness/services/session_storage.py +230 -0
  374. src/harness/src/openharness/services/token_estimation.py +15 -0
  375. src/harness/src/openharness/services/tool_outputs.py +55 -0
  376. src/harness/src/openharness/skills/__init__.py +44 -0
  377. src/harness/src/openharness/skills/_frontmatter.py +97 -0
  378. src/harness/src/openharness/skills/bundled/__init__.py +75 -0
  379. src/harness/src/openharness/skills/bundled/content/commit.md +25 -0
  380. src/harness/src/openharness/skills/bundled/content/debug.md +25 -0
  381. src/harness/src/openharness/skills/bundled/content/diagnose.md +35 -0
  382. src/harness/src/openharness/skills/bundled/content/plan.md +39 -0
  383. src/harness/src/openharness/skills/bundled/content/review.md +33 -0
  384. src/harness/src/openharness/skills/bundled/content/simplify.md +26 -0
  385. src/harness/src/openharness/skills/bundled/content/skill-creator.md +175 -0
  386. src/harness/src/openharness/skills/bundled/content/test.md +38 -0
  387. src/harness/src/openharness/skills/loader.py +229 -0
  388. src/harness/src/openharness/skills/registry.py +29 -0
  389. src/harness/src/openharness/skills/types.py +24 -0
  390. src/harness/src/openharness/state/__init__.py +6 -0
  391. src/harness/src/openharness/state/app_state.py +30 -0
  392. src/harness/src/openharness/state/store.py +40 -0
  393. src/harness/src/openharness/swarm/__init__.py +70 -0
  394. src/harness/src/openharness/swarm/in_process.py +693 -0
  395. src/harness/src/openharness/swarm/lockfile.py +24 -0
  396. src/harness/src/openharness/swarm/mailbox.py +522 -0
  397. src/harness/src/openharness/swarm/permission_sync.py +1168 -0
  398. src/harness/src/openharness/swarm/registry.py +410 -0
  399. src/harness/src/openharness/swarm/spawn_utils.py +229 -0
  400. src/harness/src/openharness/swarm/subprocess_backend.py +171 -0
  401. src/harness/src/openharness/swarm/team_lifecycle.py +910 -0
  402. src/harness/src/openharness/swarm/types.py +398 -0
  403. src/harness/src/openharness/swarm/worktree.py +315 -0
  404. src/harness/src/openharness/tasks/__init__.py +18 -0
  405. src/harness/src/openharness/tasks/local_agent_task.py +28 -0
  406. src/harness/src/openharness/tasks/local_shell_task.py +17 -0
  407. src/harness/src/openharness/tasks/manager.py +475 -0
  408. src/harness/src/openharness/tasks/stop_task.py +11 -0
  409. src/harness/src/openharness/tasks/types.py +32 -0
  410. src/harness/src/openharness/themes/__init__.py +21 -0
  411. src/harness/src/openharness/themes/builtin.py +89 -0
  412. src/harness/src/openharness/themes/loader.py +55 -0
  413. src/harness/src/openharness/themes/schema.py +54 -0
  414. src/harness/src/openharness/tools/__init__.py +107 -0
  415. src/harness/src/openharness/tools/agent_tool.py +141 -0
  416. src/harness/src/openharness/tools/ask_user_question_tool.py +46 -0
  417. src/harness/src/openharness/tools/base.py +80 -0
  418. src/harness/src/openharness/tools/bash_tool.py +259 -0
  419. src/harness/src/openharness/tools/brief_tool.py +33 -0
  420. src/harness/src/openharness/tools/config_tool.py +54 -0
  421. src/harness/src/openharness/tools/cron_create_tool.py +105 -0
  422. src/harness/src/openharness/tools/cron_delete_tool.py +32 -0
  423. src/harness/src/openharness/tools/cron_list_tool.py +69 -0
  424. src/harness/src/openharness/tools/cron_toggle_tool.py +37 -0
  425. src/harness/src/openharness/tools/enter_plan_mode_tool.py +28 -0
  426. src/harness/src/openharness/tools/enter_worktree_tool.py +80 -0
  427. src/harness/src/openharness/tools/exit_plan_mode_tool.py +28 -0
  428. src/harness/src/openharness/tools/exit_worktree_tool.py +42 -0
  429. src/harness/src/openharness/tools/file_edit_tool.py +95 -0
  430. src/harness/src/openharness/tools/file_read_tool.py +72 -0
  431. src/harness/src/openharness/tools/file_write_tool.py +87 -0
  432. src/harness/src/openharness/tools/glob_tool.py +175 -0
  433. src/harness/src/openharness/tools/grep_tool.py +374 -0
  434. src/harness/src/openharness/tools/image_generation_tool.py +447 -0
  435. src/harness/src/openharness/tools/image_to_text_tool.py +237 -0
  436. src/harness/src/openharness/tools/list_mcp_resources_tool.py +36 -0
  437. src/harness/src/openharness/tools/lsp_tool.py +154 -0
  438. src/harness/src/openharness/tools/mcp_auth_tool.py +71 -0
  439. src/harness/src/openharness/tools/mcp_tool.py +72 -0
  440. src/harness/src/openharness/tools/notebook_edit_tool.py +97 -0
  441. src/harness/src/openharness/tools/read_mcp_resource_tool.py +38 -0
  442. src/harness/src/openharness/tools/remote_trigger_tool.py +74 -0
  443. src/harness/src/openharness/tools/send_message_tool.py +58 -0
  444. src/harness/src/openharness/tools/skill_tool.py +43 -0
  445. src/harness/src/openharness/tools/sleep_tool.py +32 -0
  446. src/harness/src/openharness/tools/task_create_tool.py +56 -0
  447. src/harness/src/openharness/tools/task_get_tool.py +33 -0
  448. src/harness/src/openharness/tools/task_list_tool.py +35 -0
  449. src/harness/src/openharness/tools/task_output_tool.py +35 -0
  450. src/harness/src/openharness/tools/task_stop_tool.py +30 -0
  451. src/harness/src/openharness/tools/task_update_tool.py +50 -0
  452. src/harness/src/openharness/tools/team_create_tool.py +31 -0
  453. src/harness/src/openharness/tools/team_delete_tool.py +30 -0
  454. src/harness/src/openharness/tools/todo_write_tool.py +46 -0
  455. src/harness/src/openharness/tools/tool_search_tool.py +38 -0
  456. src/harness/src/openharness/tools/web_fetch_tool.py +117 -0
  457. src/harness/src/openharness/tools/web_search_tool.py +119 -0
  458. src/harness/src/openharness/ui/__init__.py +5 -0
  459. src/harness/src/openharness/ui/app.py +320 -0
  460. src/harness/src/openharness/ui/backend_host.py +941 -0
  461. src/harness/src/openharness/ui/coordinator_drain.py +198 -0
  462. src/harness/src/openharness/ui/input.py +32 -0
  463. src/harness/src/openharness/ui/output.py +265 -0
  464. src/harness/src/openharness/ui/permission_dialog.py +14 -0
  465. src/harness/src/openharness/ui/protocol.py +247 -0
  466. src/harness/src/openharness/ui/react_launcher.py +179 -0
  467. src/harness/src/openharness/ui/runtime.py +799 -0
  468. src/harness/src/openharness/ui/textual_app.py +495 -0
  469. src/harness/src/openharness/utils/__init__.py +0 -0
  470. src/harness/src/openharness/utils/file_lock.py +86 -0
  471. src/harness/src/openharness/utils/fs.py +98 -0
  472. src/harness/src/openharness/utils/helpers.py +77 -0
  473. src/harness/src/openharness/utils/network_guard.py +340 -0
  474. src/harness/src/openharness/utils/shell.py +147 -0
  475. src/harness/src/openharness/vim/__init__.py +5 -0
  476. src/harness/src/openharness/vim/transitions.py +8 -0
  477. src/harness/src/openharness/voice/__init__.py +7 -0
  478. src/harness/src/openharness/voice/keyterms.py +10 -0
  479. src/harness/src/openharness/voice/stream_stt.py +8 -0
  480. src/harness/src/openharness/voice/voice_mode.py +44 -0
  481. src/llm/__init__.py +29 -0
  482. src/llm/image_generation.py +402 -0
  483. src/llm/provider_registry.py +419 -0
  484. src/llm/runtime_env.py +64 -0
  485. src/llm/structured_output.py +131 -0
  486. src/llm/vision.py +198 -0
  487. src/memory/__init__.py +2 -0
  488. src/memory/api/__init__.py +1 -0
  489. src/memory/api/base_memory_system_api.py +98 -0
  490. src/memory/api/base_symbolic_memory_system_api.py +190 -0
  491. src/memory/api/base_vector_memory_system_api.py +116 -0
  492. src/memory/api/faiss_memory_system_api.py +228 -0
  493. src/memory/api/slot_process_api.py +827 -0
  494. src/memory/api/symbolic_memory_system_api.py +572 -0
  495. src/memory/memory_system/__init__.py +48 -0
  496. src/memory/memory_system/component_taxonomy.py +430 -0
  497. src/memory/memory_system/llm.py +437 -0
  498. src/memory/memory_system/models.py +168 -0
  499. src/memory/memory_system/user_prompt.py +418 -0
  500. src/memory/memory_system/utils.py +282 -0
  501. src/memory/memory_system/vectorstore.py +311 -0
  502. src/memory/memory_system/working_slot.py +99 -0
  503. src/pipeline/__init__.py +129 -0
  504. src/pipeline/contracts.py +531 -0
  505. src/pipeline/discipline_taxonomy.py +916 -0
  506. src/pipeline/evidence_coverage_ledger.py +1150 -0
  507. src/pipeline/evidence_refinement.py +620 -0
  508. src/pipeline/experiment_to_symbolic.py +137 -0
  509. src/pipeline/multimodal_evidence/__init__.py +28 -0
  510. src/pipeline/multimodal_evidence/capabilities.py +107 -0
  511. src/pipeline/multimodal_evidence/claims.py +135 -0
  512. src/pipeline/multimodal_evidence/contract.py +291 -0
  513. src/pipeline/multimodal_evidence/data_sh_compiler.py +505 -0
  514. src/pipeline/multimodal_evidence/handoff_binding.py +135 -0
  515. src/pipeline/multimodal_evidence/manifest.py +254 -0
  516. src/pipeline/multimodal_evidence/native_analysis.py +479 -0
  517. src/pipeline/multimodal_evidence/perception.py +277 -0
  518. src/pipeline/multimodal_evidence/query_binding.py +65 -0
  519. src/pipeline/multimodal_evidence/rendering.py +120 -0
  520. src/pipeline/multimodal_evidence/safety.py +42 -0
  521. src/pipeline/multimodal_evidence/sampling.py +69 -0
  522. src/pipeline/multimodal_evidence/service.py +148 -0
  523. src/pipeline/multimodal_evidence/survey_integration.py +460 -0
  524. src/pipeline/paper_identity.py +52 -0
  525. src/pipeline/research_design_inventory.py +338 -0
  526. src/pipeline/research_domain_resolution.py +669 -0
  527. src/pipeline/research_identity.py +548 -0
  528. src/pipeline/research_question_contract.py +646 -0
  529. src/pipeline/retrieval_lanes.py +889 -0
  530. src/pipeline/run_loop.py +862 -0
  531. src/pipeline/science_events.py +30 -0
  532. src/pipeline/science_manifests.py +602 -0
  533. src/pipeline/science_run.py +918 -0
  534. src/pipeline/science_stages.py +709 -0
  535. src/pipeline/science_workflow.py +807 -0
  536. src/pipeline/sh_cluster_projection.py +306 -0
  537. src/pipeline/sh_graph_provenance.py +648 -0
  538. src/pipeline/subhypothesis_decomposition.py +392 -0
  539. src/pipeline/subhypothesis_evidence.py +856 -0
  540. src/pipeline/survey_evidence_plan.py +858 -0
  541. src/pipeline/survey_gap_adjudication.py +647 -0
  542. src/pipeline/survey_gap_candidates.py +840 -0
  543. src/pipeline/survey_gap_ledger.py +683 -0
  544. src/pipeline/survey_gap_triage.py +401 -0
  545. src/pipeline/survey_handoff_persistence.py +481 -0
  546. src/pipeline/survey_handoff_projection.py +457 -0
  547. src/pipeline/survey_idea_handoff.py +1112 -0
  548. src/pipeline/survey_idea_loader.py +289 -0
  549. 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
+ )