licit-ai-cli 0.1.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.
licit/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """licit — Regulatory compliance for AI-powered development teams."""
2
+
3
+ __version__ = "0.1.0"
licit/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running licit as a module: python -m licit."""
2
+
3
+ from licit.cli import main
4
+
5
+ main()
File without changes
licit/cli.py ADDED
@@ -0,0 +1,555 @@
1
+ """licit CLI — regulatory compliance for AI-powered development teams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import click
10
+ import structlog
11
+
12
+ from licit import __version__
13
+ from licit.config.loader import load_config, save_config
14
+ from licit.config.schema import FrameworkConfig, LicitConfig
15
+ from licit.core.evidence import EvidenceCollector
16
+ from licit.core.models import ComplianceStatus, ControlResult
17
+ from licit.core.project import ProjectDetector
18
+ from licit.logging.setup import setup_logging
19
+
20
+ logger = structlog.get_logger()
21
+
22
+
23
+ @click.group()
24
+ @click.version_option(version=__version__)
25
+ @click.option("--config", "config_path", type=click.Path(), help="Path to .licit.yaml")
26
+ @click.option("--verbose", "-v", is_flag=True, help="Verbose output")
27
+ @click.pass_context
28
+ def main(ctx: click.Context, config_path: str | None, verbose: bool) -> None:
29
+ """licit — Regulatory compliance for AI-powered development teams."""
30
+ setup_logging(verbose)
31
+ ctx.ensure_object(dict)
32
+ ctx.obj["config_path"] = config_path
33
+ ctx.obj["verbose"] = verbose
34
+
35
+
36
+ @main.command()
37
+ @click.option(
38
+ "--framework",
39
+ type=click.Choice(["eu-ai-act", "owasp", "all"]),
40
+ default="all",
41
+ help="Pre-configure for a specific framework",
42
+ )
43
+ @click.pass_context
44
+ def init(ctx: click.Context, framework: str) -> None:
45
+ """Initialize licit in the current project.
46
+
47
+ Detects project characteristics, creates .licit.yaml,
48
+ and sets up the .licit/ directory.
49
+
50
+ Examples:
51
+ licit init
52
+ licit init --framework eu-ai-act
53
+ """
54
+ root = str(Path.cwd())
55
+ detector = ProjectDetector()
56
+ context = detector.detect(root)
57
+
58
+ click.echo(f"\n Project: {context.name}")
59
+ click.echo(f" Languages: {', '.join(context.languages) or 'none detected'}")
60
+ click.echo(f" Agent configs: {len(context.agent_configs)} detected")
61
+ click.echo(f" CI/CD: {context.cicd.platform}")
62
+ click.echo(f" Testing: {context.test_framework or 'none detected'}")
63
+
64
+ tools: list[str] = []
65
+ if context.security.has_vigil:
66
+ tools.append("vigil")
67
+ if context.security.has_semgrep:
68
+ tools.append("semgrep")
69
+ if context.security.has_snyk:
70
+ tools.append("snyk")
71
+ click.echo(f" Security tools: {', '.join(tools) if tools else 'none detected'}")
72
+
73
+ if context.agent_configs:
74
+ click.echo("\n Agent configurations found:")
75
+ for cfg in context.agent_configs:
76
+ click.echo(f" - {cfg.path} ({cfg.agent_type})")
77
+
78
+ # Build config
79
+ config = LicitConfig()
80
+ if framework == "eu-ai-act":
81
+ config.frameworks = FrameworkConfig(eu_ai_act=True, owasp_agentic=False)
82
+ elif framework == "owasp":
83
+ config.frameworks = FrameworkConfig(eu_ai_act=False, owasp_agentic=True)
84
+
85
+ # Auto-configure connectors
86
+ if context.has_architect:
87
+ config.connectors.architect.enabled = True
88
+ if context.architect_config_path:
89
+ config.connectors.architect.config_path = context.architect_config_path
90
+ if context.security.has_vigil:
91
+ config.connectors.vigil.enabled = True
92
+
93
+ config_file = save_config(config, ctx.obj.get("config_path"))
94
+
95
+ # Create .licit directory
96
+ (Path(root) / ".licit").mkdir(exist_ok=True)
97
+
98
+ click.echo(f"\n Created {config_file.name}")
99
+ click.echo(" Created .licit/ directory")
100
+ click.echo("\n Next steps:")
101
+ click.echo(" licit trace Track code provenance")
102
+ click.echo(" licit fria Complete FRIA assessment")
103
+ click.echo(" licit report Generate compliance report")
104
+ click.echo(" licit gaps Check compliance gaps")
105
+
106
+
107
+ @main.command()
108
+ @click.option("--since", help="Analyze commits since date (YYYY-MM-DD) or tag")
109
+ @click.option("--report", "gen_report", is_flag=True, help="Generate provenance report")
110
+ @click.option("--stats", is_flag=True, help="Show provenance statistics")
111
+ @click.pass_context
112
+ def trace(ctx: click.Context, since: str | None, gen_report: bool, stats: bool) -> None:
113
+ """Track code provenance — who (human/AI) wrote what.
114
+
115
+ Analyzes git history to infer which code was generated by AI agents.
116
+ Results are stored in .licit/provenance.jsonl.
117
+
118
+ Examples:
119
+ licit trace Analyze full git history
120
+ licit trace --since 2026-01-01 Analyze since January 2026
121
+ licit trace --stats Show provenance statistics
122
+ licit trace --report Generate full provenance report
123
+ """
124
+ config = load_config(ctx.obj.get("config_path"))
125
+
126
+ if stats:
127
+ from licit.provenance.store import ProvenanceStore # type: ignore[import-not-found]
128
+
129
+ store: Any = ProvenanceStore(config.provenance.store_path)
130
+ s: dict[str, Any] = store.get_stats()
131
+ click.echo("\n Provenance Statistics")
132
+ click.echo(f" {'─' * 40}")
133
+ click.echo(f" Total files tracked: {s['total_files']}")
134
+ click.echo(f" AI-generated: {s['ai_files']} ({s['ai_percentage']:.1f}%)")
135
+ click.echo(f" Human-written: {s['human_files']}")
136
+ click.echo(f" Mixed: {s.get('mixed_files', 0)}")
137
+ return
138
+
139
+ click.echo(" Analyzing git history for AI provenance...")
140
+
141
+ from licit.provenance.tracker import ProvenanceTracker # type: ignore[import-not-found]
142
+
143
+ root = str(Path.cwd())
144
+ tracker: Any = ProvenanceTracker(root, config.provenance)
145
+ records: list[Any] = tracker.analyze(since=since)
146
+
147
+ ai_count = sum(1 for r in records if r.source == "ai")
148
+ human_count = sum(1 for r in records if r.source == "human")
149
+ click.echo(f" Analyzed {len(records)} file records")
150
+ click.echo(f" AI-generated: {ai_count} files")
151
+ click.echo(f" Human-written: {human_count} files")
152
+
153
+ if gen_report:
154
+ from licit.provenance.report import ( # type: ignore[import-not-found]
155
+ generate_provenance_report,
156
+ )
157
+
158
+ report_path = ".licit/provenance-report.md"
159
+ generate_provenance_report(records, report_path)
160
+ click.echo(f" Report saved to: {report_path}")
161
+
162
+
163
+ @main.command()
164
+ @click.option("--since", help="Show changes since date or tag")
165
+ @click.option(
166
+ "--format",
167
+ "fmt",
168
+ type=click.Choice(["markdown", "json"]),
169
+ default="markdown",
170
+ )
171
+ @click.pass_context
172
+ def changelog(ctx: click.Context, since: str | None, fmt: str) -> None:
173
+ """Generate changelog of agent configuration changes.
174
+
175
+ Monitors CLAUDE.md, .cursorrules, AGENTS.md, architect config,
176
+ and other agent configuration files for changes across git history.
177
+
178
+ Examples:
179
+ licit changelog
180
+ licit changelog --since v1.0.0
181
+ licit changelog --format json
182
+ """
183
+ config = load_config(ctx.obj.get("config_path"))
184
+ root = str(Path.cwd())
185
+
186
+ from licit.changelog.classifier import ChangeClassifier # type: ignore[import-not-found]
187
+ from licit.changelog.renderer import ChangelogRenderer # type: ignore[import-not-found]
188
+ from licit.changelog.watcher import ConfigWatcher # type: ignore[import-not-found]
189
+
190
+ watcher: Any = ConfigWatcher(root, config.changelog.watch_files)
191
+ history: dict[str, list[Any]] = watcher.get_config_history(since=since)
192
+
193
+ if not history:
194
+ click.echo(" No agent configuration changes found.")
195
+ return
196
+
197
+ classifier: Any = ChangeClassifier()
198
+ all_changes: list[Any] = []
199
+ for file_path, snapshots in history.items():
200
+ for i in range(len(snapshots) - 1):
201
+ changes = classifier.classify_changes(
202
+ old_content=snapshots[i + 1].content,
203
+ new_content=snapshots[i].content,
204
+ file_path=file_path,
205
+ commit_sha=snapshots[i].commit_sha,
206
+ )
207
+ all_changes.extend(changes)
208
+
209
+ renderer: Any = ChangelogRenderer()
210
+ output: str = renderer.render(all_changes, fmt=fmt)
211
+
212
+ output_path = config.changelog.output_path
213
+ Path(output_path).parent.mkdir(parents=True, exist_ok=True)
214
+ Path(output_path).write_text(output, encoding="utf-8")
215
+
216
+ click.echo(output)
217
+ click.echo(f"\n Changelog saved to {output_path}")
218
+
219
+
220
+ @main.command()
221
+ @click.option("--update", is_flag=True, help="Update existing FRIA")
222
+ @click.pass_context
223
+ def fria(ctx: click.Context, update: bool) -> None:
224
+ """Complete the Fundamental Rights Impact Assessment (Art. 27).
225
+
226
+ Interactive questionnaire that generates a FRIA document.
227
+ Auto-detects answers from project configuration where possible.
228
+
229
+ Examples:
230
+ licit fria Start new FRIA
231
+ licit fria --update Update existing FRIA
232
+ """
233
+ root = str(Path.cwd())
234
+ config = load_config(ctx.obj.get("config_path"))
235
+ detector = ProjectDetector()
236
+ context = detector.detect(root)
237
+ evidence = EvidenceCollector(root, context).collect()
238
+
239
+ from licit.frameworks.eu_ai_act.fria import FRIAGenerator # type: ignore[import-not-found]
240
+
241
+ generator: Any = FRIAGenerator(context, evidence)
242
+
243
+ if update and Path(config.fria.data_path).exists():
244
+ import json
245
+
246
+ existing = json.loads(Path(config.fria.data_path).read_text(encoding="utf-8"))
247
+ click.echo(" Updating existing FRIA...")
248
+ responses: dict[str, Any] = generator.run_interactive()
249
+ for key, value in existing.items():
250
+ if key not in responses or not responses[key]:
251
+ responses[key] = value
252
+ else:
253
+ responses = generator.run_interactive()
254
+
255
+ generator.save_data(responses, config.fria.data_path)
256
+ generator.generate_report(responses, config.fria.output_path)
257
+
258
+ click.echo(f"\n FRIA data saved to: {config.fria.data_path}")
259
+ click.echo(f" FRIA report saved to: {config.fria.output_path}")
260
+
261
+
262
+ @main.command("annex-iv")
263
+ @click.option("--organization", help="Organization name")
264
+ @click.option("--product", help="Product name")
265
+ @click.pass_context
266
+ def annex_iv(ctx: click.Context, organization: str | None, product: str | None) -> None:
267
+ """Generate Annex IV Technical Documentation.
268
+
269
+ Auto-populates from project metadata (pyproject.toml, package.json,
270
+ CI/CD configs, agent configs, test frameworks).
271
+
272
+ Examples:
273
+ licit annex-iv
274
+ licit annex-iv --organization "ACME Corp" --product "WebApp"
275
+ """
276
+ root = str(Path.cwd())
277
+ config = load_config(ctx.obj.get("config_path"))
278
+ detector = ProjectDetector()
279
+ context = detector.detect(root)
280
+ evidence = EvidenceCollector(root, context).collect()
281
+
282
+ from licit.frameworks.eu_ai_act.annex_iv import ( # type: ignore[import-not-found]
283
+ AnnexIVGenerator,
284
+ )
285
+
286
+ generator: Any = AnnexIVGenerator(context, evidence)
287
+
288
+ org = organization or config.annex_iv.organization or context.name
289
+ prod = product or config.annex_iv.product_name or context.name
290
+
291
+ generator.generate(
292
+ output_path=config.annex_iv.output_path,
293
+ organization=org,
294
+ product_name=prod,
295
+ )
296
+
297
+ click.echo(f"\n Annex IV documentation saved to: {config.annex_iv.output_path}")
298
+
299
+
300
+ @main.command()
301
+ @click.option(
302
+ "--framework",
303
+ type=click.Choice(["eu-ai-act", "owasp", "all"]),
304
+ default="all",
305
+ )
306
+ @click.option(
307
+ "--format",
308
+ "fmt",
309
+ type=click.Choice(["markdown", "json", "html"]),
310
+ default="markdown",
311
+ )
312
+ @click.option("--output", "-o", type=click.Path(), help="Output file path")
313
+ @click.pass_context
314
+ def report(ctx: click.Context, framework: str, fmt: str, output: str | None) -> None:
315
+ """Generate unified compliance report.
316
+
317
+ Evaluates project against configured frameworks and generates
318
+ a report with evidence, status, and recommendations.
319
+
320
+ Examples:
321
+ licit report
322
+ licit report --framework eu-ai-act
323
+ licit report --format json -o compliance.json
324
+ licit report --format html -o compliance.html
325
+ """
326
+ root = str(Path.cwd())
327
+ config = load_config(ctx.obj.get("config_path"))
328
+ detector = ProjectDetector()
329
+ context = detector.detect(root)
330
+ evidence = EvidenceCollector(root, context).collect()
331
+
332
+ from licit.reports.unified import UnifiedReportGenerator # type: ignore[import-not-found]
333
+
334
+ generator: Any = UnifiedReportGenerator(context, evidence, config)
335
+ frameworks_to_eval = _get_frameworks(framework, config)
336
+ report_data: Any = generator.generate(frameworks_to_eval)
337
+
338
+ from licit.reports import html as html_reporter # type: ignore[attr-defined]
339
+ from licit.reports import json_fmt # type: ignore[attr-defined]
340
+ from licit.reports import markdown as md_reporter # type: ignore[attr-defined]
341
+
342
+ if fmt == "json":
343
+ content: str = json_fmt.render(report_data)
344
+ elif fmt == "html":
345
+ content = html_reporter.render(report_data)
346
+ else:
347
+ content = md_reporter.render(report_data)
348
+
349
+ ext = {"markdown": "md", "json": "json", "html": "html"}[fmt]
350
+ output_path = output or f".licit/reports/compliance-report.{ext}"
351
+ Path(output_path).parent.mkdir(parents=True, exist_ok=True)
352
+ Path(output_path).write_text(content, encoding="utf-8")
353
+
354
+ from licit.reports.summary import print_summary # type: ignore[import-not-found]
355
+
356
+ print_summary(report_data)
357
+
358
+ click.echo(f"\n Report saved to: {output_path}")
359
+
360
+
361
+ @main.command()
362
+ @click.option(
363
+ "--framework",
364
+ type=click.Choice(["eu-ai-act", "owasp", "all"]),
365
+ default="all",
366
+ )
367
+ @click.pass_context
368
+ def gaps(ctx: click.Context, framework: str) -> None:
369
+ """Identify compliance gaps with actionable recommendations.
370
+
371
+ Shows what's missing for compliance and suggests specific
372
+ actions and tools to close each gap.
373
+
374
+ Examples:
375
+ licit gaps
376
+ licit gaps --framework eu-ai-act
377
+ """
378
+ root = str(Path.cwd())
379
+ config = load_config(ctx.obj.get("config_path"))
380
+ detector = ProjectDetector()
381
+ context = detector.detect(root)
382
+ evidence = EvidenceCollector(root, context).collect()
383
+
384
+ from licit.reports.gap_analyzer import GapAnalyzer # type: ignore[import-not-found]
385
+
386
+ analyzer: Any = GapAnalyzer(context, evidence, config)
387
+ frameworks_to_eval = _get_frameworks(framework, config)
388
+ gap_items: list[Any] = analyzer.analyze(frameworks_to_eval)
389
+
390
+ if not gap_items:
391
+ click.echo("\n No compliance gaps found! All requirements met.")
392
+ return
393
+
394
+ click.echo(f"\n {len(gap_items)} compliance gap(s) found:\n")
395
+ for i, gap in enumerate(gap_items, 1):
396
+ icon = "X" if gap.status == ComplianceStatus.NON_COMPLIANT else "!"
397
+ click.echo(f" {i}. [{icon}] [{gap.requirement.id}] {gap.requirement.name}")
398
+ click.echo(f" {gap.gap_description}")
399
+ click.echo(f" -> {gap.recommendation}")
400
+ if gap.tools_suggested:
401
+ click.echo(f" Tools: {', '.join(gap.tools_suggested)}")
402
+ click.echo()
403
+
404
+
405
+ @main.command()
406
+ @click.option(
407
+ "--framework",
408
+ type=click.Choice(["eu-ai-act", "owasp", "all"]),
409
+ default="all",
410
+ )
411
+ @click.pass_context
412
+ def verify(ctx: click.Context, framework: str) -> None:
413
+ """Verify compliance and return exit code for CI/CD.
414
+
415
+ Exit code 0 if all critical requirements are met.
416
+ Exit code 1 if any critical requirement is non-compliant.
417
+ Exit code 2 if requirements are partially met.
418
+
419
+ Examples:
420
+ licit verify
421
+ licit verify --framework eu-ai-act
422
+ """
423
+ root = str(Path.cwd())
424
+ config = load_config(ctx.obj.get("config_path"))
425
+ detector = ProjectDetector()
426
+ context = detector.detect(root)
427
+ evidence = EvidenceCollector(root, context).collect()
428
+
429
+ frameworks_to_eval = _get_frameworks(framework, config)
430
+
431
+ all_results: list[ControlResult] = []
432
+ for fw in frameworks_to_eval:
433
+ results: list[ControlResult] = fw.evaluate(context, evidence)
434
+ all_results.extend(results)
435
+
436
+ non_compliant = [r for r in all_results if r.status == ComplianceStatus.NON_COMPLIANT]
437
+ partial = [r for r in all_results if r.status == ComplianceStatus.PARTIAL]
438
+ compliant = [r for r in all_results if r.status == ComplianceStatus.COMPLIANT]
439
+
440
+ click.echo("\n Compliance Verification")
441
+ click.echo(f" Compliant: {len(compliant)}")
442
+ click.echo(f" Partial: {len(partial)}")
443
+ click.echo(f" Non-compliant: {len(non_compliant)}")
444
+
445
+ if non_compliant:
446
+ click.echo("\n Non-compliant controls:")
447
+ for r in non_compliant:
448
+ click.echo(f" [X] {r.requirement.id}: {r.requirement.name}")
449
+ sys.exit(1)
450
+ elif partial:
451
+ sys.exit(2)
452
+ else:
453
+ click.echo("\n All requirements met.")
454
+ sys.exit(0)
455
+
456
+
457
+ @main.command()
458
+ @click.pass_context
459
+ def status(ctx: click.Context) -> None:
460
+ """Show licit status and connected sources.
461
+
462
+ Examples:
463
+ licit status
464
+ """
465
+ root = str(Path.cwd())
466
+ config = load_config(ctx.obj.get("config_path"))
467
+ detector = ProjectDetector()
468
+ context = detector.detect(root)
469
+ evidence = EvidenceCollector(root, context).collect()
470
+
471
+ click.echo("\n licit Status")
472
+ click.echo(f" {'─' * 40}")
473
+ click.echo(f" Project: {context.name}")
474
+ config_exists = Path(".licit.yaml").exists()
475
+ click.echo(f" Config: {'.licit.yaml' if config_exists else 'not found'}")
476
+
477
+ click.echo("\n Frameworks:")
478
+ click.echo(f" {'[x]' if config.frameworks.eu_ai_act else '[ ]'} EU AI Act")
479
+ click.echo(
480
+ f" {'[x]' if config.frameworks.owasp_agentic else '[ ]'} OWASP Agentic Top 10"
481
+ )
482
+ click.echo(" [ ] NIST AI RMF (V1)")
483
+ click.echo(" [ ] ISO 42001 (V1)")
484
+
485
+ click.echo("\n Data Sources:")
486
+ click.echo(
487
+ f" {'[x]' if context.git_initialized else '[ ]'} "
488
+ f"Git history ({context.total_commits} commits)"
489
+ )
490
+ click.echo(f" {'[x]' if evidence.has_provenance else '[ ]'} Provenance tracking")
491
+ click.echo(f" {'[x]' if evidence.has_changelog else '[ ]'} Config changelog")
492
+ click.echo(f" {'[x]' if evidence.has_fria else '[ ]'} FRIA document")
493
+ click.echo(f" {'[x]' if evidence.has_annex_iv else '[ ]'} Annex IV documentation")
494
+
495
+ click.echo("\n Connectors:")
496
+ click.echo(
497
+ f" {'[x]' if context.has_architect else '[ ]'} "
498
+ f"architect ({context.architect_config_path or 'not detected'})"
499
+ )
500
+ click.echo(
501
+ f" {'[x]' if context.security.has_vigil else '[ ]'} "
502
+ f"vigil ({context.security.vigil_config_path or 'not detected'})"
503
+ )
504
+
505
+ click.echo(f"\n Agent Configs ({len(context.agent_configs)}):")
506
+ for cfg in context.agent_configs:
507
+ click.echo(f" - {cfg.path} ({cfg.agent_type})")
508
+ if not context.agent_configs:
509
+ click.echo(" (none detected)")
510
+
511
+
512
+ @main.command()
513
+ @click.argument("connector", type=click.Choice(["architect", "vigil"]))
514
+ @click.option("--enable/--disable", default=True, help="Enable or disable the connector")
515
+ @click.pass_context
516
+ def connect(ctx: click.Context, connector: str, enable: bool) -> None:
517
+ """Configure optional connectors (architect, vigil).
518
+
519
+ Examples:
520
+ licit connect architect
521
+ licit connect vigil --enable
522
+ licit connect architect --disable
523
+ """
524
+ config = load_config(ctx.obj.get("config_path"))
525
+
526
+ if connector == "architect":
527
+ config.connectors.architect.enabled = enable
528
+ elif connector == "vigil":
529
+ config.connectors.vigil.enabled = enable
530
+
531
+ save_config(config, ctx.obj.get("config_path"))
532
+ state = "enabled" if enable else "disabled"
533
+ click.echo(f" Connector '{connector}' {state}.")
534
+
535
+
536
+ def _get_frameworks(framework: str, config: LicitConfig) -> list[Any]:
537
+ """Build list of framework evaluators based on selection and config.
538
+
539
+ Returns evaluators that implement .evaluate(context, evidence) -> list[ControlResult].
540
+ Actual types come from Phase 4+ modules.
541
+ """
542
+ frameworks: list[Any] = []
543
+ if framework in ("eu-ai-act", "all") and config.frameworks.eu_ai_act:
544
+ from licit.frameworks.eu_ai_act.evaluator import ( # type: ignore[import-not-found]
545
+ EUAIActEvaluator,
546
+ )
547
+
548
+ frameworks.append(EUAIActEvaluator())
549
+ if framework in ("owasp", "all") and config.frameworks.owasp_agentic:
550
+ from licit.frameworks.owasp_agentic.evaluator import ( # type: ignore[import-not-found]
551
+ OWASPAgenticEvaluator,
552
+ )
553
+
554
+ frameworks.append(OWASPAgenticEvaluator())
555
+ return frameworks
File without changes
@@ -0,0 +1,12 @@
1
+ """Default configuration values for licit."""
2
+
3
+ from licit.config.schema import LicitConfig
4
+
5
+ # Canonical default config instance
6
+ DEFAULTS = LicitConfig()
7
+
8
+ # Default config file name
9
+ CONFIG_FILENAME = ".licit.yaml"
10
+
11
+ # Default licit data directory
12
+ DATA_DIR = ".licit"
licit/config/loader.py ADDED
@@ -0,0 +1,80 @@
1
+ """Load and merge configuration from YAML file, defaults, and CLI overrides."""
2
+
3
+ from pathlib import Path
4
+
5
+ import structlog
6
+ import yaml
7
+
8
+ from licit.config.defaults import CONFIG_FILENAME
9
+ from licit.config.schema import LicitConfig
10
+
11
+ logger = structlog.get_logger()
12
+
13
+
14
+ def load_config(config_path: str | None = None) -> LicitConfig:
15
+ """Load configuration from YAML file, falling back to defaults.
16
+
17
+ Resolution order:
18
+ 1. Explicit path (--config flag)
19
+ 2. .licit.yaml in current directory
20
+ 3. Default values from schema
21
+ """
22
+ path = _resolve_config_path(config_path)
23
+
24
+ if path is not None:
25
+ return _load_from_file(path)
26
+
27
+ logger.debug("config_not_found", msg="Using default configuration")
28
+ return LicitConfig()
29
+
30
+
31
+ def _resolve_config_path(explicit_path: str | None) -> Path | None:
32
+ """Find the config file to load."""
33
+ if explicit_path:
34
+ p = Path(explicit_path)
35
+ if p.exists():
36
+ return p
37
+ logger.warning("config_path_not_found", path=explicit_path)
38
+ return None
39
+
40
+ default = Path.cwd() / CONFIG_FILENAME
41
+ if default.exists():
42
+ return default
43
+
44
+ return None
45
+
46
+
47
+ def _load_from_file(path: Path) -> LicitConfig:
48
+ """Parse YAML config file into LicitConfig."""
49
+ try:
50
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
51
+ except yaml.YAMLError as exc:
52
+ logger.error("config_parse_error", path=str(path), error=str(exc))
53
+ return LicitConfig()
54
+
55
+ if not isinstance(raw, dict):
56
+ logger.warning("config_not_dict", path=str(path))
57
+ return LicitConfig()
58
+
59
+ try:
60
+ config = LicitConfig.model_validate(raw)
61
+ except Exception as exc:
62
+ logger.error("config_validation_error", path=str(path), error=str(exc))
63
+ return LicitConfig()
64
+
65
+ logger.debug("config_loaded", path=str(path))
66
+ return config
67
+
68
+
69
+ def save_config(config: LicitConfig, path: str | None = None) -> Path:
70
+ """Save config to YAML file."""
71
+ target = Path(path) if path else Path.cwd() / CONFIG_FILENAME
72
+ target.parent.mkdir(parents=True, exist_ok=True)
73
+
74
+ data = config.model_dump(exclude_defaults=False)
75
+ target.write_text(
76
+ yaml.dump(data, default_flow_style=False, sort_keys=False),
77
+ encoding="utf-8",
78
+ )
79
+ logger.debug("config_saved", path=str(target))
80
+ return target