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
@@ -0,0 +1,338 @@
1
+ """Cost command for aggregating usage statistics across all sessions."""
2
+
3
+ from collections import defaultdict
4
+ from dataclasses import dataclass, field
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+
8
+ from rich.box import Box
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+ from klaude_code.command.status_cmd import format_cost, format_tokens
14
+ from klaude_code.protocol import model
15
+ from klaude_code.session.codec import decode_jsonl_line
16
+
17
+ ASCII_HORIZONAL = Box(" -- \n \n -- \n \n -- \n -- \n \n -- \n")
18
+
19
+
20
+ @dataclass
21
+ class ModelUsageStats:
22
+ """Aggregated usage stats for a single model."""
23
+
24
+ model_name: str
25
+ input_tokens: int = 0
26
+ output_tokens: int = 0
27
+ cached_tokens: int = 0
28
+ cost_usd: float = 0.0
29
+ cost_cny: float = 0.0
30
+
31
+ @property
32
+ def total_tokens(self) -> int:
33
+ return self.input_tokens + self.output_tokens
34
+
35
+ def add_usage(self, usage: model.Usage) -> None:
36
+ self.input_tokens += usage.input_tokens
37
+ self.output_tokens += usage.output_tokens
38
+ self.cached_tokens += usage.cached_tokens
39
+ if usage.total_cost is not None:
40
+ if usage.currency == "CNY":
41
+ self.cost_cny += usage.total_cost
42
+ else:
43
+ self.cost_usd += usage.total_cost
44
+
45
+
46
+ @dataclass
47
+ class DailyStats:
48
+ """Aggregated stats for a single day."""
49
+
50
+ date: str
51
+ by_model: dict[str, ModelUsageStats] = field(default_factory=lambda: dict[str, ModelUsageStats]())
52
+
53
+ def add_task_metadata(self, meta: model.TaskMetadata, date_str: str) -> None:
54
+ """Add a TaskMetadata to this day's stats."""
55
+ del date_str # unused, date is already set
56
+ if not meta.usage or not meta.model_name:
57
+ return
58
+
59
+ model_key = meta.model_name
60
+ if model_key not in self.by_model:
61
+ self.by_model[model_key] = ModelUsageStats(model_name=model_key)
62
+
63
+ self.by_model[model_key].add_usage(meta.usage)
64
+
65
+ def get_subtotal(self) -> ModelUsageStats:
66
+ """Get subtotal across all models for this day."""
67
+ subtotal = ModelUsageStats(model_name="(subtotal)")
68
+ for stats in self.by_model.values():
69
+ subtotal.input_tokens += stats.input_tokens
70
+ subtotal.output_tokens += stats.output_tokens
71
+ subtotal.cached_tokens += stats.cached_tokens
72
+ subtotal.cost_usd += stats.cost_usd
73
+ subtotal.cost_cny += stats.cost_cny
74
+ return subtotal
75
+
76
+
77
+ def iter_all_sessions() -> list[tuple[str, Path]]:
78
+ """Iterate over all sessions across all projects.
79
+
80
+ Returns list of (session_id, events_file_path) tuples.
81
+ """
82
+ projects_dir = Path.home() / ".klaude" / "projects"
83
+ if not projects_dir.exists():
84
+ return []
85
+
86
+ sessions: list[tuple[str, Path]] = []
87
+ for project_dir in projects_dir.iterdir():
88
+ if not project_dir.is_dir():
89
+ continue
90
+ sessions_dir = project_dir / "sessions"
91
+ if not sessions_dir.exists():
92
+ continue
93
+ for session_dir in sessions_dir.iterdir():
94
+ if not session_dir.is_dir():
95
+ continue
96
+ events_file = session_dir / "events.jsonl"
97
+ meta_file = session_dir / "meta.json"
98
+ # Skip sub-agent sessions by checking meta.json
99
+ if meta_file.exists():
100
+ import json
101
+
102
+ try:
103
+ meta = json.loads(meta_file.read_text(encoding="utf-8"))
104
+ if meta.get("sub_agent_state") is not None:
105
+ continue
106
+ except (json.JSONDecodeError, OSError):
107
+ pass
108
+ if events_file.exists():
109
+ sessions.append((session_dir.name, events_file))
110
+
111
+ return sessions
112
+
113
+
114
+ def extract_task_metadata_from_events(events_path: Path) -> list[tuple[str, model.TaskMetadataItem]]:
115
+ """Extract TaskMetadataItem entries from events.jsonl with their dates.
116
+
117
+ Returns list of (date_str, TaskMetadataItem) tuples.
118
+ """
119
+ results: list[tuple[str, model.TaskMetadataItem]] = []
120
+ try:
121
+ content = events_path.read_text(encoding="utf-8")
122
+ except OSError:
123
+ return results
124
+
125
+ for line in content.splitlines():
126
+ item = decode_jsonl_line(line)
127
+ if isinstance(item, model.TaskMetadataItem):
128
+ date_str = item.created_at.strftime("%Y-%m-%d")
129
+ results.append((date_str, item))
130
+
131
+ return results
132
+
133
+
134
+ def aggregate_all_sessions() -> dict[str, DailyStats]:
135
+ """Aggregate usage stats from all sessions, grouped by date.
136
+
137
+ Returns dict mapping date string to DailyStats.
138
+ """
139
+ daily_stats: dict[str, DailyStats] = defaultdict(lambda: DailyStats(date=""))
140
+
141
+ sessions = iter_all_sessions()
142
+ for _session_id, events_path in sessions:
143
+ metadata_items = extract_task_metadata_from_events(events_path)
144
+ for date_str, metadata_item in metadata_items:
145
+ if daily_stats[date_str].date == "":
146
+ daily_stats[date_str] = DailyStats(date=date_str)
147
+
148
+ # Process main agent metadata
149
+ daily_stats[date_str].add_task_metadata(metadata_item.main_agent, date_str)
150
+
151
+ # Process sub-agent metadata
152
+ for sub_meta in metadata_item.sub_agent_task_metadata:
153
+ daily_stats[date_str].add_task_metadata(sub_meta, date_str)
154
+
155
+ return dict(daily_stats)
156
+
157
+
158
+ def format_cost_dual(cost_usd: float, cost_cny: float) -> tuple[str, str]:
159
+ """Format costs for both currencies."""
160
+ usd_str = format_cost(cost_usd if cost_usd > 0 else None, "USD")
161
+ cny_str = format_cost(cost_cny if cost_cny > 0 else None, "CNY")
162
+ return usd_str, cny_str
163
+
164
+
165
+ def format_date_display(date_str: str) -> str:
166
+ """Format date string YYYY-MM-DD to 'YYYY M-D' for table display."""
167
+ parts = date_str.split("-")
168
+ if len(parts) == 3:
169
+ month = int(parts[1])
170
+ day = int(parts[2])
171
+ return f"{parts[0]} {month}-{day}"
172
+ return date_str
173
+
174
+
175
+ def render_cost_table(daily_stats: dict[str, DailyStats]) -> Table:
176
+ """Render the cost table using rich."""
177
+ table = Table(
178
+ title="Usage Statistics",
179
+ show_header=True,
180
+ header_style="bold",
181
+ padding=(0, 1, 0, 2),
182
+ box=ASCII_HORIZONAL,
183
+ )
184
+
185
+ table.add_column("Date", style="cyan", no_wrap=True)
186
+ table.add_column("Model", no_wrap=True)
187
+ table.add_column("Input", justify="right", no_wrap=True)
188
+ table.add_column("Output", justify="right", no_wrap=True)
189
+ table.add_column("Cache", justify="right", no_wrap=True)
190
+ table.add_column("Total", justify="right", no_wrap=True)
191
+ table.add_column("USD", justify="right", no_wrap=True)
192
+ table.add_column("CNY", justify="right", no_wrap=True)
193
+
194
+ # Sort dates
195
+ sorted_dates = sorted(daily_stats.keys())
196
+
197
+ # Track global totals by model
198
+ global_by_model: dict[str, ModelUsageStats] = {}
199
+
200
+ def sort_by_cost(stats: ModelUsageStats) -> tuple[float, float]:
201
+ """Sort key: USD desc, then CNY desc."""
202
+ return (-stats.cost_usd, -stats.cost_cny)
203
+
204
+ for date_str in sorted_dates:
205
+ day = daily_stats[date_str]
206
+ sorted_models = [s.model_name for s in sorted(day.by_model.values(), key=sort_by_cost)]
207
+
208
+ first_row = True
209
+ for model_name in sorted_models:
210
+ stats = day.by_model[model_name]
211
+ usd_str, cny_str = format_cost_dual(stats.cost_usd, stats.cost_cny)
212
+
213
+ # Accumulate to global totals
214
+ if model_name not in global_by_model:
215
+ global_by_model[model_name] = ModelUsageStats(model_name=model_name)
216
+ global_by_model[model_name].input_tokens += stats.input_tokens
217
+ global_by_model[model_name].output_tokens += stats.output_tokens
218
+ global_by_model[model_name].cached_tokens += stats.cached_tokens
219
+ global_by_model[model_name].cost_usd += stats.cost_usd
220
+ global_by_model[model_name].cost_cny += stats.cost_cny
221
+
222
+ table.add_row(
223
+ format_date_display(date_str) if first_row else "",
224
+ f"- {model_name}",
225
+ format_tokens(stats.input_tokens),
226
+ format_tokens(stats.output_tokens),
227
+ format_tokens(stats.cached_tokens),
228
+ format_tokens(stats.total_tokens),
229
+ usd_str,
230
+ cny_str,
231
+ )
232
+ first_row = False
233
+
234
+ # Add subtotal row for this day
235
+ subtotal = day.get_subtotal()
236
+ usd_str, cny_str = format_cost_dual(subtotal.cost_usd, subtotal.cost_cny)
237
+ table.add_row(
238
+ "",
239
+ "[cyan] (subtotal)[/cyan]",
240
+ f"[cyan]{format_tokens(subtotal.input_tokens)}[/cyan]",
241
+ f"[cyan]{format_tokens(subtotal.output_tokens)}[/cyan]",
242
+ f"[cyan]{format_tokens(subtotal.cached_tokens)}[/cyan]",
243
+ f"[cyan]{format_tokens(subtotal.total_tokens)}[/cyan]",
244
+ f"[cyan]{usd_str}[/cyan]",
245
+ f"[cyan]{cny_str}[/cyan]",
246
+ )
247
+
248
+ # Add separator between days
249
+ if date_str != sorted_dates[-1]:
250
+ table.add_section()
251
+
252
+ # Add final section for totals
253
+ table.add_section()
254
+
255
+ # Build date range label for Total
256
+ if sorted_dates:
257
+ first_date = format_date_display(sorted_dates[0])
258
+ last_date = format_date_display(sorted_dates[-1])
259
+ if first_date == last_date:
260
+ total_label = f"[bold]Total[/bold]\n[dim]{first_date}[/dim]"
261
+ else:
262
+ total_label = f"[bold]Total[/bold]\n[dim]{first_date} ~[/dim]\n[dim]{last_date}[/dim]"
263
+ else:
264
+ total_label = "[bold]Total[/bold]"
265
+
266
+ # Add per-model totals
267
+ sorted_global_models = [s.model_name for s in sorted(global_by_model.values(), key=sort_by_cost)]
268
+ first_total_row = True
269
+ for model_name in sorted_global_models:
270
+ stats = global_by_model[model_name]
271
+ usd_str, cny_str = format_cost_dual(stats.cost_usd, stats.cost_cny)
272
+ table.add_row(
273
+ total_label if first_total_row else "",
274
+ f"- {model_name}",
275
+ format_tokens(stats.input_tokens),
276
+ format_tokens(stats.output_tokens),
277
+ format_tokens(stats.cached_tokens),
278
+ format_tokens(stats.total_tokens),
279
+ usd_str,
280
+ cny_str,
281
+ )
282
+ first_total_row = False
283
+
284
+ # Add grand total row
285
+ grand_total = ModelUsageStats(model_name="(total)")
286
+ for stats in global_by_model.values():
287
+ grand_total.input_tokens += stats.input_tokens
288
+ grand_total.output_tokens += stats.output_tokens
289
+ grand_total.cached_tokens += stats.cached_tokens
290
+ grand_total.cost_usd += stats.cost_usd
291
+ grand_total.cost_cny += stats.cost_cny
292
+
293
+ usd_str, cny_str = format_cost_dual(grand_total.cost_usd, grand_total.cost_cny)
294
+ table.add_row(
295
+ "",
296
+ "[bold] (total)[/bold]",
297
+ f"[bold]{format_tokens(grand_total.input_tokens)}[/bold]",
298
+ f"[bold]{format_tokens(grand_total.output_tokens)}[/bold]",
299
+ f"[bold]{format_tokens(grand_total.cached_tokens)}[/bold]",
300
+ f"[bold]{format_tokens(grand_total.total_tokens)}[/bold]",
301
+ f"[bold]{usd_str}[/bold]",
302
+ f"[bold]{cny_str}[/bold]",
303
+ )
304
+
305
+ return table
306
+
307
+
308
+ def cost_command(
309
+ days: int | None = typer.Option(None, "--days", "-d", help="Limit to last N days"),
310
+ ) -> None:
311
+ """Display aggregated usage statistics across all sessions."""
312
+ daily_stats = aggregate_all_sessions()
313
+
314
+ if not daily_stats:
315
+ typer.echo("No usage data found.")
316
+ raise typer.Exit(0)
317
+
318
+ # Filter by days if specified
319
+ if days is not None:
320
+ cutoff = datetime.now().strftime("%Y-%m-%d")
321
+ from datetime import timedelta
322
+
323
+ cutoff_date = datetime.now() - timedelta(days=days)
324
+ cutoff = cutoff_date.strftime("%Y-%m-%d")
325
+ daily_stats = {k: v for k, v in daily_stats.items() if k >= cutoff}
326
+
327
+ if not daily_stats:
328
+ typer.echo(f"No usage data found in the last {days} days.")
329
+ raise typer.Exit(0)
330
+
331
+ table = render_cost_table(daily_stats)
332
+ console = Console()
333
+ console.print(table)
334
+
335
+
336
+ def register_cost_commands(app: typer.Typer) -> None:
337
+ """Register cost command to the given Typer app."""
338
+ app.command("cost")(cost_command)
@@ -0,0 +1,78 @@
1
+ """Debug utilities for CLI."""
2
+
3
+ import subprocess
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from klaude_code.trace import DebugType, log
10
+
11
+ DEBUG_FILTER_HELP = "Comma-separated debug types: " + ", ".join(dt.value for dt in DebugType)
12
+
13
+
14
+ def parse_debug_filters(raw: str | None) -> set[DebugType] | None:
15
+ """Parse comma-separated debug filter string into a set of DebugType."""
16
+ if raw is None:
17
+ return None
18
+ filters: set[DebugType] = set()
19
+ for chunk in raw.split(","):
20
+ normalized = chunk.strip().lower().replace("-", "_")
21
+ if not normalized:
22
+ continue
23
+ try:
24
+ filters.add(DebugType(normalized))
25
+ except ValueError: # pragma: no cover - user input validation
26
+ valid_options = ", ".join(dt.value for dt in DebugType)
27
+ log(
28
+ (
29
+ f"Invalid debug filter '{normalized}'. Valid options: {valid_options}",
30
+ "red",
31
+ )
32
+ )
33
+ raise typer.Exit(2) from None
34
+ return filters or None
35
+
36
+
37
+ def resolve_debug_settings(flag: bool, raw_filters: str | None) -> tuple[bool, set[DebugType] | None]:
38
+ """Resolve debug flag and filters into effective settings."""
39
+ filters = parse_debug_filters(raw_filters)
40
+ effective_flag = flag or (filters is not None)
41
+ return effective_flag, filters
42
+
43
+
44
+ def open_log_file_in_editor(path: Path) -> None:
45
+ """Open the given log file in a text editor without blocking the CLI."""
46
+
47
+ editor = ""
48
+
49
+ for cmd in ["code", "TextEdit", "notepad"]:
50
+ try:
51
+ subprocess.run(["which", cmd], check=True, capture_output=True)
52
+ editor = cmd
53
+ break
54
+ except (subprocess.CalledProcessError, FileNotFoundError):
55
+ continue
56
+
57
+ if not editor:
58
+ if sys.platform == "darwin":
59
+ editor = "open"
60
+ elif sys.platform == "win32":
61
+ editor = "notepad"
62
+ else:
63
+ editor = "xdg-open"
64
+
65
+ try:
66
+ # Detach stdin to prevent the editor from interfering with terminal input state.
67
+ # Without this, the spawned process inherits the parent's TTY and can disrupt
68
+ # prompt_toolkit's keyboard handling (e.g., history navigation with up/down keys).
69
+ subprocess.Popen(
70
+ [editor, str(path)],
71
+ stdin=subprocess.DEVNULL,
72
+ stdout=subprocess.DEVNULL,
73
+ stderr=subprocess.DEVNULL,
74
+ )
75
+ except FileNotFoundError:
76
+ log((f"Error: Editor '{editor}' not found", "red"))
77
+ except Exception as exc: # pragma: no cover - best effort
78
+ log((f"Warning: failed to open log file in editor: {exc}", "yellow"))