-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrun_scriptree.py
More file actions
656 lines (577 loc) · 24.5 KB
/
run_scriptree.py
File metadata and controls
656 lines (577 loc) · 24.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
#!/usr/bin/env python3
"""ScripTree launcher.
This is the top-level entry point. It adds the ScripTree package
directory to ``sys.path`` so ``scriptree`` is importable, then
delegates to ``scriptree.main.main()``.
**Vendored dependencies:** If ``lib/pypi/`` contains packages (run
``python lib/update_lib.py`` once to populate it), they are preferred
over any system-installed versions. This makes the whole folder
self-contained and portable. Set ``SCRIPTREE_USE_SYSTEM_DEPS=1`` to
disable this and fall back to the system Python environment.
Environment variables:
SCRIPTREE_PYTHON
Path to an alternate Python executable (e.g. a PortableApps
Python). When set, ScripTree offers to install dependencies
into that Python environment as well as the current one.
SCRIPTREE_USE_SYSTEM_DEPS
When set to ``1``, skip the vendored ``lib/pypi/`` directory
and use whatever the system Python provides.
Usage::
python run_scriptree.py
python run_scriptree.py path/to/tool.scriptree
python run_scriptree.py path/to/tree.scriptreetree -configuration standalone
"""
import os
import subprocess
import sys
from pathlib import Path
# ── Pre-flight checks ──────────────────────────────────────────────────
def _check_python_version():
"""Ensure Python >= 3.11."""
if sys.version_info < (3, 11):
msg = (
f"ScripTree requires Python 3.11 or later.\n"
f"You are running Python {sys.version_info.major}"
f".{sys.version_info.minor}.{sys.version_info.micro}.\n"
f"\n"
f"Download the latest Python from https://www.python.org/downloads/"
)
print(msg, file=sys.stderr)
_msgbox(msg, "ScripTree \u2014 Python Version")
sys.exit(1)
def _msgbox(text: str, title: str, *, style: int = 0x10) -> None:
"""Show a native Windows MessageBox (no-op on other platforms)."""
if sys.platform != "win32":
return
try:
import ctypes
ctypes.windll.user32.MessageBoxW(0, text, title, style)
except Exception:
pass
def _yesno_box(text: str, title: str) -> bool:
"""Show a Yes/No MessageBox on Windows. Returns True for Yes.
On non-Windows, falls back to terminal input."""
if sys.platform == "win32":
try:
import ctypes
MB_YESNO = 0x04
MB_ICONQUESTION = 0x20
IDYES = 6
result = ctypes.windll.user32.MessageBoxW(
0, text, title, MB_YESNO | MB_ICONQUESTION
)
return result == IDYES
except Exception:
pass
# Terminal fallback.
try:
answer = input(f"{text}\n\nInstall now? [y/N] ").strip().lower()
return answer in ("y", "yes")
except (EOFError, KeyboardInterrupt):
return False
def _pick_python_target(missing_names: str) -> str | None:
"""If SCRIPTREE_PYTHON is set, ask the user which Python to install
into. Returns the chosen Python executable path, or None to cancel.
When SCRIPTREE_PYTHON is not set, returns sys.executable (the
current Python) without prompting.
"""
alt_python = os.environ.get("SCRIPTREE_PYTHON", "").strip()
current = sys.executable
if not alt_python or not Path(alt_python).exists():
# No alternate — just use current Python.
return current
if alt_python == current:
return current
# Two Pythons available — ask which one.
current_label = f"Current Python ({current})"
alt_label = f"SCRIPTREE_PYTHON ({alt_python})"
if sys.platform == "win32":
try:
import ctypes
MB_YESNOCANCEL = 0x03
MB_ICONQUESTION = 0x20
IDYES = 6
IDNO = 7
# Yes = current, No = alternate, Cancel = abort
result = ctypes.windll.user32.MessageBoxW(
0,
f"ScripTree needs to install: {missing_names}\n\n"
f"Two Python installations were found:\n\n"
f" Yes \u2192 {current_label}\n"
f" No \u2192 {alt_label}\n"
f" Cancel \u2192 Don't install\n\n"
f"Which Python should the packages be installed into?",
"ScripTree \u2014 Choose Python",
MB_YESNOCANCEL | MB_ICONQUESTION,
)
if result == IDYES:
return current
elif result == IDNO:
return alt_python
else:
return None
except Exception:
pass
# Terminal fallback.
print(f"\nScripTree needs to install: {missing_names}")
print(f"\nTwo Python installations found:")
print(f" [1] {current_label}")
print(f" [2] {alt_label}")
print(f" [0] Cancel")
try:
choice = input("\nInstall into which Python? [1/2/0] ").strip()
except (EOFError, KeyboardInterrupt):
return None
if choice == "1":
return current
elif choice == "2":
return alt_python
return None
def _install_packages(python_exe: str, packages: list[str]) -> bool:
"""Run pip install for the given packages. Returns True on success."""
cmd = [python_exe, "-m", "pip", "install"] + packages
print(f"\nInstalling: {' '.join(packages)}")
print(f"Running: {' '.join(cmd)}\n")
try:
result = subprocess.run(cmd, timeout=300)
return result.returncode == 0
except FileNotFoundError:
print(f"Error: Could not find Python at {python_exe}", file=sys.stderr)
return False
except subprocess.TimeoutExpired:
print("Error: Installation timed out after 5 minutes.", file=sys.stderr)
return False
except Exception as e:
print(f"Error during installation: {e}", file=sys.stderr)
return False
def _inject_vendored_libs():
"""Prepend ``lib/pypi/`` to ``sys.path`` so vendored deps win, and
isolate the process from user site-packages that could pull in
incompatible binaries (e.g. a globally-installed PySide6 whose DLLs
mix with our vendored ones, or numpy 1.x/2.x mismatches from
unrelated global packages).
When ``SCRIPTREE_USE_SYSTEM_DEPS=1`` is set, this is a no-op and
the system Python environment provides everything.
"""
if os.environ.get("SCRIPTREE_USE_SYSTEM_DEPS", "").strip() == "1":
return
here = Path(__file__).resolve().parent
pypi = here / "lib" / "pypi"
if not pypi.is_dir():
return
# Only inject if there's something in there besides .gitkeep,
# so an empty lib/pypi/ doesn't mask the dep-missing check.
entries = [p for p in pypi.iterdir() if p.name != ".gitkeep"]
if not entries:
return
pypi_str = str(pypi)
# 1. Strip user site-packages from sys.path. Leaving them in means
# `import somepackage` can pick up a globally-installed copy that
# was compiled against a different numpy/Qt/etc, causing the
# classic "module compiled using NumPy 1.x cannot be run in
# NumPy 2.x" crash at import time.
try:
import site
bad = set()
usersite = getattr(site, "getusersitepackages", lambda: None)()
if usersite:
bad.add(os.path.normcase(os.path.abspath(usersite)))
for p in getattr(site, "getsitepackages", lambda: [])():
bad.add(os.path.normcase(os.path.abspath(p)))
sys.path[:] = [
p for p in sys.path
if os.path.normcase(os.path.abspath(p or ".")) not in bad
]
except Exception:
pass
# 2. Prepend the vendored folder so our copies win.
sys.path.insert(0, pypi_str)
# 3. Prevent any child Python processes from re-enabling user site.
os.environ["PYTHONNOUSERSITE"] = "1"
# 4. Point Qt at the vendored plugins explicitly so it doesn't try
# to auto-discover a system PySide6's plugins folder and mix DLLs.
qt_plugin_dir = pypi / "PySide6" / "plugins"
if qt_plugin_dir.is_dir():
os.environ["QT_PLUGIN_PATH"] = str(qt_plugin_dir)
platforms_dir = qt_plugin_dir / "platforms"
if platforms_dir.is_dir():
os.environ["QT_QPA_PLATFORM_PLUGIN_PATH"] = str(platforms_dir)
# 5. On Windows, add the PySide6 folder as a DLL search path so
# Qt6Core.dll etc. resolve to the vendored copies, not whatever
# is on PATH.
if sys.platform == "win32":
pyside_dir = pypi / "PySide6"
if pyside_dir.is_dir() and hasattr(os, "add_dll_directory"):
try:
os.add_dll_directory(str(pyside_dir))
except (OSError, FileNotFoundError):
pass
def _check_dependencies():
"""Check that required packages are installed. If any are missing,
offer to install them automatically."""
missing = []
try:
import PySide6 # noqa: F401
except ImportError:
missing.append("PySide6")
if not missing:
return
names = ", ".join(missing)
# Ask the user if they want to auto-install.
# Prefer the vendored workflow (lib/update_lib.py) if this project
# has the lib/ folder — it keeps the install self-contained.
here = Path(__file__).resolve().parent
have_vendor_workflow = (here / "lib" / "update_lib.py").is_file()
vendor_hint = (
"\n\nTip: this project has a vendored-deps workflow. You can\n"
"also run:\n\n python lib/update_lib.py\n\n"
"to install into lib/pypi/ instead of your system Python."
if have_vendor_workflow else ""
)
want_install = _yesno_box(
f"ScripTree is missing required dependencies:\n\n"
f" {names}\n\n"
f"Would you like ScripTree to download and install them now?\n\n"
f"(Requires an internet connection. This may take a minute.)"
+ vendor_hint,
"ScripTree \u2014 Missing Dependencies",
)
if not want_install:
msg = (
f"ScripTree cannot run without: {names}\n"
f"To install manually, run: pip install {' '.join(missing)}"
)
if have_vendor_workflow:
msg += (
"\nOr use the vendored workflow: "
"python lib/update_lib.py"
)
print(msg, file=sys.stderr)
sys.exit(1)
# Pick which Python to install into.
target = _pick_python_target(names)
if target is None:
print("Installation cancelled.", file=sys.stderr)
sys.exit(1)
success = _install_packages(target, missing)
if not success:
_msgbox(
f"Failed to install {names}.\n\n"
f"Try installing manually:\n\n"
f" {target} -m pip install {' '.join(missing)}",
"ScripTree \u2014 Installation Failed",
)
sys.exit(1)
# If we installed into an alternate Python, we need to tell the
# user to re-run with that Python.
if target != sys.executable:
msg = (
f"Dependencies installed into:\n {target}\n\n"
f"Please re-run ScripTree using that Python:\n\n"
f" \"{target}\" \"{__file__}\""
)
print(msg)
_msgbox(msg, "ScripTree \u2014 Installed Successfully", style=0x40)
sys.exit(0)
# Installed into current Python — verify the import now works.
try:
import PySide6 # noqa: F401, F811
except ImportError:
_msgbox(
f"Installation appeared to succeed but PySide6 still "
f"cannot be imported.\n\n"
f"Try restarting your terminal and running ScripTree again.",
"ScripTree \u2014 Import Error",
)
sys.exit(1)
print("Dependencies installed successfully. Starting ScripTree...\n")
# ── Launch ─────────────────────────────────────────────────────────────
def _self_heal_bundled_python():
"""Repair the bundled ``lib/python/`` directory if a user has
overwritten it with a fresh python.org embed (which strips out
our patches).
What we restore:
* ``Lib/site-packages/sitecustomize.py`` — re-creates the
directory tree if the fresh embed didn't ship with one,
and writes our path-fixing sitecustomize back into place.
* ``python<ver>._pth`` — uncomments the ``import site`` line
so ``site.main()`` runs at interpreter startup (the fresh
embed ships with it commented).
We DON'T attempt to re-bootstrap pip — that requires an
internet round-trip and a whole get-pip.py download. If
pip is missing the user can re-run ``lib/install_python.ps1``
or ``lib/update_lib.py``. The self-heal here covers only
what's required to keep tools-with-sibling-imports working.
No-op when:
* ``lib/python/`` doesn't exist (user is on system Python).
* The expected files are already in place AND look correct
(we verify by content marker, not just existence).
Failures are logged-and-ignored — this is best-effort repair,
not a critical-path operation. The v0.3.13+ runtime shim is
the durable fix; the self-heal is belt-and-suspenders for
callers who run the bundled python.exe directly.
"""
here = Path(__file__).resolve().parent
py_dir = here / "lib" / "python"
if not py_dir.is_dir():
return # System Python — nothing for us to repair.
# 1. Patch python<ver>._pth so `import site` is enabled.
try:
for pth in py_dir.glob("python*._pth"):
text = pth.read_text(encoding="utf-8")
# Match commented OR missing `import site`.
patched_lines = []
saw_uncommented = False
for line in text.splitlines():
stripped = line.strip()
if stripped == "import site":
saw_uncommented = True
patched_lines.append(line)
elif stripped in ("#import site", "# import site"):
patched_lines.append("import site")
saw_uncommented = True
else:
patched_lines.append(line)
if not saw_uncommented:
# Neither commented nor uncommented — append it.
patched_lines.append("import site")
saw_uncommented = True
new_text = "\n".join(patched_lines)
if not new_text.endswith("\n"):
new_text += "\n"
if new_text != text:
pth.write_text(new_text, encoding="utf-8")
except OSError:
pass
# 2. Restore Lib/site-packages/sitecustomize.py.
try:
sp_dir = py_dir / "Lib" / "site-packages"
sp_dir.mkdir(parents=True, exist_ok=True)
sc_path = sp_dir / "sitecustomize.py"
# The canonical sitecustomize source lives next to this
# launcher; it's the same file shipped under lib/python/
# in normal installs. We copy from one to the other when
# the destination is missing or its content has drifted.
canonical = here / "lib" / "python" / "Lib" / "site-packages" \
/ "sitecustomize.py"
# If canonical and sc_path are the same file (normal install
# where nothing was disturbed), nothing to do. We detect a
# broken state by absence or by missing marker comment.
marker = "ScripTree bundled-Python site customisation"
need_rewrite = (
not sc_path.is_file()
or marker not in sc_path.read_text(
encoding="utf-8", errors="replace"
)
)
if need_rewrite:
# Try the canonical location first. If that's ALSO
# missing (e.g. running from a partial source tree),
# fall back to a minimal inline copy of the path-fix
# logic so we self-heal even in degraded states.
if canonical.is_file() and canonical.resolve() != sc_path.resolve():
sc_path.write_bytes(canonical.read_bytes())
else:
_write_minimal_sitecustomize(sc_path)
except OSError:
pass
def _write_minimal_sitecustomize(target: Path) -> None:
"""Write a self-contained sitecustomize.py — used when even the
canonical source is missing. Smaller than the full version
because it's the last-resort fallback; just enough to make
sibling imports work."""
target.write_text(
'"""ScripTree bundled-Python site customisation '
'(self-healed minimal copy).\n\n'
'See lib/python/Lib/site-packages/sitecustomize.py in the '
'source\nrepo for the full version with comments.\n"""\n'
"import os, sys\n\n"
"def _scriptree_fix_sys_path():\n"
" seen = {os.path.normcase(os.path.abspath(p or '.')) "
"for p in sys.path}\n"
" def _prepend(d):\n"
" if not d: return\n"
" try: a = os.path.abspath(d)\n"
" except (OSError, ValueError): return\n"
" k = os.path.normcase(a)\n"
" if k in seen: return\n"
" sys.path.insert(0, a); seen.add(k)\n"
" td = os.environ.get('SCRIPTREE_TOOL_DIR', '').strip()\n"
" if td and os.path.isdir(td): _prepend(td)\n"
" if sys.argv and sys.argv[0]:\n"
" try:\n"
" a = os.path.abspath(sys.argv[0])\n"
" if os.path.isfile(a): _prepend(os.path.dirname(a))\n"
" except (OSError, ValueError): pass\n\n"
"try:\n"
" _scriptree_fix_sys_path()\n"
"except Exception:\n"
" pass\n",
encoding="utf-8",
)
_check_python_version()
_inject_vendored_libs()
_check_dependencies()
_self_heal_bundled_python()
# The ``scriptree`` package normally lives directly at the repo
# root, so adding the launcher's own directory to ``sys.path`` is
# enough for imports to resolve. The discovery walk below is
# defensive — it survives:
# - Source-zip extractions where GitHub wrapped everything in an
# extra ``<repo>-<branch>/`` folder.
# - Windows zip tools that double-nest the layout (extracting
# into a folder of the same name produces ScripTree/ScripTree/...).
# - Case-folding by extractors that normalize the inner package's
# casing to ``ScripTree`` instead of ``scriptree``.
def _find_package_dir(start: Path, max_depth: int = 4) -> Path | None:
"""Locate the directory to add to ``sys.path`` so
``import scriptree.main`` resolves.
The package is identified by the presence of a ``main.py`` inside
a folder named ``scriptree`` (case-insensitive). Returns the
PARENT directory of that ``scriptree/`` folder — that's what goes
on ``sys.path``.
Searches ``start`` itself plus up to ``max_depth`` levels of
descendants. Returns ``None`` if no candidate is found, leaving
the launcher to print a diagnostic instead of failing inside the
Python import machinery.
"""
needle = "scriptree"
def _walk(parent: Path, depth: int):
# Skip lib/, .git/, __pycache__/, etc. to avoid scanning vendored
# site-packages or version-control internals (would otherwise
# match `lib/pypi/scriptree/...` if anyone ever vendored us).
if parent.name.lower() in {
"lib", ".git", "__pycache__", ".pytest_cache",
".mypy_cache", ".ruff_cache", ".tox", "node_modules",
"site-packages", "venv", ".venv", "env",
}:
return None
try:
children = list(parent.iterdir())
except (PermissionError, OSError):
return None
# First, check direct children — does parent contain a
# `scriptree` folder with main.py?
for entry in children:
if (
entry.is_dir()
and entry.name.lower() == needle
and (entry / "main.py").is_file()
):
return parent
# Otherwise descend.
if depth <= 0:
return None
for entry in children:
if not entry.is_dir():
continue
hit = _walk(entry, depth - 1)
if hit is not None:
return hit
return None
if not start.is_dir():
return None
return _walk(start, max_depth)
def _abort_with_layout_error(start: Path) -> None:
"""Print a diagnostic explaining what we tried and exit.
Hit when ``_find_package_dir`` returns ``None`` — usually a
misplaced or partial extraction of the portable zip. The
message lists the launcher's location and the first few
direct children it saw, so a remote user can paste it back
and get an actionable answer.
"""
msg_lines = [
"ScripTree could not find its `scriptree` package on disk.",
"",
f" Launcher location: {start}",
]
if start.is_dir():
msg_lines.append(" Direct children of that folder:")
try:
kids = sorted(p.name for p in start.iterdir())[:20]
for k in kids:
msg_lines.append(f" {k}")
if len(kids) == 20:
msg_lines.append(" ... (more truncated)")
except OSError as e:
msg_lines.append(f" (could not list directory: {e})")
msg_lines.extend([
"",
"Most common cause: the portable zip was extracted into a folder",
"that already had a `ScripTree` subfolder, producing a nested",
"layout the launcher can't navigate. The launcher expects a",
"layout like:",
"",
" <run_scriptree.py>",
" scriptree/",
" main.py",
" ...",
"",
"If you see ScripTree/ScripTree/scriptree/ instead, move the",
"inner ScripTree/* contents up one level so they sit next to",
"run_scriptree.py.",
"",
"If you got the zip from a GitHub source archive (not a release",
"asset), it may extract as `<repo>-<branch>/...` — re-run from",
"INSIDE that folder, not above it.",
])
full = "\n".join(msg_lines)
print(full, file=sys.stderr)
_msgbox(full, "ScripTree \u2014 layout error")
sys.exit(1)
_HERE = Path(__file__).resolve().parent
_PKG_DIR = _find_package_dir(_HERE)
if _PKG_DIR is None:
_abort_with_layout_error(_HERE)
sys.path.insert(0, str(_PKG_DIR))
def _publish_scriptree_env() -> None:
"""Set SCRIPTREE_HOME and friends on ``os.environ`` so every
subprocess launched by ScripTree (and every tool's .scriptree
file) can reference the install root by name.
Tools can use ``%SCRIPTREE_LIB_PYTHON%/python.exe`` etc. as
their ``executable`` / ``working_directory`` / ``path_prepend``
values, and they Just Work no matter where ScripTree was
deployed (``C:\\Prod\\ScripTree``, an OneDrive sync folder, a
USB stick, etc.).
Variables published:
SCRIPTREE_HOME — the launcher's directory
SCRIPTREE_LIB — <HOME>/lib
SCRIPTREE_LIB_PYPI — <LIB>/pypi (only if it exists)
SCRIPTREE_LIB_PYTHON — <LIB>/python (only if it exists)
SCRIPTREE_APPS — <HOME>/ScripTreeApps (only if it exists)
"""
os.environ["SCRIPTREE_HOME"] = str(_HERE)
lib = _HERE / "lib"
if lib.is_dir():
os.environ["SCRIPTREE_LIB"] = str(lib)
pypi = lib / "pypi"
if pypi.is_dir():
os.environ["SCRIPTREE_LIB_PYPI"] = str(pypi)
py = lib / "python"
if py.is_dir():
os.environ["SCRIPTREE_LIB_PYTHON"] = str(py)
apps = _HERE / "ScripTreeApps"
if apps.is_dir():
os.environ["SCRIPTREE_APPS"] = str(apps)
# Tell well-behaved CLIs to skip ANSI color output. ScripTree's
# output pane is a plain QPlainTextEdit — it shows escape codes
# as literal text (e.g. "@[31m") instead of rendering them as
# color, which makes tool output hard to read. Most modern CLIs
# (dust, bat, fd, ripgrep, eza, hyperfine, gh, ls --color=auto,
# python --color=...) honor at least one of these:
#
# NO_COLOR — https://no-color.org/ (de facto standard)
# TERM=dumb — POSIX-y opt-out; some tools key off this
# CLICOLOR=0 — BSD ls / fish convention
# FORCE_COLOR=0 — Node.js / chalk convention
#
# We only set them if not already present, so a user's explicit
# FORCE_COLOR=1 (or NO_COLOR= override) still wins.
os.environ.setdefault("NO_COLOR", "1")
os.environ.setdefault("TERM", "dumb")
os.environ.setdefault("CLICOLOR", "0")
os.environ.setdefault("FORCE_COLOR", "0")
_publish_scriptree_env()
from scriptree.main import main # noqa: E402
if __name__ == "__main__":
sys.exit(main())