mangopi-cli 0.1.1__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.
mango_cli.py ADDED
@@ -0,0 +1,902 @@
1
+ #!/usr/bin/env python3
2
+
3
+ __version__ = "0.1.1"
4
+ __author__ = "moofs"
5
+ __license__ = "Apache License 2.0"
6
+
7
+ import copy
8
+ import difflib
9
+ import glob
10
+ import json
11
+ import os
12
+ import re
13
+ import subprocess
14
+ import sys
15
+ import threading
16
+ import time
17
+ import urllib.error
18
+ import urllib.request
19
+ import glob as globlib
20
+ import platform
21
+ from datetime import datetime
22
+ from typing import List, Dict, Any, Optional
23
+
24
+ # --- System Env ---
25
+ MANGO_KEY = os.environ.get("MANGO_KEY")
26
+ MANGO_API_URL = os.environ.get("MANGO_API_URL")
27
+ MANGO_MODEL = os.environ.get("MANGO_MODEL")
28
+ MANGO_MAX_CONTEXT = int(os.environ.get("MANGO_MAX_CONTEXT", 128000))
29
+
30
+ project_root = os.getcwd()
31
+ base_persist_dir = os.path.join(project_root, '.mangocli')
32
+ session_dir = os.path.join(project_root, ".mangocli", "session")
33
+
34
+ # ANSI colors
35
+ RESET, BOLD, DIM = "\033[0m", "\033[1m", "\033[2m"
36
+ BLUE, CYAN, GREEN, YELLOW, RED, GREY, ORANGE = (
37
+ "\033[34m", "\033[36m", "\033[32m", "\033[33m", "\033[31m", "\033[90m", "\033[38;2;245;78;0m")
38
+
39
+
40
+ def _c(text, color): return f"{color}{text}{RESET}"
41
+
42
+
43
+ # --- UI ---
44
+ class Printer:
45
+ SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
46
+
47
+ def __init__(self):
48
+ self._spinner_running = False
49
+ self._spinner_thread = None
50
+ self._spinner_message = ""
51
+ self._lock = threading.RLock()
52
+
53
+ @staticmethod
54
+ def _clear_spinner_line():
55
+ sys.stdout.write("\r\033[K")
56
+ sys.stdout.flush()
57
+
58
+ def _write_line(self, text: str = ""):
59
+ with self._lock:
60
+ was_running = self._spinner_running
61
+ if was_running:
62
+ self._clear_spinner_line()
63
+ print(text)
64
+ if was_running:
65
+ self._render_spinner_frame()
66
+
67
+ def _render_spinner_frame(self, frame: str = "⠋"):
68
+ # text = f"{frame} {self._spinner_message}"
69
+ text = f"{_c(frame, ORANGE)} {_c(self._spinner_message, ORANGE)}"
70
+ sys.stdout.write("\r" + text)
71
+ sys.stdout.flush()
72
+
73
+ def section(self, title):
74
+ self._write_line()
75
+ self._write_line(_c(f"• {title}", ORANGE))
76
+
77
+ def kv(self, data: Dict[str, Any]):
78
+ for k, v in data.items():
79
+ self._write_line(f"{_c(str(k), GREY)}{_c(': ', GREY)}{_c(str(v), GREY)}")
80
+
81
+ def tool_call(self, name: str, desc: str):
82
+ self.section("Tool Call")
83
+ self._write_line(f"{_c('› ', GREY)}{_c(name, CYAN)} {_c(desc, GREY)}")
84
+
85
+ def tool_result(self, ok=True, meta="applied"):
86
+ icon = "✓" if ok else "✗"
87
+ color = GREEN if ok else RED
88
+ suffix = f" {meta}" if meta else ""
89
+ self._write_line(f" {_c(icon, color)}{_c(suffix, GREY)}")
90
+
91
+ def success(self, msg: str):
92
+ self._write_line(f"{_c('✓ ', GREEN)}{_c(msg, GREY)}")
93
+
94
+ def error(self, msg: str):
95
+ self._write_line(f"{_c('✗ ', RED)}{_c(msg, GREY)}")
96
+
97
+ def warning(self, msg: str):
98
+ self._write_line(f"{_c('! ', YELLOW)}{_c(msg, GREY)}")
99
+
100
+ def text(self, msg: str):
101
+ self._write_line(_c(msg, GREY))
102
+
103
+ def separator(self):
104
+ self._write_line(f"{DIM}{'─' * min(os.get_terminal_size().columns, 80)}{RESET}")
105
+
106
+ def thinking(self, content: str):
107
+ self.section("Thinking")
108
+ for line in content.splitlines():
109
+ self._write_line(" " + _c(line, GREY))
110
+
111
+ def output(self, content: str):
112
+ self.section("Output")
113
+ for line in content.splitlines():
114
+ self._write_line(" " + _c(line, GREY))
115
+
116
+ def token_usage(self, iteration: int, input_tokens: int, output_tokens: int, context_tokens: int, max_context: int):
117
+ def fmt(n):
118
+ return f"{n / 1000:.1f}k" if n >= 1000 else str(n)
119
+
120
+ ratio = context_tokens / max_context if max_context else 0
121
+ percent = int(ratio * 100)
122
+ color = GREEN if percent < 50 else YELLOW if percent < 70 else RED
123
+
124
+ self._write_line()
125
+ self._write_line(
126
+ _c(f"round: {iteration} | tokens: {fmt(input_tokens)} in / {fmt(output_tokens)} out | ctx: ", GREY) +
127
+ _c(f"{percent}%", color))
128
+
129
+ def compact_status(self, before_tokens: int, after_tokens: int, max_context: int, strategy: str = "auto"):
130
+ saved = before_tokens - after_tokens
131
+ ratio = (after_tokens / max_context) if max_context else 0
132
+ percent = int(ratio * 100)
133
+ color = GREEN if percent < 50 else YELLOW if percent < 70 else RED
134
+
135
+ self.section("Compact")
136
+ self._write_line(f" {_c('strategy', GREY)} {_c(strategy, ORANGE)}")
137
+ self._write_line(
138
+ f" {_c('tokens', GREY)} "
139
+ f"{_c(f'{before_tokens:,}', RED)}"
140
+ f" {_c('→', GREY)} "
141
+ f"{_c(f'{after_tokens:,}', GREEN)} "
142
+ f"{_c(f'(-{saved:,})', ORANGE)}"
143
+ )
144
+ self._write_line(f" {_c('context', GREY)} {_c(f'{percent}%', color)}")
145
+
146
+ @staticmethod
147
+ def prompt_apply(message: str) -> bool:
148
+ while True:
149
+ resp = input(f"{YELLOW}{message} [y/n]: {RESET}").strip().lower()
150
+ if resp in ("y", "yes"):
151
+ return True
152
+ elif resp in ("n", "no"):
153
+ return False
154
+ else:
155
+ print("请输入 y 或 n")
156
+
157
+ def diff(self, old: str, new: str, context: int = 3, filename: str = "file.py"):
158
+ self.section("Code Diff")
159
+ old_lines = old.splitlines()
160
+ new_lines = new.splitlines()
161
+
162
+ diff_lines = difflib.unified_diff(
163
+ old_lines, new_lines, fromfile=f"a/{filename}", tofile=f"b/{filename}", lineterm="", n=context,
164
+ )
165
+
166
+ for dl in diff_lines:
167
+ if dl.startswith("+") and not dl.startswith("+++"):
168
+ self._write_line(_c(dl, GREEN))
169
+ elif dl.startswith("-") and not dl.startswith("---"):
170
+ self._write_line(_c(dl, RED))
171
+ elif dl.startswith("@@"):
172
+ self._write_line(_c(dl, CYAN))
173
+ else:
174
+ self._write_line(_c(dl, GREY))
175
+
176
+ def start_spinner(self, message: str = "Running..."):
177
+ if self._spinner_running:
178
+ return
179
+ self._spinner_running = True
180
+ self._spinner_message = message
181
+
182
+ def run():
183
+ i = 0
184
+ while self._spinner_running:
185
+ with self._lock:
186
+ frame = self.SPINNER_FRAMES[i % len(self.SPINNER_FRAMES)]
187
+ self._render_spinner_frame(frame)
188
+ time.sleep(0.1)
189
+ i += 1
190
+
191
+ self._spinner_thread = threading.Thread(target=run, daemon=True)
192
+ self._spinner_thread.start()
193
+
194
+ def end_spinner(self):
195
+ if not self._spinner_running:
196
+ return
197
+ self._spinner_running = False
198
+ if self._spinner_thread:
199
+ self._spinner_thread.join()
200
+ with self._lock:
201
+ self._clear_spinner_line()
202
+
203
+
204
+ console = Printer()
205
+
206
+
207
+ # --- i18n ---
208
+
209
+
210
+ # --- Init dir, Base data ---
211
+ def initialize_system():
212
+ if not os.path.exists(base_persist_dir):
213
+ os.mkdir(base_persist_dir)
214
+ if not os.path.exists(session_dir):
215
+ os.mkdir(session_dir)
216
+
217
+
218
+ def helper():
219
+ console.text("Mango CLI — 基于大模型的命令行编程助手")
220
+ console.text("内置命令:")
221
+ console.text(" /q, /quit 退出程序")
222
+ console.text(" /c, /compact 手动压缩当前会话(释放上下文空间)")
223
+ console.text(" /n, /new 结束当前会话并创建一个全新的会话")
224
+ console.text(" /h, /help 显示本帮助信息")
225
+
226
+
227
+ # --- Utils function ---
228
+ def _check_command_safety(command: str):
229
+ # 1.文件删除命令, 2.系统格式化和分区操作,3.危险权限修改, 4.提权命令,5.危险进程操作,6.环境变量和系统配置,7.历史和日志清理
230
+ dangerous_patterns = [
231
+ (r'\brm\s+.*-[rf]', 1), (r'\brm\s+-[rf]', 1), (r'\bunlink\b', 1),
232
+ (r'\bmkfs\b', 2), (r'\bfdisk\b', 2), (r'\bparted\b', 2), (r'\bdd\s+.*if=.*of=', 2),
233
+ (r'\bchmod\s+(?:-[a-zA-Z]+\s+)*\d*7\d*7\b', 3), (r'\bchmod\s+777\b', 3), (r'\bchmod\s+\d*7\d*7\b', 3),
234
+ (r'\bchown\s+.*root\b', 3),
235
+ (r'\bsudo\s+.*rm\b', 4), (r'\bsu\s+-\b', 4), (r'\bsu\s+root\b', 4),
236
+ (r'\bkill\s+-9\s+1\b', 5), (r'\bkillall\s+-9\b', 5), (r'\bpkill\s+-9\b', 5), (r'\bkill\s+-9\s+-\d+\b', 5),
237
+ (r'\bexport\s+PATH=', 6), (r'\bunset\s+PATH\b', 6), (r'>>?\s*/etc/', 6), (r'\becho\s+.*>\s*/etc/', 6),
238
+ (r'\bhistory\s+-c\b', 7), (r'>\s*/dev/null\s+2>&1', 7),
239
+ ]
240
+ command = command.strip()
241
+ if not command:
242
+ return False, None
243
+ for pattern, reason in dangerous_patterns:
244
+ if re.search(pattern, command, re.IGNORECASE):
245
+ return True, f"危险命令: {reason}"
246
+ return False, None
247
+
248
+
249
+ def _validate_file_path(path: str) -> Optional[str]:
250
+ """ 验证给定路径是否在项目根目录内, 返回 None 表示合法,否则返回错误描述字符串。"""
251
+ abs_path = os.path.abspath(path)
252
+ real_path = os.path.realpath(abs_path)
253
+ real_root = os.path.realpath(project_root)
254
+ if not real_path.startswith(real_root + os.sep) and real_path != real_root: # 必须位于项目根目录下
255
+ return f"path '{path}' is outside project root"
256
+ if os.path.isdir(real_path): # 不允许直接操作目录(write/edit 只能操作文件)
257
+ return f"path '{path}' is a directory, not a file"
258
+ return None
259
+
260
+
261
+ # --- Tool definitions: (description, schema, function) ---
262
+ def read(args):
263
+ lines = open(args["path"]).readlines()
264
+ offset = args.get("offset", 0)
265
+ limit = args.get("limit", len(lines))
266
+ selected = lines[offset: offset + limit]
267
+ return "".join(f"{offset + idx + 1:4}| {line}" for idx, line in enumerate(selected))
268
+
269
+
270
+ def write(args):
271
+ error = _validate_file_path(args["path"])
272
+ if error:
273
+ return f"write error: {error}"
274
+ with open(args["path"], "w") as f:
275
+ f.write(args["content"])
276
+ return f"write {len(args['content'])}byte to {len(args['path'])} ok"
277
+
278
+
279
+ def edit(args):
280
+ error = _validate_file_path(args["path"])
281
+ if error:
282
+ return f"edit error: {error}"
283
+ text = open(args["path"]).read()
284
+ old, new = args["old"], args["new"]
285
+ if old not in text:
286
+ return "edit error: old_string not found"
287
+ count = text.count(old)
288
+ if not args.get("all") and count > 1:
289
+ return f"error: old_string appears {count} times, must be unique (use all=true)"
290
+ replacement = (text.replace(old, new) if args.get("all") else text.replace(old, new, 1))
291
+ with open(args["path"], "w") as f:
292
+ f.write(replacement)
293
+ return f"edit {len(args['path'])} ok"
294
+
295
+
296
+ def search(args):
297
+ pattern = (args.get("path", ".") + "/" + args["pat"]).replace("//", "/")
298
+ files = globlib.glob(pattern, recursive=True)
299
+ files = sorted(files, key=lambda f: os.path.getmtime(f) if os.path.isfile(f) else 0, reverse=True,)
300
+ return "\n".join(files) or "none"
301
+
302
+
303
+ def grep(args):
304
+ pattern = re.compile(args["pat"])
305
+ hits = []
306
+ for filepath in glob.glob(args.get("path", ".") + "/**", recursive=True):
307
+ try:
308
+ for line_num, line in enumerate(open(filepath), 1):
309
+ if pattern.search(line):
310
+ hits.append(f"{filepath}:{line_num}:{line.rstrip()}")
311
+ except Exception as err:
312
+ return f"grep tool error: {err}"
313
+ return "\n".join(hits[:50]) or "none"
314
+
315
+
316
+ def bash(args):
317
+ proc = subprocess.Popen(args["cmd"], shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
318
+ output_lines = []
319
+ try:
320
+ while True:
321
+ line = proc.stdout.readline()
322
+ if not line and proc.poll() is not None:
323
+ break
324
+ if line:
325
+ output_lines.append(line)
326
+ proc.wait(timeout=60)
327
+ except subprocess.TimeoutExpired:
328
+ proc.kill()
329
+ output_lines.append("\n(timed out after 60s)")
330
+ return "".join(output_lines).strip() or "(empty)"
331
+
332
+
333
+ def attempt_completion(args):
334
+ return args["result"]
335
+
336
+
337
+ TOOLS = {
338
+ "read": (
339
+ "Read a file from the local filesystem",
340
+ {
341
+ "path": {"type": "string", "description": "Path to the file to read"},
342
+ "offset": {"type": "number?", "description": "Line number to start reading from (0-indexed, default 0)"},
343
+ "limit": {"type": "number?", "description": "Maximum number of lines to read (default: all lines)"}
344
+ },
345
+ read,
346
+ ),
347
+ "write": (
348
+ "Write content to a file, overwriting if it exists",
349
+ {
350
+ "path": {"type": "string", "description": "Path to the file to write"},
351
+ "content": {"type": "string", "description": "Content to write to the file"}
352
+ },
353
+ write,
354
+ ),
355
+ "edit": (
356
+ "Edit a file by replacing an exact string with a new string",
357
+ {
358
+ "path": {"type": "string", "description": "Path to the file to edit"},
359
+ "old": {"type": "string", "description": "Exact string to be replaced"},
360
+ "new": {"type": "string", "description": "String to replace it with"},
361
+ "all": {"type": "boolean?", "description": "Replace all occurrences (default: false)"}
362
+ },
363
+ edit,
364
+ ),
365
+ "search": (
366
+ "Search for files using a glob pattern",
367
+ {
368
+ "pat": {"type": "string", "description": "Glob pattern to match file paths (e.g. '**/*.py')"},
369
+ "path": {"type": "string?", "description": "Directory to start search from (default: current directory)"}
370
+ },
371
+ search,
372
+ ),
373
+ "grep": (
374
+ "Search file contents recursively using a regular expression pattern",
375
+ {
376
+ "pat": {
377
+ "type": "string",
378
+ "description": "Regular expression pattern to search for (Python regex syntax)"},
379
+ "path": {
380
+ "type": "string?",
381
+ "description": "Search directory to recursively (defaults to current working directory if omitted)"}
382
+ },
383
+ grep,
384
+ ),
385
+ "bash": (
386
+ "Execute a shell command and return its stdout/stderr output (timeout after 60s)",
387
+ {
388
+ "cmd": {"type": "string", "description": "The shell command to execute, e.g., 'ls -la' or 'git status'"}
389
+ },
390
+ bash,
391
+ ),
392
+ "attempt_completion": (
393
+ "Indicate that the task is complete and provide the final result/answer to the user",
394
+ {
395
+ "result": {"type": "string", "description": "The final result or summary of the completed task"}
396
+ },
397
+ attempt_completion,
398
+ ),
399
+ }
400
+
401
+
402
+ def tool_schema():
403
+ result = []
404
+ for name, (description, params, _fn) in TOOLS.items():
405
+ properties = {}
406
+ required = []
407
+ for param_name, param_info in params.items():
408
+ param_type = param_info['type']
409
+ is_optional = param_type.endswith("?")
410
+ base_type = param_type.rstrip("?")
411
+ properties[param_name] = {
412
+ "type": "integer" if base_type == "number" else base_type, "description": param_info['description']
413
+ }
414
+ if not is_optional:
415
+ required.append(param_name)
416
+ result.append(
417
+ {
418
+ "type": "function",
419
+ "function": {
420
+ "name": name,
421
+ "description": description,
422
+ "parameters": {
423
+ "type": "object",
424
+ "properties": properties,
425
+ "required": required,
426
+ }
427
+ }
428
+ }
429
+ )
430
+ return result
431
+
432
+
433
+ # --- Context manager: () ---
434
+ class ContextManager:
435
+ def __init__(self):
436
+ self.messages: List[Dict] = []
437
+ self.white_tool_list = []
438
+
439
+ self.auto_compact_threshold = int(MANGO_MAX_CONTEXT * 0.8)
440
+ self.auto_compact_disabled = False
441
+ self.continuous_failures = 0
442
+ self.max_failures = 3
443
+
444
+ def __len__(self): return len(self.messages)
445
+
446
+ def disabled_compact(self): self.auto_compact_disabled = True
447
+
448
+ def enabled_compact(self): self.auto_compact_disabled = False
449
+
450
+ def set_max_failures(self, n: int = 3): self.max_failures = n
451
+
452
+ def clear(self): self.messages = []
453
+
454
+ def append_system(self, content: str): self.messages.append({"role": "system", "content": content})
455
+
456
+ def append_user(self, content: str):
457
+ self.messages.append({"role": "user", "content": content, "ts": int(time.time())})
458
+
459
+ def append_assistant(self, content: dict):
460
+ content.update({"ts": int(time.time())})
461
+ self.messages.append(content)
462
+
463
+ def append_tool(self, tool_call_id: str, content: str):
464
+ self.messages.append({"role": "tool", "tool_call_id": tool_call_id, "content": content, "ts": int(time.time())})
465
+
466
+ def load(self, persist_file: str):
467
+ if os.path.exists(persist_file):
468
+ try:
469
+ with open(persist_file, "r", encoding="utf-8") as f:
470
+ self.messages = json.load(f)
471
+ except (json.JSONDecodeError, IOError) as e:
472
+ self.backup(persist_file) # 备份损坏会话文件
473
+ self.messages = [] # 清空消息列表,使后续流程以全新会话开始
474
+ console.error(f"session.json file is corrupted ({e}). "
475
+ f"The corrupted file has been backed up and a new session.json has been generated.")
476
+
477
+ def save(self, persist_file: str):
478
+ with open(persist_file, "w", encoding="utf-8") as fp:
479
+ fp.write(json.dumps(self.messages, indent=2, ensure_ascii=False))
480
+
481
+ @staticmethod
482
+ def backup(persist_file: str):
483
+ backup_path = persist_file + f".{str(int(time.time()))}.backup" # 备份会话文件
484
+ if os.path.exists(persist_file):
485
+ try:
486
+ os.rename(persist_file, backup_path)
487
+ console.warning(f"Session file backed up to {backup_path}")
488
+ except Exception as e:
489
+ console.warning(f"Failed to backup corrupted session file: {e}")
490
+
491
+ def get_messages(self) -> List[Dict[str, Any]]: return self.messages
492
+
493
+ def get_latest(self, n: int = 10) -> List[Dict]: return self.messages[-n:]
494
+
495
+ @staticmethod
496
+ def estimated_tokens(msg: Dict[str, Any]) -> int: # token 估算 (粗略)
497
+ content_len = len(msg.get("content", ""))
498
+ return content_len // 4 + 4
499
+
500
+ def total_tokens(self) -> int: return sum(self.estimated_tokens(m) for m in self.messages)
501
+
502
+ def auto_compact_if_needed(self):
503
+ if self.auto_compact_disabled:
504
+ return
505
+ if self.total_tokens() < self.auto_compact_threshold:
506
+ return
507
+ if self.continuous_failures >= self.max_failures:
508
+ return
509
+
510
+ try: # 尝试会话记忆压缩
511
+ success = self.session_memory_compact()
512
+ if success and self.total_tokens() < self.auto_compact_threshold:
513
+ self.continuous_failures = 0
514
+ return
515
+ except Exception as e:
516
+ self.continuous_failures += 1
517
+
518
+ try: # 回退传统压缩
519
+ self.compact_conversation()
520
+ self.continuous_failures = 0
521
+ except Exception as e:
522
+ self.continuous_failures += 1
523
+
524
+ def micro_compact(self, max_age_seconds: int = 21_600):
525
+ """ 扫描消息数组,查找来自可压缩工具白名单的 tool_result 块,并将其内容替换为 <Old tool result content cleared> """
526
+ now = int(time.time())
527
+ for m in self.messages: # 如果是工具消息且很旧 → 用占位符替换
528
+ if m.get("role") == "tool" and now - m.get("ts", now) > max_age_seconds:
529
+ m["content"] = "<Old tool result content expired(6hours)>"
530
+
531
+ def session_memory_compact(self, retain_count: int = 100) -> bool:
532
+ """ 保留最近用户 + 助手消息,剥离旧工具结果, 返回: True 压缩成功 """
533
+ new_msgs = []
534
+ for m in self.messages:
535
+ if m.get("role") == "system": # 先保留 system 消息
536
+ new_msgs.append(copy.deepcopy(m))
537
+
538
+ non_system = [m for m in self.messages if m.get("role") != "system"]
539
+ recent_msgs = non_system[-retain_count:]
540
+ for m in recent_msgs:
541
+ if m.get("role") == "tool":
542
+ m = copy.deepcopy(m)
543
+ m["content"] = "<Old tool result content compacted>"
544
+ new_msgs.append(m)
545
+ self.messages = new_msgs
546
+ return True
547
+
548
+ def compact_conversation(self):
549
+ """ 剥离大附件, 工具输出等内容,用占位符替代旧内容,保证 token 降到阈值以下 """
550
+ new_msgs = []
551
+ for m in self.messages:
552
+ m_copy = copy.deepcopy(m)
553
+ role = m_copy.get("role")
554
+ if role == "tool" and len(m_copy.get("content", "")) > 200:
555
+ m_copy["content"] = "<Old tool result content removed>"
556
+ elif role == "assistant":
557
+ if len(m_copy.get("content", "")) > 500:
558
+ m_copy["content"] = "<Old assistant content removed>"
559
+ if len(m_copy.get("reasoning_content", "")) > 500:
560
+ m_copy["reasoning_content"] = "<Old assistant reasoning_content removed>"
561
+ new_msgs.append(m_copy)
562
+
563
+ systems = [m for m in new_msgs if m.get("role") == "system"]
564
+ others = [m for m in new_msgs if m.get("role") != "system"]
565
+ while sum(self.estimated_tokens(m) for m in systems + others) > self.auto_compact_threshold: # 如果还是超长, 从头删除旧消息
566
+ if not others: # 防止无限循环和 IndexError
567
+ break
568
+ others.pop(0)
569
+
570
+ self.messages = systems + others
571
+
572
+ def full_compact(self): # 手动执行,调用模型进行大规模的摘要生成,后续实现
573
+ pass
574
+
575
+ def prepare_for_api(self):
576
+ self.micro_compact()
577
+ before = self.total_tokens()
578
+ self.auto_compact_if_needed()
579
+ after = self.total_tokens()
580
+ if before > after:
581
+ console.compact_status(
582
+ before_tokens=before, after_tokens=after, max_context=MANGO_MAX_CONTEXT, strategy="auto")
583
+ return self.get_messages()
584
+
585
+
586
+ def chat_completion(messages: List[Dict[str, str]], timeout: int = 60, max_retries: int = 3):
587
+ extra_body = {"thinking": {"type": "enabled"}}
588
+ body = {
589
+ "model": MANGO_MODEL, "messages": messages, "stream": False, "extra_body": extra_body, "tools": tool_schema()
590
+ }
591
+ headers = {"Content-Type": "application/json", "Authorization": f"Bearer {MANGO_KEY}"}
592
+ last_exception = None
593
+ request = urllib.request.Request(MANGO_API_URL, data=json.dumps(body).encode(), headers=headers, method="POST", )
594
+ for attempt in range(max_retries + 1):
595
+ try:
596
+ with urllib.request.urlopen(request, timeout=timeout) as response:
597
+ raw_data = response.read().decode("utf-8")
598
+ return json.loads(raw_data)
599
+ except urllib.error.HTTPError as e:
600
+ if e.code >= 500 or e.code == 429:
601
+ last_exception = e
602
+ else:
603
+ raise
604
+ except (urllib.error.URLError, json.JSONDecodeError) as e:
605
+ last_exception = e
606
+ except Exception as e:
607
+ raise e
608
+
609
+ if attempt < max_retries:
610
+ delay = 1 * (2 ** attempt)
611
+ console.warning(
612
+ f"Request failed (attempt {attempt + 1}/{max_retries + 1}), retrying in {delay:.1f}s: {last_exception}"
613
+ )
614
+ time.sleep(delay)
615
+ else:
616
+ break # 所有重试均已耗尽,跳出循环并抛出最后一个异常
617
+ raise last_exception
618
+
619
+
620
+ def parse_chat_completion(response: Dict[str, Any]) -> Dict[str, Any]:
621
+ choices = response.get("choices", [])
622
+ if not choices:
623
+ return {
624
+ "finish_reason": None,
625
+ "raw_message": {},
626
+ "content": "",
627
+ "reasoning_content": None,
628
+ "tool_calls": [],
629
+ "has_tool_calls": False,
630
+ "model": response.get("model", ""),
631
+ "usage": response.get("usage", {})
632
+ }
633
+
634
+ choice = choices[0]
635
+ message = choice.get("message", {})
636
+ finish_reason = choice.get("finish_reason", "stop")
637
+ content = message.get("content", "") or "" # 提取文本内容
638
+ reasoning_content = message.get("reasoning_content", "") or "" # 提取推理内容
639
+ raw_tool_calls = message.get("tool_calls", []) # 处理工具调用
640
+ tool_calls = []
641
+ for tc in raw_tool_calls:
642
+ function = tc.get("function", {})
643
+ args_str = function.get("arguments", "{}")
644
+ try:
645
+ arguments = json.loads(args_str) if args_str else {}
646
+ except json.JSONDecodeError as e:
647
+ raise json.JSONDecodeError(f"工具调用参数响应非 JSON: {args_str[:200]}", e.doc, e.pos, ) from e
648
+ tool_calls.append({
649
+ "name": function.get("name", ""),
650
+ "arguments": arguments,
651
+ "id": tc.get("id", ""),
652
+ "type": tc.get("type", "function")
653
+ })
654
+
655
+ return {
656
+ "finish_reason": finish_reason,
657
+ "raw_message": message,
658
+ "content": content,
659
+ "reasoning_content": reasoning_content,
660
+ "tool_calls": tool_calls,
661
+ "has_tool_calls": bool(tool_calls),
662
+ "model": response.get("model", ""),
663
+ "usage": response.get("usage", {})
664
+ }
665
+
666
+
667
+ def run_tool(tool_name, tool_args):
668
+ try:
669
+ arg_preview = str(list(tool_args.values())[0])[:50]
670
+ console.tool_call(tool_name, arg_preview)
671
+
672
+ if tool_name == "edit":
673
+ console.diff(old=tool_args["old"], new=tool_args["new"])
674
+ if console.prompt_apply(f"Apply changes to {tool_args['path']}?"):
675
+ result = TOOLS[tool_name][2](tool_args)
676
+ else:
677
+ result = "error: User denied edit"
678
+ elif tool_name == "bash":
679
+ is_dangerous, reason = _check_command_safety(tool_args["cmd"])
680
+ if is_dangerous and not console.prompt_apply(f"Execute dangerous cmd ({reason})? {tool_args['cmd']}"):
681
+ result = "error: User denied dangerous command"
682
+ else:
683
+ console.start_spinner()
684
+ result = TOOLS[tool_name][2](tool_args)
685
+ console.end_spinner()
686
+ else:
687
+ console.start_spinner()
688
+ result = TOOLS[tool_name][2](tool_args)
689
+ console.end_spinner()
690
+
691
+ if not result:
692
+ print(f" {DIM}⎿ (no output){RESET}") # 空结果直接提示
693
+ else:
694
+ result_lines = result.split("\n")
695
+ max_preview_lines = 20 # 最多展示前3行
696
+ max_line_width = 100 # 单行最大宽度,超出截断
697
+ lines_to_show = result_lines[:max_preview_lines]
698
+ preview_lines = []
699
+ for line in lines_to_show:
700
+ if len(line) > max_line_width:
701
+ line = line[:max_line_width - 3] + "..."
702
+ preview_lines.append(line)
703
+ if len(result_lines) > max_preview_lines:
704
+ more = len(result_lines) - max_preview_lines
705
+ preview_lines.append(f"... and {more} more line{'s' if more > 1 else ''}")
706
+ prefix = f" {DIM}⎿ "
707
+ for i, line in enumerate(preview_lines):
708
+ if i == 0:
709
+ print(f"{prefix}{line}{RESET}")
710
+ else:
711
+ print(f" {DIM}{line}{RESET}") # 后续行与第一行内容对齐(5个空格 + 颜色)
712
+
713
+ console.tool_result(True)
714
+ return result
715
+ except Exception as err:
716
+ return f"error: {err}"
717
+
718
+
719
+ class SystemPrompt:
720
+ """ 分层装配的提示词运行时. 可根据会话状态、记忆、环境变量等动态生成完整的 system prompt."""
721
+ def __init__(self):
722
+ self.sections = [] # 有序的 section 列表,每个元素为 (section_name, content)
723
+ self._init_default_sections() # 默认加载基础 sections
724
+
725
+ def _init_default_sections(self):
726
+ self.sections.append(("base_intro", self._build_base_intro()))
727
+ self.sections.append(("tool_guidance", self._build_tool_guidance()))
728
+ self.sections.append(("safety", self._build_safety()))
729
+ self.sections.append(("language", self._build_language()))
730
+ self.sections.append(("memory", self._build_memory()))
731
+ self.sections.append(("environment", self._build_environment()))
732
+
733
+ @staticmethod
734
+ def _build_base_intro() -> list[str]: # 基础身份和核心约束
735
+ return [
736
+ "You are an interactive agent that helps users with software engineering tasks. Use the instructions "
737
+ "below and the tools available to you to assist the user.",
738
+ "",
739
+ "IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are "
740
+ "for helping the user with programming. For file paths, always prefer absolute paths when possible. If "
741
+ "you need to read a directory, use the bash tool (ls) because the read tool cannot read directories.",
742
+ ]
743
+
744
+ @staticmethod
745
+ def _build_tool_guidance() -> list[str]: # 工具使用指导
746
+ return [
747
+ "## Tool Selection Guidelines",
748
+ "You have access to the following dedicated tools: read/write/edit/search/grep/bash/attempt_completion.",
749
+ "",
750
+ "- For reading files: use **read**.",
751
+ "- For writing or overwriting files: use **write**.",
752
+
753
+ "- For replacing exact strings within a file: use **edit**. Prefer edit when you only need to change a "
754
+ "small portion of a file.",
755
+
756
+ "- For searching file names/paths: use **search** with a glob pattern.",
757
+ "- For searching file content with regex: use **grep**.",
758
+
759
+ "- Only use **bash** when no dedicated tool can accomplish the task, or for system commands (e.g., "
760
+ "installing packages, running tests, managing directories).",
761
+
762
+ "- Always use **attempt_completion** to present the final result to the user.",
763
+ "- When using edit, ensure the `old` string is unique or set `all` to true.",
764
+ ]
765
+
766
+ @staticmethod
767
+ def _build_environment() -> list[str]: # 动态环境信息注入
768
+ os_info = f"{platform.system()} {platform.release()} ({platform.machine()})"
769
+ python_ver = sys.version.split()[0]
770
+
771
+ return [
772
+ "## Environment",
773
+ "",
774
+ f"- Current date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
775
+ f"- Working directory: {project_root}",
776
+ f"- Operating system: {os_info}",
777
+ f"- Python version: {python_ver}",
778
+ f"- Shell: {os.environ.get('SHELL', 'unknown')}",
779
+ ]
780
+
781
+ @staticmethod
782
+ def _build_language() -> list[str]:
783
+ """语言偏好(可通过环境变量 MANGO_LANG 配置), 若未设置则默认使用 English. """
784
+ lang = os.environ.get("MANGO_LANG", "zh")
785
+ if lang.lower() == "chinese" or lang.lower() == "zh":
786
+ return ["## Language", f"You should communicate with the user in Chinese (Simplified)."]
787
+ return ["## Language", f"You should communicate with the user in {lang}."]
788
+
789
+ @staticmethod
790
+ def _build_memory() -> list[str]:
791
+ """ 记忆加载, .mangocli/MEMORY.md 存在,则将其内容作为记忆注入. """
792
+ memory_path = os.path.join(project_root, ".mangocli", "MANGO.md")
793
+ if not os.path.exists(memory_path):
794
+ return ["## Memory", "No persistent memory available."]
795
+ if os.path.getsize(memory_path) == 0:
796
+ return ["## Memory", "No persistent memory available."]
797
+ content = open(memory_path, "r", encoding="utf-8").readlines()
798
+ return [f"## Persisted Memory", ""] + content
799
+
800
+ @staticmethod
801
+ def _build_safety() -> list[str]:
802
+ """ 安全边界提示. 要求模型在执行前对危险命令进行确认,并遵守工具的安全检查。"""
803
+ return [
804
+ "## Safety",
805
+ "",
806
+ "- Before executing any command that modifies the file system, deletes files, changes permissions, "
807
+ "or performs system administration, you MUST ensure the command is safe and the user has confirmed if "
808
+ "necessary.",
809
+ "- Do not attempt to access files outside the project root unless explicitly required and confirmed by "
810
+ "the user.",
811
+ ]
812
+
813
+ def assemble(self) -> str: # 将所有 section 按顺序拼接成完整的 system prompt。
814
+ _basic = []
815
+ for _, content in self.sections:
816
+ _basic.append("\n".join(content))
817
+ return "\n\n".join(_basic)
818
+
819
+
820
+ def main():
821
+ initialize_system()
822
+
823
+ print(f"{BOLD}Mango Cli v{__version__}{RESET} | {DIM}{MANGO_MODEL} | {project_root}{RESET}\n")
824
+
825
+ ctx_file_path = os.path.join(session_dir, "session.json")
826
+ ctx = ContextManager()
827
+ ctx.enabled_compact()
828
+ ctx.set_max_failures()
829
+ ctx.load(ctx_file_path)
830
+
831
+ prompt_runtime = SystemPrompt()
832
+ system_prompt = prompt_runtime.assemble()
833
+ if len(ctx) == 0: # 刚初始化的ctx才需要system prompt
834
+ ctx.append_system(system_prompt)
835
+
836
+ while True:
837
+ try:
838
+ console.separator()
839
+ user_input = input(f"{BOLD}{BLUE}❯{RESET} ").strip()
840
+ if not user_input:
841
+ continue
842
+ if user_input.startswith('/'):
843
+ if user_input.strip() in ("/q", "/quit"): # 退出
844
+ break
845
+ if user_input.strip() in ("/c", "/compact"): # 手动触发 full compact
846
+ continue
847
+ if user_input.strip() in ("/n", "/new"): # 创建新的session
848
+ ctx.backup(ctx_file_path)
849
+ ctx.clear()
850
+ ctx.append_system(system_prompt)
851
+ console.success("New session created.")
852
+ continue
853
+ if user_input.strip() in ("/h", "/help"):
854
+ helper()
855
+ continue
856
+
857
+ ctx.append_user(user_input)
858
+
859
+ # agentic loop: keep calling API until no more tool calls
860
+ iteration = 0
861
+ while True:
862
+ console.start_spinner("Request...")
863
+ response = parse_chat_completion(chat_completion(ctx.prepare_for_api()))
864
+ console.end_spinner()
865
+ ctx.append_assistant(response["raw_message"])
866
+
867
+ iteration += 1
868
+ console.token_usage(
869
+ iteration=iteration,
870
+ input_tokens=response["usage"]["prompt_tokens"],
871
+ output_tokens=response["usage"]["completion_tokens"],
872
+ context_tokens=ctx.total_tokens(),
873
+ max_context=MANGO_MAX_CONTEXT)
874
+
875
+ if response["content"]:
876
+ console.output(response["content"])
877
+ if response["reasoning_content"]:
878
+ console.thinking(response["reasoning_content"])
879
+
880
+ if response["finish_reason"] == "stop":
881
+ break # 模型明确表示结束,退出循环
882
+ if response["has_tool_calls"]:
883
+ tool_calls = response["tool_calls"]
884
+ for tool in tool_calls:
885
+ tool_name, tool_args = tool["name"], tool["arguments"]
886
+ result = run_tool(tool_name, tool_args)
887
+ ctx.append_tool(tool["id"], result)
888
+ if any(tc["name"] == "attempt_completion" for tc in tool_calls):
889
+ break
890
+ else:
891
+ break
892
+ ctx.save(ctx_file_path)
893
+ except (KeyboardInterrupt, EOFError):
894
+ break
895
+ except Exception as err:
896
+ print(f"{RED}⏺ Error: {err}{RESET}")
897
+ finally:
898
+ ctx.save(ctx_file_path)
899
+
900
+
901
+ if __name__ == '__main__':
902
+ main()
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
@@ -0,0 +1,149 @@
1
+ Metadata-Version: 2.2
2
+ Name: mangopi-cli
3
+ Version: 0.1.1
4
+ Summary: Mango Pi Cli
5
+ Author: moofs
6
+ License: Apache License 2.0
7
+ Project-URL: Homepage, https://github.com/w4n9H/mangocli
8
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
9
+ Classifier: Programming Language :: Python :: 3.8
10
+ Classifier: Programming Language :: Python :: 3.9
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Requires-Python: >=3.8
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+
18
+ # Mango CLI
19
+
20
+ > A lightweight AI coding assistant running directly in your terminal.
21
+
22
+ Mango CLI is a zero-dependency, local-first AI coding assistant inspired by Claude Code.
23
+
24
+ It supports:
25
+
26
+ * AI-powered coding workflows
27
+ * File editing and shell execution
28
+ * Tool calling
29
+ * Context-aware conversation management
30
+ * Automatic context compacting
31
+
32
+ All with instant startup and no heavy framework dependencies.
33
+
34
+ ---
35
+
36
+ # Features
37
+
38
+ * Zero dependency (Python standard library only)
39
+ * Instant startup
40
+ * Claude Code–style terminal UX
41
+ * Built-in file and shell tools
42
+ * Automatic context compacting
43
+ * Local session persistence
44
+ * Fully hackable and easy to extend
45
+
46
+ ---
47
+
48
+ # Installation
49
+
50
+ ## From PyPI
51
+
52
+ ```bash
53
+ pip install mangocli
54
+ ```
55
+
56
+ Start Mango CLI:
57
+
58
+ ```bash
59
+ mango-cli
60
+ ```
61
+
62
+ ---
63
+
64
+ ## From Source
65
+
66
+ ```bash
67
+ git clone git@github.com:w4n9H/mangocli.git
68
+ cd mangocli
69
+ python mango_cli.py
70
+ ```
71
+
72
+ ---
73
+
74
+ # Configuration
75
+
76
+ Set your API configuration:
77
+
78
+ ```bash
79
+ export MANGO_KEY="your_api_key"
80
+ export MANGO_API_URL="https://api.deepseek.com/chat/completions"
81
+ export MANGO_MODEL="deepseek-v4-flash"
82
+ ```
83
+
84
+ Optional:
85
+
86
+ ```bash
87
+ export MANGO_MAX_CONTEXT=1000000
88
+ export MANGO_LANG=zh
89
+ ```
90
+
91
+ ---
92
+
93
+ # Usage
94
+
95
+ Start the CLI:
96
+
97
+ ```bash
98
+ mango-cli
99
+ ```
100
+
101
+ or:
102
+
103
+ ```bash
104
+ python mango_cli.py
105
+ ```
106
+
107
+ Built-in commands:
108
+
109
+ | Command | Description |
110
+ |---------|-----------------|
111
+ | `/q` | Quit |
112
+ | `/n` | New session |
113
+ | `/c` | Compact session |
114
+ | `/h` | Help |
115
+
116
+ ---
117
+
118
+ # Built-in Tools
119
+
120
+ * `read`
121
+ * `write`
122
+ * `edit`
123
+ * `search`
124
+ * `grep`
125
+ * `bash`
126
+
127
+ Mango CLI can autonomously inspect files, modify code, search projects, and execute shell commands.
128
+
129
+ ---
130
+
131
+ # Philosophy
132
+
133
+ Mango CLI focuses on:
134
+
135
+ * fast startup
136
+ * zero dependency
137
+ * local-first workflows
138
+ * terminal-native AI interaction
139
+ * lightweight runtime design
140
+
141
+ No Electron, Docker, Redis, or heavyweight AI frameworks.
142
+
143
+ Just a fast and hackable AI coding assistant for the terminal.
144
+
145
+ ---
146
+
147
+ # License
148
+
149
+ Apache License 2.0
@@ -0,0 +1,7 @@
1
+ mango_cli.py,sha256=yXWIBJKTNVoQEdQYGEgDmJ2heOY4VHl7ZLH4re69YxE,35822
2
+ mangopi_cli-0.1.1.dist-info/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
3
+ mangopi_cli-0.1.1.dist-info/METADATA,sha256=nbwc0HfHuW7mnC6-Zp9gOJ2BTPh-Hy8R2jSrxs8z1FE,2528
4
+ mangopi_cli-0.1.1.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
5
+ mangopi_cli-0.1.1.dist-info/entry_points.txt,sha256=lOBRtOJ_3oD3oZd8zJ8tYmQCfKhxpuBVYnHFxULMyqY,44
6
+ mangopi_cli-0.1.1.dist-info/top_level.txt,sha256=BRbs5YtcUKqii3EUXZQIglwjDS5wELo6vgnYjrDqgOY,10
7
+ mangopi_cli-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.8.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mango-pi = mango_cli:main
@@ -0,0 +1 @@
1
+ mango_cli