dirforge-cli 0.1.0__tar.gz

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.
@@ -0,0 +1,5 @@
1
+ include README.md
2
+ include example-structure.txt
3
+ include pyproject.toml
4
+ recursive-include tests *.py
5
+ global-exclude __pycache__ *.py[cod]
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: dirforge-cli
3
+ Version: 0.1.0
4
+ Summary: Create project directory scaffolds from visual tree text files.
5
+ Keywords: cli,scaffold,project-tree,directories,filesystem
6
+ Classifier: Development Status :: 3 - Alpha
7
+ Classifier: Environment :: Console
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Topic :: Software Development :: Code Generators
17
+ Classifier: Topic :: System :: Filesystems
18
+ Classifier: Topic :: Utilities
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ Provides-Extra: dev
22
+ Requires-Dist: build>=1.2; extra == "dev"
23
+ Requires-Dist: twine>=5; extra == "dev"
24
+
25
+ # DirForge
26
+
27
+ DirForge is a dependency-free Python CLI that turns a visual project tree in a
28
+ UTF-8 text file into real directories and empty files.
29
+
30
+ > PyPI package name: `dirforge-cli`
31
+ > Installed command: `dirforge`
32
+
33
+ The distribution uses `dirforge-cli` because `dirforge` is already registered
34
+ to another project on PyPI.
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ python -m pip install dirforge-cli
40
+ ```
41
+
42
+ DirForge requires Python 3.10 or newer and has no runtime dependencies.
43
+
44
+ ## Usage
45
+
46
+ Create a text file such as `structure.txt`:
47
+
48
+ ```text
49
+ project/
50
+ ├── src/
51
+ │ └── app.py
52
+ ├── LICENSE
53
+ ├── Makefile
54
+ └── .gitignore
55
+ ```
56
+
57
+ Then create the scaffold:
58
+
59
+ ```bash
60
+ dirforge structure.txt
61
+ dirforge structure.txt --output ./projects
62
+ dirforge structure.txt -o ./projects --dry-run --verbose
63
+ ```
64
+
65
+ The repository script remains directly executable too:
66
+
67
+ ```bash
68
+ python dirforge.py example-structure.txt --dry-run
69
+ ```
70
+
71
+ ## Tree Syntax
72
+
73
+ Entries ending in `/` are directories. Every other entry is a file, including
74
+ extensionless names such as `LICENSE` and `Makefile`. Blank lines and visual
75
+ separator lines containing only `│` are ignored.
76
+
77
+ ## Options
78
+
79
+ - `-o, --output`: Choose the directory that will contain the scaffold root.
80
+ - `--dry-run`: Show planned changes without modifying the filesystem.
81
+ - `--force`: Truncate existing files before recreating them.
82
+ - `-v, --verbose`: Print every create, skip, or overwrite operation.
83
+ - `--version`: Print the installed version.
84
+
85
+ Existing directories are reused and existing files are preserved by default.
86
+ DirForge rejects path traversal, absolute paths, separators inside node names,
87
+ malformed indentation, children under files, and file/directory conflicts.
88
+
89
+ ## Development
90
+
91
+ Run the test suite with only the standard library:
92
+
93
+ ```bash
94
+ python -m unittest discover -v
95
+ ```
96
+
97
+ Install the packaging tools and build both distribution formats:
98
+
99
+ ```bash
100
+ python -m pip install -e '.[dev]'
101
+ python -m build
102
+ python -m twine check dist/*
103
+ ```
104
+
105
+ Test the release on TestPyPI before publishing it publicly:
106
+
107
+ ```bash
108
+ python -m twine upload --repository testpypi dist/*
109
+ python -m pip install --index-url https://test.pypi.org/simple/ dirforge-cli
110
+ ```
111
+
112
+ After verifying the installed `dirforge` command, upload the same artifacts to
113
+ PyPI:
114
+
115
+ ```bash
116
+ python -m twine upload dist/*
117
+ ```
118
+
119
+ PyPI does not allow replacing an uploaded version. Increment `__version__` in
120
+ `dirforge.py` before each new release; the build metadata reads it automatically.
@@ -0,0 +1,96 @@
1
+ # DirForge
2
+
3
+ DirForge is a dependency-free Python CLI that turns a visual project tree in a
4
+ UTF-8 text file into real directories and empty files.
5
+
6
+ > PyPI package name: `dirforge-cli`
7
+ > Installed command: `dirforge`
8
+
9
+ The distribution uses `dirforge-cli` because `dirforge` is already registered
10
+ to another project on PyPI.
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ python -m pip install dirforge-cli
16
+ ```
17
+
18
+ DirForge requires Python 3.10 or newer and has no runtime dependencies.
19
+
20
+ ## Usage
21
+
22
+ Create a text file such as `structure.txt`:
23
+
24
+ ```text
25
+ project/
26
+ ├── src/
27
+ │ └── app.py
28
+ ├── LICENSE
29
+ ├── Makefile
30
+ └── .gitignore
31
+ ```
32
+
33
+ Then create the scaffold:
34
+
35
+ ```bash
36
+ dirforge structure.txt
37
+ dirforge structure.txt --output ./projects
38
+ dirforge structure.txt -o ./projects --dry-run --verbose
39
+ ```
40
+
41
+ The repository script remains directly executable too:
42
+
43
+ ```bash
44
+ python dirforge.py example-structure.txt --dry-run
45
+ ```
46
+
47
+ ## Tree Syntax
48
+
49
+ Entries ending in `/` are directories. Every other entry is a file, including
50
+ extensionless names such as `LICENSE` and `Makefile`. Blank lines and visual
51
+ separator lines containing only `│` are ignored.
52
+
53
+ ## Options
54
+
55
+ - `-o, --output`: Choose the directory that will contain the scaffold root.
56
+ - `--dry-run`: Show planned changes without modifying the filesystem.
57
+ - `--force`: Truncate existing files before recreating them.
58
+ - `-v, --verbose`: Print every create, skip, or overwrite operation.
59
+ - `--version`: Print the installed version.
60
+
61
+ Existing directories are reused and existing files are preserved by default.
62
+ DirForge rejects path traversal, absolute paths, separators inside node names,
63
+ malformed indentation, children under files, and file/directory conflicts.
64
+
65
+ ## Development
66
+
67
+ Run the test suite with only the standard library:
68
+
69
+ ```bash
70
+ python -m unittest discover -v
71
+ ```
72
+
73
+ Install the packaging tools and build both distribution formats:
74
+
75
+ ```bash
76
+ python -m pip install -e '.[dev]'
77
+ python -m build
78
+ python -m twine check dist/*
79
+ ```
80
+
81
+ Test the release on TestPyPI before publishing it publicly:
82
+
83
+ ```bash
84
+ python -m twine upload --repository testpypi dist/*
85
+ python -m pip install --index-url https://test.pypi.org/simple/ dirforge-cli
86
+ ```
87
+
88
+ After verifying the installed `dirforge` command, upload the same artifacts to
89
+ PyPI:
90
+
91
+ ```bash
92
+ python -m twine upload dist/*
93
+ ```
94
+
95
+ PyPI does not allow replacing an uploaded version. Increment `__version__` in
96
+ `dirforge.py` before each new release; the build metadata reads it automatically.
@@ -0,0 +1,397 @@
1
+ #!/usr/bin/env python3
2
+ """Create a filesystem scaffold from a visual project tree."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import re
8
+ import sys
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+
12
+
13
+ INVALID_INPUT = 2
14
+ RUNTIME_FAILURE = 1
15
+
16
+ BRANCH_RE = re.compile(r"^(?P<prefix>[ │]*)(?P<branch>[├└]──)\s*(?P<name>.+?)\s*$")
17
+ WINDOWS_ABSOLUTE_RE = re.compile(r"^[A-Za-z]:[\\/]")
18
+
19
+ __version__ = "0.1.0"
20
+
21
+
22
+ class DirForgeError(Exception):
23
+ """Base class for expected, user-facing errors."""
24
+
25
+ exit_code = RUNTIME_FAILURE
26
+
27
+
28
+ class InputError(DirForgeError):
29
+ """Raised when CLI input or tree syntax is invalid."""
30
+
31
+ exit_code = INVALID_INPUT
32
+
33
+
34
+ @dataclass
35
+ class TreeNode:
36
+ name: str
37
+ is_directory: bool
38
+ line_number: int
39
+ children: list["TreeNode"] = field(default_factory=list)
40
+
41
+
42
+ @dataclass
43
+ class ScaffoldStats:
44
+ dirs_created: int = 0
45
+ files_created: int = 0
46
+ entries_skipped: int = 0
47
+ dirs_to_create: int = 0
48
+ files_to_create: int = 0
49
+ existing_entries: int = 0
50
+ files_overwritten: int = 0
51
+
52
+
53
+ def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
54
+ parser = argparse.ArgumentParser(
55
+ description="Create directories and empty files from a visual project tree stored in a .txt file.",
56
+ formatter_class=argparse.RawDescriptionHelpFormatter,
57
+ epilog="""Examples:
58
+ dirforge structure.txt
59
+ dirforge structure.txt --output ./projects
60
+ python dirforge.py structure.txt
61
+ dirforge structure.txt -o ./projects --dry-run --verbose
62
+
63
+ Tree syntax:
64
+ Names ending in / are directories. All other names are files.
65
+ Existing files are skipped by default so their contents are preserved.
66
+ """,
67
+ )
68
+ parser.add_argument("tree_file", help="Path to a UTF-8 .txt file containing the project tree.")
69
+ parser.add_argument(
70
+ "-o",
71
+ "--output",
72
+ default=".",
73
+ help="Directory where the scaffold root should be created. Defaults to the current directory.",
74
+ )
75
+ parser.add_argument(
76
+ "--dry-run",
77
+ action="store_true",
78
+ help="Parse and report planned changes without creating files or directories.",
79
+ )
80
+ parser.add_argument(
81
+ "--force",
82
+ action="store_true",
83
+ help="Truncate and recreate existing files. Warning: this can overwrite file contents.",
84
+ )
85
+ parser.add_argument(
86
+ "-v",
87
+ "--verbose",
88
+ action="store_true",
89
+ help="Print each create/skip operation.",
90
+ )
91
+ parser.add_argument(
92
+ "--version",
93
+ action="version",
94
+ version=f"%(prog)s {__version__}",
95
+ )
96
+ return parser.parse_args(argv)
97
+
98
+
99
+ def read_tree_file(path_text: str) -> list[str]:
100
+ path = Path(path_text)
101
+
102
+ if path.suffix.lower() != ".txt":
103
+ raise InputError("Input file must have a .txt extension.")
104
+ if not path.exists():
105
+ raise InputError(f"Input file does not exist: {path}")
106
+ if not path.is_file():
107
+ raise InputError(f"Input path is not a regular file: {path}")
108
+
109
+ try:
110
+ text = path.read_text(encoding="utf-8")
111
+ except UnicodeDecodeError as exc:
112
+ raise InputError(f"Input file is not valid UTF-8: {path}") from exc
113
+ except PermissionError as exc:
114
+ raise InputError(f"Input file cannot be read due to permissions: {path}") from exc
115
+ except OSError as exc:
116
+ raise InputError(f"Input file could not be read: {path}") from exc
117
+
118
+ if not text.strip():
119
+ raise InputError("Input file is empty.")
120
+
121
+ return text.splitlines()
122
+
123
+
124
+ def is_visual_separator(line: str) -> bool:
125
+ stripped = line.strip()
126
+ return bool(stripped) and all(char in {"│", " "} for char in line)
127
+
128
+
129
+ def normalize_node_name(raw_name: str, line_number: int) -> tuple[str, bool]:
130
+ name = raw_name.strip()
131
+ if not name:
132
+ raise InputError(f"Line {line_number}: entry name is empty.")
133
+
134
+ is_directory = name.endswith("/")
135
+ clean_name = name.rstrip("/") if is_directory else name
136
+ validate_node_name(clean_name, line_number)
137
+ return clean_name, is_directory
138
+
139
+
140
+ def validate_node_name(name: str, line_number: int) -> None:
141
+ if not name:
142
+ raise InputError(f"Line {line_number}: entry name is empty.")
143
+ if name in {".", ".."}:
144
+ raise InputError(f"Line {line_number}: unsafe path name is not allowed: {name}")
145
+ if Path(name).is_absolute() or WINDOWS_ABSOLUTE_RE.match(name):
146
+ raise InputError(f"Line {line_number}: absolute paths are not allowed: {name}")
147
+ if "/" in name or "\\" in name:
148
+ raise InputError(f"Line {line_number}: path separators are not allowed inside an entry name: {name}")
149
+
150
+
151
+ def indentation_depth(prefix: str, line_number: int) -> int:
152
+ if len(prefix) % 4 != 0:
153
+ raise InputError(
154
+ f"Invalid tree structure at line {line_number}. "
155
+ "Indentation must use four-character units."
156
+ )
157
+
158
+ for index in range(0, len(prefix), 4):
159
+ unit = prefix[index : index + 4]
160
+ if unit not in {"│ ", " "}:
161
+ raise InputError(
162
+ f"Invalid tree structure at line {line_number}. "
163
+ "Indentation units must be either '│ ' or four spaces."
164
+ )
165
+
166
+ return len(prefix) // 4
167
+
168
+
169
+ def parse_tree(lines: list[str]) -> TreeNode:
170
+ root: TreeNode | None = None
171
+ stack: list[tuple[int, TreeNode]] = []
172
+ previous_depth = 0
173
+
174
+ for line_number, raw_line in enumerate(lines, start=1):
175
+ line = raw_line.rstrip()
176
+ if not line.strip() or is_visual_separator(line):
177
+ continue
178
+
179
+ match = BRANCH_RE.match(line)
180
+
181
+ if root is None:
182
+ if match:
183
+ raise InputError("The first meaningful line must be the root directory without a branch marker.")
184
+
185
+ name, is_directory = normalize_node_name(line, line_number)
186
+ if not is_directory:
187
+ raise InputError(f"Line {line_number}: root entry must be a directory ending in '/'.")
188
+
189
+ root = TreeNode(name=name, is_directory=True, line_number=line_number)
190
+ stack = [(0, root)]
191
+ previous_depth = 0
192
+ continue
193
+
194
+ if not match:
195
+ raise InputError(f"Line {line_number}: invalid tree connector syntax.")
196
+
197
+ depth = indentation_depth(match.group("prefix"), line_number) + 1
198
+ name, is_directory = normalize_node_name(match.group("name"), line_number)
199
+
200
+ if depth > previous_depth + 1:
201
+ raise InputError(
202
+ f"Invalid tree structure at line {line_number}.\n"
203
+ f"Indentation jumped from level {previous_depth} to level {depth}."
204
+ )
205
+
206
+ while stack and stack[-1][0] >= depth:
207
+ stack.pop()
208
+
209
+ if not stack or stack[-1][0] != depth - 1:
210
+ raise InputError(f"Line {line_number}: entry cannot be attached to a valid parent.")
211
+
212
+ parent = stack[-1][1]
213
+ if not parent.is_directory:
214
+ raise InputError(
215
+ "A file cannot contain child entries.\n"
216
+ f"Line {line_number}: {name}"
217
+ )
218
+
219
+ node = TreeNode(name=name, is_directory=is_directory, line_number=line_number)
220
+ parent.children.append(node)
221
+ stack.append((depth, node))
222
+ previous_depth = depth
223
+
224
+ if root is None:
225
+ raise InputError("Tree contains no valid root entry.")
226
+
227
+ return root
228
+
229
+
230
+ def iter_nodes(node: TreeNode, base: Path) -> list[tuple[TreeNode, Path]]:
231
+ current_path = base / node.name
232
+ entries = [(node, current_path)]
233
+ for child in node.children:
234
+ entries.extend(iter_nodes(child, current_path))
235
+ return entries
236
+
237
+
238
+ def ensure_inside_output(path: Path, output_root: Path) -> None:
239
+ try:
240
+ path.resolve().relative_to(output_root.resolve())
241
+ except ValueError as exc:
242
+ raise InputError(f"Generated path escapes output directory: {path}") from exc
243
+
244
+
245
+ def create_scaffold(
246
+ root: TreeNode,
247
+ output_dir: Path,
248
+ *,
249
+ dry_run: bool = False,
250
+ force: bool = False,
251
+ verbose: bool = False,
252
+ ) -> tuple[Path, ScaffoldStats]:
253
+ stats = ScaffoldStats()
254
+ output_root = output_dir.expanduser()
255
+ project_root = output_root / root.name
256
+
257
+ if force and not dry_run:
258
+ print("Warning: --force is enabled. Existing files may be truncated.", file=sys.stderr)
259
+
260
+ for node, target_path in iter_nodes(root, output_root):
261
+ ensure_inside_output(target_path, output_root)
262
+ exists = target_path.exists()
263
+
264
+ if node.is_directory:
265
+ handle_directory(target_path, exists, dry_run, verbose, stats)
266
+ else:
267
+ handle_file(target_path, exists, dry_run, force, verbose, stats)
268
+
269
+ return project_root, stats
270
+
271
+
272
+ def handle_directory(
273
+ target_path: Path,
274
+ exists: bool,
275
+ dry_run: bool,
276
+ verbose: bool,
277
+ stats: ScaffoldStats,
278
+ ) -> None:
279
+ display_path = format_dir(target_path)
280
+
281
+ if exists:
282
+ if not target_path.is_dir():
283
+ raise DirForgeError(f"Cannot create directory '{target_path}' because a file exists at that path.")
284
+ stats.existing_entries += 1 if dry_run else 0
285
+ stats.entries_skipped += 0 if dry_run else 1
286
+ if verbose or dry_run:
287
+ print(("EXIST DIR " if dry_run else "SKIP DIR ") + display_path)
288
+ return
289
+
290
+ if dry_run:
291
+ stats.dirs_to_create += 1
292
+ print("[DIR] " + display_path)
293
+ return
294
+
295
+ try:
296
+ target_path.mkdir(parents=True, exist_ok=True)
297
+ except OSError as exc:
298
+ raise DirForgeError(f"Cannot create directory '{target_path}': {exc}") from exc
299
+
300
+ stats.dirs_created += 1
301
+ if verbose:
302
+ print("CREATE DIR " + display_path)
303
+
304
+
305
+ def handle_file(
306
+ target_path: Path,
307
+ exists: bool,
308
+ dry_run: bool,
309
+ force: bool,
310
+ verbose: bool,
311
+ stats: ScaffoldStats,
312
+ ) -> None:
313
+ if exists:
314
+ if target_path.is_dir():
315
+ raise DirForgeError(f"Cannot create file '{target_path}' because a directory exists at that path.")
316
+ if dry_run:
317
+ stats.existing_entries += 1
318
+ print("EXIST FILE " + str(target_path))
319
+ return
320
+ if force:
321
+ try:
322
+ target_path.write_text("", encoding="utf-8")
323
+ except OSError as exc:
324
+ raise DirForgeError(f"Cannot overwrite file '{target_path}': {exc}") from exc
325
+ stats.files_overwritten += 1
326
+ if verbose:
327
+ print("OVERWRITE FILE " + str(target_path))
328
+ return
329
+ stats.entries_skipped += 1
330
+ if verbose:
331
+ print("SKIP FILE " + str(target_path))
332
+ return
333
+
334
+ if dry_run:
335
+ stats.files_to_create += 1
336
+ print("[FILE] " + str(target_path))
337
+ return
338
+
339
+ try:
340
+ target_path.parent.mkdir(parents=True, exist_ok=True)
341
+ target_path.touch(exist_ok=False)
342
+ except OSError as exc:
343
+ raise DirForgeError(f"Cannot create file '{target_path}': {exc}") from exc
344
+
345
+ stats.files_created += 1
346
+ if verbose:
347
+ print("CREATE FILE " + str(target_path))
348
+
349
+
350
+ def format_dir(path: Path) -> str:
351
+ return str(path) + "/"
352
+
353
+
354
+ def print_summary(project_root: Path, stats: ScaffoldStats, *, dry_run: bool, force: bool) -> None:
355
+ if dry_run:
356
+ print("\nDry run completed successfully.\n")
357
+ print("Root:")
358
+ print(f" {project_root.resolve()}")
359
+ print()
360
+ print(f"Directories to create: {stats.dirs_to_create}")
361
+ print(f"Files to create: {stats.files_to_create}")
362
+ print(f"Existing entries: {stats.existing_entries}")
363
+ print("\nNo filesystem changes were made.")
364
+ return
365
+
366
+ print("\nScaffold created successfully.\n")
367
+ print("Root:")
368
+ print(f" {project_root.resolve()}")
369
+ print()
370
+ print(f"Directories created: {stats.dirs_created}")
371
+ print(f"Files created: {stats.files_created}")
372
+ print(f"Entries skipped: {stats.entries_skipped}")
373
+ if force:
374
+ print(f"Files overwritten: {stats.files_overwritten}")
375
+
376
+
377
+ def main(argv: list[str] | None = None) -> int:
378
+ try:
379
+ args = parse_arguments(argv)
380
+ lines = read_tree_file(args.tree_file)
381
+ tree = parse_tree(lines)
382
+ project_root, stats = create_scaffold(
383
+ tree,
384
+ Path(args.output),
385
+ dry_run=args.dry_run,
386
+ force=args.force,
387
+ verbose=args.verbose,
388
+ )
389
+ print_summary(project_root, stats, dry_run=args.dry_run, force=args.force)
390
+ return 0
391
+ except DirForgeError as exc:
392
+ print(f"Error: {exc}", file=sys.stderr)
393
+ return exc.exit_code
394
+
395
+
396
+ if __name__ == "__main__":
397
+ sys.exit(main())
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: dirforge-cli
3
+ Version: 0.1.0
4
+ Summary: Create project directory scaffolds from visual tree text files.
5
+ Keywords: cli,scaffold,project-tree,directories,filesystem
6
+ Classifier: Development Status :: 3 - Alpha
7
+ Classifier: Environment :: Console
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Topic :: Software Development :: Code Generators
17
+ Classifier: Topic :: System :: Filesystems
18
+ Classifier: Topic :: Utilities
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ Provides-Extra: dev
22
+ Requires-Dist: build>=1.2; extra == "dev"
23
+ Requires-Dist: twine>=5; extra == "dev"
24
+
25
+ # DirForge
26
+
27
+ DirForge is a dependency-free Python CLI that turns a visual project tree in a
28
+ UTF-8 text file into real directories and empty files.
29
+
30
+ > PyPI package name: `dirforge-cli`
31
+ > Installed command: `dirforge`
32
+
33
+ The distribution uses `dirforge-cli` because `dirforge` is already registered
34
+ to another project on PyPI.
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ python -m pip install dirforge-cli
40
+ ```
41
+
42
+ DirForge requires Python 3.10 or newer and has no runtime dependencies.
43
+
44
+ ## Usage
45
+
46
+ Create a text file such as `structure.txt`:
47
+
48
+ ```text
49
+ project/
50
+ ├── src/
51
+ │ └── app.py
52
+ ├── LICENSE
53
+ ├── Makefile
54
+ └── .gitignore
55
+ ```
56
+
57
+ Then create the scaffold:
58
+
59
+ ```bash
60
+ dirforge structure.txt
61
+ dirforge structure.txt --output ./projects
62
+ dirforge structure.txt -o ./projects --dry-run --verbose
63
+ ```
64
+
65
+ The repository script remains directly executable too:
66
+
67
+ ```bash
68
+ python dirforge.py example-structure.txt --dry-run
69
+ ```
70
+
71
+ ## Tree Syntax
72
+
73
+ Entries ending in `/` are directories. Every other entry is a file, including
74
+ extensionless names such as `LICENSE` and `Makefile`. Blank lines and visual
75
+ separator lines containing only `│` are ignored.
76
+
77
+ ## Options
78
+
79
+ - `-o, --output`: Choose the directory that will contain the scaffold root.
80
+ - `--dry-run`: Show planned changes without modifying the filesystem.
81
+ - `--force`: Truncate existing files before recreating them.
82
+ - `-v, --verbose`: Print every create, skip, or overwrite operation.
83
+ - `--version`: Print the installed version.
84
+
85
+ Existing directories are reused and existing files are preserved by default.
86
+ DirForge rejects path traversal, absolute paths, separators inside node names,
87
+ malformed indentation, children under files, and file/directory conflicts.
88
+
89
+ ## Development
90
+
91
+ Run the test suite with only the standard library:
92
+
93
+ ```bash
94
+ python -m unittest discover -v
95
+ ```
96
+
97
+ Install the packaging tools and build both distribution formats:
98
+
99
+ ```bash
100
+ python -m pip install -e '.[dev]'
101
+ python -m build
102
+ python -m twine check dist/*
103
+ ```
104
+
105
+ Test the release on TestPyPI before publishing it publicly:
106
+
107
+ ```bash
108
+ python -m twine upload --repository testpypi dist/*
109
+ python -m pip install --index-url https://test.pypi.org/simple/ dirforge-cli
110
+ ```
111
+
112
+ After verifying the installed `dirforge` command, upload the same artifacts to
113
+ PyPI:
114
+
115
+ ```bash
116
+ python -m twine upload dist/*
117
+ ```
118
+
119
+ PyPI does not allow replacing an uploaded version. Increment `__version__` in
120
+ `dirforge.py` before each new release; the build metadata reads it automatically.
@@ -0,0 +1,13 @@
1
+ MANIFEST.in
2
+ README.md
3
+ dirforge.py
4
+ example-structure.txt
5
+ pyproject.toml
6
+ dirforge_cli.egg-info/PKG-INFO
7
+ dirforge_cli.egg-info/SOURCES.txt
8
+ dirforge_cli.egg-info/dependency_links.txt
9
+ dirforge_cli.egg-info/entry_points.txt
10
+ dirforge_cli.egg-info/requires.txt
11
+ dirforge_cli.egg-info/top_level.txt
12
+ tests/__init__.py
13
+ tests/test_dirforge.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dirforge = dirforge:main
@@ -0,0 +1,4 @@
1
+
2
+ [dev]
3
+ build>=1.2
4
+ twine>=5
@@ -0,0 +1 @@
1
+ dirforge
@@ -0,0 +1,25 @@
1
+ meta-ads-analytics/
2
+ │
3
+ ├── composer.json
4
+ ├── README.md
5
+ ├── LICENSE
6
+ ├── .gitignore
7
+ │
8
+ ├── src/
9
+ │ ├── MetaAds.php
10
+ │ │
11
+ │ ├── Config/
12
+ │ │ ├── MetaConfig.php
13
+ │ │ └── ConfigurationValidator.php
14
+ │ │
15
+ │ └── Client/
16
+ │ ├── MetaClient.php
17
+ │ └── MetaClientInterface.php
18
+ │
19
+ ├── tests/
20
+ │ ├── Unit/
21
+ │ └── bootstrap.php
22
+ │
23
+ └── docs/
24
+ ├── configuration.md
25
+ └── architecture.md
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "dirforge-cli"
7
+ dynamic = ["version"]
8
+ description = "Create project directory scaffolds from visual tree text files."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ keywords = ["cli", "scaffold", "project-tree", "directories", "filesystem"]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Environment :: Console",
15
+ "Intended Audience :: Developers",
16
+ "Operating System :: OS Independent",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Programming Language :: Python :: 3.14",
23
+ "Topic :: Software Development :: Code Generators",
24
+ "Topic :: System :: Filesystems",
25
+ "Topic :: Utilities",
26
+ ]
27
+ dependencies = []
28
+
29
+ [project.optional-dependencies]
30
+ dev = [
31
+ "build>=1.2",
32
+ "twine>=5",
33
+ ]
34
+
35
+ [project.scripts]
36
+ dirforge = "dirforge:main"
37
+
38
+ [tool.setuptools]
39
+ py-modules = ["dirforge"]
40
+
41
+ [tool.setuptools.dynamic]
42
+ version = {attr = "dirforge.__version__"}
43
+
44
+ [tool.setuptools.data-files]
45
+ "share/doc/dirforge-cli" = ["example-structure.txt"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ """DirForge test suite."""
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ import sys
5
+ import tempfile
6
+ import unittest
7
+ from pathlib import Path
8
+
9
+ import dirforge
10
+
11
+
12
+ VALID_TREE = """\
13
+ sample-project/
14
+ ├── README.md
15
+ ├── src/
16
+ │ └── main.py
17
+ └── LICENSE
18
+ """
19
+
20
+
21
+ class ParseTreeTests(unittest.TestCase):
22
+ def test_parses_directories_and_extensionless_files(self) -> None:
23
+ root = dirforge.parse_tree(VALID_TREE.splitlines())
24
+
25
+ self.assertEqual(root.name, "sample-project")
26
+ self.assertTrue(root.is_directory)
27
+ self.assertEqual([child.name for child in root.children], ["README.md", "src", "LICENSE"])
28
+ self.assertFalse(root.children[-1].is_directory)
29
+ self.assertEqual(root.children[1].children[0].name, "main.py")
30
+
31
+ def test_rejects_path_traversal(self) -> None:
32
+ with self.assertRaisesRegex(dirforge.InputError, "unsafe path name"):
33
+ dirforge.parse_tree(["project/", "└── ../"])
34
+
35
+ def test_rejects_children_beneath_files(self) -> None:
36
+ tree = ["project/", "└── README.md", " └── child.txt"]
37
+
38
+ with self.assertRaisesRegex(dirforge.InputError, "file cannot contain"):
39
+ dirforge.parse_tree(tree)
40
+
41
+
42
+ class ScaffoldTests(unittest.TestCase):
43
+ def test_creates_tree_and_preserves_existing_files(self) -> None:
44
+ root = dirforge.parse_tree(VALID_TREE.splitlines())
45
+
46
+ with tempfile.TemporaryDirectory() as temp_dir:
47
+ output = Path(temp_dir)
48
+ project_root, first_stats = dirforge.create_scaffold(root, output)
49
+ readme = project_root / "README.md"
50
+ readme.write_text("keep me", encoding="utf-8")
51
+
52
+ _, second_stats = dirforge.create_scaffold(root, output)
53
+
54
+ self.assertEqual(first_stats.dirs_created, 2)
55
+ self.assertEqual(first_stats.files_created, 3)
56
+ self.assertEqual(readme.read_text(encoding="utf-8"), "keep me")
57
+ self.assertEqual(second_stats.entries_skipped, 5)
58
+
59
+ def test_dry_run_does_not_write_to_disk(self) -> None:
60
+ root = dirforge.parse_tree(VALID_TREE.splitlines())
61
+
62
+ with tempfile.TemporaryDirectory() as temp_dir:
63
+ output = Path(temp_dir)
64
+ _, stats = dirforge.create_scaffold(root, output, dry_run=True)
65
+
66
+ self.assertFalse((output / "sample-project").exists())
67
+ self.assertEqual(stats.dirs_to_create, 2)
68
+ self.assertEqual(stats.files_to_create, 3)
69
+
70
+
71
+ class CommandLineTests(unittest.TestCase):
72
+ def test_version_flag(self) -> None:
73
+ result = subprocess.run(
74
+ [sys.executable, str(Path(dirforge.__file__)), "--version"],
75
+ check=False,
76
+ capture_output=True,
77
+ text=True,
78
+ )
79
+
80
+ self.assertEqual(result.returncode, 0)
81
+ self.assertIn(dirforge.__version__, result.stdout)
82
+
83
+
84
+ if __name__ == "__main__":
85
+ unittest.main()