thefuck-leeguoo 3.41__py2.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 (218) hide show
  1. thefuck/__init__.py +0 -0
  2. thefuck/ai.py +765 -0
  3. thefuck/argument_parser.py +96 -0
  4. thefuck/conf.py +141 -0
  5. thefuck/const.py +111 -0
  6. thefuck/corrector.py +92 -0
  7. thefuck/entrypoints/__init__.py +0 -0
  8. thefuck/entrypoints/alias.py +28 -0
  9. thefuck/entrypoints/fix_command.py +105 -0
  10. thefuck/entrypoints/main.py +50 -0
  11. thefuck/entrypoints/not_configured.py +201 -0
  12. thefuck/entrypoints/setup.py +227 -0
  13. thefuck/entrypoints/shell_logger.py +79 -0
  14. thefuck/exceptions.py +10 -0
  15. thefuck/logs.py +255 -0
  16. thefuck/output_readers/__init__.py +20 -0
  17. thefuck/output_readers/read_log.py +108 -0
  18. thefuck/output_readers/rerun.py +72 -0
  19. thefuck/output_readers/shell_logger.py +60 -0
  20. thefuck/rules/__init__.py +0 -0
  21. thefuck/rules/adb_unknown_command.py +54 -0
  22. thefuck/rules/ag_literal.py +10 -0
  23. thefuck/rules/apt_get.py +50 -0
  24. thefuck/rules/apt_get_search.py +14 -0
  25. thefuck/rules/apt_invalid_operation.py +63 -0
  26. thefuck/rules/apt_list_upgradable.py +16 -0
  27. thefuck/rules/apt_upgrade.py +16 -0
  28. thefuck/rules/aws_cli.py +17 -0
  29. thefuck/rules/az_cli.py +17 -0
  30. thefuck/rules/brew_cask_dependency.py +33 -0
  31. thefuck/rules/brew_install.py +24 -0
  32. thefuck/rules/brew_link.py +15 -0
  33. thefuck/rules/brew_reinstall.py +19 -0
  34. thefuck/rules/brew_uninstall.py +14 -0
  35. thefuck/rules/brew_unknown_command.py +82 -0
  36. thefuck/rules/brew_update_formula.py +12 -0
  37. thefuck/rules/cargo.py +6 -0
  38. thefuck/rules/cargo_no_command.py +15 -0
  39. thefuck/rules/cat_dir.py +14 -0
  40. thefuck/rules/cd_correction.py +61 -0
  41. thefuck/rules/cd_cs.py +21 -0
  42. thefuck/rules/cd_mkdir.py +21 -0
  43. thefuck/rules/cd_parent.py +16 -0
  44. thefuck/rules/chmod_x.py +15 -0
  45. thefuck/rules/choco_install.py +25 -0
  46. thefuck/rules/composer_not_command.py +22 -0
  47. thefuck/rules/conda_mistype.py +17 -0
  48. thefuck/rules/cp_create_destination.py +15 -0
  49. thefuck/rules/cp_omitting_directory.py +15 -0
  50. thefuck/rules/cpp11.py +12 -0
  51. thefuck/rules/dirty_untar.py +53 -0
  52. thefuck/rules/dirty_unzip.py +60 -0
  53. thefuck/rules/django_south_ghost.py +8 -0
  54. thefuck/rules/django_south_merge.py +8 -0
  55. thefuck/rules/dnf_no_such_command.py +37 -0
  56. thefuck/rules/docker_image_being_used_by_container.py +20 -0
  57. thefuck/rules/docker_login.py +13 -0
  58. thefuck/rules/docker_not_command.py +49 -0
  59. thefuck/rules/dry.py +15 -0
  60. thefuck/rules/fab_command_not_found.py +38 -0
  61. thefuck/rules/fix_alt_space.py +15 -0
  62. thefuck/rules/fix_file.py +80 -0
  63. thefuck/rules/gem_unknown_command.py +36 -0
  64. thefuck/rules/git_add.py +27 -0
  65. thefuck/rules/git_add_force.py +13 -0
  66. thefuck/rules/git_bisect_usage.py +16 -0
  67. thefuck/rules/git_branch_0flag.py +24 -0
  68. thefuck/rules/git_branch_delete.py +13 -0
  69. thefuck/rules/git_branch_delete_checked_out.py +19 -0
  70. thefuck/rules/git_branch_exists.py +25 -0
  71. thefuck/rules/git_branch_list.py +14 -0
  72. thefuck/rules/git_checkout.py +49 -0
  73. thefuck/rules/git_clone_git_clone.py +12 -0
  74. thefuck/rules/git_clone_missing.py +42 -0
  75. thefuck/rules/git_commit_add.py +17 -0
  76. thefuck/rules/git_commit_amend.py +11 -0
  77. thefuck/rules/git_commit_reset.py +11 -0
  78. thefuck/rules/git_diff_no_index.py +16 -0
  79. thefuck/rules/git_diff_staged.py +13 -0
  80. thefuck/rules/git_fix_stash.py +37 -0
  81. thefuck/rules/git_flag_after_filename.py +31 -0
  82. thefuck/rules/git_help_aliased.py +12 -0
  83. thefuck/rules/git_hook_bypass.py +27 -0
  84. thefuck/rules/git_lfs_mistype.py +18 -0
  85. thefuck/rules/git_main_master.py +16 -0
  86. thefuck/rules/git_merge.py +18 -0
  87. thefuck/rules/git_merge_unrelated.py +12 -0
  88. thefuck/rules/git_not_command.py +18 -0
  89. thefuck/rules/git_pull.py +16 -0
  90. thefuck/rules/git_pull_clone.py +13 -0
  91. thefuck/rules/git_pull_uncommitted_changes.py +14 -0
  92. thefuck/rules/git_push.py +44 -0
  93. thefuck/rules/git_push_different_branch_names.py +12 -0
  94. thefuck/rules/git_push_force.py +18 -0
  95. thefuck/rules/git_push_pull.py +20 -0
  96. thefuck/rules/git_push_without_commits.py +12 -0
  97. thefuck/rules/git_rebase_merge_dir.py +17 -0
  98. thefuck/rules/git_rebase_no_changes.py +13 -0
  99. thefuck/rules/git_remote_delete.py +13 -0
  100. thefuck/rules/git_remote_seturl_add.py +12 -0
  101. thefuck/rules/git_rm_local_modifications.py +19 -0
  102. thefuck/rules/git_rm_recursive.py +16 -0
  103. thefuck/rules/git_rm_staged.py +19 -0
  104. thefuck/rules/git_stash.py +15 -0
  105. thefuck/rules/git_stash_pop.py +18 -0
  106. thefuck/rules/git_tag_force.py +13 -0
  107. thefuck/rules/git_two_dashes.py +14 -0
  108. thefuck/rules/go_run.py +16 -0
  109. thefuck/rules/go_unknown_command.py +28 -0
  110. thefuck/rules/gradle_no_task.py +34 -0
  111. thefuck/rules/gradle_wrapper.py +13 -0
  112. thefuck/rules/grep_arguments_order.py +23 -0
  113. thefuck/rules/grep_recursive.py +10 -0
  114. thefuck/rules/grunt_task_not_found.py +37 -0
  115. thefuck/rules/gulp_not_task.py +22 -0
  116. thefuck/rules/has_exists_script.py +13 -0
  117. thefuck/rules/heroku_multiple_apps.py +12 -0
  118. thefuck/rules/heroku_not_command.py +11 -0
  119. thefuck/rules/history.py +15 -0
  120. thefuck/rules/hostscli.py +27 -0
  121. thefuck/rules/ifconfig_device_not_found.py +23 -0
  122. thefuck/rules/java.py +17 -0
  123. thefuck/rules/javac.py +18 -0
  124. thefuck/rules/lein_not_task.py +19 -0
  125. thefuck/rules/ln_no_hard_link.py +23 -0
  126. thefuck/rules/ln_s_order.py +26 -0
  127. thefuck/rules/long_form_help.py +27 -0
  128. thefuck/rules/ls_all.py +10 -0
  129. thefuck/rules/ls_lah.py +12 -0
  130. thefuck/rules/man.py +33 -0
  131. thefuck/rules/man_no_space.py +10 -0
  132. thefuck/rules/mercurial.py +27 -0
  133. thefuck/rules/missing_space_before_subcommand.py +21 -0
  134. thefuck/rules/mkdir_p.py +13 -0
  135. thefuck/rules/mvn_no_command.py +11 -0
  136. thefuck/rules/mvn_unknown_lifecycle_phase.py +30 -0
  137. thefuck/rules/nixos_cmd_not_found.py +15 -0
  138. thefuck/rules/no_command.py +41 -0
  139. thefuck/rules/no_such_file.py +30 -0
  140. thefuck/rules/npm_missing_script.py +17 -0
  141. thefuck/rules/npm_run_script.py +17 -0
  142. thefuck/rules/npm_wrong_command.py +42 -0
  143. thefuck/rules/omnienv_no_such_command.py +35 -0
  144. thefuck/rules/open.py +40 -0
  145. thefuck/rules/pacman.py +17 -0
  146. thefuck/rules/pacman_invalid_option.py +20 -0
  147. thefuck/rules/pacman_not_found.py +26 -0
  148. thefuck/rules/path_from_history.py +53 -0
  149. thefuck/rules/php_s.py +11 -0
  150. thefuck/rules/pip_install.py +15 -0
  151. thefuck/rules/pip_unknown_command.py +19 -0
  152. thefuck/rules/port_already_in_use.py +40 -0
  153. thefuck/rules/prove_recursively.py +27 -0
  154. thefuck/rules/python_command.py +17 -0
  155. thefuck/rules/python_execute.py +15 -0
  156. thefuck/rules/python_module_error.py +13 -0
  157. thefuck/rules/quotation_marks.py +12 -0
  158. thefuck/rules/rails_migrations_pending.py +14 -0
  159. thefuck/rules/react_native_command_unrecognized.py +34 -0
  160. thefuck/rules/remove_shell_prompt_literal.py +23 -0
  161. thefuck/rules/remove_trailing_cedilla.py +11 -0
  162. thefuck/rules/rm_dir.py +16 -0
  163. thefuck/rules/rm_root.py +16 -0
  164. thefuck/rules/scm_correction.py +32 -0
  165. thefuck/rules/sed_unterminated_s.py +18 -0
  166. thefuck/rules/sl_ls.py +14 -0
  167. thefuck/rules/ssh_known_hosts.py +37 -0
  168. thefuck/rules/sudo.py +47 -0
  169. thefuck/rules/sudo_command_from_user_path.py +21 -0
  170. thefuck/rules/switch_lang.py +117 -0
  171. thefuck/rules/systemctl.py +22 -0
  172. thefuck/rules/terraform_init.py +13 -0
  173. thefuck/rules/terraform_no_command.py +16 -0
  174. thefuck/rules/test.py.py +10 -0
  175. thefuck/rules/tmux.py +18 -0
  176. thefuck/rules/touch.py +14 -0
  177. thefuck/rules/tsuru_login.py +12 -0
  178. thefuck/rules/tsuru_not_command.py +15 -0
  179. thefuck/rules/unknown_command.py +13 -0
  180. thefuck/rules/unsudo.py +15 -0
  181. thefuck/rules/vagrant_up.py +21 -0
  182. thefuck/rules/whois.py +34 -0
  183. thefuck/rules/workon_doesnt_exists.py +32 -0
  184. thefuck/rules/wrong_hyphen_before_subcommand.py +20 -0
  185. thefuck/rules/yarn_alias.py +14 -0
  186. thefuck/rules/yarn_command_not_found.py +43 -0
  187. thefuck/rules/yarn_command_replaced.py +13 -0
  188. thefuck/rules/yarn_help.py +17 -0
  189. thefuck/rules/yum_invalid_operation.py +39 -0
  190. thefuck/shells/__init__.py +52 -0
  191. thefuck/shells/bash.py +94 -0
  192. thefuck/shells/fish.py +131 -0
  193. thefuck/shells/generic.py +154 -0
  194. thefuck/shells/powershell.py +43 -0
  195. thefuck/shells/tcsh.py +44 -0
  196. thefuck/shells/zsh.py +98 -0
  197. thefuck/specific/__init__.py +0 -0
  198. thefuck/specific/apt.py +3 -0
  199. thefuck/specific/archlinux.py +48 -0
  200. thefuck/specific/brew.py +15 -0
  201. thefuck/specific/dnf.py +3 -0
  202. thefuck/specific/git.py +32 -0
  203. thefuck/specific/nix.py +3 -0
  204. thefuck/specific/npm.py +21 -0
  205. thefuck/specific/sudo.py +18 -0
  206. thefuck/specific/yum.py +3 -0
  207. thefuck/system/__init__.py +7 -0
  208. thefuck/system/unix.py +57 -0
  209. thefuck/system/win32.py +43 -0
  210. thefuck/types.py +261 -0
  211. thefuck/ui.py +116 -0
  212. thefuck/utils.py +385 -0
  213. thefuck_leeguoo-3.41.dist-info/METADATA +681 -0
  214. thefuck_leeguoo-3.41.dist-info/RECORD +218 -0
  215. thefuck_leeguoo-3.41.dist-info/WHEEL +6 -0
  216. thefuck_leeguoo-3.41.dist-info/entry_points.txt +3 -0
  217. thefuck_leeguoo-3.41.dist-info/licenses/LICENSE.md +22 -0
  218. thefuck_leeguoo-3.41.dist-info/top_level.txt +1 -0
