latex-forge 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. latex_forge/.vscode/extensions.json +9 -0
  2. latex_forge/__init__.py +3 -0
  3. latex_forge/assets/images/common/README.md +8 -0
  4. latex_forge/assets/logos/README.md +8 -0
  5. latex_forge/assets/logos/logo-university.png +0 -0
  6. latex_forge/assets/logos/logo-up-maths-info.jpg +0 -0
  7. latex_forge/cli.py +423 -0
  8. latex_forge/config.py +76 -0
  9. latex_forge/project.py +1342 -0
  10. latex_forge/setup.py +366 -0
  11. latex_forge/styles/packages/cv.sty +90 -0
  12. latex_forge/styles/packages/report-code-bash.sty +24 -0
  13. latex_forge/styles/packages/report-code-python.sty +25 -0
  14. latex_forge/styles/packages/report-colors.sty +11 -0
  15. latex_forge/styles/packages/report-metadata.sty +76 -0
  16. latex_forge/styles/packages/report-tables.sty +11 -0
  17. latex_forge/styles/packages/report-ter-titlepage.sty +157 -0
  18. latex_forge/styles/packages/report-theorems-en.sty +17 -0
  19. latex_forge/styles/packages/report-theorems-fr.sty +17 -0
  20. latex_forge/styles/packages/research-article.sty +123 -0
  21. latex_forge/styles/packages/university-project-report.sty +115 -0
  22. latex_forge/styles/packages/university-ter-report.sty +112 -0
  23. latex_forge/template_manager.py +222 -0
  24. latex_forge/templates/cv-en/main.tex +15 -0
  25. latex_forge/templates/cv-en/sections/education.tex +20 -0
  26. latex_forge/templates/cv-en/sections/experience.tex +20 -0
  27. latex_forge/templates/cv-en/sections/heading.tex +22 -0
  28. latex_forge/templates/cv-en/sections/involvement.tex +11 -0
  29. latex_forge/templates/cv-en/sections/projects.tex +18 -0
  30. latex_forge/templates/cv-en/sections/skills.tex +12 -0
  31. latex_forge/templates/cv-fr/main.tex +15 -0
  32. latex_forge/templates/cv-fr/sections/competences.tex +12 -0
  33. latex_forge/templates/cv-fr/sections/en-tete.tex +22 -0
  34. latex_forge/templates/cv-fr/sections/engagement.tex +11 -0
  35. latex_forge/templates/cv-fr/sections/experience.tex +20 -0
  36. latex_forge/templates/cv-fr/sections/formation.tex +20 -0
  37. latex_forge/templates/cv-fr/sections/projets.tex +18 -0
  38. latex_forge/templates/rapport-projet-en/backmatter/acknowledgements.tex +11 -0
  39. latex_forge/templates/rapport-projet-en/backmatter/ai-statement.tex +34 -0
  40. latex_forge/templates/rapport-projet-en/backmatter/appendices.tex +13 -0
  41. latex_forge/templates/rapport-projet-en/bibliography/references.bib +34 -0
  42. latex_forge/templates/rapport-projet-en/figures/.gitkeep +1 -0
  43. latex_forge/templates/rapport-projet-en/frontmatter/abstract.tex +13 -0
  44. latex_forge/templates/rapport-projet-en/frontmatter/metadata.tex +22 -0
  45. latex_forge/templates/rapport-projet-en/frontmatter/toc.tex +5 -0
  46. latex_forge/templates/rapport-projet-en/images/.gitkeep +1 -0
  47. latex_forge/templates/rapport-projet-en/main.tex +92 -0
  48. latex_forge/templates/rapport-projet-en/sections/architecture.tex +26 -0
  49. latex_forge/templates/rapport-projet-en/sections/conclusion.tex +8 -0
  50. latex_forge/templates/rapport-projet-en/sections/implementation.tex +20 -0
  51. latex_forge/templates/rapport-projet-en/sections/introduction.tex +15 -0
  52. latex_forge/templates/rapport-projet-en/sections/related-work.tex +18 -0
  53. latex_forge/templates/rapport-projet-en/sections/requirements.tex +32 -0
  54. latex_forge/templates/rapport-projet-en/sections/results.tex +14 -0
  55. latex_forge/templates/rapport-projet-en/sections/testing.tex +33 -0
  56. latex_forge/templates/rapport-projet-fr/backmatter/ai-statement.tex +34 -0
  57. latex_forge/templates/rapport-projet-fr/backmatter/annexes.tex +13 -0
  58. latex_forge/templates/rapport-projet-fr/backmatter/remerciements.tex +11 -0
  59. latex_forge/templates/rapport-projet-fr/bibliography/references.bib +34 -0
  60. latex_forge/templates/rapport-projet-fr/figures/.gitkeep +1 -0
  61. latex_forge/templates/rapport-projet-fr/frontmatter/abstract.tex +29 -0
  62. latex_forge/templates/rapport-projet-fr/frontmatter/metadata.tex +22 -0
  63. latex_forge/templates/rapport-projet-fr/frontmatter/toc.tex +5 -0
  64. latex_forge/templates/rapport-projet-fr/images/.gitkeep +1 -0
  65. latex_forge/templates/rapport-projet-fr/main.tex +82 -0
  66. latex_forge/templates/rapport-projet-fr/sections/architecture.tex +26 -0
  67. latex_forge/templates/rapport-projet-fr/sections/cahier-des-charges.tex +32 -0
  68. latex_forge/templates/rapport-projet-fr/sections/conclusion.tex +8 -0
  69. latex_forge/templates/rapport-projet-fr/sections/etat-de-lart.tex +18 -0
  70. latex_forge/templates/rapport-projet-fr/sections/implementation.tex +20 -0
  71. latex_forge/templates/rapport-projet-fr/sections/introduction.tex +15 -0
  72. latex_forge/templates/rapport-projet-fr/sections/resultats.tex +14 -0
  73. latex_forge/templates/rapport-projet-fr/sections/tests.tex +33 -0
  74. latex_forge/templates/research/appendix/ai-usage.tex +11 -0
  75. latex_forge/templates/research/appendix/annexes.tex +13 -0
  76. latex_forge/templates/research/backmatter/acknowledgements.tex +4 -0
  77. latex_forge/templates/research/backmatter/bibliography.tex +1 -0
  78. latex_forge/templates/research/figures/.gitkeep +1 -0
  79. latex_forge/templates/research/frontmatter/abstract.tex +8 -0
  80. latex_forge/templates/research/frontmatter/metadata.tex +21 -0
  81. latex_forge/templates/research/frontmatter/toc.tex +5 -0
  82. latex_forge/templates/research/images/.gitkeep +1 -0
  83. latex_forge/templates/research/main.tex +88 -0
  84. latex_forge/templates/research/references/references.bib +30 -0
  85. latex_forge/templates/research/sections/conclusion.tex +6 -0
  86. latex_forge/templates/research/sections/experiments.tex +14 -0
  87. latex_forge/templates/research/sections/introduction.tex +15 -0
  88. latex_forge/templates/research/sections/methodology.tex +20 -0
  89. latex_forge/templates/research/sections/related-work.tex +6 -0
  90. latex_forge-0.2.0.dist-info/METADATA +274 -0
  91. latex_forge-0.2.0.dist-info/RECORD +95 -0
  92. latex_forge-0.2.0.dist-info/WHEEL +5 -0
  93. latex_forge-0.2.0.dist-info/entry_points.txt +2 -0
  94. latex_forge-0.2.0.dist-info/licenses/LICENSE +21 -0
  95. latex_forge-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,9 @@
