-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy path_patch.py
More file actions
29 lines (21 loc) · 62 KB
/
_patch.py
File metadata and controls
29 lines (21 loc) · 62 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
#!/usr/bin/env python3
"""
_patch.py — writes interpreter.py and __main__.py into the scaffolded repo.
These files contain {{...}} syntax that Bytecraft interpolates, so they
cannot be embedded in the .bc script directly. Run from the repo root:
python _patch.py
"""
import os
BASE = os.path.dirname(os.path.abspath(__file__))
INTERP = '"""\nBytecraft DSL Interpreter - v0.6.4\nA lightweight DSL for creating files and folders.\n\n - Variables: set <n> "value"\n - Env variables: set-from-env <n> "ENV_VAR"\n - Interpolation: {{name}} in any string\n - Pipe ops: {{name|upper|lower|trim|capitalize|len|replace:a:b}}\n - Multi-line blocks: make-file "f.txt" with ---\n - Copy: copy-file "src" to "dst"\n - Move: move-file "src" to "dst"\n - Zip: make-zip "out.zip" from "folder" ["folder2" ...]\n - Extract: extract "archive.zip" to "dest/"\n - Extract file: extract-file "inner/file" from "archive.zip" to "dest/file"\n - Append: append-file "f.txt" with "content"\n - Strict mode: strict on / strict off\n - Print: print "message"\n - Templates: define-template "name" ... end-template\n use-template "name" key "value" ...\n - Include: include "other.bc"\n - Loops: for x in "a" "b" "c" ... end-for\n for i in 1 to 5 ... end-for\n - Conditionals: if exists / not exists "path" ... end-if\n if "{{var}}" is / is not "value" ... end-if\n else-if / else supported\n - External vars: load-vars "file.ebv"\n - Replace file: replace-file "f.txt" with "content"\n - Patch file: edit-file "f.txt" with --- l1+ line l2- l3> insert ---\n - New scaffold: start-new "other.bc" or start-new "https://example.com/other.bc"\n"""\n\nimport os\nimport re\nimport shutil\nimport sys\nimport zipfile\n\n\n# ─────────────────────────────────────────────\n# Errors\n# ─────────────────────────────────────────────\n\nclass BytecraftError(RuntimeError):\n pass\n\n\n# ─────────────────────────────────────────────\n# Logging\n# ─────────────────────────────────────────────\n\ndef _log(msg: str, line_num: int | None = None) -> None:\n prefix = f"[Bytecraft:{line_num}]" if line_num is not None else "[Bytecraft]"\n print(f"{prefix} {msg}")\n\n\ndef _warn(line_num: int, line: str, reason: str, state: dict) -> None:\n msg = f"[Bytecraft] WARNING (line {line_num}): {reason} → skipping: {line!r}"\n if state.get("strict"):\n raise BytecraftError(msg.replace("WARNING", "ERROR (strict mode)"))\n print(msg, file=sys.stderr)\n\n\n# ─────────────────────────────────────────────\n# Variable interpolation\n# ─────────────────────────────────────────────\n\ndef _interpolate(text: str, state: dict) -> str:\n variables = state["vars"]\n\n def _apply_fmt(val: str, fmt: str | None) -> str:\n """Apply a Python format spec to a string value."""\n if not fmt:\n return val\n try:\n return format(int(val), fmt)\n except (ValueError, TypeError):\n try:\n return format(val, fmt)\n except (ValueError, TypeError):\n return val\n\n def _apply_string_op(val: str, op_str: str) -> str:\n """Apply a pipe string operation: upper, lower, trim, capitalize, len, replace:from:to"""\n op_str = op_str.strip()\n if op_str == "upper":\n return val.upper()\n if op_str == "lower":\n return val.lower()\n if op_str == "trim":\n return val.strip()\n if op_str == "capitalize":\n return val.capitalize()\n if op_str == "len":\n return str(len(val))\n if op_str.startswith("replace:"):\n parts = op_str.split(":", 2)\n if len(parts) == 3:\n return val.replace(parts[1], parts[2])\n return val\n\n def _eval_arithmetic(expr: str) -> tuple[bool, str]:\n """\n Try to evaluate a simple binary arithmetic expression.\n Operands may be variable names or numeric literals.\n Returns (success, result_string).\n """\n m = re.match(r\'^(.+?)\\s*([+\\-*/])\\s*(.+)$\', expr.strip())\n if not m:\n return False, ""\n left_s, op, right_s = m.group(1).strip(), m.group(2), m.group(3).strip()\n left_v = variables.get(left_s, left_s)\n right_v = variables.get(right_s, right_s)\n try:\n l, r = float(left_v), float(right_v)\n except ValueError:\n return False, ""\n if op == "+" : result = l + r\n elif op == "-": result = l - r\n elif op == "*": result = l * r\n elif op == "/":\n if r == 0:\n return False, ""\n result = l / r\n else:\n return False, ""\n # Return as int string if whole number\n return True, str(int(result)) if result == int(result) else str(result)\n\n def replacer(match):\n expr = match.group(1).strip()\n\n # ── 1. String pipe operations: {{name|upper}}, {{name|replace:_:-}} ──\n if "|" in expr:\n pipe_idx = expr.index("|")\n var_part = expr[:pipe_idx].strip()\n op_part = expr[pipe_idx + 1:].strip()\n if var_part not in variables:\n msg = f"undefined variable \'{{{{{var_part}}}}}\'"\n if state.get("strict"):\n raise BytecraftError(f"[Bytecraft] ERROR (strict mode): {msg}")\n _log(f"WARNING: {msg}")\n return match.group(0)\n return _apply_string_op(variables[var_part], op_part)\n\n # ── 2. Split trailing format spec: {{i + 1:03}} or {{var:02}} ──\n # Heuristic: colon followed only by valid format spec characters\n fmt: str | None = None\n fmt_m = re.search(r\':([0-9<>^+\\-#0dfsxobegEFGX%_.]+)$\', expr)\n if fmt_m:\n fmt = fmt_m.group(1)\n expr = expr[:fmt_m.start()].strip()\n\n # ── 3. Arithmetic: {{i + 1}}, {{count * 2}}, {{total / 4}} ──\n if re.search(r\'\\s*[+\\-*/]\\s*\', expr):\n ok, result = _eval_arithmetic(expr)\n if ok:\n return _apply_fmt(result, fmt)\n # Fall through to plain variable (handles e.g. negative-prefixed names)\n\n # ── 4. Plain variable lookup ──\n if expr in variables:\n return _apply_fmt(variables[expr], fmt)\n\n msg = f"undefined variable \'{{{{{expr}}}}}\'"\n if state.get("strict"):\n raise BytecraftError(f"[Bytecraft] ERROR (strict mode): {msg}")\n _log(f"WARNING: {msg}")\n return match.group(0)\n\n return re.sub(r\'\\{\\{(.+?)\\}\\}\', replacer, text)\n\n\n# ─────────────────────────────────────────────\n# String extraction\n# ─────────────────────────────────────────────\n\ndef _extract_strings(text: str, state: dict) -> list[str]:\n quoted = re.findall(r\'"([^"]*)"\', text)\n if quoted:\n return [_interpolate(s, state) for s in quoted]\n tokens = text.strip().split()\n return [_interpolate(t, state) for t in tokens]\n\n\n# ─────────────────────────────────────────────\n# Path resolution\n# ─────────────────────────────────────────────\n\ndef _resolve(path: str, state: dict) -> str:\n working = state.get("working_folder")\n if working and not os.path.isabs(path):\n return os.path.join(working, path)\n return path\n\n\ndef _is_dry(state: dict) -> bool:\n return state.get("dry_run", False)\n\n\ndef _resolve_include(path: str, state: dict) -> str:\n if os.path.isabs(path):\n return path\n return os.path.join(state.get("script_dir", "."), path)\n\n\n# ─────────────────────────────────────────────\n# Block collector\n# Collects lines between an opener and a closer keyword,\n# respecting nesting of the same opener keyword.\n# ─────────────────────────────────────────────\n\ndef _collect_block(\n lines: list[str],\n index: int,\n opener_pattern: str,\n closer: str,\n line_num: int,\n state: dict,\n) -> tuple[list[str], int]:\n """\n Collect lines until `closer` is found, handling nested openers.\n Returns (block_lines, new_index).\n """\n body: list[str] = []\n depth = 1\n opener_re = re.compile(opener_pattern, re.IGNORECASE)\n\n while index < len(lines):\n raw = lines[index]\n index += 1\n stripped = raw.strip()\n\n if opener_re.match(stripped):\n depth += 1\n elif stripped.lower() == closer:\n depth -= 1\n if depth == 0:\n return body, index\n\n body.append(raw.rstrip("\\n").rstrip("\\r"))\n\n _warn(line_num, closer, f"block opened but \'{closer}\' never found", state)\n return body, index\n\n\n# ─────────────────────────────────────────────\n# Core execution engine\n# ─────────────────────────────────────────────\n\ndef _execute(lines: list[str], state: dict, source_label: str = "<script>") -> None:\n index = 0\n while index < len(lines):\n line = lines[index]\n line_num = index + 1\n index += 1\n index = _dispatch(line, state, line_num, lines, index, source_label)\n\n\n# ─────────────────────────────────────────────\n# Command handlers\n# ─────────────────────────────────────────────\n\ndef _handle_strict(args: str, state: dict, line_num: int, **_) -> None:\n val = args.strip().lower()\n if val == "on":\n state["strict"] = True\n _log("Strict mode enabled", line_num)\n elif val == "off":\n state["strict"] = False\n _log("Strict mode disabled", line_num)\n else:\n _warn(line_num, args, "strict requires \'on\' or \'off\'", state)\n\n\ndef _handle_set_working_folder(args: str, state: dict, line_num: int, **_) -> None:\n parts = _extract_strings(args, state)\n if not parts:\n _warn(line_num, args, "missing path for set-working-folder", state)\n return\n path = parts[0]\n if _is_dry(state):\n _log(f"[DRY RUN] Would set working folder: {path}", line_num)\n state["working_folder"] = path # still set so path resolution works\n return\n os.makedirs(path, exist_ok=True)\n state["working_folder"] = path\n _log(f"Working folder set: {path}", line_num)\n\n\ndef _handle_make_folder(args: str, state: dict, line_num: int, **_) -> None:\n parts = _extract_strings(args, state)\n if not parts:\n _warn(line_num, args, "missing path for make-folder", state)\n return\n full_path = _resolve(parts[0], state)\n if _is_dry(state):\n _log(f"[DRY RUN] Would create folder: {full_path}", line_num)\n return\n os.makedirs(full_path, exist_ok=True)\n _log(f"Created folder: {full_path}", line_num)\n\n\ndef _handle_set(args: str, state: dict, line_num: int, **_) -> None:\n match = re.match(r\'(\\w+)\\s+"([^"]*)"\', args.strip())\n if match:\n name, value = match.group(1), match.group(2)\n else:\n tokens = args.strip().split(maxsplit=1)\n if len(tokens) < 2:\n _warn(line_num, args, "set requires a name and a value", state)\n return\n name, value = tokens[0], tokens[1].strip(\'"\')\n # Warn if the value looks like multiple unquoted words\n if \' \' in value and not value.startswith(\'"\'):\n _warn(line_num, args, f"unquoted multi-word value for \'{name}\' — wrap in quotes to be explicit", state)\n value = _interpolate(value, state)\n state["vars"][name] = value\n _log(f"Variable set: {name} = {value!r}", line_num)\n\n\ndef _handle_load_vars(args: str, state: dict, line_num: int, **_) -> None:\n """load-vars "file.ebv" — load key = value pairs into state."""\n parts = _extract_strings(args, state)\n if not parts:\n _warn(line_num, args, "load-vars requires a file path", state)\n return\n\n path = _resolve_include(parts[0], state)\n if not os.path.exists(path):\n _warn(line_num, args, f"ebv file not found: {path!r}", state)\n return\n\n try:\n with open(path, "r", encoding="utf-8") as f:\n ebv_lines = f.readlines()\n except OSError as e:\n _warn(line_num, args, f"could not read ebv file: {e}", state)\n return\n\n loaded = 0\n for i, ebv_line in enumerate(ebv_lines, start=1):\n stripped = ebv_line.strip()\n if not stripped or stripped.startswith("#"):\n continue\n m = re.match(r\'(\\w+)\\s*=\\s*(.+)\', stripped)\n if not m:\n _warn(i, stripped, f"invalid ebv syntax in {path}", state)\n continue\n key = m.group(1)\n value = m.group(2).strip().strip(\'"\')\n state["vars"][key] = value\n loaded += 1\n\n _log(f"Loaded {loaded} variable(s) from: {path}", line_num)\n\n\ndef _handle_make_file(\n args: str, state: dict, line_num: int, lines: list[str], index: int, **_\n) -> int:\n with_split = re.split(r\'\\bwith\\b\', args, maxsplit=1)\n path_part = with_split[0].strip()\n content_part = with_split[1].strip() if len(with_split) > 1 else None\n\n path_strings = _extract_strings(path_part, state)\n if not path_strings:\n _warn(line_num, args, "missing path for make-file", state)\n return index\n\n full_path = _resolve(path_strings[0], state)\n\n if content_part is not None and content_part == "---":\n block_lines = []\n current = index\n closed = False\n while current < len(lines):\n raw = lines[current]\n current += 1\n stripped = raw.rstrip("\\n").rstrip("\\r")\n if stripped.strip() == "---":\n closed = True\n break\n block_lines.append(stripped)\n if not closed:\n _warn(line_num, args, "multi-line block opened with --- but never closed", state)\n content = _interpolate("\\n".join(block_lines), state)\n index = current\n else:\n content = ""\n if content_part is not None:\n content_strings = _extract_strings(content_part, state)\n content = content_strings[0] if content_strings else ""\n\n if _is_dry(state):\n action = "Would overwrite" if os.path.exists(full_path) else "Would create"\n preview = content[:60].replace("\\n", "↵") + ("..." if len(content) > 60 else "")\n _log(f"[DRY RUN] {action} file: {full_path} ({len(content)} chars: {preview!r})", line_num)\n return index\n\n if os.path.exists(full_path):\n _warn(line_num, args, f"overwriting existing file: {full_path!r}", state)\n\n parent = os.path.dirname(full_path)\n if parent:\n os.makedirs(parent, exist_ok=True)\n with open(full_path, "w", encoding="utf-8") as f:\n f.write(content)\n _log(f"Created file: {full_path}", line_num)\n return index\n\n\ndef _handle_append_file(\n args: str, state: dict, line_num: int, lines: list[str], index: int, **_\n) -> int:\n """append-file "path" with "content" — appends to a file, creates if missing."""\n with_split = re.split(r\'\\bwith\\b\', args, maxsplit=1)\n path_part = with_split[0].strip()\n content_part = with_split[1].strip() if len(with_split) > 1 else None\n\n path_strings = _extract_strings(path_part, state)\n if not path_strings:\n _warn(line_num, args, "missing path for append-file", state)\n return index\n\n full_path = _resolve(path_strings[0], state)\n\n if content_part is not None and content_part == "---":\n block_lines = []\n current = index\n closed = False\n while current < len(lines):\n raw = lines[current]\n current += 1\n stripped = raw.rstrip("\\n").rstrip("\\r")\n if stripped.strip() == "---":\n closed = True\n break\n block_lines.append(stripped)\n if not closed:\n _warn(line_num, args, "multi-line block opened with --- but never closed", state)\n content = _interpolate("\\n".join(block_lines), state)\n index = current\n else:\n content = ""\n if content_part is not None:\n content_strings = _extract_strings(content_part, state)\n content = content_strings[0] if content_strings else ""\n\n if _is_dry(state):\n preview = content[:60].replace("\\n", "↵") + ("..." if len(content) > 60 else "")\n _log(f"[DRY RUN] Would append to file: {full_path} ({len(content)} chars: {preview!r})", line_num)\n return index\n\n parent = os.path.dirname(full_path)\n if parent:\n os.makedirs(parent, exist_ok=True)\n # Append a newline before content if the file already exists and is non-empty\n prefix = ""\n if os.path.exists(full_path) and os.path.getsize(full_path) > 0:\n prefix = "\\n"\n with open(full_path, "a", encoding="utf-8") as f:\n f.write(prefix + content)\n _log(f"Appended to file: {full_path}", line_num)\n return index\n\n\ndef _handle_copy_file(args: str, state: dict, line_num: int, **_) -> None:\n to_split = re.split(r\'\\bto\\b\', args, maxsplit=1)\n if len(to_split) != 2:\n _warn(line_num, args, \'copy-file requires: copy-file "src" to "dst"\', state)\n return\n src_parts = _extract_strings(to_split[0].strip(), state)\n dst_parts = _extract_strings(to_split[1].strip(), state)\n if not src_parts or not dst_parts:\n _warn(line_num, args, "copy-file missing source or destination", state)\n return\n src = _resolve(src_parts[0], state)\n dst = _resolve(dst_parts[0], state)\n if not os.path.exists(src):\n _warn(line_num, args, f"source does not exist: {src!r}", state)\n return\n if _is_dry(state):\n _log(f"[DRY RUN] Would copy: {src} → {dst}", line_num)\n return\n dst_parent = os.path.dirname(dst)\n if dst_parent:\n os.makedirs(dst_parent, exist_ok=True)\n if os.path.isdir(src):\n if os.path.exists(dst):\n shutil.rmtree(dst)\n shutil.copytree(src, dst)\n else:\n shutil.copy2(src, dst)\n _log(f"Copied: {src} → {dst}", line_num)\n\n\ndef _handle_move_file(args: str, state: dict, line_num: int, **_) -> None:\n to_split = re.split(r\'\\bto\\b\', args, maxsplit=1)\n if len(to_split) != 2:\n _warn(line_num, args, \'move-file requires: move-file "src" to "dst"\', state)\n return\n src_parts = _extract_strings(to_split[0].strip(), state)\n dst_parts = _extract_strings(to_split[1].strip(), state)\n if not src_parts or not dst_parts:\n _warn(line_num, args, "move-file missing source or destination", state)\n return\n src = _resolve(src_parts[0], state)\n dst = _resolve(dst_parts[0], state)\n if not os.path.exists(src):\n _warn(line_num, args, f"source does not exist: {src!r}", state)\n return\n if _is_dry(state):\n _log(f"[DRY RUN] Would move: {src} → {dst}", line_num)\n return\n dst_parent = os.path.dirname(dst)\n if dst_parent:\n os.makedirs(dst_parent, exist_ok=True)\n shutil.move(src, dst)\n _log(f"Moved: {src} → {dst}", line_num)\n\n\ndef _handle_make_zip(args: str, state: dict, line_num: int, **_) -> None:\n from_split = re.split(r\'\\bfrom\\b\', args, maxsplit=1)\n if len(from_split) != 2:\n _warn(line_num, args, \'make-zip requires: make-zip "out.zip" from "source" ["source2" ...]\', state)\n return\n zip_parts = _extract_strings(from_split[0].strip(), state)\n src_parts = _extract_strings(from_split[1].strip(), state)\n if not zip_parts or not src_parts:\n _warn(line_num, args, "make-zip missing output path or source(s)", state)\n return\n zip_path = _resolve(zip_parts[0], state)\n sources = [_resolve(s, state) for s in src_parts]\n\n missing = [s for s in sources if not os.path.exists(s)]\n if missing:\n for m in missing:\n _warn(line_num, args, f"source does not exist: {m!r}", state)\n return\n\n src_label = ", ".join(sources)\n if _is_dry(state):\n _log(f"[DRY RUN] Would create zip: {zip_path} ← {src_label}", line_num)\n return\n\n zip_parent = os.path.dirname(zip_path)\n if zip_parent:\n os.makedirs(zip_parent, exist_ok=True)\n with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:\n for src in sources:\n if os.path.isdir(src):\n for root, _, files in os.walk(src):\n for file in files:\n abs_path = os.path.join(root, file)\n arc_name = os.path.relpath(abs_path, start=os.path.dirname(src))\n zf.write(abs_path, arc_name)\n else:\n zf.write(src, os.path.basename(src))\n _log(f"Created zip: {zip_path} ← {src_label}", line_num)\n\n\ndef _handle_extract(args: str, state: dict, line_num: int, **_) -> None:\n """extract \\"archive.zip\\" to \\"dest/\\" — extracts all contents, merging into dest if it exists."""\n to_split = re.split(r\'\\bto\\b\', args, maxsplit=1)\n if len(to_split) != 2:\n _warn(line_num, args, \'extract requires: extract "archive.zip" to "dest/"\', state)\n return\n zip_parts = _extract_strings(to_split[0].strip(), state)\n dst_parts = _extract_strings(to_split[1].strip(), state)\n if not zip_parts or not dst_parts:\n _warn(line_num, args, "extract missing zip path or destination", state)\n return\n zip_path = _resolve(zip_parts[0], state)\n dst = _resolve(dst_parts[0], state)\n if not os.path.exists(zip_path):\n _warn(line_num, args, f"zip file does not exist: {zip_path!r}", state)\n return\n if not zipfile.is_zipfile(zip_path):\n _warn(line_num, args, f"not a valid zip file: {zip_path!r}", state)\n return\n if _is_dry(state):\n _log(f"[DRY RUN] Would extract zip: {zip_path} → {dst}", line_num)\n return\n os.makedirs(dst, exist_ok=True)\n with zipfile.ZipFile(zip_path, "r") as zf:\n zf.extractall(dst)\n _log(f"Extracted zip: {zip_path} → {dst}", line_num)\n\n\ndef _handle_extract_file(args: str, state: dict, line_num: int, **_) -> None:\n """extract-file \\"inner/file.csv\\" from \\"archive.zip\\" to \\"output/file.csv\\" — extracts a single file."""\n from_split = re.split(r\'\\bfrom\\b\', args, maxsplit=1)\n if len(from_split) != 2:\n _warn(line_num, args, \'extract-file requires: extract-file "inner/path" from "archive.zip" to "dest"\', state)\n return\n inner_parts = _extract_strings(from_split[0].strip(), state)\n rest = from_split[1].strip()\n\n to_split = re.split(r\'\\bto\\b\', rest, maxsplit=1)\n if len(to_split) != 2:\n _warn(line_num, args, \'extract-file requires: extract-file "inner/path" from "archive.zip" to "dest"\', state)\n return\n zip_parts = _extract_strings(to_split[0].strip(), state)\n dst_parts = _extract_strings(to_split[1].strip(), state)\n\n if not inner_parts or not zip_parts or not dst_parts:\n _warn(line_num, args, "extract-file missing inner path, zip path, or destination", state)\n return\n\n inner_path = inner_parts[0]\n zip_path = _resolve(zip_parts[0], state)\n dst = _resolve(dst_parts[0], state)\n\n if not os.path.exists(zip_path):\n _warn(line_num, args, f"zip file does not exist: {zip_path!r}", state)\n return\n if not zipfile.is_zipfile(zip_path):\n _warn(line_num, args, f"not a valid zip file: {zip_path!r}", state)\n return\n\n with zipfile.ZipFile(zip_path, "r") as zf:\n if inner_path not in zf.namelist():\n _warn(line_num, args, f"file not found inside zip: {inner_path!r}", state)\n return\n if _is_dry(state):\n _log(f"[DRY RUN] Would extract file: {inner_path} from {zip_path} → {dst}", line_num)\n return\n dst_parent = os.path.dirname(dst)\n if dst_parent:\n os.makedirs(dst_parent, exist_ok=True)\n with zf.open(inner_path) as src_f, open(dst, "wb") as dst_f:\n dst_f.write(src_f.read())\n _log(f"Extracted file: {inner_path} from {zip_path} → {dst}", line_num)\n\n\ndef _handle_delete_file(args: str, state: dict, line_num: int, **_) -> None:\n """delete-file "path" — deletes a file. Warns if it doesn\'t exist."""\n parts = _extract_strings(args, state)\n if not parts:\n _warn(line_num, args, "missing path for delete-file", state)\n return\n full_path = _resolve(parts[0], state)\n if not os.path.exists(full_path):\n _warn(line_num, args, f"file does not exist: {full_path!r}", state)\n return\n if os.path.isdir(full_path):\n _warn(line_num, args, f"path is a folder, use delete-folder: {full_path!r}", state)\n return\n if _is_dry(state):\n _log(f"[DRY RUN] Would delete file: {full_path}", line_num)\n return\n os.remove(full_path)\n _log(f"Deleted file: {full_path}", line_num)\n\n\ndef _handle_delete_folder(args: str, state: dict, line_num: int, **_) -> None:\n """delete-folder "path" — deletes a folder and all its contents. Warns if it doesn\'t exist."""\n parts = _extract_strings(args, state)\n if not parts:\n _warn(line_num, args, "missing path for delete-folder", state)\n return\n full_path = _resolve(parts[0], state)\n if not os.path.exists(full_path):\n _warn(line_num, args, f"folder does not exist: {full_path!r}", state)\n return\n if not os.path.isdir(full_path):\n _warn(line_num, args, f"path is a file, use delete-file: {full_path!r}", state)\n return\n if _is_dry(state):\n _log(f"[DRY RUN] Would delete folder: {full_path}", line_num)\n return\n shutil.rmtree(full_path)\n _log(f"Deleted folder: {full_path}", line_num)\n\n\ndef _handle_define_template(\n args: str, state: dict, line_num: int, lines: list[str], index: int, **_\n) -> int:\n name_parts = _extract_strings(args, state)\n if not name_parts:\n _warn(line_num, args, "define-template requires a name", state)\n while index < len(lines):\n if lines[index].strip().lower() == "end-template":\n index += 1\n break\n index += 1\n return index\n\n name = name_parts[0]\n body, index = _collect_block(lines, index, r\'define-template\\b\', "end-template", line_num, state)\n state["templates"][name] = body\n _log(f"Template defined: {name} ({len(body)} lines)", line_num)\n return index\n\n\ndef _handle_use_template(args: str, state: dict, line_num: int, **_) -> None:\n name_match = re.match(r\'\\s*"([^"]+)"\\s*(.*)\', args) or re.match(r\'\\s*(\\S+)\\s*(.*)\', args)\n if not name_match:\n _warn(line_num, args, "use-template requires a template name", state)\n return\n\n name = _interpolate(name_match.group(1), state)\n rest = name_match.group(2).strip()\n\n if name not in state["templates"]:\n _warn(line_num, args, f"undefined template: \'{name}\'", state)\n return\n\n call_vars: dict[str, str] = {}\n for m in re.finditer(r\'(\\w+)\\s+"([^"]*)"\', rest):\n call_vars[m.group(1)] = _interpolate(m.group(2), state)\n\n child_state: dict = {\n "working_folder": state["working_folder"],\n "script_dir": state["script_dir"],\n "strict": state["strict"],\n "dry_run": state.get("dry_run", False),\n "templates": state["templates"],\n "vars": {**state["vars"], **call_vars},\n }\n\n _log(f"Using template: {name}" + (f" with {call_vars}" if call_vars else ""), line_num)\n _execute(\n [line + "\\n" for line in state["templates"][name]],\n child_state,\n source_label=f"<template:{name}>",\n )\n state["working_folder"] = child_state["working_folder"]\n\n\ndef _handle_include(args: str, state: dict, line_num: int, **_) -> None:\n parts = _extract_strings(args, state)\n if not parts:\n _warn(line_num, args, "include requires a file path", state)\n return\n path = parts[0]\n # Remote includes are not supported — include is local-only\n if path.startswith("http://") or path.startswith("https://"):\n _warn(line_num, args, "include does not support remote URLs — use a local path", state)\n return\n path = _resolve_include(path, state)\n if not os.path.exists(path):\n _warn(line_num, args, f"included file not found: {path!r}", state)\n return\n _log(f"Including: {path}", line_num)\n try:\n with open(path, "r", encoding="utf-8") as f:\n included_lines = f.readlines()\n except OSError as e:\n _warn(line_num, args, f"could not read included file: {e}", state)\n return\n prev_script_dir = state["script_dir"]\n state["script_dir"] = os.path.dirname(os.path.abspath(path))\n _execute(included_lines, state, source_label=path)\n state["script_dir"] = prev_script_dir\n\n\ndef _handle_for(\n args: str, state: dict, line_num: int, lines: list[str], index: int, **_\n) -> int:\n """\n for <var> in "a" "b" "c" — value list\n for <var> in 1 to 5 — integer range (inclusive)\n """\n # Collect the loop body first\n body, index = _collect_block(lines, index, r\'for\\b\', "end-for", line_num, state)\n\n # Parse: for <var> in ...\n m = re.match(r\'(\\w+)\\s+in\\s+(.*)\', args.strip(), re.IGNORECASE)\n if not m:\n _warn(line_num, args, "for syntax: for <var> in <values> OR for <var> in <n> to <m>", state)\n return index\n\n var_name = m.group(1)\n rest = m.group(2).strip()\n\n # Range: "1 to 5" or "1 to {{count}}" (variable bounds)\n range_m = re.match(r\'^(.+?)\\s+to\\s+(.+)$\', rest, re.IGNORECASE)\n if range_m:\n start_str = _interpolate(range_m.group(1).strip(), state)\n end_str = _interpolate(range_m.group(2).strip(), state)\n try:\n start, end = int(start_str), int(end_str)\n values = [str(i) for i in range(start, end + 1)]\n except ValueError:\n _warn(line_num, args, f"range bounds must be integers, got {start_str!r} and {end_str!r}", state)\n return index\n else:\n # Value list: quoted or bare tokens (bare tokens are interpolated)\n quoted = re.findall(r\'"([^"]*)"\', rest)\n if quoted:\n values = quoted\n else:\n values = [_interpolate(t, state) for t in rest.split()]\n\n for val in values:\n child_state: dict = {\n "working_folder": state["working_folder"],\n "script_dir": state["script_dir"],\n "strict": state["strict"],\n "dry_run": state.get("dry_run", False),\n "templates": state["templates"],\n "vars": {**state["vars"], var_name: _interpolate(val, state)},\n }\n _execute([line + "\\n" for line in body], child_state, source_label=f"<for:{var_name}={val}>")\n # Propagate working_folder changes out of the loop body\n state["working_folder"] = child_state["working_folder"]\n\n return index\n\n\ndef _split_if_block(body: list[str]) -> list[tuple[str | None, list[str]]]:\n """\n Split a collected if-body into segments:\n [(condition_or_None, lines), ...]\n The first segment\'s condition is None (it was the original if condition).\n else-if segments have a condition string.\n else segment has condition None.\n Handles nesting by tracking depth.\n """\n segments: list[tuple] = []\n current: list[str] = []\n current_cond: str | None = "__initial__"\n depth = 0\n\n for line in body:\n stripped = line.strip().lower()\n\n if re.match(r\'if\\b\', stripped):\n depth += 1\n current.append(line)\n elif stripped == "end-if":\n if depth > 0:\n depth -= 1\n current.append(line)\n # else: shouldn\'t happen since _collect_block consumed the outer end-if\n elif depth == 0 and re.match(r\'else-if\\b\', stripped):\n segments.append((current_cond, current))\n # Extract the condition from "else-if <condition>"\n current_cond = line.strip()[len("else-if"):].strip()\n current = []\n elif depth == 0 and stripped == "else":\n segments.append((current_cond, current))\n current_cond = None # None = unconditional else\n current = []\n else:\n current.append(line)\n\n segments.append((current_cond, current))\n return segments\n\n\ndef _handle_if(\n args: str, state: dict, line_num: int, lines: list[str], index: int, **_\n) -> int:\n """\n if <condition>\n ...\n else-if <condition>\n ...\n else\n ...\n end-if\n """\n body, index = _collect_block(lines, index, r\'if\\b\', "end-if", line_num, state)\n\n # Build the chain: [(condition, body_lines), ...]\n # First entry uses the original if condition\n segments = _split_if_block(body)\n\n for seg_idx, (cond, seg_body) in enumerate(segments):\n if seg_idx == 0:\n # First segment: use the original if condition\n result = _evaluate_condition(args.strip(), state, line_num)\n elif cond is None:\n # else block — always executes if we get here\n result = True\n else:\n result = _evaluate_condition(cond, state, line_num)\n\n if result:\n _execute([line + "\\n" for line in seg_body], state, source_label="<if>")\n break # short-circuit: only execute the first matching branch\n\n return index\n\n\ndef _evaluate_condition(condition: str, state: dict, line_num: int) -> bool:\n """Evaluate an if condition and return True or False."""\n\n # if exists "path" / if not exists "path"\n m = re.match(r\'^(not\\s+)?exists\\s+"([^"]*)"$\', condition, re.IGNORECASE)\n if m:\n negate = bool(m.group(1))\n path = _resolve(_interpolate(m.group(2), state), state)\n result = os.path.exists(path)\n return not result if negate else result\n\n # if "{{var}}" is "value" / if "{{var}}" is not "value"\n m = re.match(r\'^"([^"]*)"\\s+is(\\s+not)?\\s+"([^"]*)"$\', condition, re.IGNORECASE)\n if m:\n lhs = _interpolate(m.group(1), state)\n negate = bool(m.group(2))\n rhs = _interpolate(m.group(3), state)\n result = lhs == rhs\n return not result if negate else result\n\n # Forgiving: bare word comparison without quotes\n m = re.match(r\'^(\\S+)\\s+is(\\s+not)?\\s+(\\S+)$\', condition, re.IGNORECASE)\n if m:\n lhs = _interpolate(m.group(1), state)\n negate = bool(m.group(2))\n rhs = _interpolate(m.group(3), state)\n result = lhs == rhs\n return not result if negate else result\n\n _warn(line_num, condition, "unrecognised if condition", state)\n return False\n\n\ndef _handle_print(args: str, state: dict, line_num: int, **_) -> None:\n """print "message" — outputs a message to stdout."""\n parts = _extract_strings(args, state)\n msg = parts[0] if parts else _interpolate(args.strip(), state)\n print(msg)\n\n\ndef _handle_set_from_env(args: str, state: dict, line_num: int, **_) -> None:\n """set-from-env <name> "ENV_VAR" — loads an environment variable into state."""\n match = re.match(r\'(\\w+)\\s+"([^"]*)"\', args.strip()) or re.match(r\'(\\w+)\\s+(\\S+)\', args.strip())\n if not match:\n _warn(line_num, args, \'set-from-env requires: set-from-env <name> "ENV_VAR"\', state)\n return\n name, env_key = match.group(1), match.group(2)\n value = os.environ.get(env_key)\n if value is None:\n _warn(line_num, args, f"environment variable {env_key!r} is not set", state)\n return\n state["vars"][name] = value\n _log(f"Variable set from env: {name} = {value!r}", line_num)\n\n\n\ndef _handle_replace_file(\n args: str, state: dict, line_num: int, lines: list[str], index: int, **_\n) -> int:\n """replace-file — same as make-file but always overwrites, even in strict mode."""\n with_split = re.split(r\'\\bwith\\b\', args, maxsplit=1)\n path_part = with_split[0].strip()\n content_part = with_split[1].strip() if len(with_split) > 1 else None\n\n path_strings = _extract_strings(path_part, state)\n if not path_strings:\n _warn(line_num, args, "missing path for replace-file", state)\n return index\n\n full_path = _resolve(path_strings[0], state)\n\n if content_part is not None and content_part == "---":\n block_lines = []\n current = index\n closed = False\n while current < len(lines):\n raw = lines[current]\n current += 1\n stripped = raw.rstrip("\\n").rstrip("\\r")\n if stripped.strip() == "---":\n closed = True\n break\n block_lines.append(stripped)\n if not closed:\n _warn(line_num, args, "multi-line block opened with --- but never closed", state)\n content = _interpolate("\\n".join(block_lines), state)\n index = current\n else:\n content = ""\n if content_part is not None:\n content_strings = _extract_strings(content_part, state)\n content = content_strings[0] if content_strings else ""\n\n if _is_dry(state):\n action = "Would overwrite" if os.path.exists(full_path) else "Would create"\n preview = content[:60].replace("\\n", "↵") + ("..." if len(content) > 60 else "")\n _log(f"[DRY RUN] {action} file (replace): {full_path} ({len(content)} chars: {preview!r})", line_num)\n return index\n\n parent = os.path.dirname(full_path)\n if parent:\n os.makedirs(parent, exist_ok=True)\n with open(full_path, "w", encoding="utf-8") as f:\n f.write(content)\n _log(f"Replaced file: {full_path}", line_num)\n return index\n\n\ndef _parse_patch(patch_lines: list[str], state: dict) -> list[tuple]:\n """\n Parse patch instructions into operations:\n ("replace", line_num, content) — l1+ new content\n ("delete", line_num, guard) — l1- [exact match guard]\n ("insert", line_num, content) — l1> insert before line N\n\n Line numbers are 1-based and refer to the ORIGINAL file.\n """\n ops = []\n for raw in patch_lines:\n line = raw.strip()\n if not line or line.startswith("#"):\n continue\n m = re.match(r\'l(\\d+)([+\\->])(.*)\', line)\n if not m:\n continue\n lnum = int(m.group(1))\n op = m.group(2)\n payload = m.group(3).strip() if m.group(3).strip() else None\n payload = _interpolate(payload, state) if payload else None\n\n if op == "+":\n ops.append(("replace", lnum, payload or ""))\n elif op == "-":\n ops.append(("delete", lnum, payload))\n elif op == ">":\n ops.append(("insert", lnum, payload or ""))\n return ops\n\n\ndef _apply_patch(\n original_lines: list[str], ops: list[tuple], state: dict, line_num: int\n) -> list[str]:\n """\n Apply patch ops to original_lines.\n All line numbers refer to the ORIGINAL file (1-based).\n Parsed first, then applied in one pass.\n """\n replacements: dict[int, str] = {}\n deletions: set[int] = set()\n insertions: dict[int, list[str]] = {}\n\n for op_type, lnum, payload in ops:\n if op_type == "replace":\n replacements[lnum] = payload\n elif op_type == "delete":\n orig = (\n original_lines[lnum - 1].rstrip("\\n").rstrip("\\r")\n if lnum <= len(original_lines) else None\n )\n if payload is None or orig == payload:\n deletions.add(lnum)\n else:\n _warn(line_num, f"l{lnum}- {payload}",\n f"line {lnum} mismatch — expected {payload!r}, got {orig!r}", state)\n elif op_type == "insert":\n insertions.setdefault(lnum, []).append(payload)\n\n result: list[str] = []\n for i, orig_line in enumerate(original_lines, start=1):\n for ins in insertions.get(i, []):\n result.append(ins + "\\n")\n if i in deletions:\n continue\n if i in replacements:\n result.append(replacements[i] + "\\n")\n else:\n result.append(orig_line if orig_line.endswith("\\n") else orig_line + "\\n")\n\n # Handle ops beyond end of file\n beyond = sorted(set(\n list({n for n in replacements if n > len(original_lines)}) +\n list({n for n in insertions if n > len(original_lines)})\n ))\n for n in beyond:\n for ins in insertions.get(n, []):\n result.append(ins + "\\n")\n if n in replacements:\n result.append(replacements[n] + "\\n")\n\n return result\n\n\ndef _handle_edit_file(\n args: str, state: dict, line_num: int, lines: list[str], index: int, **_\n) -> int:\n """\n edit-file "path" with ---\n l1+ replacement line\n l2> insert before line 2\n l3-\n l5- exact match guard\n ---\n """\n with_split = re.split(r\'\\bwith\\b\', args, maxsplit=1)\n path_part = with_split[0].strip()\n content_part = with_split[1].strip() if len(with_split) > 1 else None\n\n path_strings = _extract_strings(path_part, state)\n if not path_strings:\n _warn(line_num, args, "missing path for edit-file", state)\n return index\n\n if content_part != "---":\n _warn(line_num, args, "edit-file requires a --- patch block", state)\n return index\n\n # Collect patch block\n patch_raw: list[str] = []\n current = index\n closed = False\n while current < len(lines):\n raw = lines[current]\n current += 1\n if raw.strip() == "---":\n closed = True\n break\n patch_raw.append(raw.rstrip("\\n").rstrip("\\r"))\n if not closed:\n _warn(line_num, args, "edit-file patch block opened with --- but never closed", state)\n index = current\n\n full_path = _resolve(path_strings[0], state)\n\n if not os.path.exists(full_path):\n _warn(line_num, args, f"edit-file: file does not exist: {full_path!r}", state)\n return index\n\n ops = _parse_patch(patch_raw, state)\n\n if _is_dry(state):\n _log(f"[DRY RUN] Would edit file: {full_path} ({len(ops)} operation(s))", line_num)\n return index\n\n try:\n with open(full_path, "r", encoding="utf-8") as f:\n original_lines = f.readlines()\n except OSError as e:\n _warn(line_num, args, f"edit-file: could not read file: {e}", state)\n return index\n\n result = _apply_patch(original_lines, ops, state, line_num)\n\n with open(full_path, "w", encoding="utf-8") as f:\n f.writelines(result)\n\n _log(f"Edited file: {full_path} ({len(ops)} operation(s))", line_num)\n return index\n\n\ndef _handle_start_new(args: str, state: dict, line_num: int, **_) -> None:\n """start-new \\"path.bc\\" or start-new \\"https://...\\" — runs a new script with a clean state."""\n import urllib.request\n import urllib.error\n\n parts = _extract_strings(args, state)\n if not parts:\n raise BytecraftError(f"[Bytecraft] ERROR (line {line_num}): start-new requires a file path or URL")\n\n target = parts[0]\n is_remote = target.startswith("http://") or target.startswith("https://")\n\n if _is_dry(state):\n _log(f"[DRY RUN] Would start new scaffold: {target}", line_num)\n return\n\n # Build a completely clean state — no vars, no templates, no working folder\n new_state: dict = {\n "working_folder": None,\n "vars": {},\n "templates": {},\n "strict": False,\n "dry_run": state.get("dry_run", False),\n "script_dir": os.getcwd(),\n }\n\n if is_remote:\n _log(f"Starting new scaffold from remote: {target}", line_num)\n try:\n with urllib.request.urlopen(target, timeout=15) as response:\n source = response.read().decode("utf-8")\n except urllib.error.HTTPError as e:\n raise BytecraftError(\n f"[Bytecraft] ERROR (line {line_num}): HTTP {e.code} fetching {target!r}"\n )\n except urllib.error.URLError as e:\n raise BytecraftError(\n f"[Bytecraft] ERROR (line {line_num}): Could not reach {target!r} — {e.reason}"\n )\n except Exception as e:\n raise BytecraftError(\n f"[Bytecraft] ERROR (line {line_num}): Failed to fetch {target!r} — {e}"\n )\n lines = source.splitlines(keepends=True)\n _execute(lines, new_state, source_label=target)\n else:\n path = _resolve_include(target, state)\n if not os.path.exists(path):\n raise BytecraftError(\n f"[Bytecraft] ERROR (line {line_num}): start-new file not found: {path!r}"\n )\n _log(f"Starting new scaffold from: {path}", line_num)\n new_state["script_dir"] = os.path.dirname(os.path.abspath(path))\n try:\n with open(path, "r", encoding="utf-8") as f:\n lines = f.readlines()\n except OSError as e:\n raise BytecraftError(\n f"[Bytecraft] ERROR (line {line_num}): Could not read {path!r} — {e}"\n )\n _execute(lines, new_state, source_label=path)\n\n\n# ─────────────────────────────────────────────\n# Dispatcher\n# ─────────────────────────────────────────────\n\nCOMMANDS = [\n ("set-working-folder", _handle_set_working_folder),\n ("set-from-env", _handle_set_from_env),\n ("define-template", _handle_define_template),\n ("use-template", _handle_use_template),\n ("replace-file", _handle_replace_file),\n ("edit-file", _handle_edit_file),\n ("append-file", _handle_append_file),\n ("make-folder", _handle_make_folder),\n ("make-file", _handle_make_file),\n ("make-zip", _handle_make_zip),\n ("extract-file", _handle_extract_file),\n ("extract", _handle_extract),\n ("copy-file", _handle_copy_file),\n ("delete-folder", _handle_delete_folder),\n ("delete-file", _handle_delete_file),\n ("move-file", _handle_move_file),\n ("load-vars", _handle_load_vars),\n ("include", _handle_include),\n ("start-new", _handle_start_new),\n ("strict", _handle_strict),\n ("print", _handle_print),\n ("set", _handle_set),\n ("for", _handle_for),\n ("if", _handle_if),\n]\n\n_MULTILINE_CMDS = {"make-file", "replace-file", "edit-file", "append-file", "define-template", "for", "if"}\n\n\ndef _dispatch(\n line: str, state: dict, line_num: int,\n lines: list[str], index: int, source_label: str = "<script>",\n) -> int:\n stripped = line.strip()\n if not stripped or stripped.startswith("#"):\n return index\n\n lower = stripped.lower()\n for cmd, handler in COMMANDS:\n if lower.startswith(cmd):\n # Ensure it\'s a whole word match (not e.g. "set-working-folder" matching "set")\n rest = stripped[len(cmd):]\n if rest and rest[0].isalnum():\n continue\n args = rest.strip()\n if cmd in _MULTILINE_CMDS:\n index = handler(args, state, line_num, lines=lines, index=index)\n else:\n handler(args, state, line_num)\n return index\n\n _warn(line_num, stripped, "unknown command", state)\n return index\n\n\n# ─────────────────────────────────────────────\n# Public entry point\n# ─────────────────────────────────────────────\n\ndef run(script_path: str, dry_run: bool = False) -> None:\n try:\n with open(script_path, "r", encoding="utf-8") as f:\n lines = f.readlines()\n except FileNotFoundError:\n print(f"[Bytecraft] ERROR: File not found: {script_path!r}", file=sys.stderr)\n sys.exit(1)\n except OSError as e:\n print(f"[Bytecraft] ERROR: Could not read file: {e}", file=sys.stderr)\n sys.exit(1)\n\n state: dict = {\n "working_folder": None,\n "vars": {},\n "templates": {},\n "strict": False,\n "dry_run": dry_run,\n "script_dir": os.path.dirname(os.path.abspath(script_path)),\n }\n\n if dry_run:\n print("[Bytecraft] *** DRY RUN — no files or folders will be written ***")\n\n try:\n _execute(lines, state, source_label=script_path)\n except BytecraftError as e:\n print(str(e), file=sys.stderr)\n sys.exit(1)\n\n\ndef run_from_url(url: str, dry_run: bool = False) -> None:\n import urllib.request\n import urllib.error\n\n _log(f"Fetching remote script: {url}")\n try:\n with urllib.request.urlopen(url, timeout=15) as response:\n source = response.read().decode("utf-8")\n except urllib.error.HTTPError as e:\n print(f"[Bytecraft] ERROR: HTTP {e.code} fetching {url!r}", file=sys.stderr)\n sys.exit(1)\n except urllib.error.URLError as e:\n print(f"[Bytecraft] ERROR: Could not reach {url!r} — {e.reason}", file=sys.stderr)\n sys.exit(1)\n except Exception as e:\n print(f"[Bytecraft] ERROR: Failed to fetch {url!r} — {e}", file=sys.stderr)\n sys.exit(1)\n\n lines = source.splitlines(keepends=True)\n\n state: dict = {\n "working_folder": None,\n "vars": {},\n "templates": {},\n "strict": False,\n "dry_run": dry_run,\n "script_dir": os.getcwd(),\n }\n\n if dry_run:\n print("[Bytecraft] *** DRY RUN — no files or folders will be written ***")\n\n try:\n _execute(lines, state, source_label=url)\n except BytecraftError as e:\n print(str(e), file=sys.stderr)\n sys.exit(1)\n'
MAIN = '"""\nEntry point for: python -m bytecraft file.bc\n\nUsage:\n bytecraft <script.bc> Run a local script\n bytecraft <https://...> Fetch and run a remote .bc script\n bytecraft --dry-run <script.bc> Preview what the script would do (no writes)\n bytecraft --help Show language reference\n bytecraft --version Show version\n"""\n\nimport sys\nfrom .interpreter import run, run_from_url\n\nVERSION = "0.6.4"\n\nHELP_TEXT = """\n╔══════════════════════════════════════════════════════════════╗\n║ Bytecraft DSL · Language Reference ║\n║ v0.6.4 ║\n╚══════════════════════════════════════════════════════════════╝\n\nUSAGE\n bytecraft [--dry-run] <script.bc>\n bytecraft [--dry-run] <https://example.com/script.bc>\n bytecraft --help\n bytecraft --version\n\n --dry-run Preview all operations without writing anything to disk.\n Works with both local and remote scripts.\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nREMOTE SCRIPTS\n Pass a URL instead of a file path to fetch and run a .bc script\n directly from the internet — no download required.\n\n bytecraft https://example.com/scaffold.bc\n bytecraft --dry-run https://example.com/scaffold.bc\n\n Notes:\n · The script is fetched over HTTP/HTTPS and executed in memory.\n · Relative paths in the script resolve from your current directory.\n · include is local-only — remote scripts cannot include other URLs.\n · If the URL is unreachable or returns an error, execution halts.\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nCOMMENTS\n # This is a comment\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nVARIABLES\n set <n> "value"\n Assign a variable. Value supports interpolation.\n Example: set app "myapp"\n\n set-from-env <n> "ENV_VAR"\n Load an environment variable into a bytecraft variable.\n Example: set-from-env token "GITHUB_TOKEN"\n\n load-vars "file.ebv"\n Load key = value pairs from an external vars file.\n Example: load-vars "config.ebv"\n File format:\n name = myapp\n version = 1.0\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nINTERPOLATION\n Use {{name}} anywhere in a string to substitute a variable.\n\n Pipe transforms:\n {{name|upper}} -> "MYAPP"\n {{name|lower}} -> "myapp"\n {{name|capitalize}} -> "Myapp"\n {{name|trim}} -> strips whitespace\n {{name|len}} -> character count as string\n {{name|replace:_:-}} -> replace underscores with hyphens\n\n Arithmetic:\n {{i + 1}} -> integer addition\n {{count * 2}} -> multiply\n {{total / 4}} -> divide\n {{score - 1}} -> subtract\n\n Format specs:\n {{i:03}} -> zero-padded integer, e.g. "007"\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nFILES AND FOLDERS\n make-folder "path"\n Create a directory (and any missing parents).\n Example: make-folder "src/utils"\n\n make-file "path" with "content"\n Create a file with inline content.\n Example: make-file "VERSION" with "1.0.0"\n\n make-file "path" with ---\n Multi-line file content block.\n Example:\n make-file "README.md" with ---\n # {{project}}\n Welcome to {{project}}.\n ---\n\n append-file "path" with "content"\n Append to a file (creates it if missing). Also supports --- blocks.\n\n replace-file "path" with "content"\n Overwrite a file unconditionally — no overwrite warning, even in\n strict mode. Use when intentional replacement is the expected\n behaviour. Also supports --- multi-line blocks.\n Example: replace-file "config.json" with "{ \\"env\\": \\"prod\\" }"\n\n copy-file "src" to "dst"\n Copy a file or directory tree.\n\n move-file "src" to "dst"\n Move a file or directory.\n\n delete-file "path"\n Delete a file.\n\n delete-folder "path"\n Delete a folder and all its contents.\n\n make-zip "out.zip" from "folder" ["folder2" ...]\n Create a zip archive from one or more files/folders.\n\n extract "archive.zip" to "dest/"\n Extract all contents of a zip archive to a folder.\n If the destination exists, contents are merged in and\n conflicting files are overwritten.\n\n extract-file "inner/file" from "archive.zip" to "dest/file"\n Extract a single file from inside a zip archive.\n Warns and skips if the file is not found inside the zip.\n\n set-working-folder "path"\n Set a base directory. All subsequent relative paths resolve\n against it. Creates the folder if it does not exist.\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nLOOPS\n for <var> in "a" "b" "c"\n ...\n end-for\n\n for <var> in 1 to 5\n ...\n end-for\n\n Variable bounds also work:\n set count "3"\n for i in 1 to {{count}}\n print "Step {{i}}"\n end-for\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nCONDITIONALS\n if exists "path"\n if not exists "path"\n if "{{var}}" is "value"\n if "{{var}}" is not "value"\n\n else-if and else are supported:\n if "{{env}}" is "prod"\n make-file "config.toml" with "env = production"\n else-if "{{env}}" is "staging"\n make-file "config.toml" with "env = staging"\n else\n make-file "config.toml" with "env = development"\n end-if\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nTEMPLATES\n define-template "name"\n ...\n end-template\n\n use-template "name" key "value" key2 "value2"\n\n Example:\n define-template "service"\n make-folder "services/{{name}}"\n make-file "services/{{name}}/index.py" with "# {{name}} service"\n end-template\n\n use-template "service" name "auth"\n use-template "service" name "billing"\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nINCLUDES\n include "other.bc"\n Execute another .bc file inline. Paths resolve relative to\n the including file. Remote URLs are not supported in include.\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nSTART NEW SCAFFOLD\n start-new "other.bc"\n start-new "https://example.com/other.bc"\n Run a separate .bc script with a completely clean state —\n no variables, no templates, no working folder carried over.\n Use this to split large scaffolds across multiple files\n without hitting file size limits.\n\n Supports both local paths and remote URLs. If the file is\n missing or the URL fails, execution halts with a fatal error.\n\n Local paths resolve relative to the calling script\'s directory.\n Remote scripts resolve relative paths from the current working\n directory, same as running them directly from the CLI.\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nOUTPUT AND MODES\n print "message"\n Write a message to stdout. Supports interpolation.\n\n strict on\n strict off\n In strict mode, warnings become errors and halt execution.\n Recommended for CI pipelines.\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nEXAMPLE SCRIPT\n set project "my-app"\n set author "Alice"\n\n make-folder "{{project}}/src"\n make-folder "{{project}}/tests"\n\n make-file "{{project}}/README.md" with ---\n # {{project|capitalize}}\n By {{author}}.\n ---\n\n for env in "dev" "staging" "prod"\n make-file "{{project}}/config.{{env}}.toml" with ---\n [env]\n name = "{{env}}"\n ---\n end-for\n\n print "Done -- {{project}} scaffolded."\n"""\n\n\ndef main() -> None:\n args = sys.argv[1:]\n\n if not args or args[0] in ("--help", "-h"):\n print(HELP_TEXT)\n sys.exit(0)\n\n if args[0] in ("--version", "-v"):\n print(f"bytecraft {VERSION}")\n sys.exit(0)\n\n dry_run = False\n if args[0] == "--dry-run":\n dry_run = True\n args = args[1:]\n\n if not args:\n print("Error: no script file or URL provided.")\n print("Usage: bytecraft [--dry-run] <script.bc>")\n print(" bytecraft [--dry-run] <https://example.com/script.bc>")\n print(" bytecraft --help")\n sys.exit(1)\n\n script = args[0]\n\n if script.startswith("http://") or script.startswith("https://"):\n run_from_url(script, dry_run=dry_run)\n else:\n run(script, dry_run=dry_run)\n\n\nif __name__ == "__main__":\n main()\n'
FILES = {
os.path.join(BASE, "bytecraft", "interpreter.py"): INTERP,
os.path.join(BASE, "bytecraft", "__main__.py"): MAIN,
}
for path, content in FILES.items():
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as fh:
fh.write(content)
print("Written: " + path)
print("All done. The repo is complete.")