canisend 0.2.0b2__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 (62) hide show
  1. canisend/__init__.py +3 -0
  2. canisend/cli.py +426 -0
  3. canisend/config_schema.py +56 -0
  4. canisend/evidence.py +288 -0
  5. canisend/examples.py +112 -0
  6. canisend/jobs.py +178 -0
  7. canisend/llm.py +104 -0
  8. canisend/match.py +389 -0
  9. canisend/material_review.py +92 -0
  10. canisend/materials.py +175 -0
  11. canisend/package_check.py +71 -0
  12. canisend/parse.py +167 -0
  13. canisend/pipeline.py +220 -0
  14. canisend/profile.py +189 -0
  15. canisend/resource_files.py +62 -0
  16. canisend/resources/.env.example +6 -0
  17. canisend/resources/__init__.py +1 -0
  18. canisend/resources/agent-skills/canisend/SKILL.md +67 -0
  19. canisend/resources/agent-skills/canisend/agents/openai.yaml +4 -0
  20. canisend/resources/agent-skills/canisend/references/agent-orchestration.md +73 -0
  21. canisend/resources/agent-skills/canisend/references/file-contracts.md +103 -0
  22. canisend/resources/agent-skills/canisend/references/job-lifecycle.md +63 -0
  23. canisend/resources/agent-skills/canisend/references/platforms.md +62 -0
  24. canisend/resources/agent-skills/canisend/references/privacy.md +59 -0
  25. canisend/resources/agent-skills/canisend/references/provider-config.md +70 -0
  26. canisend/resources/agent-skills/canisend/references/quality-gates.md +68 -0
  27. canisend/resources/agent-skills/canisend/references/typst-profile.md +71 -0
  28. canisend/resources/agent-skills/canisend/references/workflow.md +126 -0
  29. canisend/resources/examples/end_to_end/README.md +80 -0
  30. canisend/resources/examples/end_to_end/fake_llm_provider.py +119 -0
  31. canisend/resources/examples/end_to_end/full_job_advert.md +19 -0
  32. canisend/resources/examples/end_to_end/jobs_ac_uk_sample.xml +18 -0
  33. canisend/resources/examples/end_to_end/profile/generated/.gitkeep +1 -0
  34. canisend/resources/examples/end_to_end/profile/profile.yaml +13 -0
  35. canisend/resources/examples/end_to_end/profile/typst/cover_letter_base.typ +19 -0
  36. canisend/resources/examples/end_to_end/profile/typst/cv.typ +18 -0
  37. canisend/resources/examples/end_to_end/profile/typst/research_statement.typ +13 -0
  38. canisend/resources/examples/end_to_end/profile/typst/teaching_statement.typ +13 -0
  39. canisend/resources/platform-bridges/AGENTS.md +22 -0
  40. canisend/resources/platform-bridges/CLAUDE.md +21 -0
  41. canisend/resources/platform-bridges/GEMINI.md +21 -0
  42. canisend/resources/prompts/cover_letter_writer.md +45 -0
  43. canisend/resources/prompts/criteria_checker.md +44 -0
  44. canisend/resources/prompts/cv_tailor.md +44 -0
  45. canisend/resources/prompts/job_parser.md +38 -0
  46. canisend/resources/prompts/package_builder.md +27 -0
  47. canisend/resources/prompts/profile_matcher.md +37 -0
  48. canisend/resources/schemas/criteria_check.schema.json +22 -0
  49. canisend/resources/schemas/fit_report.schema.json +12 -0
  50. canisend/resources/schemas/parsed_job.schema.json +51 -0
  51. canisend/resources/templates/typst/application_package.typ +35 -0
  52. canisend/resources/templates/typst/cover_letter.typ +40 -0
  53. canisend/resources/templates/typst/cv_notes.typ +18 -0
  54. canisend/rss.py +89 -0
  55. canisend/typst.py +29 -0
  56. canisend/typst_mapping.py +209 -0
  57. canisend/workspace.py +214 -0
  58. canisend-0.2.0b2.dist-info/METADATA +378 -0
  59. canisend-0.2.0b2.dist-info/RECORD +62 -0
  60. canisend-0.2.0b2.dist-info/WHEEL +4 -0
  61. canisend-0.2.0b2.dist-info/entry_points.txt +2 -0
  62. canisend-0.2.0b2.dist-info/licenses/LICENSE +21 -0
