klaude-code 1.2.6__py3-none-any.whl → 1.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. klaude_code/auth/__init__.py +24 -0
  2. klaude_code/auth/codex/__init__.py +20 -0
  3. klaude_code/auth/codex/exceptions.py +17 -0
  4. klaude_code/auth/codex/jwt_utils.py +45 -0
  5. klaude_code/auth/codex/oauth.py +229 -0
  6. klaude_code/auth/codex/token_manager.py +84 -0
  7. klaude_code/cli/auth_cmd.py +73 -0
  8. klaude_code/cli/config_cmd.py +91 -0
  9. klaude_code/cli/cost_cmd.py +338 -0
  10. klaude_code/cli/debug.py +78 -0
  11. klaude_code/cli/list_model.py +307 -0
  12. klaude_code/cli/main.py +233 -134
  13. klaude_code/cli/runtime.py +309 -117
  14. klaude_code/{version.py → cli/self_update.py} +114 -5
  15. klaude_code/cli/session_cmd.py +37 -21
  16. klaude_code/command/__init__.py +88 -27
  17. klaude_code/command/clear_cmd.py +8 -7
  18. klaude_code/command/command_abc.py +31 -31
  19. klaude_code/command/debug_cmd.py +79 -0
  20. klaude_code/command/export_cmd.py +19 -53
  21. klaude_code/command/export_online_cmd.py +154 -0
  22. klaude_code/command/fork_session_cmd.py +267 -0
  23. klaude_code/command/help_cmd.py +7 -8
  24. klaude_code/command/model_cmd.py +60 -10
  25. klaude_code/command/model_select.py +84 -0
  26. klaude_code/command/prompt-jj-describe.md +32 -0
  27. klaude_code/command/prompt_command.py +19 -11
  28. klaude_code/command/refresh_cmd.py +8 -10
  29. klaude_code/command/registry.py +139 -40
  30. klaude_code/command/release_notes_cmd.py +84 -0
  31. klaude_code/command/resume_cmd.py +111 -0
  32. klaude_code/command/status_cmd.py +104 -60
  33. klaude_code/command/terminal_setup_cmd.py +7 -9
  34. klaude_code/command/thinking_cmd.py +98 -0
  35. klaude_code/config/__init__.py +14 -6
  36. klaude_code/config/assets/__init__.py +1 -0
  37. klaude_code/config/assets/builtin_config.yaml +303 -0
  38. klaude_code/config/builtin_config.py +38 -0
  39. klaude_code/config/config.py +378 -109
  40. klaude_code/config/select_model.py +117 -53
  41. klaude_code/config/thinking.py +269 -0
  42. klaude_code/{const/__init__.py → const.py} +50 -19
  43. klaude_code/core/agent.py +20 -28
  44. klaude_code/core/executor.py +327 -112
  45. klaude_code/core/manager/__init__.py +2 -4
  46. klaude_code/core/manager/llm_clients.py +1 -15
  47. klaude_code/core/manager/llm_clients_builder.py +10 -11
  48. klaude_code/core/manager/sub_agent_manager.py +37 -6
  49. klaude_code/core/prompt.py +63 -44
  50. klaude_code/core/prompts/prompt-claude-code.md +2 -13
  51. klaude_code/core/prompts/prompt-codex-gpt-5-1-codex-max.md +117 -0
  52. klaude_code/core/prompts/prompt-codex-gpt-5-2-codex.md +117 -0
  53. klaude_code/core/prompts/prompt-codex.md +9 -42
  54. klaude_code/core/prompts/prompt-minimal.md +12 -0
  55. klaude_code/core/prompts/{prompt-subagent-explore.md → prompt-sub-agent-explore.md} +16 -3
  56. klaude_code/core/prompts/{prompt-subagent-oracle.md → prompt-sub-agent-oracle.md} +1 -2
  57. klaude_code/core/prompts/prompt-sub-agent-web.md +51 -0
  58. klaude_code/core/reminders.py +283 -95
  59. klaude_code/core/task.py +113 -75
  60. klaude_code/core/tool/__init__.py +24 -31
  61. klaude_code/core/tool/file/_utils.py +36 -0
  62. klaude_code/core/tool/file/apply_patch.py +17 -25
  63. klaude_code/core/tool/file/apply_patch_tool.py +57 -77
  64. klaude_code/core/tool/file/diff_builder.py +151 -0
  65. klaude_code/core/tool/file/edit_tool.py +50 -63
  66. klaude_code/core/tool/file/move_tool.md +41 -0
  67. klaude_code/core/tool/file/move_tool.py +435 -0
  68. klaude_code/core/tool/file/read_tool.md +1 -1
  69. klaude_code/core/tool/file/read_tool.py +86 -86
  70. klaude_code/core/tool/file/write_tool.py +59 -69
  71. klaude_code/core/tool/report_back_tool.py +84 -0
  72. klaude_code/core/tool/shell/bash_tool.py +265 -22
  73. klaude_code/core/tool/shell/command_safety.py +3 -6
  74. klaude_code/core/tool/{memory → skill}/skill_tool.py +16 -26
  75. klaude_code/core/tool/sub_agent_tool.py +13 -2
  76. klaude_code/core/tool/todo/todo_write_tool.md +0 -157
  77. klaude_code/core/tool/todo/todo_write_tool.py +1 -1
  78. klaude_code/core/tool/todo/todo_write_tool_raw.md +182 -0
  79. klaude_code/core/tool/todo/update_plan_tool.py +1 -1
  80. klaude_code/core/tool/tool_abc.py +18 -0
  81. klaude_code/core/tool/tool_context.py +27 -12
  82. klaude_code/core/tool/tool_registry.py +7 -7
  83. klaude_code/core/tool/tool_runner.py +44 -36
  84. klaude_code/core/tool/truncation.py +29 -14
  85. klaude_code/core/tool/web/mermaid_tool.md +43 -0
  86. klaude_code/core/tool/web/mermaid_tool.py +2 -5
  87. klaude_code/core/tool/web/web_fetch_tool.md +1 -1
  88. klaude_code/core/tool/web/web_fetch_tool.py +112 -22
  89. klaude_code/core/tool/web/web_search_tool.md +23 -0
  90. klaude_code/core/tool/web/web_search_tool.py +130 -0
  91. klaude_code/core/turn.py +168 -66
  92. klaude_code/llm/__init__.py +2 -10
  93. klaude_code/llm/anthropic/client.py +190 -178
  94. klaude_code/llm/anthropic/input.py +39 -15
  95. klaude_code/llm/bedrock/__init__.py +3 -0
  96. klaude_code/llm/bedrock/client.py +60 -0
  97. klaude_code/llm/client.py +7 -21
  98. klaude_code/llm/codex/__init__.py +5 -0
  99. klaude_code/llm/codex/client.py +149 -0
  100. klaude_code/llm/google/__init__.py +3 -0
  101. klaude_code/llm/google/client.py +309 -0
  102. klaude_code/llm/google/input.py +215 -0
  103. klaude_code/llm/input_common.py +3 -9
  104. klaude_code/llm/openai_compatible/client.py +72 -164
  105. klaude_code/llm/openai_compatible/input.py +6 -4
  106. klaude_code/llm/openai_compatible/stream.py +273 -0
  107. klaude_code/llm/openai_compatible/tool_call_accumulator.py +17 -1
  108. klaude_code/llm/openrouter/client.py +89 -160
  109. klaude_code/llm/openrouter/input.py +18 -30
  110. klaude_code/llm/openrouter/reasoning.py +118 -0
  111. klaude_code/llm/registry.py +39 -7
  112. klaude_code/llm/responses/client.py +184 -171
  113. klaude_code/llm/responses/input.py +20 -1
  114. klaude_code/llm/usage.py +17 -12
  115. klaude_code/protocol/commands.py +17 -1
  116. klaude_code/protocol/events.py +31 -4
  117. klaude_code/protocol/llm_param.py +13 -10
  118. klaude_code/protocol/model.py +232 -29
  119. klaude_code/protocol/op.py +90 -1
  120. klaude_code/protocol/op_handler.py +35 -1
  121. klaude_code/protocol/sub_agent/__init__.py +117 -0
  122. klaude_code/protocol/sub_agent/explore.py +63 -0
  123. klaude_code/protocol/sub_agent/oracle.py +91 -0
  124. klaude_code/protocol/sub_agent/task.py +61 -0
  125. klaude_code/protocol/sub_agent/web.py +79 -0
  126. klaude_code/protocol/tools.py +4 -2
  127. klaude_code/session/__init__.py +2 -2
  128. klaude_code/session/codec.py +71 -0
  129. klaude_code/session/export.py +293 -86
  130. klaude_code/session/selector.py +89 -67
  131. klaude_code/session/session.py +320 -309
  132. klaude_code/session/store.py +220 -0
  133. klaude_code/session/templates/export_session.html +595 -83
  134. klaude_code/session/templates/mermaid_viewer.html +926 -0
  135. klaude_code/skill/__init__.py +27 -0
  136. klaude_code/skill/assets/deslop/SKILL.md +17 -0
  137. klaude_code/skill/assets/dev-docs/SKILL.md +108 -0
  138. klaude_code/skill/assets/handoff/SKILL.md +39 -0
  139. klaude_code/skill/assets/jj-workspace/SKILL.md +20 -0
  140. klaude_code/skill/assets/skill-creator/SKILL.md +139 -0
  141. klaude_code/{core/tool/memory/skill_loader.py → skill/loader.py} +55 -15
  142. klaude_code/skill/manager.py +70 -0
  143. klaude_code/skill/system_skills.py +192 -0
  144. klaude_code/trace/__init__.py +20 -2
  145. klaude_code/trace/log.py +150 -5
  146. klaude_code/ui/__init__.py +4 -9
  147. klaude_code/ui/core/input.py +1 -1
  148. klaude_code/ui/core/stage_manager.py +7 -7
  149. klaude_code/ui/modes/debug/display.py +2 -1
  150. klaude_code/ui/modes/repl/__init__.py +3 -48
  151. klaude_code/ui/modes/repl/clipboard.py +5 -5
  152. klaude_code/ui/modes/repl/completers.py +487 -123
  153. klaude_code/ui/modes/repl/display.py +5 -4
  154. klaude_code/ui/modes/repl/event_handler.py +370 -117
  155. klaude_code/ui/modes/repl/input_prompt_toolkit.py +552 -105
  156. klaude_code/ui/modes/repl/key_bindings.py +146 -23
  157. klaude_code/ui/modes/repl/renderer.py +189 -99
  158. klaude_code/ui/renderers/assistant.py +9 -2
  159. klaude_code/ui/renderers/bash_syntax.py +178 -0
  160. klaude_code/ui/renderers/common.py +78 -0
  161. klaude_code/ui/renderers/developer.py +104 -48
  162. klaude_code/ui/renderers/diffs.py +87 -6
  163. klaude_code/ui/renderers/errors.py +11 -6
  164. klaude_code/ui/renderers/mermaid_viewer.py +57 -0
  165. klaude_code/ui/renderers/metadata.py +112 -76
  166. klaude_code/ui/renderers/sub_agent.py +92 -7
  167. klaude_code/ui/renderers/thinking.py +40 -18
  168. klaude_code/ui/renderers/tools.py +405 -227
  169. klaude_code/ui/renderers/user_input.py +73 -13
  170. klaude_code/ui/rich/__init__.py +10 -1
  171. klaude_code/ui/rich/cjk_wrap.py +228 -0
  172. klaude_code/ui/rich/code_panel.py +131 -0
  173. klaude_code/ui/rich/live.py +17 -0
  174. klaude_code/ui/rich/markdown.py +305 -170
  175. klaude_code/ui/rich/searchable_text.py +10 -13
  176. klaude_code/ui/rich/status.py +190 -49
  177. klaude_code/ui/rich/theme.py +135 -39
  178. klaude_code/ui/terminal/__init__.py +55 -0
  179. klaude_code/ui/terminal/color.py +1 -1
  180. klaude_code/ui/terminal/control.py +13 -22
  181. klaude_code/ui/terminal/notifier.py +44 -4
  182. klaude_code/ui/terminal/selector.py +658 -0
  183. klaude_code/ui/utils/common.py +0 -18
  184. klaude_code-1.8.0.dist-info/METADATA +377 -0
  185. klaude_code-1.8.0.dist-info/RECORD +219 -0
  186. {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/entry_points.txt +1 -0
  187. klaude_code/command/diff_cmd.py +0 -138
  188. klaude_code/command/prompt-dev-docs-update.md +0 -56
  189. klaude_code/command/prompt-dev-docs.md +0 -46
  190. klaude_code/config/list_model.py +0 -162
  191. klaude_code/core/manager/agent_manager.py +0 -127
  192. klaude_code/core/prompts/prompt-subagent-webfetch.md +0 -46
  193. klaude_code/core/tool/file/multi_edit_tool.md +0 -42
  194. klaude_code/core/tool/file/multi_edit_tool.py +0 -199
  195. klaude_code/core/tool/memory/memory_tool.md +0 -16
  196. klaude_code/core/tool/memory/memory_tool.py +0 -462
  197. klaude_code/llm/openrouter/reasoning_handler.py +0 -209
  198. klaude_code/protocol/sub_agent.py +0 -348
  199. klaude_code/ui/utils/debouncer.py +0 -42
  200. klaude_code-1.2.6.dist-info/METADATA +0 -178
  201. klaude_code-1.2.6.dist-info/RECORD +0 -167
  202. /klaude_code/core/prompts/{prompt-subagent.md → prompt-sub-agent.md} +0 -0
  203. /klaude_code/core/tool/{memory → skill}/__init__.py +0 -0
  204. /klaude_code/core/tool/{memory → skill}/skill_tool.md +0 -0
  205. {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/WHEEL +0 -0
@@ -1,4 +1,6 @@
1
1
  import asyncio
2
+ import os
3
+ import re
2
4
  from functools import lru_cache
3
5
  from pathlib import Path
4
6
  from typing import Any, cast
@@ -6,172 +8,439 @@ from typing import Any, cast
6
8
  import yaml
7
9
  from pydantic import BaseModel, Field, ValidationError, model_validator
8
10
 
11
+ from klaude_code.config.builtin_config import SUPPORTED_API_KEY_ENVS, get_builtin_provider_configs
9
12
  from klaude_code.protocol import llm_param
10
13
  from klaude_code.protocol.sub_agent import iter_sub_agent_profiles
11
14
  from klaude_code.trace import log
12
15
 
16
+ # Pattern to match ${ENV_VAR} syntax
17
+ _ENV_VAR_PATTERN = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$")
18
+
19
+
20
+ def parse_env_var_syntax(value: str | None) -> tuple[str | None, str | None]:
21
+ """Parse a value that may use ${ENV_VAR} syntax.
22
+
23
+ Returns:
24
+ A tuple of (env_var_name, resolved_value).
25
+ - If value uses ${ENV_VAR} syntax: (env_var_name, os.environ.get(env_var_name))
26
+ - If value is a plain string: (None, value)
27
+ - If value is None: (None, None)
28
+ """
29
+ if value is None:
30
+ return None, None
31
+
32
+ match = _ENV_VAR_PATTERN.match(value)
33
+ if match:
34
+ env_var_name = match.group(1)
35
+ return env_var_name, os.environ.get(env_var_name)
36
+
37
+ return None, value
38
+
39
+
40
+ def is_env_var_syntax(value: str | None) -> bool:
41
+ """Check if a value uses ${ENV_VAR} syntax."""
42
+ if value is None:
43
+ return False
44
+ return _ENV_VAR_PATTERN.match(value) is not None
45
+
46
+
47
+ def resolve_api_key(value: str | None) -> str | None:
48
+ """Resolve an API key value, expanding ${ENV_VAR} syntax if present."""
49
+ _, resolved = parse_env_var_syntax(value)
50
+ return resolved
51
+
52
+
13
53
  config_path = Path.home() / ".klaude" / "klaude-config.yaml"
54
+ example_config_path = Path.home() / ".klaude" / "klaude-config.example.yaml"
14
55
 
15
56
 
16
57
  class ModelConfig(BaseModel):
58
+ model_name: str
59
+ model_params: llm_param.LLMConfigModelParameter
60
+
61
+
62
+ class ProviderConfig(llm_param.LLMConfigProviderParameter):
63
+ """Full provider configuration (used in merged config)."""
64
+
65
+ model_list: list[ModelConfig] = Field(default_factory=lambda: [])
66
+
67
+ def get_resolved_api_key(self) -> str | None:
68
+ """Get the resolved API key, expanding ${ENV_VAR} syntax if present."""
69
+ return resolve_api_key(self.api_key)
70
+
71
+ def get_api_key_env_var(self) -> str | None:
72
+ """Get the environment variable name if ${ENV_VAR} syntax is used."""
73
+ env_var, _ = parse_env_var_syntax(self.api_key)
74
+ return env_var
75
+
76
+ def is_api_key_missing(self) -> bool:
77
+ """Check if the API key is missing (either not set or env var not found).
78
+
79
+ For codex protocol, checks OAuth login status instead of API key.
80
+ For bedrock protocol, checks AWS credentials instead of API key.
81
+ """
82
+ from klaude_code.protocol.llm_param import LLMClientProtocol
83
+
84
+ if self.protocol == LLMClientProtocol.CODEX:
85
+ # Codex uses OAuth authentication, not API key
86
+ from klaude_code.auth.codex.token_manager import CodexTokenManager
87
+
88
+ token_manager = CodexTokenManager()
89
+ state = token_manager.get_state()
90
+ # Consider available if logged in and token not expired
91
+ return state is None or state.is_expired()
92
+
93
+ if self.protocol == LLMClientProtocol.BEDROCK:
94
+ # Bedrock uses AWS credentials, not API key. Region is always required.
95
+ _, resolved_profile = parse_env_var_syntax(self.aws_profile)
96
+ _, resolved_region = parse_env_var_syntax(self.aws_region)
97
+
98
+ # When using profile, we still need region to initialize the client.
99
+ if resolved_profile:
100
+ return resolved_region is None
101
+
102
+ _, resolved_access_key = parse_env_var_syntax(self.aws_access_key)
103
+ _, resolved_secret_key = parse_env_var_syntax(self.aws_secret_key)
104
+ return resolved_region is None or resolved_access_key is None or resolved_secret_key is None
105
+
106
+ return self.get_resolved_api_key() is None
107
+
108
+
109
+ class UserProviderConfig(BaseModel):
110
+ """User provider configuration (allows partial overrides).
111
+
112
+ Unlike ProviderConfig, protocol is optional here since user may only want
113
+ to add models to an existing builtin provider.
114
+ """
115
+
116
+ provider_name: str
117
+ protocol: llm_param.LLMClientProtocol | None = None
118
+ base_url: str | None = None
119
+ api_key: str | None = None
120
+ is_azure: bool = False
121
+ azure_api_version: str | None = None
122
+ model_list: list[ModelConfig] = Field(default_factory=lambda: [])
123
+
124
+
125
+ class ModelEntry(BaseModel):
17
126
  model_name: str
18
127
  provider: str
19
128
  model_params: llm_param.LLMConfigModelParameter
20
129
 
21
130
 
131
+ class UserConfig(BaseModel):
132
+ """User configuration (what gets saved to disk)."""
133
+
134
+ main_model: str | None = None
135
+ sub_agent_models: dict[str, str] = Field(default_factory=dict)
136
+ theme: str | None = None
137
+ provider_list: list[UserProviderConfig] = Field(default_factory=lambda: [])
138
+
139
+ @model_validator(mode="before")
140
+ @classmethod
141
+ def _normalize_sub_agent_models(cls, data: dict[str, Any]) -> dict[str, Any]:
142
+ raw_val: Any = data.get("sub_agent_models") or {}
143
+ raw_models: dict[str, Any] = cast(dict[str, Any], raw_val) if isinstance(raw_val, dict) else {}
144
+ normalized: dict[str, str] = {}
145
+ key_map = {p.name.lower(): p.name for p in iter_sub_agent_profiles()}
146
+ for key, value in dict(raw_models).items():
147
+ canonical = key_map.get(str(key).lower(), str(key))
148
+ normalized[canonical] = str(value)
149
+ data["sub_agent_models"] = normalized
150
+ return data
151
+
152
+
22
153
  class Config(BaseModel):
23
- provider_list: list[llm_param.LLMConfigProviderParameter]
24
- model_list: list[ModelConfig]
25
- main_model: str
26
- subagent_models: dict[str, str] = Field(default_factory=dict)
154
+ """Merged configuration (builtin + user) for runtime use."""
155
+
156
+ main_model: str | None = None
157
+ sub_agent_models: dict[str, str] = Field(default_factory=dict)
27
158
  theme: str | None = None
159
+ provider_list: list[ProviderConfig] = Field(default_factory=lambda: [])
160
+
161
+ # Internal: reference to original user config for saving
162
+ _user_config: UserConfig | None = None
28
163
 
29
164
  @model_validator(mode="before")
30
165
  @classmethod
31
- def _normalize_subagent_models(cls, data: dict[str, Any]) -> dict[str, Any]:
32
- raw_val: Any = data.get("subagent_models") or {}
166
+ def _normalize_sub_agent_models(cls, data: dict[str, Any]) -> dict[str, Any]:
167
+ raw_val: Any = data.get("sub_agent_models") or {}
33
168
  raw_models: dict[str, Any] = cast(dict[str, Any], raw_val) if isinstance(raw_val, dict) else {}
34
169
  normalized: dict[str, str] = {}
35
170
  key_map = {p.name.lower(): p.name for p in iter_sub_agent_profiles()}
36
171
  for key, value in dict(raw_models).items():
37
172
  canonical = key_map.get(str(key).lower(), str(key))
38
173
  normalized[canonical] = str(value)
39
- data["subagent_models"] = normalized
174
+ data["sub_agent_models"] = normalized
40
175
  return data
41
176
 
42
- def get_main_model_config(self) -> llm_param.LLMConfigParameter:
43
- return self.get_model_config(self.main_model)
177
+ def set_user_config(self, user_config: UserConfig | None) -> None:
178
+ """Set the user config reference for saving."""
179
+ object.__setattr__(self, "_user_config", user_config)
44
180
 
45
181
  def get_model_config(self, model_name: str) -> llm_param.LLMConfigParameter:
46
- model = next(
47
- (model for model in self.model_list if model.model_name == model_name),
48
- None,
49
- )
50
- if model is None:
51
- raise ValueError(f"Unknown model: {model_name}")
52
-
53
- provider = next(
54
- (provider for provider in self.provider_list if provider.provider_name == model.provider),
55
- None,
56
- )
57
- if provider is None:
58
- raise ValueError(f"Unknown provider: {model.provider}")
59
-
60
- return llm_param.LLMConfigParameter(
61
- **provider.model_dump(),
62
- **model.model_params.model_dump(),
63
- )
182
+ for provider in self.provider_list:
183
+ # Resolve ${ENV_VAR} syntax for api_key
184
+ api_key = provider.get_resolved_api_key()
185
+ if not api_key:
186
+ continue
187
+ for model in provider.model_list:
188
+ if model.model_name == model_name:
189
+ provider_dump = provider.model_dump(exclude={"model_list"})
190
+ provider_dump["api_key"] = api_key
191
+ return llm_param.LLMConfigParameter(
192
+ **provider_dump,
193
+ **model.model_params.model_dump(),
194
+ )
64
195
 
65
- async def save(self) -> None:
196
+ raise ValueError(f"Unknown model: {model_name}")
197
+
198
+ def iter_model_entries(self, only_available: bool = False) -> list[ModelEntry]:
199
+ """Return all model entries with their provider names.
200
+
201
+ Args:
202
+ only_available: If True, only return models from providers with valid API keys.
66
203
  """
67
- Save config to file.
68
- Notice: it won't preserve comments in the config file.
204
+ return [
205
+ ModelEntry(
206
+ model_name=model.model_name,
207
+ provider=provider.provider_name,
208
+ model_params=model.model_params,
209
+ )
210
+ for provider in self.provider_list
211
+ if not only_available or not provider.is_api_key_missing()
212
+ for model in provider.model_list
213
+ ]
214
+
215
+ async def save(self) -> None:
216
+ """Save user config to file (excludes builtin providers).
217
+
218
+ Only saves user-specific settings like main_model and custom providers.
219
+ Builtin providers are never written to the user config file.
69
220
  """
70
- config_dict = self.model_dump(mode="json", exclude_none=True)
221
+ # Get user config, creating one if needed
222
+ user_config = self._user_config
223
+ if user_config is None:
224
+ user_config = UserConfig()
225
+
226
+ # Sync user-modifiable fields from merged config to user config
227
+ user_config.main_model = self.main_model
228
+ user_config.sub_agent_models = self.sub_agent_models
229
+ user_config.theme = self.theme
230
+ # Note: provider_list is NOT synced - user providers are already in user_config
231
+
232
+ config_dict = user_config.model_dump(mode="json", exclude_none=True, exclude_defaults=True)
71
233
 
72
234
  def _save_config() -> None:
73
235
  config_path.parent.mkdir(parents=True, exist_ok=True)
74
- _ = config_path.write_text(yaml.dump(config_dict, default_flow_style=False, sort_keys=False))
236
+ yaml_content = yaml.dump(config_dict, default_flow_style=False, sort_keys=False)
237
+ _ = config_path.write_text(str(yaml_content or ""))
75
238
 
76
239
  await asyncio.to_thread(_save_config)
77
240
 
78
241
 
79
- def get_example_config() -> Config:
80
- return Config(
81
- main_model="gpt-5.1",
82
- subagent_models={"explore": "haiku", "oracle": "gpt-5.1-high"},
242
+ def get_example_config() -> UserConfig:
243
+ """Generate example config for user reference (will be commented out)."""
244
+ return UserConfig(
245
+ main_model="opus",
246
+ sub_agent_models={"explore": "haiku", "oracle": "gpt-5.2", "webagent": "sonnet", "task": "sonnet"},
83
247
  provider_list=[
84
- llm_param.LLMConfigProviderParameter(
85
- provider_name="openai",
86
- protocol=llm_param.LLMClientProtocol.RESPONSES,
87
- api_key="your-openai-api-key",
88
- base_url="https://api.openai.com/v1",
89
- ),
90
- llm_param.LLMConfigProviderParameter(
91
- provider_name="openrouter",
92
- protocol=llm_param.LLMClientProtocol.OPENROUTER,
93
- api_key="your-openrouter-api-key",
94
- ),
95
- ],
96
- model_list=[
97
- ModelConfig(
98
- model_name="gpt-5.1",
99
- provider="openai",
100
- model_params=llm_param.LLMConfigModelParameter(
101
- model="gpt-5.1-2025-11-13",
102
- max_tokens=32000,
103
- verbosity="medium",
104
- thinking=llm_param.Thinking(
105
- reasoning_effort="medium",
106
- reasoning_summary="auto",
107
- type="enabled",
108
- budget_tokens=None,
248
+ UserProviderConfig(
249
+ provider_name="my-provider",
250
+ protocol=llm_param.LLMClientProtocol.OPENAI,
251
+ api_key="${MY_API_KEY}",
252
+ base_url="https://api.example.com/v1",
253
+ model_list=[
254
+ ModelConfig(
255
+ model_name="my-model",
256
+ model_params=llm_param.LLMConfigModelParameter(
257
+ model="model-id-from-provider",
258
+ max_tokens=16000,
259
+ context_limit=200000,
260
+ cost=llm_param.Cost(
261
+ input=1,
262
+ output=10,
263
+ cache_read=0.1,
264
+ ),
265
+ ),
109
266
  ),
110
- context_limit=368000,
111
- ),
112
- ),
113
- ModelConfig(
114
- model_name="gpt-5.1-high",
115
- provider="openai",
116
- model_params=llm_param.LLMConfigModelParameter(
117
- model="gpt-5.1-2025-11-13",
118
- max_tokens=32000,
119
- verbosity="medium",
120
- thinking=llm_param.Thinking(
121
- reasoning_effort="high",
122
- reasoning_summary="auto",
123
- type="enabled",
124
- budget_tokens=None,
125
- ),
126
- context_limit=368000,
127
- ),
128
- ),
129
- ModelConfig(
130
- model_name="haiku",
131
- provider="openrouter",
132
- model_params=llm_param.LLMConfigModelParameter(
133
- model="anthropic/claude-haiku-4.5",
134
- max_tokens=32000,
135
- provider_routing=llm_param.OpenRouterProviderRouting(
136
- sort="throughput",
137
- ),
138
- context_limit=168000,
139
- ),
267
+ ],
140
268
  ),
141
269
  ],
142
270
  )
143
271
 
144
272
 
145
- @lru_cache(maxsize=1)
146
- def load_config() -> Config | None:
273
+ def _get_builtin_config() -> Config:
274
+ """Load built-in provider configurations."""
275
+ # Re-validate to ensure compatibility with current ProviderConfig class
276
+ # (needed for tests that may monkeypatch the class)
277
+ providers = [ProviderConfig.model_validate(p.model_dump()) for p in get_builtin_provider_configs()]
278
+ return Config(provider_list=providers)
279
+
280
+
281
+ def _merge_provider(builtin: ProviderConfig, user: UserProviderConfig) -> ProviderConfig:
282
+ """Merge user provider config with builtin provider config.
283
+
284
+ Strategy:
285
+ - model_list: merge by model_name, user models override builtin models with same name
286
+ - Other fields (api_key, base_url, etc.): user config takes precedence if set
287
+ """
288
+ # Merge model_list: builtin first, then user overrides/appends
289
+ merged_models: dict[str, ModelConfig] = {}
290
+ for m in builtin.model_list:
291
+ merged_models[m.model_name] = m
292
+ for m in user.model_list:
293
+ merged_models[m.model_name] = m
294
+
295
+ # For other fields, use user values if explicitly set, otherwise use builtin
296
+ # We check if user explicitly provided a value by comparing to defaults
297
+ merged_data = builtin.model_dump()
298
+ user_data = user.model_dump(exclude_defaults=True, exclude={"model_list"})
299
+
300
+ # Update with user's explicit settings
301
+ for key, value in user_data.items():
302
+ if value is not None:
303
+ merged_data[key] = value
304
+
305
+ merged_data["model_list"] = list(merged_models.values())
306
+ return ProviderConfig.model_validate(merged_data)
307
+
308
+
309
+ def _merge_configs(user_config: UserConfig | None, builtin_config: Config) -> Config:
310
+ """Merge user config with builtin config.
311
+
312
+ Strategy:
313
+ - provider_list: merge by provider_name
314
+ - Same name: merge model_list (user models override/append), other fields user takes precedence
315
+ - New name: add to list
316
+ - main_model: user config takes precedence
317
+ - sub_agent_models: merge, user takes precedence
318
+ - theme: user config takes precedence
319
+
320
+ The returned Config keeps a reference to user_config for saving.
321
+ """
322
+ if user_config is None:
323
+ # No user config - return builtin with empty user config reference
324
+ merged = builtin_config.model_copy()
325
+ merged.set_user_config(None)
326
+ return merged
327
+
328
+ # Build lookup for builtin providers
329
+ builtin_providers: dict[str, ProviderConfig] = {p.provider_name: p for p in builtin_config.provider_list}
330
+
331
+ # Merge provider_list
332
+ merged_providers: dict[str, ProviderConfig] = dict(builtin_providers)
333
+ for user_provider in user_config.provider_list:
334
+ if user_provider.provider_name in builtin_providers:
335
+ # Merge with builtin provider
336
+ merged_providers[user_provider.provider_name] = _merge_provider(
337
+ builtin_providers[user_provider.provider_name], user_provider
338
+ )
339
+ else:
340
+ # New provider from user - must have protocol
341
+ if user_provider.protocol is None:
342
+ raise ValueError(
343
+ f"Provider '{user_provider.provider_name}' requires 'protocol' field (not a builtin provider)"
344
+ )
345
+ merged_providers[user_provider.provider_name] = ProviderConfig.model_validate(user_provider.model_dump())
346
+
347
+ # Merge sub_agent_models
348
+ merged_sub_agent_models = {**builtin_config.sub_agent_models, **user_config.sub_agent_models}
349
+
350
+ merged = Config(
351
+ main_model=user_config.main_model or builtin_config.main_model,
352
+ sub_agent_models=merged_sub_agent_models,
353
+ theme=user_config.theme or builtin_config.theme,
354
+ provider_list=list(merged_providers.values()),
355
+ )
356
+ # Keep reference to user config for saving
357
+ merged.set_user_config(user_config)
358
+ return merged
359
+
360
+
361
+ def _load_user_config() -> UserConfig | None:
362
+ """Load user config from disk. Returns None if file doesn't exist or is empty."""
147
363
  if not config_path.exists():
148
- log(f"Config file not found: {config_path}")
149
- example_config = get_example_config()
150
- config_path.parent.mkdir(parents=True, exist_ok=True)
151
- config_dict = example_config.model_dump(mode="json", exclude_none=True)
152
-
153
- # Comment out all example config lines
154
- yaml_str = yaml.dump(config_dict, default_flow_style=False, sort_keys=False)
155
- commented_yaml = "\n".join(f"# {line}" if line.strip() else "#" for line in yaml_str.splitlines())
156
- _ = config_path.write_text(commented_yaml)
157
-
158
- log(f"Example config created at: {config_path}")
159
- log("Please edit the config file to set up your models", style="yellow bold")
160
364
  return None
161
365
 
162
366
  config_yaml = config_path.read_text()
163
367
  config_dict = yaml.safe_load(config_yaml)
164
368
 
165
369
  if config_dict is None:
166
- log(f"Config file is empty or all commented: {config_path}", style="red bold")
167
- log("Please edit the config file to set up your models", style="yellow bold")
168
370
  return None
169
371
 
170
372
  try:
171
- config = Config.model_validate(config_dict)
373
+ return UserConfig.model_validate(config_dict)
172
374
  except ValidationError as e:
173
375
  log(f"Invalid config file: {config_path}", style="red bold")
174
376
  log(str(e), style="red")
175
377
  raise ValueError(f"Invalid config file: {config_path}") from e
176
378
 
177
- return config
379
+
380
+ def create_example_config() -> bool:
381
+ """Create example config file if it doesn't exist.
382
+
383
+ Returns:
384
+ True if file was created, False if it already exists.
385
+ """
386
+ if example_config_path.exists():
387
+ return False
388
+
389
+ example_config = get_example_config()
390
+ example_config_path.parent.mkdir(parents=True, exist_ok=True)
391
+ config_dict = example_config.model_dump(mode="json", exclude_none=True)
392
+
393
+ yaml_str = yaml.dump(config_dict, default_flow_style=False, sort_keys=False) or ""
394
+ header = "# Example configuration for klaude-code\n"
395
+ header += "# Copy this file to klaude-config.yaml and modify as needed.\n"
396
+ header += "# Run `klaude list` to see available models.\n"
397
+ header += "#\n"
398
+ header += "# Built-in providers (anthropic, openai, openrouter, deepseek) are available automatically.\n"
399
+ header += "# Just set the corresponding API key environment variable to use them.\n\n"
400
+ _ = example_config_path.write_text(header + yaml_str)
401
+ return True
402
+
403
+
404
+ def _load_config_uncached() -> Config:
405
+ """Load and merge builtin + user config. Always returns a valid Config."""
406
+ builtin_config = _get_builtin_config()
407
+ user_config = _load_user_config()
408
+
409
+ return _merge_configs(user_config, builtin_config)
410
+
411
+
412
+ @lru_cache(maxsize=1)
413
+ def _load_config_cached() -> Config:
414
+ return _load_config_uncached()
415
+
416
+
417
+ def load_config() -> Config:
418
+ """Load config from disk (builtin + user merged).
419
+
420
+ Always returns a valid Config. Use config.iter_model_entries(only_available=True)
421
+ to check if any models are actually usable.
422
+ """
423
+ try:
424
+ return _load_config_cached()
425
+ except ValueError:
426
+ _load_config_cached.cache_clear()
427
+ raise
428
+
429
+
430
+ def print_no_available_models_hint() -> None:
431
+ """Print helpful message when no models are available due to missing API keys."""
432
+ log("No available models. Please set one of the following environment variables:", style="yellow")
433
+ log("")
434
+ for env_var in SUPPORTED_API_KEY_ENVS:
435
+ current_value = os.environ.get(env_var)
436
+ if current_value:
437
+ log(f" {env_var} = (set)", style="green")
438
+ else:
439
+ log(f" export {env_var}=<your-api-key>", style="dim")
440
+ log("")
441
+ log(f"Or add custom providers in: {config_path}", style="dim")
442
+ log(f"See example config: {example_config_path}", style="dim")
443
+
444
+
445
+ # Expose cache control for tests and callers that need to invalidate the cache.
446
+ load_config.cache_clear = _load_config_cached.cache_clear # type: ignore[attr-defined]