linkedin-apply-assistant 0.1.1

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 (55) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.yml +72 -0
  2. package/.github/ISSUE_TEMPLATE/config.yml +5 -0
  3. package/.github/ISSUE_TEMPLATE/config_help.yml +49 -0
  4. package/.github/ISSUE_TEMPLATE/docs.yml +40 -0
  5. package/.github/ISSUE_TEMPLATE/feature_request.yml +45 -0
  6. package/.github/ISSUE_TEMPLATE/safety_compliance.yml +48 -0
  7. package/.github/PULL_REQUEST_TEMPLATE.md +43 -0
  8. package/CHANGELOG.md +47 -0
  9. package/CODE_OF_CONDUCT.md +47 -0
  10. package/CONTRIBUTING.md +64 -0
  11. package/GOVERNANCE.md +41 -0
  12. package/LEGAL.md +38 -0
  13. package/LICENSE +22 -0
  14. package/MIGRATION.md +50 -0
  15. package/README.md +167 -0
  16. package/RELEASE_CHECKLIST.md +454 -0
  17. package/SAFETY.md +33 -0
  18. package/SECURITY.md +37 -0
  19. package/SUPPORT.md +44 -0
  20. package/THIRD_PARTY_NOTICES.md +67 -0
  21. package/bin/linkedin-apply-assistant.mjs +95 -0
  22. package/configs/config.example.yml +24 -0
  23. package/configs/qa_bank.example.yml +35 -0
  24. package/docs/apply.md +40 -0
  25. package/docs/assist.md +35 -0
  26. package/docs/browser-session.md +45 -0
  27. package/docs/ci-and-release-policy.md +105 -0
  28. package/docs/commands.md +176 -0
  29. package/docs/install-and-configuration.md +265 -0
  30. package/docs/registry-publication-strategy.md +169 -0
  31. package/docs/reports.md +35 -0
  32. package/docs/search.md +39 -0
  33. package/docs/troubleshooting.md +57 -0
  34. package/examples/dry_run_input.example.json +25 -0
  35. package/examples/reports/apply-audit.example.json +31 -0
  36. package/examples/reports/search-report.example.json +40 -0
  37. package/install.ps1 +178 -0
  38. package/package.json +59 -0
  39. package/pyproject.toml +51 -0
  40. package/src/linkedin_apply_assistant/__init__.py +8 -0
  41. package/src/linkedin_apply_assistant/apply_reports.py +229 -0
  42. package/src/linkedin_apply_assistant/ats_handlers.py +217 -0
  43. package/src/linkedin_apply_assistant/browser_sessions.py +155 -0
  44. package/src/linkedin_apply_assistant/cli.py +570 -0
  45. package/src/linkedin_apply_assistant/config.py +109 -0
  46. package/src/linkedin_apply_assistant/contracts.py +255 -0
  47. package/src/linkedin_apply_assistant/form_engine.py +180 -0
  48. package/src/linkedin_apply_assistant/linkedin_layer.py +436 -0
  49. package/src/linkedin_apply_assistant/page_actions.py +110 -0
  50. package/src/linkedin_apply_assistant/page_selectors.py +88 -0
  51. package/src/linkedin_apply_assistant/paths.py +135 -0
  52. package/src/linkedin_apply_assistant/qa_bank.py +352 -0
  53. package/src/linkedin_apply_assistant/redaction.py +119 -0
  54. package/src/linkedin_apply_assistant/safety.py +230 -0
  55. package/src/linkedin_apply_assistant/workflows.py +435 -0