canisend/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Academic application preparation CLI package."""
2
+
3
+ __version__ = "0.2.0b2"
canisend/cli.py ADDED
@@ -0,0 +1,426 @@
1
+ from pathlib import Path
2
+
3
+ import typer
4
+
5
+ from canisend.evidence import extract_profile_evidence
6
+ from canisend.examples import run_packaged_example
7
+ from canisend.jobs import create_job, create_job_from_lead, list_jobs as list_job_folders
8
+ from canisend.pipeline import run_pipeline as run_job_pipeline
9
+ from canisend.profile import init_profile as create_profile
10
+ from canisend.rss import fetch_rss_text, filter_job_leads, parse_jobs_ac_uk_rss, write_job_leads
11
+ from canisend.typst import render_typst_files
12
+ from canisend.workspace import (
13
+ doctor_lines,
14
+ init_workspace as create_workspace,
15
+ load_workspace_config,
16
+ update_workspace_defaults,
17
+ )
18
+
19
+ app = typer.Typer(
20
+ help="Prepare evidence-backed academic and professional job application materials from local files.",
21
+ no_args_is_help=True,
22
+ )
23
+
24
+
25
+ @app.command("init-profile")
26
+ def init_profile(
27
+ workspace: Path = typer.Option(
28
+ Path("."),
29
+ "--workspace",
30
+ help="User workspace directory containing canisend.yaml.",
31
+ ),
32
+ profile_dir: Path | None = typer.Option(
33
+ None,
34
+ "--profile-dir",
35
+ help="Directory for profile evidence files. Relative paths are resolved against --workspace.",
36
+ ),
37
+ mode: str = typer.Option(
38
+ "hybrid",
39
+ "--mode",
40
+ help="Profile scaffold mode: markdown, typst, or hybrid.",
41
+ ),
42
+ ) -> None:
43
+ """Create starter profile files."""
44
+ config = load_workspace_config(workspace)
45
+ resolved_profile_dir = config.path("profile_dir", profile_dir)
46
+ try:
47
+ created = create_profile(resolved_profile_dir, mode=mode)
48
+ except ValueError as exc:
49
+ raise typer.BadParameter(str(exc)) from exc
50
+ typer.echo(f"Profile ready at {resolved_profile_dir}")
51
+ if created:
52
+ typer.echo(f"Created {len(created)} profile files.")
53
+ else:
54
+ typer.echo("No files created; existing profile files were left unchanged.")
55
+
56
+
57
+ @app.command("init-workspace")
58
+ def init_workspace(
59
+ workspace: Path = typer.Option(
60
+ Path("."),
61
+ "--workspace",
62
+ help="User workspace directory for private profile, jobs, local prompts, and agent skills.",
63
+ ),
64
+ profile_mode: str = typer.Option(
65
+ "typst",
66
+ "--profile-mode",
67
+ help="Profile scaffold mode: markdown, typst, or hybrid.",
68
+ ),
69
+ overwrite: bool = typer.Option(
70
+ False,
71
+ "--overwrite",
72
+ help="Overwrite existing default resources and config files.",
73
+ ),
74
+ ) -> None:
75
+ """Create a productized user workspace without requiring a repository fork."""
76
+ try:
77
+ created = create_workspace(workspace, profile_mode=profile_mode, overwrite=overwrite)
78
+ except ValueError as exc:
79
+ raise typer.BadParameter(str(exc)) from exc
80
+ typer.echo(f"Workspace ready at {workspace}")
81
+ if created:
82
+ typer.echo(f"Created or updated {len(created)} files.")
83
+ else:
84
+ typer.echo("No files changed; existing workspace files were left unchanged.")
85
+
86
+
87
+ @app.command("update-workspace")
88
+ def update_workspace(
89
+ workspace: Path = typer.Option(
90
+ Path("."),
91
+ "--workspace",
92
+ help="User workspace directory whose default resources should be refreshed.",
93
+ ),
94
+ overwrite: bool = typer.Option(
95
+ False,
96
+ "--overwrite",
97
+ help="Overwrite local default-resource copies. Leave off to preserve local edits.",
98
+ ),
99
+ ) -> None:
100
+ """Copy current packaged prompts, templates, schemas, and agent skills into a workspace."""
101
+ copied = update_workspace_defaults(workspace, overwrite=overwrite)
102
+ typer.echo(f"Workspace defaults checked at {workspace}")
103
+ if copied:
104
+ typer.echo(f"Created or updated {len(copied)} files.")
105
+ else:
106
+ typer.echo("No default files changed; existing local files were left unchanged.")
107
+
108
+
109
+ @app.command("doctor")
110
+ def doctor(
111
+ workspace: Path = typer.Option(
112
+ Path("."),
113
+ "--workspace",
114
+ help="User workspace directory to inspect.",
115
+ ),
116
+ ) -> None:
117
+ """Report local workspace, provider, and rendering readiness."""
118
+ for line in doctor_lines(workspace):
119
+ typer.echo(line)
120
+
121
+
122
+ @app.command("run-example")
123
+ def run_example(
124
+ workspace: Path = typer.Option(
125
+ Path("/tmp/canisend-example"),
126
+ "--workspace",
127
+ help="Directory where the packaged fake-data example workspace should be created.",
128
+ ),
129
+ overwrite: bool = typer.Option(
130
+ False,
131
+ "--overwrite",
132
+ help="Replace an existing non-empty example workspace.",
133
+ ),
134
+ ) -> None:
135
+ """Run the packaged end-to-end fake-data workflow locally."""
136
+ try:
137
+ result = run_packaged_example(workspace, overwrite=overwrite)
138
+ except ValueError as exc:
139
+ raise typer.BadParameter(str(exc)) from exc
140
+
141
+ typer.echo(f"Example workflow complete at {result.workspace}")
142
+ typer.echo(f"Job: {result.job_dir.relative_to(result.workspace)}")
143
+ typer.echo(f"RSS leads: {result.leads_file.relative_to(result.workspace)}")
144
+ typer.echo("Key outputs:")
145
+ for output in [
146
+ "parsed_job.json",
147
+ "02_fit_report.md",
148
+ "03_cover_letter_draft.md",
149
+ "05_criteria_checklist.md",
150
+ "07_material_review_checklist.md",
151
+ "typst/cover_letter_content.json",
152
+ "typst/cover_letter.typ",
153
+ "typst/application_package_content.json",
154
+ "typst/application_package.typ",
155
+ ]:
156
+ typer.echo(f" - {result.job_dir.relative_to(result.workspace) / output}")
157
+ typer.echo("Next: inspect the generated job folder, then try the same workflow with your private workspace.")
158
+
159
+
160
+ @app.command("extract-profile-evidence")
161
+ def extract_profile_evidence_command(
162
+ workspace: Path = typer.Option(
163
+ Path("."),
164
+ "--workspace",
165
+ help="User workspace directory containing canisend.yaml.",
166
+ ),
167
+ profile_dir: Path | None = typer.Option(
168
+ None,
169
+ "--profile-dir",
170
+ help="Directory containing profile.yaml and Typst profile sources. Relative paths are resolved against --workspace.",
171
+ ),
172
+ ) -> None:
173
+ """Generate normalized evidence Markdown from local profile sources."""
174
+ config = load_workspace_config(workspace)
175
+ written = extract_profile_evidence(config.path("profile_dir", profile_dir))
176
+ typer.echo(f"Generated {len(written)} evidence files.")
177
+
178
+
179
+ @app.command("new-job")
180
+ def new_job(
181
+ title: str = typer.Option(..., "--title", help="Job title."),
182
+ institution: str = typer.Option(..., "--institution", help="Hiring institution."),
183
+ deadline: str = typer.Option("unknown", "--deadline", help="Application deadline."),
184
+ source_url: str = typer.Option("", "--source-url", help="Original job advert URL."),
185
+ workspace: Path = typer.Option(
186
+ Path("."),
187
+ "--workspace",
188
+ help="User workspace directory containing canisend.yaml.",
189
+ ),
190
+ jobs_dir: Path | None = typer.Option(
191
+ None,
192
+ "--jobs-dir",
193
+ help="Directory for job folders. Relative paths are resolved against --workspace.",
194
+ ),
195
+ advert_file: Path | None = typer.Option(
196
+ None,
197
+ "--advert-file",
198
+ help="Local .md or .txt job advert file to import.",
199
+ ),
200
+ ) -> None:
201
+ """Create a local job folder and advert file."""
202
+ config = load_workspace_config(workspace)
203
+ try:
204
+ job_dir = create_job(
205
+ jobs_dir=config.path("jobs_dir", jobs_dir),
206
+ title=title,
207
+ institution=institution,
208
+ deadline=deadline,
209
+ source_url=source_url,
210
+ advert_file=advert_file,
211
+ )
212
+ except ValueError as exc:
213
+ raise typer.BadParameter(str(exc)) from exc
214
+ typer.echo(f"Created job at {job_dir}")
215
+
216
+
217
+ @app.command("new-job-from-lead")
218
+ def new_job_from_lead(
219
+ workspace: Path = typer.Option(
220
+ Path("."),
221
+ "--workspace",
222
+ help="User workspace directory containing canisend.yaml.",
223
+ ),
224
+ leads_file: Path | None = typer.Option(
225
+ None,
226
+ "--leads-file",
227
+ help="Local RSS lead JSON file created by fetch-jobs-ac-uk. Relative paths are resolved against --workspace.",
228
+ ),
229
+ lead_index: int = typer.Option(..., "--lead-index", help="Zero-based index of the selected lead."),
230
+ institution: str = typer.Option(..., "--institution", help="Hiring institution for the job workspace."),
231
+ deadline: str = typer.Option("unknown", "--deadline", help="Application deadline."),
232
+ title: str | None = typer.Option(None, "--title", help="Override the RSS lead title."),
233
+ jobs_dir: Path | None = typer.Option(
234
+ None,
235
+ "--jobs-dir",
236
+ help="Directory for job folders. Relative paths are resolved against --workspace.",
237
+ ),
238
+ ) -> None:
239
+ """Create a local job folder from a selected RSS lead without scraping."""
240
+ config = load_workspace_config(workspace)
241
+ try:
242
+ job_dir = create_job_from_lead(
243
+ leads_file=config.lead_file(leads_file),
244
+ lead_index=lead_index,
245
+ jobs_dir=config.path("jobs_dir", jobs_dir),
246
+ institution=institution,
247
+ deadline=deadline,
248
+ title=title,
249
+ )
250
+ except ValueError as exc:
251
+ raise typer.BadParameter(str(exc)) from exc
252
+ typer.echo(f"Created job from lead at {job_dir}")
253
+
254
+
255
+ @app.command("list-jobs")
256
+ def list_jobs_command(
257
+ workspace: Path = typer.Option(
258
+ Path("."),
259
+ "--workspace",
260
+ help="User workspace directory containing canisend.yaml.",
261
+ ),
262
+ jobs_dir: Path | None = typer.Option(
263
+ None,
264
+ "--jobs-dir",
265
+ help="Directory containing job folders. Relative paths are resolved against --workspace.",
266
+ ),
267
+ ) -> None:
268
+ """List all job folders with status, deadline, and institution."""
269
+ config = load_workspace_config(workspace)
270
+ jobs = list_job_folders(config.path("jobs_dir", jobs_dir))
271
+ if not jobs:
272
+ typer.echo("No job folders found.")
273
+ return
274
+ typer.echo(f"{'Deadline':<12} {'Status':<18} {'Institution':<30} {'Title'}")
275
+ typer.echo("-" * 90)
276
+ for job in jobs:
277
+ typer.echo(f"{job['deadline']:<12} {job['status']:<18} {job['institution'][:28]:<30} {job['title'][:40]}")
278
+ typer.echo(f"\n{len(jobs)} job(s) found.")
279
+
280
+
281
+ @app.command("fetch-jobs-ac-uk")
282
+ def fetch_jobs_ac_uk(
283
+ workspace: Path = typer.Option(
284
+ Path("."),
285
+ "--workspace",
286
+ help="User workspace directory containing canisend.yaml.",
287
+ ),
288
+ feed_url: str = typer.Option("", "--feed-url", help="jobs.ac.uk RSS feed URL."),
289
+ rss_file: Path | None = typer.Option(
290
+ None,
291
+ "--rss-file",
292
+ help="Local RSS XML file for testing or offline import.",
293
+ ),
294
+ output: Path | None = typer.Option(
295
+ None,
296
+ "--output",
297
+ help="JSON output path. Relative paths are resolved against --workspace.",
298
+ ),
299
+ include: list[str] = typer.Option([], "--include", help="Include jobs matching this keyword."),
300
+ exclude: list[str] = typer.Option([], "--exclude", help="Exclude jobs matching this keyword."),
301
+ limit: int = typer.Option(100, "--limit", help="Maximum number of leads to write."),
302
+ ) -> None:
303
+ """Fetch jobs.ac.uk RSS leads and apply local keyword filters."""
304
+ if rss_file is None and not feed_url:
305
+ raise typer.BadParameter("Provide --feed-url or --rss-file.")
306
+
307
+ config = load_workspace_config(workspace)
308
+ output_path = config.lead_file(output)
309
+ xml_text = rss_file.read_text(encoding="utf-8") if rss_file is not None else fetch_rss_text(feed_url)
310
+ leads = parse_jobs_ac_uk_rss(xml_text, feed_url=feed_url)
311
+ filtered = filter_job_leads(leads, include_keywords=include, exclude_keywords=exclude)
312
+ limited = filtered[:limit]
313
+ write_job_leads(output_path, limited)
314
+ typer.echo(f"Wrote {len(limited)} jobs.ac.uk leads to {output_path}")
315
+
316
+
317
+ @app.command("run")
318
+ def run_pipeline(
319
+ job: Path = typer.Option(..., "--job", help="Path to the job folder."),
320
+ workspace: Path = typer.Option(
321
+ Path("."),
322
+ "--workspace",
323
+ help="User workspace directory containing canisend.yaml.",
324
+ ),
325
+ profile_dir: Path | None = typer.Option(
326
+ None,
327
+ "--profile-dir",
328
+ help="Directory containing generated profile evidence. Relative paths are resolved against --workspace.",
329
+ ),
330
+ llm_parser: bool = typer.Option(
331
+ False,
332
+ "--llm-parser",
333
+ help="Use configured LLM provider and prompts/job_parser.md instead of deterministic parsing.",
334
+ ),
335
+ llm_drafts: bool = typer.Option(
336
+ False,
337
+ "--llm-drafts",
338
+ help="Use configured LLM provider for fit report, cover letter, CV notes, and criteria checklist.",
339
+ ),
340
+ prompt_dir: Path | None = typer.Option(
341
+ None,
342
+ "--prompt-dir",
343
+ help="Directory containing application prompt files. Relative paths are resolved against --workspace.",
344
+ ),
345
+ dry_run: bool = typer.Option(
346
+ False,
347
+ "--dry-run",
348
+ help="Preview what would be generated without writing any files.",
349
+ ),
350
+ ) -> None:
351
+ """Run the application preparation pipeline for one job."""
352
+ config = load_workspace_config(workspace)
353
+ job_dir = config.job_dir(job)
354
+ if dry_run:
355
+ from canisend.evidence import load_generated_evidence
356
+ from canisend.llm import load_llm_config, provider_from_config
357
+ from canisend.parse import parse_job_advert, parse_job_advert_with_provider
358
+ from canisend.resource_files import read_resource_text
359
+
360
+ import yaml as _yaml
361
+
362
+ metadata = _yaml.safe_load((job_dir / "job.yaml").read_text(encoding="utf-8"))
363
+ advert_text = (job_dir / "job_advert.md").read_text(encoding="utf-8")
364
+ evidence = load_generated_evidence(config.path("profile_dir", profile_dir))
365
+ if llm_parser:
366
+ prompt_text = read_resource_text("prompts/job_parser.md", local_path=config.path("prompt_dir", prompt_dir) / "job_parser.md")
367
+ provider = provider_from_config(load_llm_config())
368
+ parsed_job = parse_job_advert_with_provider(advert_text=advert_text, metadata=metadata, provider=provider, prompt_text=prompt_text)
369
+ typer.echo("Parser: LLM-backed")
370
+ else:
371
+ parsed_job = parse_job_advert(advert_text, metadata)
372
+ typer.echo("Parser: deterministic")
373
+
374
+ typer.echo(f" Title: {parsed_job['title']}")
375
+ typer.echo(f" Institution: {parsed_job['institution']}")
376
+ typer.echo(f" Essential criteria: {len(parsed_job['essential_criteria'])}")
377
+ typer.echo(f" Desirable criteria: {len(parsed_job['desirable_criteria'])}")
378
+ typer.echo(f" Required documents: {len(parsed_job['required_documents'])}")
379
+ typer.echo(f" Evidence items available: {len(evidence)}")
380
+ typer.echo(f"\nOutputs that would be generated:")
381
+
382
+ outputs = [
383
+ "parsed_job.json", "01_job_summary.md", "02_fit_report.md",
384
+ "03_cover_letter_draft.md", "04_cv_tailoring_notes.md",
385
+ "05_criteria_checklist.md", "06_final_application_package.md",
386
+ "07_material_review_checklist.md",
387
+ "typst/cover_letter_content.json", "typst/cover_letter.typ",
388
+ "typst/application_package_content.json", "typst/application_package.typ",
389
+ ]
390
+ for output in outputs:
391
+ typer.echo(f" - {job_dir}/{output}")
392
+ typer.echo(f"\nDraft mode: {'LLM-backed' if llm_drafts else 'deterministic'}")
393
+ return
394
+
395
+ written = run_job_pipeline(
396
+ job_dir,
397
+ profile_dir=config.path("profile_dir", profile_dir),
398
+ use_llm_parser=llm_parser,
399
+ use_llm_drafts=llm_drafts,
400
+ prompt_dir=config.path("prompt_dir", prompt_dir),
401
+ )
402
+ typer.echo(f"Generated {len(written)} files for {job_dir}")
403
+
404
+
405
+ @app.command("render-typst")
406
+ def render_typst(
407
+ job: Path = typer.Option(..., "--job", help="Path to the job folder."),
408
+ workspace: Path = typer.Option(
409
+ Path("."),
410
+ "--workspace",
411
+ help="User workspace directory containing canisend.yaml.",
412
+ ),
413
+ typst_bin: str = typer.Option("typst", "--typst-bin", help="Typst executable path or command name."),
414
+ ) -> None:
415
+ """Render generated Typst files for one job."""
416
+ config = load_workspace_config(workspace)
417
+ job_dir = config.job_dir(job)
418
+ try:
419
+ rendered = render_typst_files(job_dir, typst_bin=typst_bin)
420
+ except FileNotFoundError as exc:
421
+ typer.echo(str(exc))
422
+ raise typer.Exit(code=1) from exc
423
+ except RuntimeError as exc:
424
+ typer.echo(str(exc))
425
+ raise typer.Exit(code=1) from exc
426
+ typer.echo(f"Rendered {len(rendered)} PDF files for {job_dir}")
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import yaml
7
+
8
+
9
+ VALID_CONFIG_KEYS = {
10
+ "profile_dir",
11
+ "jobs_dir",
12
+ "job_leads_dir",
13
+ "prompt_dir",
14
+ "template_dir",
15
+ "schema_dir",
16
+ "agent_skills_dir",
17
+ }
18
+
19
+
20
+ def validate_workspace_config(config_path: Path) -> list[str]:
21
+ if not config_path.exists():
22
+ return ["canisend.yaml not found. Run `canisend init-workspace` to create it."]
23
+
24
+ try:
25
+ data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
26
+ except yaml.YAMLError as exc:
27
+ return [f"canisend.yaml is not valid YAML: {exc}"]
28
+
29
+ if not isinstance(data, dict):
30
+ return ["canisend.yaml must contain a mapping of key-value pairs."]
31
+
32
+ warnings: list[str] = []
33
+ for key, value in data.items():
34
+ if key not in VALID_CONFIG_KEYS:
35
+ warnings.append(f"Unknown key in canisend.yaml: '{key}'. Valid keys: {', '.join(sorted(VALID_CONFIG_KEYS))}")
36
+ if not isinstance(value, (str, type(None))):
37
+ warnings.append(f"Value for '{key}' must be a string or null, got {type(value).__name__}.")
38
+
39
+ for key in VALID_CONFIG_KEYS:
40
+ if key not in data:
41
+ warnings.append(f"Missing key in canisend.yaml: '{key}'. Default will be used: '{_default_for(key)}'.")
42
+
43
+ return warnings
44
+
45
+
46
+ def _default_for(key: str) -> str:
47
+ defaults: dict[str, str] = {
48
+ "profile_dir": "profile",
49
+ "jobs_dir": "jobs",
50
+ "job_leads_dir": "job_leads",
51
+ "prompt_dir": "prompts",
52
+ "template_dir": "templates",
53
+ "schema_dir": "schemas",
54
+ "agent_skills_dir": "agent-skills",
55
+ }
56
+ return defaults.get(key, "")