thefuck/ai.py ADDED
@@ -0,0 +1,765 @@
1
+ import json
2
+ import re
3
+ import sys
4
+ from collections import namedtuple
5
+ from itertools import chain
6
+ import colorama
7
+ import six
8
+ from six.moves.urllib import request
9
+ from six.moves.urllib.error import HTTPError, URLError
10
+
11
+ from . import const, logs
12
+ from .conf import settings
13
+ from .types import CorrectedCommand
14
+
15
+
16
+ AiResult = namedtuple('AiResult', [
17
+ 'commands', 'explanation', 'streamed', 'descriptions'
18
+ ])
19
+
20
+
21
+ SYSTEM_PROMPT = """You are a CLI command correction assistant.
22
+
23
+ IMPORTANT: Use proper spacing between all words in your response.
24
+
25
+ Reply format:
26
+
27
+ think: [1-2 sentence explanation with normal word spacing]
28
+
29
+ answer: [JSON object]
30
+
31
+ JSON schema:
32
+ {"primary": {"command": "...", "desc": "..."}, "alternatives": [{"command": "...", "desc": "..."}]}
33
+
34
+ Rules:
35
+ 1. Commands must be exact shell commands with correct spacing
36
+ 2. Description must use normal English with spaces between words
37
+ 3. If unsure, use empty command ""
38
+
39
+ Example:
40
+
41
+ think: The command gti is a typo for git.
42
+
43
+ answer: {"primary": {"command": "git status", "desc": "Show repository status"}, "alternatives": []}"""
44
+
45
+
46
+ def is_enabled():
47
+ return bool(settings.ai_enabled and settings.ai_url)
48
+
49
+
50
+ def _build_ai_theme():
51
+ from rich.theme import Theme
52
+ return Theme({
53
+ 'ai.label': 'bold green',
54
+ 'ai.text': 'white',
55
+ 'ai.heading': 'bold green',
56
+ 'ai.command': 'bold cyan',
57
+ 'ai.desc': 'yellow',
58
+ 'ai.punct': 'yellow',
59
+ 'markdown': 'white',
60
+ })
61
+
62
+
63
+ def emit_ai_result(result):
64
+ if not result:
65
+ return
66
+
67
+ explanation = result.explanation or ''
68
+ commands = result.commands or []
69
+ descriptions = result.descriptions or {}
70
+ if not explanation and not commands:
71
+ return
72
+
73
+ think_text = _strip_commands_section(explanation)
74
+ try:
75
+ from rich.console import Console
76
+ from rich.markdown import Markdown
77
+ from rich.text import Text
78
+ except Exception:
79
+ _emit_ai_plain(think_text, commands, descriptions, numbered=True)
80
+ return
81
+
82
+ console = Console(stderr=True, theme=_build_ai_theme())
83
+ console.print('AI:', style='ai.label')
84
+ if think_text:
85
+ console.print(Markdown(think_text), style='ai.text')
86
+ if commands:
87
+ console.print()
88
+ console.print('Commands', style='ai.heading')
89
+ for idx, cmd in enumerate(commands, 1):
90
+ line = Text()
91
+ line.append('{}. '.format(idx), style='ai.punct')
92
+ line.append(cmd, style='ai.command')
93
+ desc = descriptions.get(cmd, '')
94
+ if desc:
95
+ line.append(' — ', style='ai.punct')
96
+ line.append(desc, style='ai.desc')
97
+ console.print(line)
98
+
99
+
100
+ def emit_ai_commands(result):
101
+ if not result:
102
+ return
103
+ commands = result.commands or []
104
+ if not commands:
105
+ return
106
+ descriptions = result.descriptions or {}
107
+ try:
108
+ from rich.console import Console
109
+ from rich.text import Text
110
+ except Exception:
111
+ _emit_ai_plain('', commands, descriptions, numbered=True)
112
+ return
113
+
114
+ console = Console(stderr=True, theme=_build_ai_theme())
115
+ console.print()
116
+ console.print('Commands', style='ai.heading')
117
+ for idx, cmd in enumerate(commands, 1):
118
+ line = Text()
119
+ line.append('{}. '.format(idx), style='ai.punct')
120
+ line.append(cmd, style='ai.command')
121
+ desc = descriptions.get(cmd, '')
122
+ if desc:
123
+ line.append(' — ', style='ai.punct')
124
+ line.append(desc, style='ai.desc')
125
+ console.print(line)
126
+
127
+
128
+ def _emit_ai_plain(text, commands, descriptions, numbered=False):
129
+ label = logs.color(colorama.Style.BRIGHT + colorama.Fore.GREEN)
130
+ body = logs.color(colorama.Fore.WHITE)
131
+ cmd_color = logs.color(colorama.Style.BRIGHT + colorama.Fore.CYAN)
132
+ desc_color = logs.color(colorama.Fore.YELLOW)
133
+ punct = logs.color(colorama.Fore.YELLOW)
134
+ reset = logs.color(colorama.Style.RESET_ALL)
135
+
136
+ if text or commands:
137
+ sys.stderr.write(u'{label}AI:{reset}\n'.format(
138
+ label=label, reset=reset))
139
+ if text:
140
+ sys.stderr.write(u'{body}{text}{reset}\n'.format(
141
+ body=body, text=text, reset=reset))
142
+ if commands:
143
+ sys.stderr.write(u'\n{body}Commands{reset}\n'.format(
144
+ body=body, reset=reset))
145
+ for idx, cmd in enumerate(commands, 1):
146
+ prefix = u'{}. '.format(idx) if numbered else u''
147
+ line = u'{punct}{prefix}{reset}{cmd_color}{cmd}{reset}'.format(
148
+ punct=punct, prefix=prefix, reset=reset,
149
+ cmd_color=cmd_color, cmd=cmd)
150
+ desc = descriptions.get(cmd, '')
151
+ if desc:
152
+ line += u' — {desc_color}{desc}{reset}'.format(
153
+ desc_color=desc_color, desc=desc, reset=reset)
154
+ sys.stderr.write(line + '\n')
155
+
156
+
157
+ def emit_markdown(explanation):
158
+ if not explanation:
159
+ return
160
+ try:
161
+ from rich.console import Console
162
+ from rich.markdown import Markdown
163
+ except Exception:
164
+ sys.stderr.write(u'AI:\n{}\n'.format(explanation))
165
+ return
166
+
167
+ console = Console(stderr=True)
168
+ console.print('AI:')
169
+ console.print(Markdown(explanation))
170
+
171
+
172
+ def build_corrected_commands(result):
173
+ if not result or not result.commands:
174
+ return []
175
+
176
+ def _side_effect(old_cmd, new_cmd):
177
+ emit_ai_result(result)
178
+
179
+ if result.streamed:
180
+ side_effect = None
181
+ else:
182
+ side_effect = _side_effect if result.explanation else None
183
+ descriptions = result.descriptions or {}
184
+ corrected = []
185
+ total = len(result.commands)
186
+ for idx, cmd in enumerate(result.commands):
187
+ corrected_command = CorrectedCommand(
188
+ script=cmd,
189
+ side_effect=side_effect,
190
+ priority=const.DEFAULT_PRIORITY * (idx + 1))
191
+ desc = descriptions.get(cmd)
192
+ if desc:
193
+ corrected_command.desc = desc
194
+ corrected_command._tf_source = 'ai'
195
+ corrected_command._tf_index = idx + 1
196
+ corrected_command._tf_total = total
197
+ corrected.append(corrected_command)
198
+ return corrected
199
+
200
+
201
+ def fallback_corrected_commands(command, corrected_commands):
202
+ try:
203
+ first = next(corrected_commands)
204
+ except StopIteration:
205
+ result = get_ai_suggestion(command)
206
+ corrected = build_corrected_commands(result)
207
+ if corrected:
208
+ return iter(corrected), result
209
+ return iter(()), result
210
+
211
+ return chain([first], corrected_commands), None
212
+
213
+
214
+ def get_ai_suggestion(command, prompt=None, warn_on_error=False):
215
+ if not is_enabled():
216
+ return
217
+
218
+ stream_output = bool(settings.ai_stream_output)
219
+ payload = _build_payload(command, prompt)
220
+ try:
221
+ response_text, streamed = _send_request(
222
+ payload, stream_output=stream_output)
223
+ except Exception as exc:
224
+ if warn_on_error:
225
+ logs.warn(u'AI request failed: {}'.format(exc))
226
+ else:
227
+ logs.debug(u'AI request failed: {}'.format(exc))
228
+ return
229
+
230
+ result = _parse_response(response_text, streamed)
231
+ if not result:
232
+ return
233
+ return result
234
+
235
+
236
+ def _build_payload(command, prompt):
237
+ output = six.text_type(command.output or '')
238
+ user_lines = [
239
+ 'Failed command:',
240
+ six.text_type(command.script or ''),
241
+ '',
242
+ 'Output:',
243
+ output
244
+ ]
245
+ if prompt:
246
+ user_lines.extend(['', 'User prompt:', six.text_type(prompt)])
247
+
248
+ payload = {
249
+ 'model': settings.ai_model,
250
+ 'messages': [
251
+ {'role': 'system', 'content': SYSTEM_PROMPT},
252
+ {'role': 'user', 'content': '\n'.join(user_lines)}
253
+ ],
254
+ 'stream': bool(settings.ai_stream)
255
+ }
256
+ if settings.ai_reasoning_effort:
257
+ payload['reasoning'] = {'effort': settings.ai_reasoning_effort}
258
+ return payload
259
+
260
+
261
+ def _send_request(payload, stream_output=False):
262
+ data = json.dumps(payload).encode('utf-8')
263
+ headers = {'Content-Type': 'application/json'}
264
+ if settings.ai_token:
265
+ headers['Authorization'] = 'Bearer {}'.format(settings.ai_token)
266
+ if settings.ai_stream:
267
+ headers['Accept'] = 'text/event-stream'
268
+ req = request.Request(settings.ai_url, data=data, headers=headers)
269
+ try:
270
+ response = request.urlopen(req, timeout=settings.ai_timeout)
271
+ try:
272
+ if settings.ai_stream:
273
+ content_type = response.headers.get('Content-Type', '')
274
+ if 'text/event-stream' in content_type:
275
+ return _read_sse_response(
276
+ response, stream_output=stream_output)
277
+ return response.read().decode('utf-8'), False
278
+ finally:
279
+ response.close()
280
+ except HTTPError as exc:
281
+ body = exc.read().decode('utf-8', errors='replace')
282
+ raise RuntimeError('status {}: {}'.format(exc.code, body))
283
+ except URLError as exc:
284
+ raise RuntimeError(str(exc))
285
+
286
+
287
+ def _read_sse_response(response, stream_output=False):
288
+ chunks = []
289
+ stream_writer = _make_stream_writer() if stream_output else None
290
+ while True:
291
+ line = response.readline()
292
+ if not line:
293
+ break
294
+ if isinstance(line, bytes):
295
+ line = line.decode('utf-8', errors='replace')
296
+ line = line.rstrip('\r\n')
297
+ if not line:
298
+ continue
299
+ if not line.startswith('data:'):
300
+ continue
301
+ data = line[5:]
302
+ if data.startswith(' '):
303
+ data = data[1:]
304
+ data = data.rstrip('\r\n')
305
+ if data == '[DONE]':
306
+ break
307
+ chunk = _extract_stream_chunk(data)
308
+ if chunk:
309
+ chunks.append(chunk)
310
+ if stream_writer:
311
+ stream_writer.feed(chunk)
312
+ streamed = False
313
+ if stream_writer:
314
+ stream_writer.finish()
315
+ streamed = stream_writer.streamed
316
+ return ''.join(chunks), streamed
317
+
318
+
319
+ def _extract_stream_chunk(data):
320
+ try:
321
+ parsed = json.loads(data)
322
+ except ValueError:
323
+ return data
324
+
325
+ if isinstance(parsed, dict):
326
+ choices = parsed.get('choices')
327
+ if isinstance(choices, list) and choices:
328
+ choice = choices[0] or {}
329
+ if isinstance(choice, dict):
330
+ delta = choice.get('delta')
331
+ if isinstance(delta, dict) and 'content' in delta:
332
+ return delta.get('content') or ''
333
+ message = choice.get('message')
334
+ if isinstance(message, dict) and 'content' in message:
335
+ return message.get('content') or ''
336
+ if 'text' in choice:
337
+ return choice.get('text') or ''
338
+ if 'content' in parsed:
339
+ return parsed.get('content') or ''
340
+
341
+ return ''
342
+
343
+
344
+ class _StreamWriter(object):
345
+ def __init__(self):
346
+ self.streamed = False
347
+ self._buffer = ''
348
+ self._pending = ''
349
+ self._started = False
350
+ self._done = False
351
+ self._text = ''
352
+ self._console = None
353
+ self._live = None
354
+ self._markdown_cls = None
355
+ self._use_rich = False
356
+ self._init_rich()
357
+
358
+ def _init_rich(self):
359
+ try:
360
+ from rich.console import Console
361
+ from rich.live import Live
362
+ from rich.markdown import Markdown
363
+ except Exception:
364
+ return
365
+ self._use_rich = True
366
+ self._console = Console(stderr=True, theme=_build_ai_theme())
367
+ self._live = Live(Markdown(''), console=self._console,
368
+ refresh_per_second=8)
369
+ self._markdown_cls = Markdown
370
+
371
+ def feed(self, chunk):
372
+ if self._done:
373
+ return
374
+ self._buffer += chunk
375
+ lower = self._buffer.lower()
376
+ idx = lower.find('answer:')
377
+ if idx != -1:
378
+ out = self._buffer[:idx]
379
+ self._buffer = self._buffer[idx:]
380
+ self._done = True
381
+ self._emit(out, final=True)
382
+ return
383
+
384
+ keep = len('answer:') - 1
385
+ if len(self._buffer) > keep:
386
+ out = self._buffer[:-keep]
387
+ self._buffer = self._buffer[-keep:]
388
+ self._emit(out, final=False)
389
+
390
+ def finish(self):
391
+ if not self._done and self._buffer:
392
+ self._emit(self._buffer, final=True)
393
+ self._buffer = ''
394
+ if self._use_rich and self._live and self._started:
395
+ self._live.stop()
396
+ elif self._started:
397
+ sys.stderr.write('\n')
398
+ sys.stderr.flush()
399
+
400
+ def _emit(self, text, final):
401
+ if not text:
402
+ return
403
+ if not self._started:
404
+ self._pending += text
405
+ if len(self._pending) < 16 and not final:
406
+ return
407
+ text = self._strip_think_prefix(self._pending)
408
+ self._pending = ''
409
+ if not text and not final:
410
+ return
411
+ self._start()
412
+ if self._use_rich:
413
+ self._text += text
414
+ self._live.update(self._markdown_cls(self._text))
415
+ else:
416
+ sys.stderr.write(text)
417
+ sys.stderr.flush()
418
+
419
+ def _start(self):
420
+ if self._started:
421
+ return
422
+ self._started = True
423
+ self.streamed = True
424
+ if self._use_rich:
425
+ self._console.print('AI:', style='ai.label')
426
+ self._live.start()
427
+ else:
428
+ sys.stderr.write('{}AI:{} '.format(
429
+ logs.color(colorama.Style.BRIGHT + colorama.Fore.GREEN),
430
+ logs.color(colorama.Style.RESET_ALL)))
431
+ sys.stderr.flush()
432
+
433
+ def _strip_think_prefix(self, text):
434
+ stripped = text.lstrip()
435
+ lower = stripped.lower()
436
+ if lower.startswith('think:'):
437
+ stripped = stripped[len('think:'):].lstrip()
438
+ return stripped
439
+
440
+
441
+ def _make_stream_writer():
442
+ return _StreamWriter()
443
+
444
+
445
+ def _parse_response(response_text, streamed):
446
+ try:
447
+ data = json.loads(response_text)
448
+ except ValueError:
449
+ logs.debug(u'AI response is not JSON')
450
+ return _parse_content(response_text, streamed)
451
+
452
+ content = _extract_content(data)
453
+ if content is None:
454
+ logs.debug(u'AI response missing content')
455
+ return
456
+
457
+ result = _parse_content(content, streamed)
458
+ if not result:
459
+ return
460
+ return result
461
+
462
+
463
+ def _extract_content(data):
464
+ if isinstance(data, dict):
465
+ if 'command' in data or 'commands' in data or 'explanation' in data:
466
+ return data
467
+ choices = data.get('choices')
468
+ if isinstance(choices, list) and choices:
469
+ choice = choices[0] or {}
470
+ if isinstance(choice, dict):
471
+ message = choice.get('message', {})
472
+ if isinstance(message, dict) and 'content' in message:
473
+ return message.get('content')
474
+ if 'text' in choice:
475
+ return choice.get('text')
476
+ if 'content' in data:
477
+ return data.get('content')
478
+ return None
479
+
480
+
481
+ def _parse_content(content, streamed):
482
+ if isinstance(content, dict):
483
+ structured = _normalize_structured(content, streamed)
484
+ if structured:
485
+ return structured
486
+ return _normalize_result(content, streamed)
487
+
488
+ if not isinstance(content, six.string_types):
489
+ content = six.text_type(content)
490
+ content = content.strip()
491
+ if not content:
492
+ return
493
+
494
+ structured = _parse_think_answer(content, streamed)
495
+ if structured:
496
+ return structured
497
+
498
+ parsed = _try_parse_json(content)
499
+ if parsed is None:
500
+ commands = _extract_commands_from_markdown(content)
501
+ explanation = _strip_commands_section(content)
502
+ return AiResult(commands=commands,
503
+ explanation=explanation,
504
+ streamed=streamed,
505
+ descriptions={})
506
+ if isinstance(parsed, dict):
507
+ structured = _normalize_structured(parsed, streamed)
508
+ if structured:
509
+ return structured
510
+ return _normalize_result(parsed, streamed)
511
+ return _parse_content(six.text_type(parsed), streamed)
512
+
513
+
514
+ def _try_parse_json(content):
515
+ try:
516
+ return json.loads(content)
517
+ except ValueError:
518
+ start = content.find('{')
519
+ end = content.rfind('}')
520
+ if start != -1 and end != -1 and end > start:
521
+ snippet = content[start:end + 1]
522
+ try:
523
+ return json.loads(snippet)
524
+ except ValueError:
525
+ return
526
+ return
527
+
528
+
529
+ def _normalize_result(parsed, streamed):
530
+ if not isinstance(parsed, dict):
531
+ return
532
+
533
+ commands = parsed.get('commands', parsed.get('command', ''))
534
+ explanation = parsed.get('explanation', '')
535
+
536
+ if commands is None:
537
+ commands = ''
538
+ if explanation is None:
539
+ explanation = ''
540
+
541
+ commands = _normalize_commands(commands)
542
+ explanation = six.text_type(explanation).strip()
543
+
544
+ return AiResult(commands=commands, explanation=explanation, streamed=streamed,
545
+ descriptions={})
546
+
547
+
548
+ def _normalize_structured(parsed, streamed):
549
+ if not isinstance(parsed, dict):
550
+ return
551
+
552
+ if 'think' in parsed and 'answer' in parsed:
553
+ think = parsed.get('think', '')
554
+ answer = parsed.get('answer', {})
555
+ answer_obj = _coerce_json(answer)
556
+ return _build_from_answer(answer_obj, think, streamed)
557
+
558
+ if 'primary' in parsed or 'alternatives' in parsed:
559
+ return _build_from_answer(parsed, '', streamed)
560
+
561
+ return
562
+
563
+
564
+ def _parse_think_answer(content, streamed):
565
+ match = re.search(r'(?i)answer\s*:\s*', content)
566
+ if not match:
567
+ return
568
+
569
+ answer_text = content[match.end():]
570
+ think_text = content[:match.start()]
571
+ think_text = _strip_think_prefix(think_text)
572
+ answer_text = _strip_code_fence(answer_text)
573
+ answer_obj = _try_parse_json(answer_text)
574
+ if not isinstance(answer_obj, dict):
575
+ answer_obj = _extract_json_from_text(answer_text)
576
+ return _build_from_answer(answer_obj, think_text, streamed)
577
+
578
+
579
+ def _strip_think_prefix(text):
580
+ match = re.search(r'(?i)think\s*:\s*', text)
581
+ if match:
582
+ return text[match.end():].strip()
583
+ return text.strip()
584
+
585
+
586
+ def _extract_json_from_text(text):
587
+ start = text.find('{')
588
+ end = text.rfind('}')
589
+ if start != -1 and end != -1 and end > start:
590
+ try:
591
+ return json.loads(text[start:end + 1])
592
+ except ValueError:
593
+ return
594
+ return
595
+
596
+
597
+ def _coerce_json(value):
598
+ if isinstance(value, dict):
599
+ return value
600
+ if isinstance(value, six.string_types):
601
+ value = _strip_code_fence(value)
602
+ return _try_parse_json(value) or _extract_json_from_text(value) or {}
603
+ return {}
604
+
605
+
606
+ def _build_from_answer(answer_obj, think_text, streamed):
607
+ if not isinstance(answer_obj, dict):
608
+ answer_obj = {}
609
+
610
+ if 'primary' not in answer_obj and 'alternatives' not in answer_obj:
611
+ if 'command' in answer_obj or 'desc' in answer_obj:
612
+ answer_obj = {
613
+ 'primary': {
614
+ 'command': answer_obj.get('command', ''),
615
+ 'desc': answer_obj.get('desc', '')
616
+ },
617
+ 'alternatives': []
618
+ }
619
+
620
+ primary = answer_obj.get('primary') or {}
621
+ alternatives = answer_obj.get('alternatives') or []
622
+
623
+ commands = []
624
+ descriptions = []
625
+ primary_command = _extract_command(primary)
626
+ if primary_command:
627
+ commands.append(primary_command)
628
+ desc = _extract_desc(primary)
629
+ descriptions.append((primary_command, desc, True))
630
+
631
+ for alt in alternatives[:3]:
632
+ alt_command = _extract_command(alt)
633
+ if alt_command and alt_command not in commands:
634
+ commands.append(alt_command)
635
+ desc = _extract_desc(alt)
636
+ descriptions.append((alt_command, desc, False))
637
+
638
+ explanation = _format_think_with_commands(think_text, descriptions)
639
+ descriptions_map = {
640
+ cmd: desc for cmd, desc, _ in descriptions if desc
641
+ }
642
+ explanation = think_text.strip()
643
+ return AiResult(commands=commands, explanation=explanation, streamed=streamed,
644
+ descriptions=descriptions_map)
645
+
646
+
647
+ def _extract_command(item):
648
+ if isinstance(item, dict):
649
+ return _strip_code_fence(six.text_type(item.get('command', '') or '')).strip()
650
+ return _strip_code_fence(six.text_type(item or '')).strip()
651
+
652
+
653
+ def _extract_desc(item):
654
+ if isinstance(item, dict):
655
+ return six.text_type(item.get('desc', '') or '').strip()
656
+ return ''
657
+
658
+
659
+ def _format_think_with_commands(think_text, descriptions):
660
+ parts = []
661
+ if think_text:
662
+ parts.append(think_text.strip())
663
+ if descriptions:
664
+ parts.append('### Commands')
665
+ for cmd, desc, is_primary in descriptions:
666
+ prefix = '- **{}**'.format(cmd) if is_primary else '- {}'.format(cmd)
667
+ if desc:
668
+ parts.append('{} — {}'.format(prefix, desc))
669
+ else:
670
+ parts.append(prefix)
671
+ return '\n'.join(parts).strip()
672
+
673
+
674
+ def _normalize_commands(commands):
675
+ if commands is None:
676
+ return []
677
+ if isinstance(commands, six.string_types):
678
+ return _clean_commands([commands])
679
+ if isinstance(commands, (list, tuple)):
680
+ return _clean_commands(commands)
681
+ return _clean_commands([six.text_type(commands)])
682
+
683
+
684
+ def _clean_commands(commands):
685
+ cleaned = []
686
+ for cmd in commands:
687
+ if cmd is None:
688
+ continue
689
+ cmd_text = _strip_code_fence(six.text_type(cmd)).strip()
690
+ if cmd_text:
691
+ cleaned.append(cmd_text)
692
+ return cleaned
693
+
694
+
695
+ def _extract_commands_from_markdown(content):
696
+ lines = content.splitlines()
697
+ commands = []
698
+ in_section = False
699
+ for line in lines:
700
+ stripped = line.strip()
701
+ if not stripped:
702
+ continue
703
+ if _is_commands_heading(stripped):
704
+ in_section = True
705
+ continue
706
+ if in_section:
707
+ if stripped.startswith('#'):
708
+ break
709
+ command = _parse_command_line(stripped)
710
+ if command:
711
+ commands.append(command)
712
+ elif commands:
713
+ break
714
+ return commands
715
+
716
+
717
+ def _is_commands_heading(line):
718
+ heading = line.lstrip('#').strip().rstrip(':').lower()
719
+ return heading in (
720
+ 'commands', 'command', 'command suggestions', 'suggested commands'
721
+ )
722
+
723
+
724
+ def _parse_command_line(line):
725
+ stripped = line.strip()
726
+ if stripped.startswith(('-', '*', '+')):
727
+ stripped = stripped[1:].strip()
728
+ elif stripped[:2].isdigit() and stripped[1] in ('.', ')'):
729
+ stripped = stripped[2:].strip()
730
+ elif stripped[:3].isdigit() and stripped[2] in ('.', ')'):
731
+ stripped = stripped[3:].strip()
732
+ if stripped.startswith('`') and stripped.endswith('`'):
733
+ stripped = stripped[1:-1].strip()
734
+ return stripped or None
735
+
736
+
737
+ def _strip_commands_section(content):
738
+ lines = content.splitlines()
739
+ output = []
740
+ in_section = False
741
+ for line in lines:
742
+ stripped = line.strip()
743
+ if _is_commands_heading(stripped):
744
+ in_section = True
745
+ continue
746
+ if in_section:
747
+ if stripped.startswith('#') or not stripped:
748
+ in_section = False
749
+ else:
750
+ continue
751
+ if not in_section:
752
+ output.append(line)
753
+ return '\n'.join(output).strip()
754
+
755
+
756
+ def _strip_code_fence(text):
757
+ text = text.strip()
758
+ if text.startswith('```'):
759
+ lines = text.splitlines()
760
+ if lines and lines[0].startswith('```'):
761
+ lines = lines[1:]
762
+ if lines and lines[-1].startswith('```'):
763
+ lines = lines[:-1]
764
+ return '\n'.join(lines).strip()
765
+ return text