@@ -0,0 +1,570 @@
1
+ """Command-line boundary for the standalone assistant."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .apply_reports import RuntimeReportSink
12
+ from .ats_handlers import DisabledSubmissionPolicy
13
+ from .browser_sessions import PLAYWRIGHT_CHROMIUM_INSTALL_COMMAND, VisibleBrowserSessionFactory
14
+ from .config import AssistantConfig, load_config
15
+ from .contracts import AssistRequest, SearchRequest
16
+ from .linkedin_layer import (
17
+ BrowserLinkedInDiscovery,
18
+ CurrentSurfaceDetector,
19
+ CurrentSurfaceFillAdapter,
20
+ StaticLinkedInDiscovery,
21
+ )
22
+ from .paths import resolve_runtime_paths
23
+ from .qa_bank import QABank
24
+ from .safety import BROWSER_PROFILE_WARNING, disabled_submit_audit_payload
25
+ from .workflows import compact_assist_feedback, run_assist_workflow, run_search_workflow
26
+
27
+
28
+ REQUIRED_JOB_FIELDS = ("title", "company", "url", "location", "description")
29
+ CONFIG_CHECK_COMMAND = "linkedin-apply-assistant config check"
30
+ CONFIG_EXAMPLE_PATH = "configs/config.example.yml"
31
+ QA_BANK_EXAMPLE_PATH = "configs/qa_bank.example.yml"
32
+ NO_SUBMIT_HELP = (
33
+ "Safety: public workflows are no-submit by default; assist is fill-only and "
34
+ "browser submission remains disabled in apply."
35
+ )
36
+
37
+
38
+ class CliError(Exception):
39
+ """User-facing CLI error."""
40
+
41
+
42
+ def _common_default(suppress_defaults: bool) -> str | None:
43
+ return argparse.SUPPRESS if suppress_defaults else None
44
+
45
+
46
+ def _add_common_options(
47
+ parser: argparse.ArgumentParser, *, suppress_defaults: bool = False
48
+ ) -> None:
49
+ default = _common_default(suppress_defaults)
50
+ parser.add_argument(
51
+ "--workspace",
52
+ default=default,
53
+ help="Use a local workspace for config, data, browser profile, output, and reports.",
54
+ )
55
+ parser.add_argument(
56
+ "--config",
57
+ default=default,
58
+ help="Path to an assistant YAML config file.",
59
+ )
60
+ parser.add_argument(
61
+ "--qa-bank",
62
+ default=default,
63
+ help="Path to a Q&A bank YAML file.",
64
+ )
65
+ parser.add_argument(
66
+ "--browser-profile",
67
+ default=default,
68
+ help="Path to the visible-browser profile directory.",
69
+ )
70
+ parser.add_argument(
71
+ "--output-dir",
72
+ default=default,
73
+ help="Path for local command outputs.",
74
+ )
75
+ parser.add_argument(
76
+ "--verbose",
77
+ action="store_true",
78
+ default=argparse.SUPPRESS if suppress_defaults else False,
79
+ help="Print additional command-boundary details.",
80
+ )
81
+
82
+
83
+ def build_parser() -> argparse.ArgumentParser:
84
+ root_common = argparse.ArgumentParser(add_help=False)
85
+ _add_common_options(root_common)
86
+ subcommand_common = argparse.ArgumentParser(add_help=False)
87
+ _add_common_options(subcommand_common, suppress_defaults=True)
88
+ formatter = argparse.RawDescriptionHelpFormatter
89
+
90
+ parser = argparse.ArgumentParser(
91
+ prog="linkedin-apply-assistant",
92
+ parents=[root_common],
93
+ formatter_class=formatter,
94
+ description=(
95
+ "Local, user-visible LinkedIn application assistant for search-only, "
96
+ "assistive fill-only, approval-gated apply, dry-run, and report generation."
97
+ ),
98
+ epilog=f"""First run:
99
+ {CONFIG_CHECK_COMMAND}
100
+ python -m playwright install chromium
101
+
102
+ Common workflows:
103
+ linkedin-apply-assistant search --query "python" --limit 5
104
+ linkedin-apply-assistant assist --mode on-demand
105
+ linkedin-apply-assistant dry-run --input examples/dry_run_input.example.json
106
+
107
+ Outputs use the resolved output directory and reports are written under its reports folder.
108
+ {NO_SUBMIT_HELP}
109
+ """,
110
+ )
111
+ subparsers = parser.add_subparsers(dest="command", required=True)
112
+
113
+ config = subparsers.add_parser(
114
+ "config",
115
+ parents=[subcommand_common],
116
+ formatter_class=formatter,
117
+ help="Inspect first-run paths and setup gaps without writing files.",
118
+ description="Read-only configuration diagnostics for first-run setup.",
119
+ epilog=f"""Examples:
120
+ {CONFIG_CHECK_COMMAND}
121
+ linkedin-apply-assistant --workspace .assistant-workspace config check
122
+
123
+ The diagnostic resolves config, Q&A bank, browser profile, output, reports, data, and cache paths.
124
+ It creates no files and no directories.
125
+ """,
126
+ )
127
+ config_subparsers = config.add_subparsers(dest="config_command", required=True)
128
+ config_check = config_subparsers.add_parser(
129
+ "check",
130
+ parents=[subcommand_common],
131
+ formatter_class=formatter,
132
+ help="Report resolved paths and setup guidance without writing files.",
133
+ description="Check first-run paths and setup gaps without creating or overwriting files.",
134
+ epilog=f"""Examples:
135
+ {CONFIG_CHECK_COMMAND}
136
+ linkedin-apply-assistant config check --workspace .assistant-workspace
137
+
138
+ Status labels: ok, missing, warning.
139
+ No files or directories are created by this command.
140
+ """,
141
+ )
142
+ config_check.set_defaults(handler=_handle_config_check)
143
+
144
+ search = subparsers.add_parser(
145
+ "search",
146
+ parents=[subcommand_common],
147
+ formatter_class=formatter,
148
+ help="Search-only boundary for collecting candidate jobs.",
149
+ description="Search LinkedIn jobs and write local search reports without submitting applications.",
150
+ epilog=f"""Examples:
151
+ linkedin-apply-assistant search --query "python" --location "Remote" --limit 5
152
+ linkedin-apply-assistant search --workspace .assistant-workspace --search-url "https://www.linkedin.com/jobs/search/" --limit 10 --verbose
153
+
154
+ Reports are written under the resolved reports directory.
155
+ Run `{CONFIG_CHECK_COMMAND}` to inspect paths before browser workflows.
156
+ {NO_SUBMIT_HELP}
157
+ """,
158
+ )
159
+ search.add_argument("--query", default=None, help="Search query text.")
160
+ search.add_argument("--location", default=None, help="Search location text.")
161
+ search.add_argument("--limit", type=int, default=10, help="Maximum jobs to inspect.")
162
+ search.add_argument("--search-url", default=None, help="Existing LinkedIn jobs search URL.")
163
+ search.set_defaults(handler=_handle_search)
164
+
165
+ assist = subparsers.add_parser(
166
+ "assist",
167
+ parents=[subcommand_common],
168
+ formatter_class=formatter,
169
+ help="Assistive fill-only boundary where the user drives the visible browser.",
170
+ description="Open a visible browser session and fill detected application forms without submitting.",
171
+ epilog=f"""Examples:
172
+ linkedin-apply-assistant assist --mode on-demand
173
+ linkedin-apply-assistant assist --workspace .assistant-workspace --browser-profile .browser-profile --verbose
174
+
175
+ Install browser support with: {PLAYWRIGHT_CHROMIUM_INSTALL_COMMAND}
176
+ Reports are written under the resolved reports directory.
177
+ {NO_SUBMIT_HELP}
178
+ """,
179
+ )
180
+ assist.add_argument("--start-url", default=None, help="Optional first page to open.")
181
+ assist.add_argument(
182
+ "--mode",
183
+ choices=("auto-watch", "on-demand"),
184
+ default="auto-watch",
185
+ help="Assist mode for visible-browser filling.",
186
+ )
187
+ assist.add_argument(
188
+ "--max-cycles", type=int, default=1, help="Maximum detected surfaces to inspect."
189
+ )
190
+ assist.set_defaults(handler=_handle_assist)
191
+
192
+ apply_cmd = subparsers.add_parser(
193
+ "apply",
194
+ parents=[subcommand_common],
195
+ formatter_class=formatter,
196
+ help="Apply-with-approval boundary; submissions require explicit confirmation.",
197
+ description="Prepare local apply reports while browser submission remains disabled.",
198
+ epilog=f"""Examples:
199
+ linkedin-apply-assistant apply --input candidates.json --limit 3
200
+ linkedin-apply-assistant apply --workspace .assistant-workspace --input candidates.json --verbose
201
+
202
+ Current package behavior is prepare-only. Browser submission remains disabled.
203
+ Reports are written under the resolved reports directory.
204
+ {NO_SUBMIT_HELP}
205
+ """,
206
+ )
207
+ apply_cmd.add_argument("--input", default=None, help="Path to candidate jobs JSON.")
208
+ apply_cmd.add_argument("--limit", type=int, default=10, help="Maximum jobs to prepare.")
209
+ apply_cmd.add_argument(
210
+ "--confirm-submit",
211
+ action="store_true",
212
+ help=(
213
+ "Guarded future option: every submission still requires explicit "
214
+ "per-submission confirmation and Phase 16 safety guardrails."
215
+ ),
216
+ )
217
+ apply_cmd.set_defaults(handler=_handle_apply)
218
+
219
+ dry_run = subparsers.add_parser(
220
+ "dry-run",
221
+ parents=[subcommand_common],
222
+ formatter_class=formatter,
223
+ help="Validate local job input without browser submission.",
224
+ description="Validate local JSON job input without config, Q&A bank, Playwright, or a browser profile.",
225
+ epilog="""Example:
226
+ linkedin-apply-assistant dry-run --input examples/dry_run_input.example.json
227
+
228
+ This command is browser-free and does not require config or Q&A setup.
229
+ """,
230
+ )
231
+ dry_run.add_argument("--input", required=True, help="Path to dry-run JSON input.")
232
+ dry_run.set_defaults(handler=_handle_dry_run)
233
+
234
+ report = subparsers.add_parser(
235
+ "report",
236
+ parents=[subcommand_common],
237
+ formatter_class=formatter,
238
+ help="Read a local report JSON file and print a simple summary.",
239
+ description="Summarize a local report JSON file without config, Q&A bank, Playwright, or a browser profile.",
240
+ epilog="""Example:
241
+ linkedin-apply-assistant report output/reports/search_example.json
242
+
243
+ This command is browser-free and reads an existing local report file.
244
+ """,
245
+ )
246
+ report.add_argument("report_json", help="Path to the report JSON file.")
247
+ report.set_defaults(handler=_handle_report)
248
+
249
+ return parser
250
+
251
+
252
+ def _runtime_from_args(args: argparse.Namespace):
253
+ return resolve_runtime_paths(
254
+ workspace=args.workspace,
255
+ config=args.config,
256
+ qa_bank=args.qa_bank,
257
+ browser_profile=args.browser_profile,
258
+ output_dir=args.output_dir,
259
+ )
260
+
261
+
262
+ def _with_config_check_hint(message: str) -> str:
263
+ return f"{message}\nTry: {CONFIG_CHECK_COMMAND}"
264
+
265
+
266
+ def _print_error(message: str, *hints: str) -> None:
267
+ print(f"Error: {message}", file=sys.stderr)
268
+ for hint in hints:
269
+ print(hint, file=sys.stderr)
270
+
271
+
272
+ def _path_status(path: Path, *, expected: str) -> str:
273
+ if not path.exists():
274
+ return "missing" if expected == "file" else "warning"
275
+ if expected == "file" and not path.is_file():
276
+ return "warning"
277
+ if expected == "directory" and not path.is_dir():
278
+ return "warning"
279
+ return "ok"
280
+
281
+
282
+ def _diagnostic_rows(paths: Any) -> list[tuple[str, str, Path, str]]:
283
+ return [
284
+ (
285
+ "config file",
286
+ _path_status(paths.config_file, expected="file"),
287
+ paths.config_file,
288
+ f"Copy {CONFIG_EXAMPLE_PATH} and edit it before browser workflows if needed.",
289
+ ),
290
+ (
291
+ "Q&A bank",
292
+ _path_status(paths.qa_bank_file, expected="file"),
293
+ paths.qa_bank_file,
294
+ (
295
+ f"Copy {QA_BANK_EXAMPLE_PATH} and answer truthfully; missing answers are "
296
+ "captured as pending questions during assist workflows."
297
+ ),
298
+ ),
299
+ (
300
+ "browser profile",
301
+ _path_status(paths.browser_profile_dir, expected="directory"),
302
+ paths.browser_profile_dir,
303
+ "Created only by visible-browser workflows; override with --browser-profile <path>.",
304
+ ),
305
+ (
306
+ "output directory",
307
+ _path_status(paths.output_dir, expected="directory"),
308
+ paths.output_dir,
309
+ "Created when commands write local outputs.",
310
+ ),
311
+ (
312
+ "reports directory",
313
+ _path_status(paths.reports_dir, expected="directory"),
314
+ paths.reports_dir,
315
+ "Created when commands write report JSON files.",
316
+ ),
317
+ (
318
+ "data directory",
319
+ _path_status(paths.data_dir, expected="directory"),
320
+ paths.data_dir,
321
+ "Used for local assistant data such as pending questions.",
322
+ ),
323
+ (
324
+ "cache directory",
325
+ _path_status(paths.cache_dir, expected="directory"),
326
+ paths.cache_dir,
327
+ "Used for local cache data when workflows need it.",
328
+ ),
329
+ ]
330
+
331
+
332
+ def _handle_config_check(args: argparse.Namespace) -> int:
333
+ paths = _runtime_from_args(args)
334
+ print("Config diagnostics")
335
+ print("No files or directories were created.")
336
+ print()
337
+ print(f"{'status':<8} {'item':<18} path")
338
+ print(f"{'-' * 8} {'-' * 18} {'-' * 4}")
339
+ for label, status, path, detail in _diagnostic_rows(paths):
340
+ print(f"{status:<8} {label:<18} {path}")
341
+ print(f"{'':<8} {'':<18} {detail}")
342
+ print()
343
+ print(f"Try: {PLAYWRIGHT_CHROMIUM_INSTALL_COMMAND} before visible-browser workflows.")
344
+ print(f"Try: {CONFIG_CHECK_COMMAND} after changing --workspace or path flags.")
345
+ return 0
346
+
347
+
348
+ def _qa_bank_setup_warning(paths: Any, bank: QABank) -> str | None:
349
+ pairs = bank.data.get("qa_pairs")
350
+ if not paths.qa_bank_file.exists():
351
+ return (
352
+ f"Warning: Q&A bank is missing: {paths.qa_bank_file}\n"
353
+ f"Copy {QA_BANK_EXAMPLE_PATH} and answer truthfully before filling forms."
354
+ )
355
+ if not isinstance(pairs, list) or not pairs:
356
+ return (
357
+ f"Warning: Q&A bank has no qa_pairs: {paths.qa_bank_file}\n"
358
+ f"Use {QA_BANK_EXAMPLE_PATH} as the format and answer truthfully."
359
+ )
360
+ return None
361
+
362
+
363
+ def _load_config_if_requested(args: argparse.Namespace) -> AssistantConfig:
364
+ if args.config:
365
+ try:
366
+ return load_config(args.config, workspace=args.workspace)
367
+ except FileNotFoundError as exc:
368
+ raise CliError(_with_config_check_hint(f"Config file not found: {exc}")) from exc
369
+ except ValueError as exc:
370
+ raise CliError(_with_config_check_hint(f"Invalid config: {exc}")) from exc
371
+ return AssistantConfig()
372
+
373
+
374
+ def _handle_search(args: argparse.Namespace) -> int:
375
+ config = _load_config_if_requested(args)
376
+ paths = _runtime_from_args(args)
377
+ should_discover = args.limit > 0 and bool(args.search_url or args.query or args.location)
378
+ discovery = (
379
+ BrowserLinkedInDiscovery(VisibleBrowserSessionFactory(paths, close_on_exit=True))
380
+ if should_discover
381
+ else StaticLinkedInDiscovery([])
382
+ )
383
+ try:
384
+ result = run_search_workflow(
385
+ SearchRequest(
386
+ limit=args.limit,
387
+ search_url=args.search_url,
388
+ query=args.query,
389
+ location=args.location,
390
+ profile=dict(config.profile),
391
+ paths=paths,
392
+ ),
393
+ discovery,
394
+ RuntimeReportSink(paths=paths),
395
+ DisabledSubmissionPolicy(),
396
+ )
397
+ except RuntimeError as exc:
398
+ raise CliError(str(exc)) from exc
399
+
400
+ print("Search complete.")
401
+ print(f"Requested limit: {args.limit}")
402
+ print(f"Effective limit: {result.summary.get('effective_limit', args.limit)}")
403
+ print(f"Jobs recorded: {len(result.jobs)}")
404
+ print(f"Search URL: {result.search_url}")
405
+ for artifact in result.reports:
406
+ print(f"{artifact.kind} report: {artifact.path}")
407
+ if args.verbose:
408
+ print(f"Output directory: {paths.output_dir}")
409
+ return 0
410
+
411
+
412
+ def _handle_assist(args: argparse.Namespace) -> int:
413
+ config = _load_config_if_requested(args)
414
+ paths = _runtime_from_args(args)
415
+ try:
416
+ bank = QABank.from_runtime_paths(paths, profile=dict(config.profile))
417
+ except ValueError as exc:
418
+ raise CliError(_with_config_check_hint(f"Invalid Q&A bank: {exc}")) from exc
419
+ qa_warning = _qa_bank_setup_warning(paths, bank)
420
+ if qa_warning:
421
+ print(qa_warning)
422
+ print(BROWSER_PROFILE_WARNING)
423
+ try:
424
+ result = run_assist_workflow(
425
+ AssistRequest(
426
+ start_url=args.start_url,
427
+ mode=args.mode,
428
+ max_cycles=args.max_cycles,
429
+ profile=dict(config.profile),
430
+ documents=dict(config.documents),
431
+ paths=paths,
432
+ ),
433
+ VisibleBrowserSessionFactory(paths),
434
+ CurrentSurfaceDetector(profile=dict(config.profile), bank=bank),
435
+ CurrentSurfaceFillAdapter(),
436
+ RuntimeReportSink(paths=paths),
437
+ DisabledSubmissionPolicy(),
438
+ bank,
439
+ )
440
+ except RuntimeError as exc:
441
+ raise CliError(str(exc)) from exc
442
+ print("Assist complete.")
443
+ print(f"Mode: {result.summary.get('mode', args.mode)}")
444
+ print(f"Events: {result.summary.get('events', len(result.events))}")
445
+ print(f"Filled: {result.summary.get('filled', 0)}")
446
+ print(f"Blocked: {result.summary.get('blocked', 0)}")
447
+ print(f"Submitted: {result.summary.get('submitted', 0)}")
448
+ for event in result.events:
449
+ print(compact_assist_feedback(event))
450
+ for artifact in result.reports:
451
+ print(f"{artifact.kind} report: {artifact.path}")
452
+ if args.verbose:
453
+ print(f"Browser profile directory: {paths.browser_profile_dir}")
454
+ return 0
455
+
456
+
457
+ def _handle_apply(args: argparse.Namespace) -> int:
458
+ _load_config_if_requested(args)
459
+ paths = _runtime_from_args(args)
460
+ sink = RuntimeReportSink(paths=paths)
461
+ print("Apply boundary ready. Submissions require explicit approval and confirmation.")
462
+ print("Browser submission is disabled in this package boundary.")
463
+ if args.input:
464
+ print(f"Input: {args.input}")
465
+ print(f"Limit: {args.limit}")
466
+ if args.confirm_submit:
467
+ print("Confirmation flag noted; browser submission remains disabled in this boundary.")
468
+ if args.confirm_submit or args.input:
469
+ confirmation_state = "flagged_but_disabled" if args.confirm_submit else "input_boundary"
470
+ report = disabled_submit_audit_payload(
471
+ command="apply",
472
+ action="submit",
473
+ context={},
474
+ confirmation_state=confirmation_state,
475
+ )
476
+ report["summary"]["requested_limit"] = args.limit
477
+ report["summary"]["input_provided"] = bool(args.input)
478
+ artifacts = sink.write("apply", report)
479
+ for artifact in artifacts:
480
+ print(f"{artifact.kind} report: {artifact.path}")
481
+ if args.verbose:
482
+ print(f"Reports directory: {paths.reports_dir}")
483
+ return 0
484
+
485
+
486
+ def _load_json(path: str | Path) -> Any:
487
+ json_path = Path(path).expanduser()
488
+ if not json_path.exists():
489
+ raise ValueError(f"JSON file not found: {json_path}")
490
+ try:
491
+ return json.loads(json_path.read_text(encoding="utf-8"))
492
+ except json.JSONDecodeError as exc:
493
+ raise ValueError(f"Invalid JSON in {json_path}: {exc.msg}") from exc
494
+
495
+
496
+ def _validate_dry_run_jobs(payload: Any) -> list[dict[str, Any]]:
497
+ if isinstance(payload, list):
498
+ jobs = payload
499
+ elif isinstance(payload, dict) and isinstance(payload.get("jobs"), list):
500
+ jobs = payload["jobs"]
501
+ else:
502
+ raise ValueError("Dry-run input must be a job list or an object with a jobs list")
503
+
504
+ validated: list[dict[str, Any]] = []
505
+ for index, job in enumerate(jobs, start=1):
506
+ if not isinstance(job, dict):
507
+ raise ValueError(f"Job {index} must be an object")
508
+ missing = [field for field in REQUIRED_JOB_FIELDS if not job.get(field)]
509
+ if missing:
510
+ raise ValueError(f"Job {index} missing required field(s): {', '.join(missing)}")
511
+ validated.append(job)
512
+ return validated
513
+
514
+
515
+ def _handle_dry_run(args: argparse.Namespace) -> int:
516
+ try:
517
+ payload = _load_json(args.input)
518
+ jobs = _validate_dry_run_jobs(payload)
519
+ except ValueError as exc:
520
+ _print_error(str(exc), "Try: check the --input path and JSON format.")
521
+ return 2
522
+ print(f"Dry run input valid: {len(jobs)} job(s)")
523
+ return 0
524
+
525
+
526
+ def _handle_report(args: argparse.Namespace) -> int:
527
+ try:
528
+ payload = _load_json(args.report_json)
529
+ except ValueError as exc:
530
+ _print_error(str(exc), "Try: pass a local report JSON path from the reports directory.")
531
+ return 2
532
+
533
+ if isinstance(payload, dict):
534
+ summary = payload.get("summary")
535
+ jobs = payload.get("jobs")
536
+ events = payload.get("events")
537
+ print("Report summary:")
538
+ if isinstance(summary, dict):
539
+ for key in sorted(summary):
540
+ print(f"{key}: {summary[key]}")
541
+ if isinstance(jobs, list):
542
+ print(f"jobs: {len(jobs)}")
543
+ if isinstance(events, list):
544
+ print(f"events: {len(events)}")
545
+ if (
546
+ not isinstance(summary, dict)
547
+ and not isinstance(jobs, list)
548
+ and not isinstance(events, list)
549
+ ):
550
+ print(f"object keys: {len(payload)}")
551
+ elif isinstance(payload, list):
552
+ print(f"Report summary: list items={len(payload)}")
553
+ else:
554
+ print(f"Report summary: {type(payload).__name__}")
555
+ return 0
556
+
557
+
558
+ def main(argv: list[str] | None = None) -> int:
559
+ parser = build_parser()
560
+ args = parser.parse_args(argv)
561
+ handler = getattr(args, "handler")
562
+ try:
563
+ return int(handler(args))
564
+ except CliError as exc:
565
+ print(f"Error: {exc}", file=sys.stderr)
566
+ return 2
567
+
568
+
569
+ if __name__ == "__main__":
570
+ sys.exit(main())
@@ -0,0 +1,109 @@
1
+ """Minimal config loading boundary for the standalone assistant."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import yaml
10
+
11
+ from .paths import RuntimePaths, resolve_runtime_paths
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class AssistantConfig:
16
+ """Parsed assistant configuration with resolved runtime paths."""
17
+
18
+ profile: dict[str, Any] = field(default_factory=dict)
19
+ defaults: dict[str, Any] = field(default_factory=dict)
20
+ documents: dict[str, Any] = field(default_factory=dict)
21
+ runtime: RuntimePaths | None = None
22
+ raw: dict[str, Any] = field(default_factory=dict)
23
+
24
+ @property
25
+ def document_paths(self) -> dict[str, Any]:
26
+ """Compatibility alias for callers that name the document section explicitly."""
27
+
28
+ return self.documents
29
+
30
+
31
+ def _as_dict(value: Any, section: str) -> dict[str, Any]:
32
+ if value is None:
33
+ return {}
34
+ if not isinstance(value, dict):
35
+ raise ValueError(f"{section} must be a mapping")
36
+ return dict(value)
37
+
38
+
39
+ def _resolve_document_value(value: Any, base_dir: Path | None) -> Any:
40
+ if value is None:
41
+ return None
42
+ if not isinstance(value, (str, Path)):
43
+ return value
44
+ text = str(value).strip()
45
+ if not text:
46
+ return None
47
+ if text.lower().startswith(("http://", "https://")):
48
+ return text
49
+ path = Path(value).expanduser()
50
+ if base_dir is not None and not path.is_absolute():
51
+ return base_dir / path
52
+ return path
53
+
54
+
55
+ def _resolve_documents(value: Any, base_dir: Path | None) -> dict[str, Any]:
56
+ documents = _as_dict(value, "documents")
57
+ return {
58
+ str(key): _resolve_document_value(document_value, base_dir)
59
+ for key, document_value in documents.items()
60
+ }
61
+
62
+
63
+ def load_config(
64
+ path: str | Path | None = None,
65
+ workspace: str | Path | None = None,
66
+ ) -> AssistantConfig:
67
+ """Load a minimal YAML config and resolve paths without hidden defaults."""
68
+
69
+ raw: dict[str, Any] = {}
70
+ requested_config_path = Path(path).expanduser() if path is not None else None
71
+ config_path = (
72
+ resolve_runtime_paths(workspace=workspace, config=requested_config_path).config_file
73
+ if requested_config_path is not None
74
+ else None
75
+ )
76
+
77
+ if config_path is not None:
78
+ if not config_path.exists():
79
+ raise FileNotFoundError(config_path)
80
+ parsed = yaml.safe_load(config_path.read_text(encoding="utf-8"))
81
+ if parsed is None:
82
+ parsed = {}
83
+ if not isinstance(parsed, dict):
84
+ raise ValueError("config root must be a mapping")
85
+ raw = dict(parsed)
86
+
87
+ profile = _as_dict(raw.get("profile"), "profile")
88
+ defaults = _as_dict(raw.get("defaults"), "defaults")
89
+ path_values = _as_dict(raw.get("paths"), "paths")
90
+
91
+ runtime = resolve_runtime_paths(
92
+ workspace=workspace,
93
+ config=config_path,
94
+ qa_bank=path_values.get("qa_bank"),
95
+ browser_profile=path_values.get("browser_profile"),
96
+ output_dir=path_values.get("output_dir"),
97
+ )
98
+ document_base = runtime.workspace
99
+ if document_base is None and config_path is not None:
100
+ document_base = config_path.parent
101
+ documents = _resolve_documents(raw.get("documents"), document_base)
102
+
103
+ return AssistantConfig(
104
+ profile=profile,
105
+ defaults=defaults,
106
+ documents=documents,
107
+ runtime=runtime,
108
+ raw=raw,
109
+ )