1
+ {
2
+ "recommendations": [
3
+ "James-Yu.latex-workshop",
4
+ "ltex-plus.vscode-ltex-plus",
5
+ "streetsidesoftware.code-spell-checker",
6
+ "streetsidesoftware.code-spell-checker-french",
7
+ "MS-vsliveshare.vsliveshare"
8
+ ]
9
+ }
@@ -0,0 +1,3 @@
1
+ __all__ = ["__version__"]
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,8 @@
1
+ Place ici les PNG, JPG ou PDF que tu reutilises dans plusieurs projets.
2
+
3
+ Exemples :
4
+
5
+ - schemas generiques
6
+ - captures d'ecran recurrentes
7
+ - signatures visuelles
8
+ - icones maison
@@ -0,0 +1,8 @@
1
+ Place ici les logos reutilises entre plusieurs documents.
2
+
3
+ Exemples :
4
+
5
+ - logo labo
6
+ - logo entreprise
7
+ - logo universite
8
+ - logo-up-maths-info.jpg pour le template TER IAD/VMI
latex_forge/cli.py ADDED
@@ -0,0 +1,423 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+ from importlib.metadata import PackageNotFoundError, version
7
+ from pathlib import Path
8
+
9
+ import argcomplete
10
+
11
+ from .config import get_default_output_dir, get_default_template
12
+ from .project import (
13
+ TEMPLATE_DESCRIPTIONS,
14
+ available_templates,
15
+ create_project,
16
+ rename_current_project,
17
+ rename_project,
18
+ templates_dir,
19
+ validate_name,
20
+ )
21
+ from .setup import (
22
+ is_first_run,
23
+ mark_initialized,
24
+ offer_open_vscode,
25
+ run_first_launch_check,
26
+ run_profile_setup,
27
+ run_setup,
28
+ warn_if_latex_missing,
29
+ )
30
+
31
+
32
+ def _get_version() -> str:
33
+ try:
34
+ return version("latex-forge")
35
+ except PackageNotFoundError:
36
+ return "unknown"
37
+
38
+
39
+ def _is_interactive() -> bool:
40
+ try:
41
+ return sys.stdin.isatty()
42
+ except Exception:
43
+ return False
44
+
45
+
46
+ def _ask_project_name() -> str:
47
+ while True:
48
+ try:
49
+ name = input("Project name: ").strip()
50
+ except (EOFError, OSError, KeyboardInterrupt):
51
+ print("")
52
+ sys.exit(1)
53
+ try:
54
+ validate_name(name)
55
+ return name
56
+ except ValueError as exc:
57
+ print(str(exc))
58
+
59
+
60
+ def _select_template_interactively() -> str:
61
+ templates = available_templates()
62
+ width = max(len(t) for t in templates)
63
+ print("Available templates:")
64
+ for i, t in enumerate(templates, 1):
65
+ desc = TEMPLATE_DESCRIPTIONS.get(t, "")
66
+ suffix = f" {desc}" if desc else ""
67
+ print(f" {i}. {t:<{width}}{suffix}")
68
+ while True:
69
+ try:
70
+ answer = input(f"Choose a template [1-{len(templates)}]: ").strip()
71
+ idx = int(answer) - 1
72
+ if 0 <= idx < len(templates):
73
+ return templates[idx]
74
+ except (EOFError, OSError, KeyboardInterrupt):
75
+ print("")
76
+ sys.exit(1)
77
+ except ValueError:
78
+ pass
79
+ print(f"Please enter a number between 1 and {len(templates)}.")
80
+
81
+
82
+ def _ask_output_dir() -> Path:
83
+ config_dir = get_default_output_dir()
84
+ default = config_dir if config_dir is not None else Path.cwd()
85
+ try:
86
+ answer = input(f"Create project in [{default}]: ").strip()
87
+ except (EOFError, OSError, KeyboardInterrupt):
88
+ print("")
89
+ sys.exit(1)
90
+ if not answer:
91
+ return default
92
+ path = Path(answer).expanduser().resolve()
93
+ if not path.exists():
94
+ try:
95
+ path.mkdir(parents=True, exist_ok=True)
96
+ except OSError as exc:
97
+ print(f"Cannot create directory: {exc}", file=sys.stderr)
98
+ sys.exit(1)
99
+ return path
100
+
101
+
102
+ def build_parser() -> argparse.ArgumentParser:
103
+ parser = argparse.ArgumentParser(
104
+ prog="latex-forge",
105
+ description="Utilities for generating standalone LaTeX projects from the toolbox.",
106
+ )
107
+ parser.add_argument(
108
+ "--version",
109
+ action="version",
110
+ version=f"%(prog)s {_get_version()}",
111
+ )
112
+ subparsers = parser.add_subparsers(dest="command", required=True)
113
+
114
+ create_parser = subparsers.add_parser(
115
+ "create",
116
+ help="Create a new LaTeX project from a template.",
117
+ )
118
+ create_parser.add_argument(
119
+ "--name",
120
+ default=None,
121
+ help="Name of the project (prompted interactively if omitted).",
122
+ )
123
+ template_arg = create_parser.add_argument(
124
+ "--template",
125
+ default=None,
126
+ help="Template name to use (prompted interactively if omitted).",
127
+ )
128
+ template_arg.completer = lambda **kwargs: available_templates()
129
+ create_parser.add_argument(
130
+ "--output",
131
+ default=None,
132
+ help="Directory where the project will be created (default: current directory).",
133
+ )
134
+
135
+ rename_parser = subparsers.add_parser(
136
+ "rename",
137
+ help="Rename a generated LaTeX project folder and its main .tex file.",
138
+ )
139
+ rename_parser.add_argument(
140
+ "names",
141
+ nargs="+",
142
+ help="Use `old_name new_name` from the parent folder, or only `new_name` from inside the project folder.",
143
+ )
144
+
145
+ setup_parser = subparsers.add_parser(
146
+ "setup",
147
+ help="Install VS Code extensions when possible and check LaTeX prerequisites.",
148
+ )
149
+ setup_parser.add_argument(
150
+ "--check-only",
151
+ action="store_true",
152
+ help="Only check the current environment without installing VS Code extensions.",
153
+ )
154
+ setup_parser.add_argument(
155
+ "--skip-extensions",
156
+ action="store_true",
157
+ help="Skip VS Code extension installation.",
158
+ )
159
+ setup_parser.add_argument(
160
+ "--install-tex",
161
+ action="store_true",
162
+ help="Try to install a LaTeX distribution with a common package manager for the current OS.",
163
+ )
164
+
165
+ subparsers.add_parser(
166
+ "list-templates",
167
+ help="List the available templates.",
168
+ )
169
+
170
+ profile_parser = subparsers.add_parser(
171
+ "profile",
172
+ help="View or update your profile used to pre-fill project metadata.",
173
+ )
174
+ profile_parser.add_argument(
175
+ "--set",
176
+ action="store_true",
177
+ help="Run interactive profile setup.",
178
+ )
179
+
180
+ template_parser = subparsers.add_parser(
181
+ "template",
182
+ help="Install, remove or list templates.",
183
+ )
184
+ template_sub = template_parser.add_subparsers(dest="template_command", required=True)
185
+
186
+ t_install = template_sub.add_parser(
187
+ "install",
188
+ help="Install a template from a GitHub URL, ZIP URL, or local path.",
189
+ )
190
+ t_install.add_argument(
191
+ "source",
192
+ help="GitHub URL, ZIP URL, local directory, or local .zip file.",
193
+ )
194
+ t_install.add_argument(
195
+ "--name",
196
+ default=None,
197
+ help="Name to give the installed template (defaults to repo/folder name).",
198
+ )
199
+
200
+ template_sub.add_parser(
201
+ "list",
202
+ help="List built-in and user-installed templates.",
203
+ )
204
+
205
+ t_remove = template_sub.add_parser(
206
+ "remove",
207
+ help="Remove a user-installed template.",
208
+ )
209
+ t_remove.add_argument("name", help="Name of the template to remove.")
210
+
211
+ completion_parser = subparsers.add_parser(
212
+ "completion",
213
+ help="Print shell completion setup code.",
214
+ )
215
+ completion_parser.add_argument(
216
+ "--shell",
217
+ choices=["bash", "zsh", "fish"],
218
+ default=None,
219
+ help="Shell type (auto-detected from $SHELL if omitted).",
220
+ )
221
+
222
+ return parser
223
+
224
+
225
+ def main(argv: list[str] | None = None) -> int:
226
+ parser = build_parser()
227
+ argcomplete.autocomplete(parser)
228
+ args = parser.parse_args(argv)
229
+
230
+ if args.command == "template":
231
+ from .template_manager import install_template, list_user_templates, remove_template
232
+
233
+ if args.template_command == "install":
234
+ try:
235
+ name, path = install_template(args.source, args.name)
236
+ except (ValueError, FileNotFoundError, OSError) as exc:
237
+ print(str(exc), file=sys.stderr)
238
+ return 1
239
+ print(f"Template installed: {name}")
240
+ print(f"Location: {path}")
241
+ print(f"Use it with: latex-forge create --template {name}")
242
+ return 0
243
+
244
+ if args.template_command == "list":
245
+ built_in = sorted(p.name for p in templates_dir().iterdir() if p.is_dir())
246
+ user = list_user_templates()
247
+ width = max((len(t) for t in built_in + user), default=0)
248
+ print("Built-in templates:")
249
+ for t in built_in:
250
+ desc = TEMPLATE_DESCRIPTIONS.get(t, "")
251
+ suffix = f" {desc}" if desc else ""
252
+ print(f" {t:<{width}}{suffix}")
253
+ if user:
254
+ print("\nInstalled templates:")
255
+ for t in user:
256
+ print(f" {t}")
257
+ else:
258
+ print("\nNo user-installed templates.")
259
+ print("Install one with: latex-forge template install <url-or-path>")
260
+ return 0
261
+
262
+ if args.template_command == "remove":
263
+ try:
264
+ remove_template(args.name)
265
+ except (ValueError, FileNotFoundError) as exc:
266
+ print(str(exc), file=sys.stderr)
267
+ return 1
268
+ print(f"Template removed: {args.name}")
269
+ return 0
270
+
271
+ if args.command == "list-templates":
272
+ templates = available_templates()
273
+ width = max(len(t) for t in templates)
274
+ for t in templates:
275
+ desc = TEMPLATE_DESCRIPTIONS.get(t, "")
276
+ if desc:
277
+ print(f" {t:<{width}} {desc}")
278
+ else:
279
+ print(f" {t}")
280
+ return 0
281
+
282
+ if args.command == "profile":
283
+ if args.set:
284
+ run_profile_setup()
285
+ return 0
286
+
287
+ from .config import get_profile
288
+ profile = get_profile()
289
+ if not profile:
290
+ print("No profile configured.")
291
+ print("Run `latex-forge profile --set` to set up your profile.")
292
+ return 0
293
+
294
+ print("Profile:")
295
+ for key, label in [
296
+ ("name", "Name"),
297
+ ("university", "University"),
298
+ ("program", "Program"),
299
+ ("github", "GitHub"),
300
+ ]:
301
+ value = profile.get(key)
302
+ if value:
303
+ display = f"github.com/{value}" if key == "github" else value
304
+ print(f" {label:<12} {display}")
305
+ return 0
306
+
307
+ if args.command == "completion":
308
+ shell = args.shell
309
+ if shell is None:
310
+ shell_path = os.environ.get("SHELL", "")
311
+ shell = Path(shell_path).name if shell_path else "bash"
312
+ if shell not in ("bash", "zsh", "fish"):
313
+ shell = "bash"
314
+ print(argcomplete.shellcode(["latex-forge"], shell=shell))
315
+ return 0
316
+
317
+ if args.command == "create":
318
+ name = args.name
319
+ template = args.template
320
+ guided = False
321
+
322
+ if name is None:
323
+ if not _is_interactive():
324
+ print("--name is required in non-interactive mode.", file=sys.stderr)
325
+ return 1
326
+ name = _ask_project_name()
327
+ guided = True
328
+
329
+ if template is None:
330
+ config_template = get_default_template()
331
+ if config_template is not None:
332
+ if config_template in available_templates():
333
+ template = config_template
334
+ else:
335
+ print(
336
+ f"Warning: default_template '{config_template}' in "
337
+ "~/.latex-forge.toml does not match any available template — ignoring.",
338
+ file=sys.stderr,
339
+ )
340
+ if template is None:
341
+ if not _is_interactive():
342
+ print("--template is required in non-interactive mode.", file=sys.stderr)
343
+ return 1
344
+ template = _select_template_interactively()
345
+ guided = True
346
+
347
+ if args.output is not None:
348
+ output_dir = Path(args.output).resolve()
349
+ elif guided:
350
+ output_dir = _ask_output_dir()
351
+ else:
352
+ config_dir = get_default_output_dir()
353
+ output_dir = config_dir if config_dir is not None else Path.cwd()
354
+
355
+ first_run = is_first_run()
356
+ if first_run:
357
+ run_first_launch_check()
358
+ mark_initialized()
359
+
360
+ try:
361
+ target_dir, main_tex_file = create_project(
362
+ name=name,
363
+ template=template,
364
+ output_dir=output_dir,
365
+ )
366
+ except (ValueError, FileExistsError) as exc:
367
+ print(str(exc), file=sys.stderr)
368
+ return 1
369
+
370
+ print(f"Project created: {target_dir}")
371
+ print(f"Edit: {main_tex_file.relative_to(target_dir.parent)}")
372
+ if template in ("cv-fr", "cv-en"):
373
+ first_file = "sections/en-tete.tex" if template == "cv-fr" else "sections/heading.tex"
374
+ print(f"Next: fill in {first_file} then save to compile.")
375
+ else:
376
+ print("Next: fill in frontmatter/metadata.tex then save to compile.")
377
+
378
+ if not first_run:
379
+ warn_if_latex_missing()
380
+
381
+ offer_open_vscode(target_dir)
382
+
383
+ return 0
384
+
385
+ if args.command == "rename":
386
+ try:
387
+ if len(args.names) == 1:
388
+ target_dir, main_tex_file = rename_current_project(
389
+ new_name=args.names[0],
390
+ )
391
+ elif len(args.names) == 2:
392
+ target_dir, main_tex_file = rename_project(
393
+ old_name=args.names[0],
394
+ new_name=args.names[1],
395
+ )
396
+ else:
397
+ print(
398
+ "Usage: latex-forge rename <new-name> "
399
+ "or latex-forge rename <old-name> <new-name>",
400
+ file=sys.stderr,
401
+ )
402
+ return 1
403
+ except (FileNotFoundError, FileExistsError) as exc:
404
+ print(str(exc), file=sys.stderr)
405
+ return 1
406
+
407
+ print(f"Project renamed: {target_dir}")
408
+ print(f"Main file: {main_tex_file.name}")
409
+ return 0
410
+
411
+ if args.command == "setup":
412
+ return run_setup(
413
+ check_only=args.check_only,
414
+ skip_extensions=args.skip_extensions,
415
+ install_tex=args.install_tex,
416
+ )
417
+
418
+ parser.print_help()
419
+ return 1
420
+
421
+
422
+ if __name__ == "__main__":
423
+ raise SystemExit(main())
latex_forge/config.py ADDED
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ if sys.version_info >= (3, 11):
7
+ import tomllib
8
+ else:
9
+ try:
10
+ import tomli as tomllib # type: ignore[no-redef]
11
+ except ImportError:
12
+ tomllib = None # type: ignore[assignment]
13
+
14
+ _CONFIG_PATH = Path.home() / ".latex-forge.toml"
15
+
16
+
17
+ def load_config() -> dict:
18
+ if tomllib is None or not _CONFIG_PATH.exists():
19
+ return {}
20
+ try:
21
+ with open(_CONFIG_PATH, "rb") as f:
22
+ return tomllib.load(f)
23
+ except Exception:
24
+ return {}
25
+
26
+
27
+ def get_default_template() -> str | None:
28
+ value = load_config().get("default_template")
29
+ return value if isinstance(value, str) and value else None
30
+
31
+
32
+ def get_default_output_dir() -> Path | None:
33
+ value = load_config().get("default_output_dir")
34
+ if not isinstance(value, str) or not value:
35
+ return None
36
+ path = Path(value).expanduser().resolve()
37
+ return path if path.is_dir() else None
38
+
39
+
40
+ def get_profile() -> dict:
41
+ raw = load_config().get("profile", {})
42
+ return raw if isinstance(raw, dict) else {}
43
+
44
+
45
+ def save_profile(profile: dict) -> None:
46
+ existing_content = _CONFIG_PATH.read_text(encoding="utf-8") if _CONFIG_PATH.exists() else ""
47
+
48
+ # Rebuild file without the existing [profile] section
49
+ lines: list[str] = []
50
+ in_profile = False
51
+ for line in existing_content.splitlines():
52
+ if line.strip() == "[profile]":
53
+ in_profile = True
54
+ continue
55
+ if in_profile:
56
+ if line.strip().startswith("["):
57
+ in_profile = False
58
+ lines.append(line)
59
+ # else: skip profile lines
60
+ else:
61
+ lines.append(line)
62
+
63
+ # Strip trailing blank lines
64
+ while lines and not lines[-1].strip():
65
+ lines.pop()
66
+
67
+ # Append new [profile] section
68
+ if lines:
69
+ lines.append("")
70
+ lines.append("[profile]")
71
+ for key in ("name", "university", "program", "github"):
72
+ value = profile.get(key, "")
73
+ if value:
74
+ lines.append(f'{key} = "{value}"')
75
+
76
+ _CONFIG_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8")