NeoShell 2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
neoshell.py ADDED
@@ -0,0 +1,1403 @@
1
+ #!/usr/bin/env python3
2
+ """neoshell 2 - a CMD-style shell written in Python, with live syntax highlighting.
3
+
4
+ pip install prompt_toolkit psutil
5
+ python neoshell.py interactive shell
6
+ python neoshell.py -c "dir /b & ver" run one line and exit
7
+ neoshell --install / --uninstall add / remove the Windows Terminal profile
8
+ neoshell --no-install start without auto-registering the profile
9
+
10
+ When built into neoshell.exe (see build_exe.bat) it registers its own Windows
11
+ Terminal profile, with icon and color scheme, the first time it runs.
12
+
13
+ Uses CMD command names and syntax (dir, copy, del, tasklist, findstr, %VAR%,
14
+ &, &&, ||, if exist ...). On Windows, any CMD command not built in here
15
+ (assoc, mklink, vol, ...) is passed through to the real cmd.exe.
16
+ """
17
+ import ast
18
+ import contextlib
19
+ import datetime
20
+ import fnmatch
21
+ import getpass
22
+ import glob
23
+ import io
24
+ import json
25
+ import operator
26
+ import os
27
+ import platform
28
+ import random
29
+ import re
30
+ import shutil
31
+ import socket
32
+ import subprocess
33
+ import sys
34
+ import time
35
+
36
+ IS_WIN = os.name == "nt"
37
+ if IS_WIN:
38
+ os.system("") # turn on ANSI escape support in the console
39
+
40
+ try:
41
+ from prompt_toolkit import PromptSession
42
+ from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
43
+ from prompt_toolkit.completion import Completer, Completion
44
+ from prompt_toolkit.history import FileHistory
45
+ from prompt_toolkit.lexers import Lexer
46
+ HAVE_PT = True
47
+ except ImportError:
48
+ HAVE_PT = False
49
+ Lexer = Completer = object
50
+ try:
51
+ import psutil
52
+ except ImportError:
53
+ psutil = None
54
+
55
+ VERSION = "2.2"
56
+ HIST = os.path.expanduser("~/.neoshell_history")
57
+ aliases = {} # doskey macros
58
+ dir_stack = [] # pushd / popd
59
+ last_status = 0 # %ERRORLEVEL%
60
+ last_dur = 0.0
61
+ old_dir = None
62
+ session = None
63
+ _which_cache = {}
64
+ _prog_cache = None
65
+
66
+ WIN_INTERNAL = {"assoc", "attrib", "chcp", "cmd", "ftype", "label", "mklink", "vol",
67
+ "verify", "for", "call", "ipconfig", "ping", "netstat", "systeminfo"}
68
+ GLOB_CMDS = {"type", "copy", "xcopy", "move", "del", "erase", "ren", "rename"}
69
+
70
+
71
+ class Op(str):
72
+ """An unquoted operator token such as | & && || > >> <"""
73
+
74
+
75
+ class Q(str):
76
+ """A token that was written in double quotes (never glob-expanded)."""
77
+
78
+
79
+ # ---------------------------------------------------------------- helpers
80
+ def paint(s, code):
81
+ return f"\033[{code}m{s}\033[0m" if sys.stdout.isatty() else s
82
+
83
+
84
+ EXT_COLORS = {".exe": "32", ".bat": "32", ".cmd": "32", ".com": "32", ".sh": "32",
85
+ ".py": "33", ".js": "33", ".zip": "31", ".7z": "31", ".rar": "31",
86
+ ".gz": "31", ".png": "35", ".jpg": "35", ".jpeg": "35", ".gif": "35",
87
+ ".mp3": "35", ".mp4": "35"}
88
+
89
+
90
+ def colorize_name(path, name):
91
+ if os.path.isdir(path):
92
+ return paint(name, "1;34")
93
+ code = EXT_COLORS.get(os.path.splitext(name)[1].lower())
94
+ return paint(name, code) if code else name
95
+
96
+
97
+ def env_get(name):
98
+ if name in os.environ:
99
+ return os.environ[name]
100
+ up = name.upper()
101
+ return next((v for k, v in os.environ.items() if k.upper() == up), None)
102
+
103
+
104
+ def expand_vars(line):
105
+ def sub(m):
106
+ name = m.group(1)
107
+ u = name.upper()
108
+ if u == "ERRORLEVEL": return str(last_status)
109
+ if u == "CD": return os.getcwd()
110
+ if u == "DATE": return datetime.date.today().strftime("%m/%d/%Y")
111
+ if u == "TIME": return datetime.datetime.now().strftime("%H:%M:%S.%f")[:-4]
112
+ if u == "RANDOM": return str(random.randint(0, 32767))
113
+ v = env_get(name)
114
+ return v if v is not None else m.group(0)
115
+ return re.sub(r"%([^%\s]+)%", sub, line)
116
+
117
+
118
+ def tokenize(line):
119
+ """Split a CMD line. Only double quotes quote; backslashes are literal."""
120
+ toks, cur, quoted, inq, i = [], [], False, False, 0
121
+
122
+ def flush():
123
+ nonlocal cur, quoted
124
+ if cur or quoted:
125
+ s = "".join(cur)
126
+ toks.append(Q(s) if quoted else s)
127
+ cur, quoted = [], False
128
+
129
+ while i < len(line):
130
+ ch = line[i]
131
+ if ch == '"':
132
+ inq, quoted = not inq, True
133
+ elif inq:
134
+ cur.append(ch)
135
+ elif ch.isspace():
136
+ flush()
137
+ elif line[i:i + 2] in ("&&", "||", ">>"):
138
+ flush(); toks.append(Op(line[i:i + 2])); i += 1
139
+ elif ch in "&|<>":
140
+ flush(); toks.append(Op(ch))
141
+ else:
142
+ cur.append(ch)
143
+ i += 1
144
+ flush()
145
+ return toks
146
+
147
+
148
+ def expand_aliases(tokens):
149
+ out, at_cmd = [], True
150
+ for t in tokens:
151
+ if at_cmd and not isinstance(t, Op) and str(t).lower() in aliases:
152
+ out.extend(tokenize(aliases[str(t).lower()]))
153
+ else:
154
+ out.append(t)
155
+ at_cmd = isinstance(t, Op) and t in ("&&", "||", "&", "|")
156
+ return out
157
+
158
+
159
+ def split_flags(args):
160
+ """Separate /flags from other arguments."""
161
+ flags, rest = [], []
162
+ for a in args:
163
+ if re.fullmatch(r"/[A-Za-z?][A-Za-z0-9:,\-]*", a) and not (len(a) > 3 and os.path.exists(a)):
164
+ flags.append(a.lower())
165
+ else:
166
+ rest.append(a)
167
+ return flags, rest
168
+
169
+
170
+ def human(n):
171
+ for u in ("B", "K", "M", "G", "T"):
172
+ if n < 1024:
173
+ return f"{n:.0f}{u}"
174
+ n /= 1024
175
+ return f"{n:.1f}P"
176
+
177
+
178
+ def git_branch():
179
+ d = os.getcwd()
180
+ while True:
181
+ g = os.path.join(d, ".git")
182
+ if os.path.isdir(g):
183
+ try:
184
+ with open(os.path.join(g, "HEAD")) as f:
185
+ head = f.read().strip()
186
+ return head[16:] if head.startswith("ref: refs/heads/") else head[:7]
187
+ except OSError:
188
+ return ""
189
+ parent = os.path.dirname(d)
190
+ if parent == d:
191
+ return ""
192
+ d = parent
193
+
194
+
195
+ # --- tiny safe arithmetic for `set /a`
196
+ def _shl(a, b):
197
+ if not 0 <= b < 64:
198
+ raise ValueError
199
+ return a << b
200
+
201
+
202
+ _BIN = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
203
+ ast.Div: lambda a, b: int(a / b), ast.Mod: operator.mod, ast.LShift: _shl,
204
+ ast.RShift: operator.rshift, ast.BitAnd: operator.and_,
205
+ ast.BitOr: operator.or_, ast.BitXor: operator.xor}
206
+
207
+
208
+ def calc(expr):
209
+ def ev(n):
210
+ if isinstance(n, ast.Expression): return ev(n.body)
211
+ if isinstance(n, ast.Constant) and isinstance(n.value, int): return n.value
212
+ if isinstance(n, ast.Name):
213
+ v = env_get(n.id)
214
+ return int(v) if v and re.fullmatch(r"-?\d+", v) else 0
215
+ if isinstance(n, ast.BinOp) and type(n.op) in _BIN:
216
+ return _BIN[type(n.op)](ev(n.left), ev(n.right))
217
+ if isinstance(n, ast.UnaryOp):
218
+ v = ev(n.operand)
219
+ if isinstance(n.op, ast.USub): return -v
220
+ if isinstance(n.op, ast.UAdd): return v
221
+ if isinstance(n.op, ast.Invert): return ~v
222
+ raise ValueError("bad expression")
223
+ return ev(ast.parse(expr.strip(), mode="eval"))
224
+
225
+
226
+ # ---------------------------------------------------------------- builtins
227
+ BUILTINS, HELP = {}, {}
228
+
229
+
230
+ def builtin(*names, doc=""):
231
+ def deco(f):
232
+ for n in names:
233
+ BUILTINS[n] = f
234
+ HELP[names[0]] = doc
235
+ return f
236
+ return deco
237
+
238
+
239
+ @builtin("cd", "chdir", doc="Show or change directory. cd - goes back")
240
+ def b_cd(a):
241
+ global old_dir
242
+ _, rest = split_flags(a)
243
+ if not rest:
244
+ print(os.getcwd()); return 0
245
+ target = " ".join(rest)
246
+ if target == "-" and old_dir:
247
+ target = old_dir
248
+ here = os.getcwd()
249
+ try:
250
+ os.chdir(target)
251
+ old_dir = here
252
+ return 0
253
+ except OSError:
254
+ print("The system cannot find the path specified."); return 1
255
+
256
+
257
+ @builtin("pushd", doc="Save the current directory and change to another")
258
+ def b_pushd(a):
259
+ here = os.getcwd()
260
+ rc = b_cd(a)
261
+ if rc == 0 and a:
262
+ dir_stack.append(here)
263
+ return rc
264
+
265
+
266
+ @builtin("popd", doc="Return to the last pushd directory")
267
+ def b_popd(a):
268
+ if not dir_stack:
269
+ return 0
270
+ return b_cd([dir_stack.pop()])
271
+
272
+
273
+ @builtin("cls", doc="Clear the screen")
274
+ def b_cls(a):
275
+ print("\033[2J\033[3J\033[H", end="", flush=True); return 0
276
+
277
+
278
+ @builtin("echo", doc="Print text")
279
+ def b_echo(a):
280
+ print("ECHO is on." if not a else " ".join(a)); return 0
281
+
282
+
283
+ @builtin("title", doc="Set the window title")
284
+ def b_title(a):
285
+ sys.stdout.write(f"\033]0;{' '.join(a)}\007"); sys.stdout.flush(); return 0
286
+
287
+
288
+ @builtin("exit", doc="Quit")
289
+ def b_exit(a):
290
+ _, rest = split_flags(a)
291
+ sys.exit(int(rest[0]) if rest and rest[0].isdigit() else 0)
292
+
293
+
294
+ @builtin("ver", doc="Show version")
295
+ def b_ver(a):
296
+ if IS_WIN:
297
+ print(f"\nMicrosoft Windows [Version {platform.version()}]")
298
+ print(f"neoshell {VERSION} on {platform.system()} {platform.release()}"); return 0
299
+
300
+
301
+ @builtin("hostname", doc="Show computer name")
302
+ def b_hostname(a): print(socket.gethostname()); return 0
303
+
304
+
305
+ @builtin("whoami", doc="Show current user")
306
+ def b_whoami(a):
307
+ print(f"{socket.gethostname()}\\{getpass.getuser()}" if IS_WIN else getpass.getuser()); return 0
308
+
309
+
310
+ @builtin("date", doc="Show the date")
311
+ def b_date(a):
312
+ d = datetime.datetime.now().strftime("%a %m/%d/%Y")
313
+ print(d if "/t" in [x.lower() for x in a] else f"The current date is: {d}"); return 0
314
+
315
+
316
+ @builtin("time", doc="Show the time")
317
+ def b_time(a):
318
+ now = datetime.datetime.now()
319
+ if "/t" in [x.lower() for x in a]:
320
+ print(now.strftime("%I:%M %p"))
321
+ else:
322
+ print("The current time is: " + now.strftime("%H:%M:%S.%f")[:-4])
323
+ return 0
324
+
325
+
326
+ @builtin("dir", doc="List files. /b bare /w wide /s recurse /ad dirs only /o:n|s|d|e sort (-x reverses)")
327
+ def b_dir(a):
328
+ fl, specs = split_flags(a)
329
+ bare, wide, rec, only_dirs = "/b" in fl, "/w" in fl, "/s" in fl, "/ad" in fl
330
+ order = next((f[3:] for f in fl if f.startswith("/o:")), "gn")
331
+ mode = order.lstrip("-g")[:1] or "n"
332
+ rev = order.startswith("-")
333
+
334
+ def key(p):
335
+ try:
336
+ st = os.stat(p)
337
+ except OSError:
338
+ return (False, "")
339
+ k = {"n": os.path.basename(p).lower(), "s": st.st_size, "d": st.st_mtime,
340
+ "e": os.path.splitext(p)[1].lower()}.get(mode, "")
341
+ return (not os.path.isdir(p), k)
342
+
343
+ def show(d, entries):
344
+ if only_dirs:
345
+ entries = [e for e in entries if os.path.isdir(e)]
346
+ entries.sort(key=key, reverse=rev)
347
+ if not bare:
348
+ print(f"\n Directory of {os.path.abspath(d)}\n")
349
+ nf = nd = size = 0
350
+ cells = []
351
+ for e in entries:
352
+ try:
353
+ st = os.stat(e)
354
+ except OSError:
355
+ continue
356
+ name, isdir = os.path.basename(e), os.path.isdir(e)
357
+ if isdir: nd += 1
358
+ else: nf += 1; size += st.st_size
359
+ if bare:
360
+ print(colorize_name(e, e if rec else name))
361
+ elif wide:
362
+ cells.append((e, f"[{name}]" if isdir else name))
363
+ else:
364
+ t = datetime.datetime.fromtimestamp(st.st_mtime).strftime("%m/%d/%Y %I:%M %p")
365
+ col = "<DIR>".ljust(14) if isdir else f"{st.st_size:>14,}"
366
+ print(f"{t} {col} {colorize_name(e, name)}")
367
+ if wide and cells:
368
+ w = max(len(l) for _, l in cells) + 2
369
+ per = max(1, shutil.get_terminal_size().columns // w)
370
+ for i in range(0, len(cells), per):
371
+ print("".join(colorize_name(e, l.ljust(w)) for e, l in cells[i:i + per]))
372
+ if not bare:
373
+ print(f"{nf:>16} File(s) {size:>14,} bytes")
374
+ try:
375
+ free = shutil.disk_usage(d).free
376
+ except OSError:
377
+ free = 0
378
+ print(f"{nd:>16} Dir(s) {free:>14,} bytes free")
379
+
380
+ found = False
381
+ for spec in specs or ["."]:
382
+ if rec:
383
+ base, pat = (spec, "*") if os.path.isdir(spec) else (os.path.dirname(spec) or ".", os.path.basename(spec))
384
+ for root, _, _ in os.walk(base):
385
+ ents = [os.path.join(root, n) for n in os.listdir(root) if fnmatch.fnmatch(n, pat)]
386
+ if ents:
387
+ show(root, ents); found = True
388
+ elif os.path.isdir(spec):
389
+ show(spec, [os.path.join(spec, n) for n in os.listdir(spec)]); found = True
390
+ else:
391
+ hits = glob.glob(spec)
392
+ if hits:
393
+ show(os.path.dirname(spec) or ".", hits); found = True
394
+ if not found:
395
+ print("File Not Found"); return 1
396
+ return 0
397
+
398
+
399
+ @builtin("tree", doc="Draw a directory tree. /f includes files")
400
+ def b_tree(a):
401
+ fl, rest = split_flags(a)
402
+ root, files = (rest[0] if rest else "."), "/f" in fl
403
+ print(os.path.abspath(root))
404
+
405
+ def walk(d, prefix):
406
+ try:
407
+ names = sorted(os.listdir(d), key=str.lower)
408
+ except OSError:
409
+ return
410
+ items = [n for n in names if files or os.path.isdir(os.path.join(d, n))]
411
+ for i, n in enumerate(items):
412
+ last, p = i == len(items) - 1, os.path.join(d, n)
413
+ print(prefix + ("└── " if last else "├── ") + colorize_name(p, n))
414
+ if os.path.isdir(p) and not os.path.islink(p):
415
+ walk(p, prefix + (" " if last else "│ "))
416
+ walk(root, "")
417
+ return 0
418
+
419
+
420
+ @builtin("type", doc="Print file contents")
421
+ def b_type(a):
422
+ _, files = split_flags(a)
423
+ rc = 0
424
+ for f in files:
425
+ try:
426
+ with open(f, encoding="utf-8", errors="replace") as fh:
427
+ sys.stdout.write(fh.read())
428
+ except OSError:
429
+ print("The system cannot find the file specified."); rc = 1
430
+ return rc
431
+
432
+
433
+ @builtin("more", doc="Page through a file or piped text")
434
+ def b_more(a):
435
+ _, files = split_flags(a)
436
+ lines = []
437
+ for f in files:
438
+ try:
439
+ with open(f, encoding="utf-8", errors="replace") as fh:
440
+ lines += fh.read().splitlines()
441
+ except OSError:
442
+ print("Cannot access file " + f); return 1
443
+ if not files:
444
+ lines = sys.stdin.read().splitlines()
445
+ page = max(5, shutil.get_terminal_size().lines - 1)
446
+ for i, ln in enumerate(lines):
447
+ print(ln)
448
+ if sys.stdout.isatty() and (i + 1) % page == 0 and i + 1 < len(lines):
449
+ try:
450
+ if sys.__stdin__.readline().strip().lower() == "q":
451
+ break
452
+ except Exception:
453
+ break
454
+ return 0
455
+
456
+
457
+ @builtin("copy", doc="Copy files")
458
+ def b_copy(a):
459
+ _, args = split_flags(a)
460
+ if len(args) < 2:
461
+ print("The syntax of the command is incorrect."); return 1
462
+ *srcs, dst = args
463
+ if len(srcs) > 1 and not os.path.isdir(dst):
464
+ print("The syntax of the command is incorrect."); return 1
465
+ n, rc = 0, 0
466
+ for s in srcs:
467
+ try:
468
+ shutil.copy2(s, dst); n += 1
469
+ except OSError:
470
+ print("The system cannot find the file specified."); rc = 1
471
+ print(f" {n} file(s) copied.")
472
+ return rc
473
+
474
+
475
+ @builtin("xcopy", doc="Copy files and folders. /e includes subfolders")
476
+ def b_xcopy(a):
477
+ _, args = split_flags(a)
478
+ if len(args) < 2:
479
+ print("Invalid number of parameters"); return 4
480
+ src, dst = args[0], args[1]
481
+ try:
482
+ if os.path.isdir(src):
483
+ shutil.copytree(src, dst, dirs_exist_ok=True)
484
+ else:
485
+ shutil.copy2(src, dst)
486
+ print("File(s) copied"); return 0
487
+ except OSError:
488
+ print(f"File not found - {src}"); return 4
489
+
490
+
491
+ @builtin("move", doc="Move files or folders")
492
+ def b_move(a):
493
+ _, args = split_flags(a)
494
+ if len(args) < 2:
495
+ print("The syntax of the command is incorrect."); return 1
496
+ *srcs, dst = args
497
+ for s in srcs:
498
+ try:
499
+ shutil.move(s, dst)
500
+ except (OSError, shutil.Error):
501
+ print("The system cannot find the file specified."); return 1
502
+ print(f" {len(srcs)} file(s) moved.")
503
+ return 0
504
+
505
+
506
+ @builtin("ren", "rename", doc="Rename a file or folder")
507
+ def b_ren(a):
508
+ _, args = split_flags(a)
509
+ if len(args) != 2:
510
+ print("The syntax of the command is incorrect."); return 1
511
+ try:
512
+ os.rename(args[0], os.path.join(os.path.dirname(args[0]), args[1])); return 0
513
+ except OSError:
514
+ print("The system cannot find the file specified."); return 1
515
+
516
+
517
+ @builtin("del", "erase", doc="Delete files. /q quiet /s include subfolders")
518
+ def b_del(a):
519
+ fl, pats = split_flags(a)
520
+ targets = []
521
+ for p in pats:
522
+ targets += glob.glob(p)
523
+ if "/s" in fl:
524
+ targets += glob.glob(os.path.join("**", os.path.basename(p)), recursive=True)
525
+ rc = 0
526
+ if not targets:
527
+ print("Could Not Find " + (os.path.abspath(pats[0]) if pats else "")); return 1
528
+ for t in dict.fromkeys(targets):
529
+ try:
530
+ if os.path.isdir(t):
531
+ for n in os.listdir(t):
532
+ f = os.path.join(t, n)
533
+ if os.path.isfile(f): os.remove(f)
534
+ else:
535
+ os.remove(t)
536
+ except OSError:
537
+ print(f"Access is denied: {t}"); rc = 1
538
+ return rc
539
+
540
+
541
+ @builtin("md", "mkdir", doc="Make directories")
542
+ def b_md(a):
543
+ _, dirs = split_flags(a)
544
+ rc = 0
545
+ for d in dirs:
546
+ try:
547
+ os.makedirs(d)
548
+ except FileExistsError:
549
+ print(f"A subdirectory or file {d} already exists."); rc = 1
550
+ except OSError:
551
+ print("The system cannot find the path specified."); rc = 1
552
+ return rc
553
+
554
+
555
+ @builtin("rd", "rmdir", doc="Remove directories. /s whole tree /q no prompt")
556
+ def b_rd(a):
557
+ fl, dirs = split_flags(a)
558
+ for d in dirs:
559
+ try:
560
+ if "/s" in fl:
561
+ if "/q" not in fl and input(f"{d}, Are you sure (Y/N)? ").strip().lower() != "y":
562
+ continue
563
+ shutil.rmtree(d)
564
+ else:
565
+ os.rmdir(d)
566
+ except FileNotFoundError:
567
+ print("The system cannot find the file specified."); return 1
568
+ except OSError:
569
+ print("The directory is not empty."); return 145
570
+ return 0
571
+
572
+
573
+ @builtin("set", doc="Show/set variables. set X=1 set /a X=X+2 set /p X=Prompt")
574
+ def b_set(a):
575
+ fl, rest = split_flags(a)
576
+ text = " ".join(rest)
577
+ if "/a" in fl:
578
+ res = None
579
+ for part in text.split(","):
580
+ m = re.fullmatch(r"\s*(\w+)\s*(<<|>>|[-+*/%&|^])?=\s*(.+)", part)
581
+ try:
582
+ if m:
583
+ name, op, rhs = m.groups()
584
+ val = calc(rhs)
585
+ if op:
586
+ val = calc(f"{env_get(name) or 0}{op}({val})")
587
+ os.environ[name] = str(val); res = val
588
+ else:
589
+ res = calc(part)
590
+ except (ValueError, SyntaxError, ZeroDivisionError):
591
+ print("Invalid number. Numeric constants are either decimal."); return 1
592
+ print(res); return 0
593
+ if "/p" in fl:
594
+ name, _, prompt = text.partition("=")
595
+ os.environ[name.strip()] = input(prompt)
596
+ return 0
597
+ if not text:
598
+ for k, v in sorted(os.environ.items(), key=lambda kv: kv[0].upper()):
599
+ print(f"{k}={v}")
600
+ return 0
601
+ if "=" in text:
602
+ k, v = text.split("=", 1)
603
+ if v: os.environ[k.strip()] = v
604
+ else: os.environ.pop(k.strip(), None)
605
+ return 0
606
+ hits = [(k, v) for k, v in os.environ.items() if k.upper().startswith(text.upper())]
607
+ for k, v in sorted(hits):
608
+ print(f"{k}={v}")
609
+ if not hits:
610
+ print(f"Environment variable {text} not defined"); return 1
611
+ return 0
612
+
613
+
614
+ @builtin("path", doc="Show or set PATH")
615
+ def b_path(a):
616
+ if not a:
617
+ print("PATH=" + os.environ.get("PATH", ""))
618
+ else:
619
+ text = " ".join(a).lstrip("=")
620
+ os.environ["PATH"] = "" if text == ";" else text
621
+ _which_cache.clear()
622
+ return 0
623
+
624
+
625
+ @builtin("doskey", doc="Define macros. doskey ll=dir /w doskey /macros")
626
+ def b_doskey(a):
627
+ text = " ".join(a)
628
+ if not text or text.lower() == "/macros":
629
+ for k, v in sorted(aliases.items()): print(f"{k}={v}")
630
+ elif "=" in text:
631
+ k, v = text.split("=", 1)
632
+ if v.strip(): aliases[k.strip().lower()] = v.strip()
633
+ else: aliases.pop(k.strip().lower(), None)
634
+ return 0
635
+
636
+
637
+ @builtin("where", doc="Locate programs on PATH")
638
+ def b_where(a):
639
+ _, names = split_flags(a)
640
+ dirs = [os.getcwd()] + [d for d in os.environ.get("PATH", "").split(os.pathsep) if d]
641
+ exts = [""] + (os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(";") if IS_WIN else [])
642
+ seen = set()
643
+ for n in names:
644
+ for d in dirs:
645
+ for e in exts:
646
+ for hit in glob.glob(os.path.join(glob.escape(d), n + e)):
647
+ if os.path.isfile(hit) and (IS_WIN or os.access(hit, os.X_OK)) and hit not in seen:
648
+ seen.add(hit); print(hit)
649
+ if not seen:
650
+ print("INFO: Could not find files for the given pattern(s)."); return 1
651
+ return 0
652
+
653
+
654
+ @builtin("find", doc='Search text: find [/i] [/v] [/c] [/n] "text" [files]')
655
+ def b_find(a):
656
+ fl, rest = split_flags(a)
657
+ if not rest:
658
+ print("FIND: Parameter format not correct"); return 2
659
+ needle, files = rest[0], rest[1:]
660
+ ci, inv, cnt, nums = "/i" in fl, "/v" in fl, "/c" in fl, "/n" in fl
661
+ total = 0
662
+
663
+ def scan(lines, label=None):
664
+ n = hits = 0
665
+ if label and not cnt:
666
+ print(f"\n---------- {label.upper()}")
667
+ for ln in lines:
668
+ n += 1
669
+ s = ln.rstrip("\n")
670
+ ok = (needle.lower() in s.lower()) if ci else (needle in s)
671
+ if ok != inv:
672
+ hits += 1
673
+ if not cnt:
674
+ print((f"[{n}]" if nums else "") + s)
675
+ if cnt:
676
+ print(f"---------- {label.upper()}: {hits}" if label else hits)
677
+ return hits
678
+
679
+ if not files:
680
+ total = scan(sys.stdin)
681
+ for f in files:
682
+ try:
683
+ with open(f, encoding="utf-8", errors="replace") as fh:
684
+ total += scan(fh, f)
685
+ except OSError:
686
+ print(f"File not found - {f.upper()}")
687
+ return 0 if total else 1
688
+
689
+
690
+ @builtin("findstr", doc="Search with /i /n /v /r /s /c:\"phrase\" (space = OR of words)")
691
+ def b_findstr(a):
692
+ ci = nums = inv = regex = sub = False
693
+ lits, rest = [], []
694
+ for x in a:
695
+ xl = x.lower()
696
+ if xl.startswith("/c:"): lits.append(x[3:])
697
+ elif xl == "/i": ci = True
698
+ elif xl == "/n": nums = True
699
+ elif xl == "/v": inv = True
700
+ elif xl == "/r": regex = True
701
+ elif xl == "/s": sub = True
702
+ elif re.fullmatch(r"/[A-Za-z]", x): pass
703
+ else: rest.append(x)
704
+ if not lits:
705
+ if not rest:
706
+ print("FINDSTR: Bad command line"); return 2
707
+ pat = rest.pop(0)
708
+ lits = [pat] if regex else pat.split()
709
+ try:
710
+ rx = re.compile("|".join(lits if regex else map(re.escape, lits)), re.I if ci else 0)
711
+ except re.error:
712
+ print("FINDSTR: Bad regular expression"); return 2
713
+
714
+ files = []
715
+ for spec in rest:
716
+ if sub:
717
+ base, pat2 = os.path.dirname(spec) or ".", os.path.basename(spec)
718
+ for root, _, names in os.walk(base):
719
+ files += [os.path.join(root, n) for n in names if fnmatch.fnmatch(n, pat2)]
720
+ else:
721
+ files += glob.glob(spec) or [spec]
722
+ prefix = len(files) > 1 or sub
723
+ tty = sys.stdout.isatty()
724
+
725
+ def scan(lines, label):
726
+ found = 0
727
+ for n, ln in enumerate(lines, 1):
728
+ s = ln.rstrip("\n")
729
+ if bool(rx.search(s)) != inv:
730
+ found += 1
731
+ if tty and not inv:
732
+ s = rx.sub(lambda m: f"\033[1;31m{m.group()}\033[0m", s)
733
+ head = (f"{label}:" if label and prefix else "") + (f"{n}:" if nums else "")
734
+ print(head + s)
735
+ return found
736
+
737
+ total = 0
738
+ if not files:
739
+ total = scan(sys.stdin, None)
740
+ for f in files:
741
+ try:
742
+ with open(f, encoding="utf-8", errors="replace") as fh:
743
+ total += scan(fh, f)
744
+ except OSError:
745
+ print(f"FINDSTR: Cannot open {f}")
746
+ return 0 if total else 1
747
+
748
+
749
+ @builtin("tasklist", doc='List processes. /fi "imagename eq x" or "pid eq n" to filter')
750
+ def b_tasklist(a):
751
+ if not psutil:
752
+ print("Install psutil first: pip install psutil"); return 1
753
+ fl, rest = split_flags(a)
754
+ flt = None
755
+ if "/fi" in fl and rest:
756
+ m = re.fullmatch(r"\s*(imagename|pid)\s+(eq|ne)\s+(.+?)\s*", rest[0], re.I)
757
+ if m: flt = (m.group(1).lower(), m.group(2).lower(), m.group(3).lower())
758
+ rows = []
759
+ for p in psutil.process_iter(["pid", "name", "memory_info"]):
760
+ i = p.info
761
+ name = i["name"] or "?"
762
+ if flt:
763
+ v = name.lower() if flt[0] == "imagename" else str(i["pid"])
764
+ if (v == flt[2]) != (flt[1] == "eq"):
765
+ continue
766
+ rows.append((name, i["pid"], i["memory_info"].rss // 1024 if i["memory_info"] else 0))
767
+ if not rows:
768
+ print("INFO: No tasks are running which match the specified criteria."); return 0
769
+ rows.sort(key=lambda r: r[0].lower())
770
+ print(f"{'Image Name':<28}{'PID':>8}{'Mem Usage':>16}")
771
+ print("=" * 27 + " " + "=" * 8 + " " + "=" * 15)
772
+ for n, pid, mem in rows:
773
+ print(f"{n[:27]:<28}{pid:>8}{mem:>13,} K")
774
+ return 0
775
+
776
+
777
+ @builtin("taskkill", doc="Kill a process: taskkill /pid N or /im name [/f]")
778
+ def b_taskkill(a):
779
+ if not psutil:
780
+ print("Install psutil first: pip install psutil"); return 1
781
+ pid = im = None
782
+ force = False
783
+ it = iter(a)
784
+ for x in it:
785
+ xl = x.lower()
786
+ if xl == "/pid": pid = next(it, None)
787
+ elif xl == "/im": im = next(it, None)
788
+ elif xl == "/f": force = True
789
+ victims = []
790
+ for p in psutil.process_iter(["pid", "name"]):
791
+ if (pid and str(p.info["pid"]) == pid) or (im and (p.info["name"] or "").lower() == im.lower()):
792
+ victims.append(p)
793
+ if not victims:
794
+ print(f'ERROR: The process "{pid or im}" not found.'); return 128
795
+ rc = 0
796
+ for p in victims:
797
+ try:
798
+ p.kill() if force else p.terminate()
799
+ print(f'SUCCESS: Sent termination signal to the process "{p.info["name"]}" with PID {p.info["pid"]}.')
800
+ except Exception as e:
801
+ print(f"ERROR: {e}"); rc = 1
802
+ return rc
803
+
804
+
805
+ @builtin("sysinfo", doc="CPU, memory and disk summary")
806
+ def b_sysinfo(a):
807
+ if not psutil:
808
+ print("Install psutil first: pip install psutil"); return 1
809
+ vm, d = psutil.virtual_memory(), psutil.disk_usage(os.path.abspath(os.sep))
810
+ print(f"CPU {psutil.cpu_percent(interval=0.3)}% ({psutil.cpu_count()} logical cores)")
811
+ print(f"RAM {human(vm.used)} / {human(vm.total)} ({vm.percent}%)")
812
+ print(f"Disk {human(d.used)} / {human(d.total)} ({d.percent}%)")
813
+ return 0
814
+
815
+
816
+ @builtin("history", doc="Show command history")
817
+ def b_history(a):
818
+ if session:
819
+ for i, s in enumerate(session.history.get_strings(), 1):
820
+ print(f"{i:>5} {s}")
821
+ return 0
822
+
823
+
824
+ @builtin("start", doc="Open a file/URL with its default app, or launch a program")
825
+ def b_start(a):
826
+ a = list(a)
827
+ if a and a[0] == "":
828
+ a.pop(0)
829
+ if not a:
830
+ return 0
831
+ try:
832
+ if len(a) == 1 and hasattr(os, "startfile"):
833
+ os.startfile(a[0])
834
+ elif len(a) == 1:
835
+ subprocess.Popen(["open" if sys.platform == "darwin" else "xdg-open", a[0]])
836
+ else:
837
+ subprocess.Popen(a)
838
+ return 0
839
+ except OSError as e:
840
+ print(f"Cannot start: {e}"); return 1
841
+
842
+
843
+ @builtin("timeout", doc="Wait N seconds: timeout /t 5")
844
+ def b_timeout(a):
845
+ nums = [x for x in a if x.lstrip("-").isdigit()]
846
+ n = int(nums[0]) if nums else 5
847
+ try:
848
+ for i in range(n, 0, -1):
849
+ print(f"\rWaiting for {i} seconds, press Ctrl+C to quit ...", end="", flush=True)
850
+ time.sleep(1)
851
+ except KeyboardInterrupt:
852
+ pass
853
+ print()
854
+ return 0
855
+
856
+
857
+ @builtin("pause", doc="Wait for Enter")
858
+ def b_pause(a):
859
+ try:
860
+ input("Press Enter to continue . . . ")
861
+ except EOFError:
862
+ pass
863
+ return 0
864
+
865
+
866
+ @builtin("help", doc="List commands, or help <command>")
867
+ def b_help(a):
868
+ if a:
869
+ n = a[0].lower()
870
+ if n in HELP:
871
+ print(f"{n}: {HELP[n]}"); return 0
872
+ if IS_WIN:
873
+ return subprocess.run(["cmd", "/c", "help", n]).returncode
874
+ print("No help for that command."); return 1
875
+ print(f"neoshell {VERSION} built-in commands:\n")
876
+ for n in sorted(HELP):
877
+ print(f" {paint(n.ljust(10), '1;32')} {HELP[n]}")
878
+ print("\nSyntax: a & b a && b a || b a | b > >> < %VAR% %ERRORLEVEL%")
879
+ print(" if [not] exist X cmd if [not] errorlevel N cmd if a==b cmd rem comment")
880
+ if IS_WIN:
881
+ print("Anything else CMD knows (assoc, mklink, vol, ...) is passed to cmd.exe.")
882
+ return 0
883
+
884
+
885
+ # ---------------------------------------------------------------- execution
886
+ def run_command(argv, data, last, redir):
887
+ """Run one command. Returns (stdout_bytes_or_None, status)."""
888
+ name = str(argv[0]).lower()
889
+ m = re.fullmatch(r"(cd|chdir)(\.\.+|[\\/])", name) # cd.. and cd\
890
+ if m:
891
+ argv, name = [m.group(1), m.group(2)] + list(argv[1:]), m.group(1)
892
+ elif name.startswith(("echo.", "echo/")): # echo. prints a blank line
893
+ argv, name = ["echo", str(argv[0])[5:]] + list(argv[1:]), "echo"
894
+
895
+ capture = (not last) or redir
896
+ fn = BUILTINS.get(name)
897
+ if fn:
898
+ args = []
899
+ for x in argv[1:]:
900
+ if name in GLOB_CMDS and not isinstance(x, Q) and any(c in x for c in "*?["):
901
+ args += sorted(glob.glob(x)) or [str(x)]
902
+ else:
903
+ args.append(str(x))
904
+ buf, old_in = io.StringIO(), sys.stdin
905
+ if data is not None:
906
+ sys.stdin = io.StringIO(data.decode("utf-8", "replace"))
907
+ try:
908
+ with (contextlib.redirect_stdout(buf) if capture else contextlib.nullcontext()):
909
+ st = fn(args)
910
+ except KeyboardInterrupt:
911
+ st = 130
912
+ finally:
913
+ sys.stdin = old_in
914
+ return (buf.getvalue().encode() if capture else None), (st or 0)
915
+
916
+ argv = [str(x) for x in argv]
917
+ pipe = subprocess.PIPE if capture else None
918
+ try:
919
+ p = subprocess.run(argv, input=data, stdout=pipe)
920
+ except OSError:
921
+ if IS_WIN: # let the real cmd.exe handle its own internal commands
922
+ try:
923
+ p = subprocess.run(["cmd", "/c"] + argv, input=data, stdout=pipe)
924
+ except KeyboardInterrupt:
925
+ return None, 130
926
+ else:
927
+ print(paint(f"'{argv[0]}' is not recognized as an internal or external command,\n"
928
+ "operable program or batch file.", "31"))
929
+ return None, 9009
930
+ except KeyboardInterrupt:
931
+ return None, 130
932
+ return (p.stdout if capture else None), p.returncode
933
+
934
+
935
+ def devnull(path):
936
+ return os.devnull if path.lower() == "nul" else os.path.expanduser(path)
937
+
938
+
939
+ def run_if(t):
940
+ i = 1
941
+ ci = False
942
+ if i < len(t) and str(t[i]).lower() == "/i":
943
+ ci = True; i += 1
944
+ neg = False
945
+ if i < len(t) and str(t[i]).lower() == "not":
946
+ neg = True; i += 1
947
+ kind = str(t[i]).lower() if i < len(t) else ""
948
+ try:
949
+ if kind == "exist":
950
+ cond, i = os.path.exists(str(t[i + 1])), i + 2
951
+ elif kind == "errorlevel":
952
+ cond, i = last_status >= int(t[i + 1]), i + 2
953
+ elif kind == "defined":
954
+ cond, i = env_get(str(t[i + 1])) is not None, i + 2
955
+ else:
956
+ s = str(t[i])
957
+ if "==" in s and not (s == "==" and not isinstance(t[i], Q)):
958
+ x, y = s.split("==", 1); i += 1 # a==b or "a"=="b"
959
+ else:
960
+ x, y = s, str(t[i + 2]); i += 3 # a == b
961
+ cond = (x.lower() == y.lower()) if ci else (x == y)
962
+ except (IndexError, ValueError):
963
+ print("The syntax of the command is incorrect."); return 1
964
+ rest = t[i:]
965
+ if cond != neg and rest:
966
+ return run_tokens(rest)
967
+ return last_status
968
+
969
+
970
+ def run_pipeline(toks):
971
+ if not isinstance(toks[0], Q) and str(toks[0]).lower() == "if":
972
+ return run_if(toks)
973
+ cmds, cur = [], []
974
+ for t in toks:
975
+ if isinstance(t, Op) and t == "|":
976
+ cmds.append(cur); cur = []
977
+ else:
978
+ cur.append(t)
979
+ cmds.append(cur)
980
+ data, st = None, 0
981
+ for n, c in enumerate(cmds):
982
+ argv, out, inp, j = [], None, None, 0
983
+ while j < len(c):
984
+ t = c[j]
985
+ if isinstance(t, Op) and t in (">", ">>") and j + 1 < len(c):
986
+ out = (str(t), str(c[j + 1])); j += 2
987
+ elif isinstance(t, Op) and t == "<" and j + 1 < len(c):
988
+ inp = str(c[j + 1]); j += 2
989
+ else:
990
+ argv.append(t); j += 1
991
+ if not argv:
992
+ continue
993
+ if inp:
994
+ try:
995
+ with open(devnull(inp), "rb") as f:
996
+ data = f.read()
997
+ except OSError:
998
+ print("The system cannot find the file specified."); return 1
999
+ data, st = run_command(argv, data, n == len(cmds) - 1, out is not None)
1000
+ if out and data is not None:
1001
+ try:
1002
+ with open(devnull(out[1]), "ab" if out[0] == ">>" else "wb") as f:
1003
+ f.write(data)
1004
+ except OSError:
1005
+ print("Access is denied."); st = 1
1006
+ data = None
1007
+ return st
1008
+
1009
+
1010
+ def run_tokens(tokens):
1011
+ global last_status
1012
+ groups, cur, op = [], [], "&"
1013
+ for t in tokens:
1014
+ if isinstance(t, Op) and t in ("&", "&&", "||"):
1015
+ groups.append((op, cur)); cur, op = [], str(t)
1016
+ else:
1017
+ cur.append(t)
1018
+ groups.append((op, cur))
1019
+ for op, toks in groups:
1020
+ if (op == "&&" and last_status != 0) or (op == "||" and last_status == 0):
1021
+ continue
1022
+ if toks:
1023
+ last_status = run_pipeline(toks)
1024
+ return last_status
1025
+
1026
+
1027
+ def run_line(line):
1028
+ line = line.strip()
1029
+ if not line or line.startswith("::") or re.match(r"(?i)rem(\s|$)", line):
1030
+ return
1031
+ toks = expand_aliases(tokenize(expand_vars(line)))
1032
+ try:
1033
+ run_tokens(toks)
1034
+ finally:
1035
+ _which_cache.clear()
1036
+
1037
+
1038
+ # ---------------------------------------------------------------- highlighting
1039
+ def is_command(name):
1040
+ low = name.lower()
1041
+ if low in BUILTINS or low in aliases or (IS_WIN and low in WIN_INTERNAL):
1042
+ return True
1043
+ if low not in _which_cache:
1044
+ _which_cache[low] = bool(shutil.which(name)) or os.path.isfile(name)
1045
+ return _which_cache[low]
1046
+
1047
+
1048
+ def arg_style(t):
1049
+ if t.startswith('"'): return "fg:ansiyellow"
1050
+ if t.startswith("%"): return "fg:ansiblue bold"
1051
+ if re.fullmatch(r"[/-][A-Za-z?][\w:,-]*", t): return "fg:ansicyan"
1052
+ if re.fullmatch(r"\d+", t): return "fg:ansimagenta"
1053
+ if os.path.exists(os.path.expandvars(t)): return "underline"
1054
+ return ""
1055
+
1056
+
1057
+ class CmdLexer(Lexer):
1058
+ TOK = re.compile(r'"[^"]*"?|%[^%\s"]*%?|&&|\|\||>>|[&|<>]|\s+|[^\s"%&|<>]+')
1059
+ CHAIN = ("&&", "||", "&", "|")
1060
+
1061
+ def lex_document(self, document):
1062
+ lines = document.lines
1063
+
1064
+ def get_line(n):
1065
+ line = lines[n]
1066
+ if re.match(r"\s*(::|rem(\s|$))", line, re.I):
1067
+ return [("fg:ansibrightblack italic", line)]
1068
+ out, state, skip = [], "cmd", 0
1069
+ for m in self.TOK.finditer(line):
1070
+ t = m.group()
1071
+ if t.isspace():
1072
+ out.append(("", t)); continue
1073
+ if t in ("&&", "||", "&", "|", ">", ">>", "<"):
1074
+ out.append(("fg:ansimagenta bold", t))
1075
+ state = "cmd" if t in self.CHAIN else "arg"
1076
+ continue
1077
+ bare = t.strip('"')
1078
+ low = bare.lower()
1079
+ style = ""
1080
+ if state == "cmd":
1081
+ if low == "if":
1082
+ style, state = "fg:ansimagenta bold", "if"
1083
+ else:
1084
+ style = "fg:ansigreen bold" if is_command(bare) else "fg:ansired"
1085
+ state = "arg"
1086
+ elif state == "if":
1087
+ if low in ("not", "/i"):
1088
+ style = "fg:ansimagenta"
1089
+ elif low in ("exist", "errorlevel", "defined"):
1090
+ style, state, skip = "fg:ansimagenta", "skip", 1
1091
+ elif "==" in bare and bare != "==":
1092
+ state = "cmd"
1093
+ else:
1094
+ state, skip = "skip", 2
1095
+ elif state == "skip":
1096
+ skip -= 1
1097
+ if skip <= 0:
1098
+ state = "cmd"
1099
+ out.append((style or arg_style(t), t))
1100
+ return out
1101
+ return get_line
1102
+
1103
+
1104
+ def programs():
1105
+ global _prog_cache
1106
+ if _prog_cache is None:
1107
+ exts = tuple(e.lower() for e in os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(";")) if IS_WIN else ()
1108
+ found = set()
1109
+ for d in os.environ.get("PATH", "").split(os.pathsep):
1110
+ try:
1111
+ for n in os.listdir(d):
1112
+ if IS_WIN:
1113
+ base, ext = os.path.splitext(n)
1114
+ if ext.lower() in exts: found.add(base.lower())
1115
+ elif os.access(os.path.join(d, n), os.X_OK):
1116
+ found.add(n)
1117
+ except OSError:
1118
+ pass
1119
+ _prog_cache = found
1120
+ return _prog_cache
1121
+
1122
+
1123
+ class CmdCompleter(Completer):
1124
+ DIR_CMDS = {"cd", "chdir", "rd", "rmdir", "pushd", "tree"}
1125
+
1126
+ def get_completions(self, document, complete_event):
1127
+ text = document.text_before_cursor
1128
+ m = re.search(r'("[^"]*|[^\s&|<>"]*)$', text)
1129
+ raw, before = m.group(1), text[:m.start()]
1130
+ word, quote, start = raw.lstrip('"'), raw.startswith('"'), -len(raw)
1131
+ seg = re.split(r"&&|\|\||[&|]", before)[-1].split()
1132
+ at_cmd = not seg and not re.search(r"[<>]\s*$", before)
1133
+
1134
+ if word.startswith("%"):
1135
+ pre = word[1:].upper()
1136
+ for k in sorted(os.environ):
1137
+ if k.upper().startswith(pre):
1138
+ yield Completion(f"%{k}%", start_position=start, display_meta="variable")
1139
+ return
1140
+ if at_cmd and not re.search(r"[\\/]", word):
1141
+ pool = {n: "builtin" for n in BUILTINS}
1142
+ pool.update({n: "macro" for n in aliases})
1143
+ for n in programs():
1144
+ pool.setdefault(n, "program")
1145
+ for n in sorted(pool):
1146
+ if n.lower().startswith(word.lower()):
1147
+ yield Completion(n, start_position=start, display_meta=pool[n])
1148
+ return
1149
+ dirs_only = bool(seg) and seg[0].lower() in self.DIR_CMDS
1150
+ d, base = os.path.split(word)
1151
+ try:
1152
+ names = os.listdir(os.path.expanduser(d) if d else ".")
1153
+ except OSError:
1154
+ return
1155
+ for n in sorted(names, key=str.lower):
1156
+ if not n.lower().startswith(base.lower()):
1157
+ continue
1158
+ full = os.path.join(d, n) if d else n
1159
+ isdir = os.path.isdir(full)
1160
+ if dirs_only and not isdir:
1161
+ continue
1162
+ out = full + (os.sep if isdir else "")
1163
+ if " " in out or quote:
1164
+ out = '"' + out + ("" if isdir else '"')
1165
+ yield Completion(out, start_position=start, display=n + (os.sep if isdir else ""))
1166
+
1167
+
1168
+ # ---------------------------------------------------------------- Windows Terminal profile
1169
+ PROFILE_GUID = "{9d4b7e32-6a1c-4c8f-b5d0-3e8a2f1c7b64}"
1170
+ FROZEN = getattr(sys, "frozen", False) # True when running as neoshell.exe
1171
+ TERM_SCHEME = {
1172
+ "name": "neoshell",
1173
+ "background": "#0F1420", "foreground": "#D7DEEA",
1174
+ "cursorColor": "#4DE1FF", "selectionBackground": "#25324D",
1175
+ "black": "#1B2233", "red": "#FF6B81", "green": "#5BEAA0", "yellow": "#FFD479",
1176
+ "blue": "#6EA8FF", "purple": "#C792FF", "cyan": "#4DE1FF", "white": "#D7DEEA",
1177
+ "brightBlack": "#5C6B8A", "brightRed": "#FF8FA1", "brightGreen": "#8BF5BE",
1178
+ "brightYellow": "#FFE3A0", "brightBlue": "#9CC4FF", "brightPurple": "#DDB8FF",
1179
+ "brightCyan": "#8CEBFF", "brightWhite": "#FFFFFF",
1180
+ }
1181
+
1182
+
1183
+ def _profile_paths():
1184
+ local = os.environ.get("LOCALAPPDATA") # only set on Windows
1185
+ if not local:
1186
+ return None
1187
+ return (os.path.join(local, "neoshell"),
1188
+ os.path.join(local, "Microsoft", "Windows Terminal", "Fragments", "neoshell"))
1189
+
1190
+
1191
+ def _resource(name):
1192
+ base = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)))
1193
+ return os.path.join(base, name)
1194
+
1195
+
1196
+ def _self_path():
1197
+ return os.path.abspath(sys.executable if FROZEN else __file__)
1198
+
1199
+
1200
+ def _profile_command(inst):
1201
+ if FROZEN:
1202
+ return f'"{os.path.join(inst, "neoshell.exe")}"'
1203
+ return f'"{sys.executable}" "{os.path.join(inst, "neoshell.py")}"'
1204
+
1205
+
1206
+ def _remove_legacy():
1207
+ """Remove the profile + install folder from when this program was called pyshell."""
1208
+ local = os.environ.get("LOCALAPPDATA")
1209
+ if not local:
1210
+ return False
1211
+ old_frag = os.path.join(local, "Microsoft", "Windows Terminal", "Fragments", "pyshell")
1212
+ found = os.path.isdir(old_frag) or os.path.isdir(os.path.join(local, "pyshell"))
1213
+ shutil.rmtree(old_frag, ignore_errors=True)
1214
+ shutil.rmtree(os.path.join(local, "pyshell"), ignore_errors=True)
1215
+ return found
1216
+
1217
+
1218
+ def install_profile():
1219
+ """Copy neoshell to %LOCALAPPDATA%\\neoshell and register a Windows Terminal profile.
1220
+
1221
+ Uses a Windows Terminal "fragment" file, so the user's settings.json is never edited."""
1222
+ paths = _profile_paths()
1223
+ if not paths:
1224
+ print("Windows Terminal profiles need Windows."); return False
1225
+ inst, frag_dir = paths
1226
+ target = os.path.join(inst, "neoshell.exe" if FROZEN else "neoshell.py")
1227
+ try:
1228
+ os.makedirs(inst, exist_ok=True)
1229
+ if _self_path().lower() != os.path.abspath(target).lower():
1230
+ shutil.copy2(_self_path(), target)
1231
+ icon = os.path.join(inst, "neoshell.ico")
1232
+ if os.path.isfile(_resource("neoshell.ico")):
1233
+ shutil.copy2(_resource("neoshell.ico"), icon)
1234
+ profile = {"guid": PROFILE_GUID, "name": "neoshell", "commandline": _profile_command(inst),
1235
+ "startingDirectory": "%USERPROFILE%", "colorScheme": "neoshell",
1236
+ "tabTitle": "neoshell", "padding": "10", "cursorShape": "bar"}
1237
+ if os.path.isfile(icon):
1238
+ profile["icon"] = icon
1239
+ os.makedirs(frag_dir, exist_ok=True)
1240
+ with open(os.path.join(frag_dir, "neoshell.json"), "w", encoding="utf-8") as f:
1241
+ json.dump({"profiles": [profile], "schemes": [TERM_SCHEME]}, f, indent=2)
1242
+ except OSError as e:
1243
+ print(paint(f"Could not install the profile: {e}", "31")); return False
1244
+ if _remove_legacy():
1245
+ print(" (removed the old 'pyshell' profile)")
1246
+ print(paint("Windows Terminal profile installed.", "32"))
1247
+ print(f" Restart Windows Terminal, then pick 'neoshell' from the dropdown (the arrow next to +).")
1248
+ print(f" Make it the default: Settings > Startup > Default profile. Installed in {inst}")
1249
+ return True
1250
+
1251
+
1252
+ def uninstall_profile():
1253
+ paths = _profile_paths()
1254
+ if not paths:
1255
+ print("Nothing to remove (not Windows)."); return False
1256
+ inst, frag_dir = paths
1257
+ shutil.rmtree(frag_dir, ignore_errors=True)
1258
+ shutil.rmtree(inst, ignore_errors=True)
1259
+ _remove_legacy()
1260
+ print("Profile removed. Restart Windows Terminal." +
1261
+ (f"\nIf {inst} is still there, close neoshell and delete it." if os.path.isdir(inst) else ""))
1262
+ return True
1263
+
1264
+
1265
+ def profile_current():
1266
+ """True if the registered profile already points at an up-to-date install."""
1267
+ paths = _profile_paths()
1268
+ if not paths:
1269
+ return True
1270
+ inst, frag_dir = paths
1271
+ if os.path.isdir(os.path.join(os.path.dirname(frag_dir), "pyshell")):
1272
+ return False # old pyshell profile still registered
1273
+ if os.path.dirname(_self_path()).lower() == os.path.abspath(inst).lower():
1274
+ return True # we ARE the installed copy
1275
+ try:
1276
+ with open(os.path.join(frag_dir, "neoshell.json"), encoding="utf-8") as f:
1277
+ if json.load(f)["profiles"][0]["commandline"] != _profile_command(inst):
1278
+ return False
1279
+ if FROZEN: # newer exe than the installed one -> update it
1280
+ return os.path.getsize(os.path.join(inst, "neoshell.exe")) == os.path.getsize(sys.executable)
1281
+ except (OSError, ValueError, KeyError, IndexError):
1282
+ return False
1283
+ return True
1284
+
1285
+
1286
+ def start_in_home():
1287
+ """Don't leave the user in System32 / Program Files / neoshell's own folder.
1288
+
1289
+ Double-clicking the exe starts it in an awkward place. In those cases move to the
1290
+ logged-in user's folder. If the user opened neoshell in a folder on purpose (for
1291
+ example "Open in Terminal" from Explorer), leave it alone."""
1292
+ home = os.path.expanduser("~")
1293
+ if not os.path.isdir(home):
1294
+ return
1295
+ norm = lambda x: os.path.abspath(x).lower().rstrip("\\/")
1296
+ here = norm(os.getcwd())
1297
+ exact = {norm(os.path.dirname(_self_path()))}
1298
+ if _profile_paths():
1299
+ exact.add(norm(_profile_paths()[0]))
1300
+ prefixes = [norm(os.environ[v]) for v in ("SystemRoot", "ProgramFiles", "ProgramFiles(x86)")
1301
+ if os.environ.get(v)]
1302
+ if here in exact or any(here == p or here.startswith(p + os.sep) for p in prefixes):
1303
+ os.chdir(home)
1304
+
1305
+
1306
+ # ---------------------------------------------------------------- main loop
1307
+ def prompt_text():
1308
+ parts = [("fg:ansiblue bold", os.getcwd())]
1309
+ br = git_branch()
1310
+ if br:
1311
+ parts.append(("fg:ansimagenta", f" ({br})"))
1312
+ parts.append(("fg:ansigreen bold" if last_status == 0 else "fg:ansired bold", " > "))
1313
+ return parts
1314
+
1315
+
1316
+ def rprompt_text():
1317
+ bits = []
1318
+ if last_status:
1319
+ bits.append(("fg:ansired", f"x{last_status} "))
1320
+ if last_dur >= 1:
1321
+ bits.append(("fg:ansibrightblack", f"{last_dur:.1f}s "))
1322
+ bits.append(("fg:ansibrightblack", time.strftime("%H:%M")))
1323
+ return bits
1324
+
1325
+
1326
+ LOGO = ["┏┓╻┏━╸┏━┓┏━┓╻ ╻┏━╸╻ ╻ ",
1327
+ "┃┗┫┣╸ ┃ ┃┗━┓┣━┫┣╸ ┃ ┃ ",
1328
+ "╹ ╹┗━╸┗━┛┗━┛╹ ╹┗━╸┗━╸┗━╸"]
1329
+
1330
+
1331
+ def banner():
1332
+ """Gradient ASCII logo (cyan -> violet, same colors as the app icon)."""
1333
+ if not sys.stdout.isatty():
1334
+ print(f"neoshell {VERSION}"); return
1335
+ c1, c2 = (77, 225, 255), (199, 146, 255)
1336
+ width = max(len(r) for r in LOGO)
1337
+ print()
1338
+ for row in LOGO:
1339
+ out = ""
1340
+ for x, ch in enumerate(row):
1341
+ t = x / max(1, width - 1)
1342
+ r, g, b = (int(c1[i] + (c2[i] - c1[i]) * t) for i in range(3))
1343
+ out += f"\033[38;2;{r};{g};{b}m{ch}"
1344
+ print(" " + out + "\033[0m")
1345
+ print(f" \033[90mCMD-style shell · v{VERSION} · type \033[0m\033[1mhelp\033[0m\033[90m for commands\033[0m\n")
1346
+
1347
+
1348
+ def main():
1349
+ global session, last_dur
1350
+ argv = sys.argv[1:]
1351
+ if "--uninstall" in argv:
1352
+ return 0 if uninstall_profile() else 1
1353
+ if "--install" in argv:
1354
+ return 0 if install_profile() else 1
1355
+ no_install = "--no-install" in argv
1356
+ if argv[:1] == ["-c"]:
1357
+ try:
1358
+ run_line(" ".join(argv[1:]))
1359
+ except SystemExit as e:
1360
+ return e.code or 0
1361
+ return last_status
1362
+
1363
+ old_hist = os.path.expanduser("~/.pyshell_history")
1364
+ if os.path.isfile(old_hist) and not os.path.isfile(HIST):
1365
+ with contextlib.suppress(OSError):
1366
+ shutil.copy2(old_hist, HIST)
1367
+ if HAVE_PT:
1368
+ session = PromptSession(history=FileHistory(HIST), auto_suggest=AutoSuggestFromHistory(),
1369
+ lexer=CmdLexer(), completer=CmdCompleter(),
1370
+ complete_while_typing=False)
1371
+ read = lambda: session.prompt(prompt_text, rprompt=rprompt_text)
1372
+ else:
1373
+ print("(tip: pip install prompt_toolkit for syntax highlighting and tab completion)")
1374
+ read = lambda: input(f"{os.getcwd()}> ")
1375
+
1376
+ start_in_home()
1377
+ b_title(["neoshell"])
1378
+ banner()
1379
+ if FROZEN and not no_install and _profile_paths() and not profile_current():
1380
+ install_profile()
1381
+ print()
1382
+ while True:
1383
+ try:
1384
+ line = read()
1385
+ except KeyboardInterrupt:
1386
+ continue
1387
+ except EOFError:
1388
+ print(); break
1389
+ t0 = time.time()
1390
+ try:
1391
+ run_line(line)
1392
+ except SystemExit as e:
1393
+ return e.code or 0
1394
+ except KeyboardInterrupt:
1395
+ print("^C")
1396
+ except Exception as e:
1397
+ print(paint(f"neoshell error: {e}", "31"))
1398
+ last_dur = time.time() - t0
1399
+ return 0
1400
+
1401
+
1402
+ if __name__ == "__main__":
1403
+ sys.exit(main())