frontos 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.
frontos/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """FrontOS: a local frontend specialist for AI coding agents."""
2
+
3
+ __version__ = "0.2.0"
frontos/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
6
+
frontos/cli.py ADDED
@@ -0,0 +1,474 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import shutil
7
+ import signal
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from frontos import __version__
13
+ from frontos.core.store import Store
14
+ from frontos.engines.design_pack import DesignPackCompiler, list_presets, load_design_pack
15
+ from frontos.engines.detect import detect_project
16
+ from frontos.engines.patches import PatchEngine
17
+ from frontos.engines.qa import QAEngine, load_latest_qa
18
+ from frontos.engines.scanner import ProjectScanner, load_latest_scan
19
+ from frontos.engines.security import SecurityAuditor
20
+ from frontos.engines.visual import VisualQAEngine, load_latest_visual_comparison, load_latest_visual_report
21
+ from frontos.engines.workflow import WorkflowEngine, load_latest_workflow
22
+ from frontos.mcp.server import run_stdio
23
+ from frontos.server.http_api import serve
24
+
25
+
26
+ def main(argv: list[str] | None = None) -> int:
27
+ parser = build_parser()
28
+ args = parser.parse_args(argv)
29
+ if not hasattr(args, "func"):
30
+ parser.print_help()
31
+ return 0
32
+ try:
33
+ result = args.func(args)
34
+ except BrokenPipeError:
35
+ return 1
36
+ except Exception as exc:
37
+ print(f"frontos: {exc}", file=sys.stderr)
38
+ return 1
39
+ if result is not None:
40
+ print_json(result)
41
+ return 0
42
+
43
+
44
+ def build_parser() -> argparse.ArgumentParser:
45
+ parser = argparse.ArgumentParser(prog="frontos", description="Local frontend specialist for AI coding agents.")
46
+ parser.add_argument("--home", help="Override FRONTOS_HOME for this command.")
47
+ parser.add_argument("--version", action="version", version=f"frontos {__version__}")
48
+ sub = parser.add_subparsers(dest="command")
49
+
50
+ status = sub.add_parser("status", help="Show FrontOS home, daemon, and registry status.")
51
+ status.set_defaults(func=cmd_status)
52
+
53
+ serve_cmd = sub.add_parser("serve", help="Run the local HTTP daemon in the foreground.")
54
+ serve_cmd.add_argument("--host", default="127.0.0.1")
55
+ serve_cmd.add_argument("--port", type=int, default=4791)
56
+ serve_cmd.set_defaults(func=cmd_serve)
57
+
58
+ stop = sub.add_parser("stop", help="Stop a foreground/background FrontOS daemon by PID file.")
59
+ stop.set_defaults(func=cmd_stop)
60
+
61
+ mcp = sub.add_parser("mcp", help="Run the MCP-compatible stdio server or print client config.")
62
+ mcp.add_argument("mcp_args", nargs="*", help="Use `frontos mcp config codex|cursor|claude` for setup snippets.")
63
+ mcp.set_defaults(func=cmd_mcp)
64
+
65
+ project = sub.add_parser("project", help="Manage registered projects.")
66
+ project_sub = project.add_subparsers(dest="project_command", required=True)
67
+ project_add = project_sub.add_parser("add", help="Register a local project.")
68
+ project_add.add_argument("path")
69
+ project_add.add_argument("--name")
70
+ project_add.add_argument("--write", action="store_true", help="Allow direct writes. Default is patch-only.")
71
+ project_add.set_defaults(func=cmd_project_add)
72
+
73
+ project_list = project_sub.add_parser("list", help="List registered projects.")
74
+ project_list.set_defaults(func=cmd_project_list)
75
+
76
+ project_scan = project_sub.add_parser("scan", help="Scan a registered project.")
77
+ project_scan.add_argument("project")
78
+ project_scan.set_defaults(func=cmd_project_scan)
79
+
80
+ project_configure = project_sub.add_parser("configure", help="Configure project runtime URL and dev command.")
81
+ project_configure.add_argument("project")
82
+ project_configure.add_argument("--base-url")
83
+ project_configure.add_argument("--dev-command")
84
+ project_configure.set_defaults(func=cmd_project_configure)
85
+
86
+ scan = sub.add_parser("scan", help="Alias for project scan.")
87
+ scan.add_argument("project")
88
+ scan.set_defaults(func=cmd_project_scan)
89
+
90
+ design = sub.add_parser("design-pack", help="Create or inspect Design Packs.")
91
+ design_sub = design.add_subparsers(dest="design_command", required=True)
92
+ design_presets = design_sub.add_parser("presets", help="List available Design Pack presets.")
93
+ design_presets.set_defaults(func=cmd_design_pack_presets)
94
+ design_create = design_sub.add_parser("create", help="Create a project-specific Design Pack.")
95
+ design_create.add_argument("project")
96
+ design_create.add_argument("--preset", help="Use a specific Style DNA preset.")
97
+ design_create.set_defaults(func=cmd_design_pack_create)
98
+ design_get = design_sub.add_parser("get", help="Print the latest Design Pack.")
99
+ design_get.add_argument("project")
100
+ design_get.add_argument("--pack-id")
101
+ design_get.set_defaults(func=cmd_design_pack_get)
102
+
103
+ qa = sub.add_parser("qa", help="Run design QA for a project.")
104
+ qa.add_argument("project")
105
+ qa.set_defaults(func=cmd_qa)
106
+
107
+ security = sub.add_parser("security", help="Run local security checks.")
108
+ security_sub = security.add_subparsers(dest="security_command", required=True)
109
+ security_audit = security_sub.add_parser("audit", help="Scan a registered project for secret-like values.")
110
+ security_audit.add_argument("project")
111
+ security_audit.set_defaults(func=cmd_security_audit)
112
+
113
+ patch = sub.add_parser("patch", help="Generate or preview patch artifacts.")
114
+ patch_sub = patch.add_subparsers(dest="patch_command", required=True)
115
+ patch_generate = patch_sub.add_parser("generate", help="Generate a patch artifact.")
116
+ patch_generate.add_argument("project")
117
+ patch_generate.add_argument("--route")
118
+ patch_generate.add_argument("--mode", default="repair_existing_frontend")
119
+ patch_generate.set_defaults(func=cmd_patch_generate)
120
+ patch_preview = patch_sub.add_parser("preview", help="Preview a patch artifact.")
121
+ patch_preview.add_argument("patch_id")
122
+ patch_preview.add_argument("--diff", action="store_true", help="Print the unified diff instead of JSON metadata.")
123
+ patch_preview.set_defaults(func=cmd_patch_preview)
124
+ patch_validate = patch_sub.add_parser("validate", help="Validate a staged patch before applying it.")
125
+ patch_validate.add_argument("patch_id")
126
+ patch_validate.set_defaults(func=cmd_patch_validate)
127
+ patch_apply = patch_sub.add_parser("apply", help="Apply a staged patch to the registered project.")
128
+ patch_apply.add_argument("patch_id")
129
+ patch_apply.add_argument("--confirm", action="store_true", help="Allow writing into a patch-only project.")
130
+ patch_apply.add_argument("--dry-run", action="store_true", help="Validate without writing staged files.")
131
+ patch_apply.set_defaults(func=cmd_patch_apply)
132
+ patch_rollback = patch_sub.add_parser("rollback", help="Rollback an applied patch using its snapshot.")
133
+ patch_rollback.add_argument("patch_id")
134
+ patch_rollback.add_argument("--confirm", action="store_true", help="Allow writing rollback files into the project.")
135
+ patch_rollback.set_defaults(func=cmd_patch_rollback)
136
+
137
+ visual = sub.add_parser("visual", help="Capture screenshots and visual QA metadata.")
138
+ visual_sub = visual.add_subparsers(dest="visual_command", required=True)
139
+ visual_capture = visual_sub.add_parser("capture", help="Capture route screenshots for a running project.")
140
+ visual_capture.add_argument("project")
141
+ visual_capture.add_argument("--phase", default="before", choices=["before", "after", "current"])
142
+ visual_capture.add_argument("--base-url")
143
+ visual_capture.add_argument("--route", action="append")
144
+ visual_capture.add_argument("--viewport", action="append", choices=["desktop", "tablet", "mobile"])
145
+ visual_capture.add_argument("--timeout-ms", type=int, default=30000)
146
+ visual_capture.add_argument("--dry-run", action="store_true")
147
+ visual_capture.set_defaults(func=cmd_visual_capture)
148
+ visual_latest = visual_sub.add_parser("latest", help="Print the latest visual QA report for a project.")
149
+ visual_latest.add_argument("project")
150
+ visual_latest.set_defaults(func=cmd_visual_latest)
151
+ visual_compare = visual_sub.add_parser("compare", help="Compare latest before/after screenshots.")
152
+ visual_compare.add_argument("project")
153
+ visual_compare.add_argument("--route")
154
+ visual_compare.add_argument("--viewport", action="append", choices=["desktop", "tablet", "mobile"])
155
+ visual_compare.add_argument("--min-change-percent", type=float, default=0.1)
156
+ visual_compare.add_argument("--max-change-percent", type=float, default=65.0)
157
+ visual_compare.set_defaults(func=cmd_visual_compare)
158
+ visual_compare_latest = visual_sub.add_parser(
159
+ "comparison-latest", help="Print the latest visual comparison report."
160
+ )
161
+ visual_compare_latest.add_argument("project")
162
+ visual_compare_latest.set_defaults(func=cmd_visual_compare_latest)
163
+ visual_doctor = visual_sub.add_parser("doctor", help="Check optional visual QA dependencies.")
164
+ visual_doctor.add_argument("--check-browser", action="store_true", help="Launch Chromium to verify browser setup.")
165
+ visual_doctor.set_defaults(func=cmd_visual_doctor)
166
+
167
+ workflow = sub.add_parser("workflow", help="Run orchestrated FrontOS workflows.")
168
+ workflow_sub = workflow.add_subparsers(dest="workflow_command", required=True)
169
+ workflow_repair = workflow_sub.add_parser("repair", help="Run the full repair workflow.")
170
+ workflow_repair.add_argument("project")
171
+ workflow_repair.add_argument("--route")
172
+ workflow_repair.add_argument("--mode", default="repair_existing_frontend")
173
+ workflow_repair.add_argument("--base-url")
174
+ workflow_repair.add_argument("--dev-command")
175
+ workflow_repair.add_argument("--apply", action="store_true")
176
+ workflow_repair.add_argument("--confirm", action="store_true")
177
+ workflow_repair.add_argument("--skip-visual", action="store_true")
178
+ workflow_repair.add_argument("--visual-dry-run", action="store_true")
179
+ workflow_repair.add_argument("--start-server", action="store_true")
180
+ workflow_repair.add_argument("--keep-server", action="store_true")
181
+ workflow_repair.add_argument("--viewport", action="append", choices=["desktop", "tablet", "mobile"])
182
+ workflow_repair.add_argument("--timeout-ms", type=int, default=30000)
183
+ workflow_repair.add_argument("--startup-timeout-s", type=int, default=30)
184
+ workflow_repair.set_defaults(func=cmd_workflow_repair)
185
+ workflow_latest = workflow_sub.add_parser("latest", help="Print the latest workflow report.")
186
+ workflow_latest.add_argument("project")
187
+ workflow_latest.set_defaults(func=cmd_workflow_latest)
188
+
189
+ repair = sub.add_parser("repair", help="Scan, design, patch, and QA a project or route.")
190
+ repair.add_argument("project")
191
+ repair.add_argument("--route")
192
+ repair.add_argument("--mode", default="repair_existing_frontend")
193
+ repair.set_defaults(func=cmd_repair)
194
+
195
+ return parser
196
+
197
+
198
+ def cmd_status(args: argparse.Namespace) -> dict[str, Any]:
199
+ store = _store(args)
200
+ pid_path = store.home / "frontos.pid"
201
+ daemon = {"running": False, "pid": None}
202
+ if pid_path.exists():
203
+ pid = int(pid_path.read_text(encoding="utf-8").strip())
204
+ daemon = {"running": _pid_running(pid), "pid": pid}
205
+ return {
206
+ "version": __version__,
207
+ "home": str(store.home),
208
+ "db": str(store.db_path),
209
+ "projects": store.count_projects(),
210
+ "daemon": daemon,
211
+ }
212
+
213
+
214
+ def cmd_serve(args: argparse.Namespace) -> None:
215
+ print(f"FrontOS serving on http://{args.host}:{args.port}", file=sys.stderr)
216
+ serve(args.host, args.port, _home(args))
217
+ return None
218
+
219
+
220
+ def cmd_stop(args: argparse.Namespace) -> dict[str, Any]:
221
+ store = _store(args)
222
+ pid_path = store.home / "frontos.pid"
223
+ if not pid_path.exists():
224
+ return {"status": "not_running"}
225
+ pid = int(pid_path.read_text(encoding="utf-8").strip())
226
+ if not _pid_running(pid):
227
+ pid_path.unlink(missing_ok=True)
228
+ return {"status": "stale_pid_removed", "pid": pid}
229
+ os.kill(pid, signal.SIGTERM)
230
+ return {"status": "stopping", "pid": pid}
231
+
232
+
233
+ def cmd_mcp(args: argparse.Namespace) -> dict[str, Any] | None:
234
+ if args.mcp_args:
235
+ if len(args.mcp_args) == 2 and args.mcp_args[0] == "config":
236
+ return _mcp_config(args.mcp_args[1])
237
+ raise ValueError("Use `frontos mcp` or `frontos mcp config codex|cursor|claude`")
238
+ run_stdio(_home(args))
239
+ return None
240
+
241
+
242
+ def cmd_project_add(args: argparse.Namespace) -> dict[str, Any]:
243
+ path = Path(args.path).expanduser().resolve()
244
+ return _store(args).add_project(path, args.name, detect_project(path), args.write)
245
+
246
+
247
+ def cmd_project_list(args: argparse.Namespace) -> dict[str, Any]:
248
+ return {"projects": _store(args).list_projects()}
249
+
250
+
251
+ def cmd_project_scan(args: argparse.Namespace) -> dict[str, Any]:
252
+ store = _store(args)
253
+ project = store.get_project(args.project)
254
+ return ProjectScanner(store).scan(project)
255
+
256
+
257
+ def cmd_project_configure(args: argparse.Namespace) -> dict[str, Any]:
258
+ store = _store(args)
259
+ project = store.get_project(args.project)
260
+ if args.base_url is None and args.dev_command is None:
261
+ return store.get_project_runtime(project["id"])
262
+ return store.set_project_runtime(project["id"], args.base_url, args.dev_command)
263
+
264
+
265
+ def cmd_design_pack_create(args: argparse.Namespace) -> dict[str, Any]:
266
+ store = _store(args)
267
+ project = store.get_project(args.project)
268
+ scan = load_latest_scan(store, project["id"]) or ProjectScanner(store).scan(project)
269
+ return DesignPackCompiler(store).create(project, scan, preset_id=args.preset)
270
+
271
+
272
+ def cmd_design_pack_presets(args: argparse.Namespace) -> dict[str, Any]:
273
+ return {"presets": list_presets()}
274
+
275
+
276
+ def cmd_design_pack_get(args: argparse.Namespace) -> dict[str, Any]:
277
+ store = _store(args)
278
+ project = store.get_project(args.project)
279
+ pack = load_design_pack(store, project["id"], args.pack_id)
280
+ if pack is None:
281
+ raise KeyError(f"No design pack found for {project['id']}")
282
+ return pack
283
+
284
+
285
+ def cmd_qa(args: argparse.Namespace) -> dict[str, Any]:
286
+ store = _store(args)
287
+ project = store.get_project(args.project)
288
+ scan = load_latest_scan(store, project["id"]) or ProjectScanner(store).scan(project)
289
+ pack = load_design_pack(store, project["id"])
290
+ return QAEngine(store).run(project, scan, pack)
291
+
292
+
293
+ def cmd_security_audit(args: argparse.Namespace) -> dict[str, Any]:
294
+ store = _store(args)
295
+ project = store.get_project(args.project)
296
+ return SecurityAuditor(store).audit_project(project)
297
+
298
+
299
+ def cmd_patch_generate(args: argparse.Namespace) -> dict[str, Any]:
300
+ store = _store(args)
301
+ project = store.get_project(args.project)
302
+ scan = load_latest_scan(store, project["id"]) or ProjectScanner(store).scan(project)
303
+ pack = load_design_pack(store, project["id"]) or DesignPackCompiler(store).create(project, scan)
304
+ return PatchEngine(store).generate(project, scan, pack, route=args.route, mode=args.mode)
305
+
306
+
307
+ def cmd_patch_preview(args: argparse.Namespace) -> Any:
308
+ preview = PatchEngine(_store(args)).preview(args.patch_id)
309
+ if args.diff:
310
+ print(preview["diff"])
311
+ return None
312
+ return preview["manifest"]
313
+
314
+
315
+ def cmd_patch_validate(args: argparse.Namespace) -> dict[str, Any]:
316
+ return PatchEngine(_store(args)).validate(args.patch_id)
317
+
318
+
319
+ def cmd_patch_apply(args: argparse.Namespace) -> dict[str, Any]:
320
+ return PatchEngine(_store(args)).apply(args.patch_id, confirm=args.confirm, dry_run=args.dry_run)
321
+
322
+
323
+ def cmd_patch_rollback(args: argparse.Namespace) -> dict[str, Any]:
324
+ return PatchEngine(_store(args)).rollback(args.patch_id, confirm=args.confirm)
325
+
326
+
327
+ def cmd_visual_capture(args: argparse.Namespace) -> dict[str, Any]:
328
+ store = _store(args)
329
+ project = store.get_project(args.project)
330
+ scan = load_latest_scan(store, project["id"]) or ProjectScanner(store).scan(project)
331
+ return VisualQAEngine(store).capture(
332
+ project,
333
+ scan,
334
+ phase=args.phase,
335
+ base_url=args.base_url,
336
+ routes=args.route,
337
+ viewports=args.viewport,
338
+ timeout_ms=args.timeout_ms,
339
+ dry_run=args.dry_run,
340
+ )
341
+
342
+
343
+ def cmd_visual_latest(args: argparse.Namespace) -> dict[str, Any]:
344
+ store = _store(args)
345
+ project = store.get_project(args.project)
346
+ report = load_latest_visual_report(store, project["id"])
347
+ if report is None:
348
+ raise KeyError(f"No visual report found for {project['id']}")
349
+ return report
350
+
351
+
352
+ def cmd_visual_compare(args: argparse.Namespace) -> dict[str, Any]:
353
+ store = _store(args)
354
+ project = store.get_project(args.project)
355
+ return VisualQAEngine(store).compare(
356
+ project,
357
+ route=args.route,
358
+ viewports=args.viewport,
359
+ minimum_change_percent=args.min_change_percent,
360
+ maximum_change_percent=args.max_change_percent,
361
+ )
362
+
363
+
364
+ def cmd_visual_compare_latest(args: argparse.Namespace) -> dict[str, Any]:
365
+ store = _store(args)
366
+ project = store.get_project(args.project)
367
+ report = load_latest_visual_comparison(store, project["id"])
368
+ if report is None:
369
+ raise KeyError(f"No visual comparison report found for {project['id']}")
370
+ return report
371
+
372
+
373
+ def cmd_visual_doctor(args: argparse.Namespace) -> dict[str, Any]:
374
+ return VisualQAEngine(_store(args)).doctor(check_browser=args.check_browser)
375
+
376
+
377
+ def cmd_workflow_repair(args: argparse.Namespace) -> dict[str, Any]:
378
+ return WorkflowEngine(_store(args)).repair(
379
+ args.project,
380
+ route=args.route,
381
+ mode=args.mode,
382
+ base_url=args.base_url,
383
+ dev_command=args.dev_command,
384
+ apply_patch=args.apply,
385
+ confirm=args.confirm,
386
+ capture_visual=not args.skip_visual,
387
+ visual_dry_run=args.visual_dry_run,
388
+ viewports=args.viewport,
389
+ timeout_ms=args.timeout_ms,
390
+ start_server=args.start_server,
391
+ stop_server=not args.keep_server,
392
+ startup_timeout_seconds=args.startup_timeout_s,
393
+ )
394
+
395
+
396
+ def cmd_workflow_latest(args: argparse.Namespace) -> dict[str, Any]:
397
+ store = _store(args)
398
+ project = store.get_project(args.project)
399
+ report = load_latest_workflow(store, project["id"])
400
+ if report is None:
401
+ raise KeyError(f"No workflow report found for {project['id']}")
402
+ return report
403
+
404
+
405
+ def cmd_repair(args: argparse.Namespace) -> dict[str, Any]:
406
+ store = _store(args)
407
+ project = store.get_project(args.project)
408
+ scan = ProjectScanner(store).scan(project)
409
+ pack = load_design_pack(store, project["id"]) or DesignPackCompiler(store).create(project, scan)
410
+ patch = PatchEngine(store).generate(project, scan, pack, route=args.route, mode=args.mode)
411
+ qa = QAEngine(store).run(project, scan, pack, patch, job_id=patch["id"].replace("patch_", "job_"))
412
+ return {
413
+ "jobId": qa["jobId"],
414
+ "project": project["id"],
415
+ "mode": args.mode,
416
+ "route": args.route,
417
+ "designPack": {
418
+ "id": pack["id"],
419
+ "styleDNA": pack["styleDNA"]["name"],
420
+ "tokensGenerated": True,
421
+ "componentContractsGenerated": True,
422
+ "screenPatternsGenerated": True,
423
+ },
424
+ "artifacts": {
425
+ "appMap": f"frontos://project/{project['id']}/app-map",
426
+ "behaviorContracts": f"frontos://project/{project['id']}/behavior-contracts",
427
+ "frontendAudit": f"frontos://project/{project['id']}/audit/latest",
428
+ "patch": f"frontos://patch/{patch['id']}",
429
+ "beforeScreenshot": f"frontos://project/{project['id']}/screenshots/before",
430
+ "afterScreenshot": f"frontos://project/{project['id']}/screenshots/after",
431
+ "visualDiff": f"frontos://project/{project['id']}/visual-comparison/latest",
432
+ "visualComparisonReport": f"frontos://project/{project['id']}/visual-comparison/latest",
433
+ "qaReport": f"frontos://qa/{qa['jobId']}",
434
+ },
435
+ "scores": qa["scores"],
436
+ "status": "ready_for_review" if qa["status"] == "passed" else "needs_repair",
437
+ }
438
+
439
+
440
+ def print_json(data: Any) -> None:
441
+ print(json.dumps(data, indent=2, sort_keys=True))
442
+
443
+
444
+ def _store(args: argparse.Namespace) -> Store:
445
+ return Store(_home(args))
446
+
447
+
448
+ def _home(args: argparse.Namespace) -> str | None:
449
+ return getattr(args, "home", None)
450
+
451
+
452
+ def _pid_running(pid: int) -> bool:
453
+ try:
454
+ os.kill(pid, 0)
455
+ return True
456
+ except OSError:
457
+ return False
458
+
459
+
460
+ def _mcp_config(client: str) -> dict[str, Any]:
461
+ command = shutil.which("frontos") or "frontos"
462
+ if client == "codex":
463
+ return {
464
+ "client": "codex",
465
+ "config": f'[mcp_servers.frontos]\ncommand = "{command}"\nargs = ["mcp"]\n',
466
+ }
467
+ if client == "cursor":
468
+ return {"client": "cursor", "config": {"mcpServers": {"frontos": {"command": command, "args": ["mcp"]}}}}
469
+ if client in {"claude", "claude-desktop"}:
470
+ return {
471
+ "client": "claude",
472
+ "config": {"mcpServers": {"frontos": {"command": command, "args": ["mcp"]}}},
473
+ }
474
+ raise ValueError("Supported MCP config clients: codex, cursor, claude")
@@ -0,0 +1,2 @@
1
+ """Core storage and filesystem helpers."""
2
+
frontos/core/paths.py ADDED
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+
7
+ FRONTOS_DIRS = (
8
+ "projects",
9
+ "design-packs",
10
+ "scans",
11
+ "behavior-contracts",
12
+ "screenshots",
13
+ "patches",
14
+ "qa-reports",
15
+ "registry",
16
+ "adapters",
17
+ )
18
+
19
+
20
+ def frontos_home(value: str | os.PathLike[str] | None = None) -> Path:
21
+ """Return the configured FrontOS home directory."""
22
+
23
+ raw = value or os.environ.get("FRONTOS_HOME") or "~/.frontos"
24
+ return Path(raw).expanduser().resolve()
25
+
26
+
27
+ def ensure_frontos_home(value: str | os.PathLike[str] | None = None) -> Path:
28
+ """Create the FrontOS home layout and default config file."""
29
+
30
+ home = frontos_home(value)
31
+ home.mkdir(parents=True, exist_ok=True)
32
+ for dirname in FRONTOS_DIRS:
33
+ (home / dirname).mkdir(parents=True, exist_ok=True)
34
+
35
+ config_path = home / "config.yml"
36
+ if not config_path.exists():
37
+ config_path.write_text(
38
+ "\n".join(
39
+ [
40
+ "version: 1",
41
+ "mode: local-first",
42
+ "default_permissions:",
43
+ " read: true",
44
+ " write: false",
45
+ " patchOnly: true",
46
+ "network:",
47
+ " allowSourceUpload: false",
48
+ "server:",
49
+ " host: 127.0.0.1",
50
+ " port: 4791",
51
+ "",
52
+ ]
53
+ ),
54
+ encoding="utf-8",
55
+ )
56
+ return home
57
+
58
+
59
+ def slugify(value: str) -> str:
60
+ cleaned = []
61
+ previous_dash = False
62
+ for char in value.lower().strip():
63
+ if char.isalnum():
64
+ cleaned.append(char)
65
+ previous_dash = False
66
+ elif not previous_dash:
67
+ cleaned.append("-")
68
+ previous_dash = True
69
+ return "".join(cleaned).strip("-") or "project"
70
+
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+
6
+ SECRET_PATTERNS = {
7
+ "github_token": re.compile(r"\bgh[pousr]_[A-Za-z0-9_]{20,}\b"),
8
+ "openai_key": re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"),
9
+ "aws_access_key": re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
10
+ "private_key": re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", re.S),
11
+ "env_assignment": re.compile(
12
+ r"(?i)\b(api[_-]?key|token|secret|password|private[_-]?key)\s*=\s*['\"]?[^'\"\s]{8,}"
13
+ ),
14
+ }
15
+
16
+
17
+ def redact_text(text: str) -> str:
18
+ redacted = text
19
+ for name, pattern in SECRET_PATTERNS.items():
20
+ redacted = pattern.sub(f"[REDACTED:{name}]", redacted)
21
+ return redacted
22
+
23
+
24
+ def secret_findings(text: str) -> list[dict[str, str]]:
25
+ findings = []
26
+ for name, pattern in SECRET_PATTERNS.items():
27
+ for match in pattern.finditer(text):
28
+ findings.append({"type": name, "preview": _preview(match.group(0))})
29
+ return findings
30
+
31
+
32
+ def _preview(value: str) -> str:
33
+ if len(value) <= 12:
34
+ return "[REDACTED]"
35
+ return f"{value[:4]}...{value[-4:]}"