floop 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.
floop/cli.py ADDED
@@ -0,0 +1,797 @@
1
+ """floop CLI entry point."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+ from floop import __version__
9
+ from floop.adapters import (
10
+ ADAPTERS,
11
+ SUPPORTED_AGENTS,
12
+ )
13
+
14
+
15
+ @click.group()
16
+ @click.version_option(__version__, prog_name="floop")
17
+ def main():
18
+ """floop — AI-native prototype quality toolkit.
19
+
20
+ Manage your design like code. Agent Skill + review workflow CLI.
21
+
22
+ \b
23
+ Quick start:
24
+ floop init Initialize a floop project
25
+ floop enable copilot Install skills (GitHub Copilot)
26
+ floop enable cursor Install skills (Cursor)
27
+ floop enable claude Install skills (Claude Code)
28
+ floop enable trae Install skills (Trae IDE)
29
+ floop enable qwen-code Install skills (Qwen Code)
30
+ floop enable opencode Install skills (OpenCode)
31
+ floop enable openclaw Install skills (OpenClaw)
32
+ """
33
+
34
+
35
+ @main.command()
36
+ @click.option(
37
+ "--project-dir",
38
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
39
+ default=".",
40
+ help="Project root directory (default: current directory).",
41
+ )
42
+ def init(project_dir: Path):
43
+ """Initialize a floop project.
44
+
45
+ Creates a .floop/ directory with subdirectories for prototypes,
46
+ design tokens, and project configuration.
47
+ """
48
+ project_dir = project_dir.resolve()
49
+ floop_dir = project_dir / ".floop"
50
+
51
+ if floop_dir.exists():
52
+ click.secho("⚠ .floop/ already exists, skipping initialization.", fg="yellow")
53
+ return
54
+
55
+ # Create directory structure
56
+ dirs = {
57
+ "build": "Generated artifacts (token previews, component views, prototype pages)",
58
+ "tokens": "Design system tokens (colors, typography, spacing)",
59
+ "versions": "Prototype version snapshots (read-only archives)",
60
+ }
61
+
62
+ for name in dirs:
63
+ (floop_dir / name).mkdir(parents=True, exist_ok=True)
64
+
65
+ # Write config file
66
+ config = {
67
+ "version": __version__,
68
+ }
69
+ import json
70
+ config_path = floop_dir / "config.json"
71
+ config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
72
+
73
+ # Write .gitignore inside .floop
74
+ gitignore_path = floop_dir / ".gitignore"
75
+ gitignore_path.write_text(
76
+ "# Generated artifacts — track via floop-server, not git\n"
77
+ "build/\n",
78
+ encoding="utf-8",
79
+ )
80
+
81
+ click.secho("✓ floop project initialized", fg="green", bold=True)
82
+ click.echo(f" .floop/config.json")
83
+ click.echo(f" .floop/build/")
84
+ click.echo(f" .floop/tokens/")
85
+ click.echo(f" .floop/versions/")
86
+
87
+
88
+ @main.command()
89
+ @click.argument("agent", type=click.Choice(SUPPORTED_AGENTS, case_sensitive=False))
90
+ @click.option(
91
+ "--project-dir",
92
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
93
+ default=".",
94
+ help="Project root directory (default: current directory).",
95
+ )
96
+ def enable(agent: str, project_dir: Path):
97
+ """Install floop skills into an AI agent platform.
98
+
99
+ \b
100
+ Supported agents:
101
+ copilot GitHub Copilot (VS Code) — .github/skills/ + .github/instructions/
102
+ cursor Cursor — .cursor/rules/
103
+ claude Claude Code — .claude/skills/ + CLAUDE.md
104
+ trae Trae IDE — .trae/project_rules.md
105
+ qwen-code Qwen Code (CLI) — AGENTS.md
106
+ opencode OpenCode (CLI) — .opencode/skills/ + AGENTS.md
107
+ openclaw OpenClaw — .openclaw/skills/ + AGENTS.md
108
+ """
109
+ project_dir = project_dir.resolve()
110
+ adapter = ADAPTERS[agent]()
111
+ created = adapter.install(project_dir)
112
+
113
+ click.secho(f"✓ floop skills installed for {agent}", fg="green", bold=True)
114
+ for path in created:
115
+ rel = path.relative_to(project_dir)
116
+ click.echo(f" {rel}")
117
+
118
+
119
+ if __name__ == "__main__":
120
+ main() # pragma: no cover
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # floop token — Design Token management (W3C DTCG)
125
+ # ---------------------------------------------------------------------------
126
+
127
+
128
+ @main.group()
129
+ def token():
130
+ """Manage design tokens (W3C DTCG format).
131
+
132
+ \b
133
+ Commands:
134
+ floop token init Generate default token files
135
+ floop token validate Validate token files
136
+ floop token view Generate HTML preview page
137
+ """
138
+
139
+
140
+ @token.command("init")
141
+ @click.option(
142
+ "--project-dir",
143
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
144
+ default=".",
145
+ help="Project root directory (default: current directory).",
146
+ )
147
+ @click.option(
148
+ "--force",
149
+ is_flag=True,
150
+ default=False,
151
+ help="Overwrite existing token files.",
152
+ )
153
+ def token_init_cmd(project_dir: Path, force: bool):
154
+ """Generate W3C DTCG token template files.
155
+
156
+ Creates three files in .floop/tokens/:
157
+ global.tokens.json Primitive design values
158
+ semantic.tokens.json Semantic aliases
159
+ component.tokens.json Component-level tokens
160
+ """
161
+ from floop.tokens import token_init
162
+
163
+ project_dir = project_dir.resolve()
164
+ tokens_dir = project_dir / ".floop" / "tokens"
165
+
166
+ if not (project_dir / ".floop").exists():
167
+ click.secho(
168
+ "⚠ .floop/ not found. Run 'floop init' first.", fg="yellow", err=True
169
+ )
170
+ raise SystemExit(1)
171
+
172
+ existing = list(tokens_dir.glob("*.tokens.json"))
173
+ if existing and not force:
174
+ click.secho(
175
+ "⚠ Token files already exist. Use --force to overwrite.", fg="yellow"
176
+ )
177
+ return
178
+
179
+ created = token_init(tokens_dir)
180
+
181
+ click.secho("✓ Token files generated (W3C DTCG format)", fg="green", bold=True)
182
+ for path in created:
183
+ rel = path.relative_to(project_dir)
184
+ click.echo(f" {rel}")
185
+
186
+
187
+ @token.command("validate")
188
+ @click.option(
189
+ "--project-dir",
190
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
191
+ default=".",
192
+ help="Project root directory (default: current directory).",
193
+ )
194
+ @click.option(
195
+ "--json-output",
196
+ "output_json",
197
+ is_flag=True,
198
+ default=False,
199
+ help="Output structured JSON (for Agent consumption).",
200
+ )
201
+ def token_validate_cmd(project_dir: Path, output_json: bool):
202
+ """Validate design token files against W3C DTCG spec.
203
+
204
+ \b
205
+ Three validation layers:
206
+ L1 Format compliance (valid JSON, valid $type/$value)
207
+ L2 Reference integrity (broken refs, circular deps)
208
+ L3 Design suggestions (recommended semantic tokens)
209
+ """
210
+ from floop.tokens import token_validate
211
+
212
+ project_dir = project_dir.resolve()
213
+ tokens_dir = project_dir / ".floop" / "tokens"
214
+
215
+ result = token_validate(tokens_dir)
216
+
217
+ if output_json:
218
+ click.echo(json.dumps(result, indent=2, ensure_ascii=False))
219
+ else:
220
+ # Human-readable output
221
+ stats = result["stats"]
222
+ click.echo(
223
+ f"Checked {stats['files']} file(s): "
224
+ f"{stats['tokens']} tokens, "
225
+ f"{stats['references']} references, "
226
+ f"{stats['groups']} groups"
227
+ )
228
+
229
+ for err in result["errors"]:
230
+ loc = err["path"] or err["file"]
231
+ click.secho(f" ✗ [{err['code']}] {loc}: {err['message']}", fg="red")
232
+ if err.get("suggestion"):
233
+ click.echo(f" → {err['suggestion']}")
234
+
235
+ for warn in result["warnings"]:
236
+ loc = warn["path"] or warn["file"]
237
+ click.secho(
238
+ f" ⚠ [{warn['code']}] {loc}: {warn['message']}", fg="yellow"
239
+ )
240
+ if warn.get("suggestion"):
241
+ click.echo(f" → {warn['suggestion']}")
242
+
243
+ if result["valid"]:
244
+ click.secho("✓ All tokens valid", fg="green", bold=True)
245
+ else:
246
+ click.secho(
247
+ f"✗ {len(result['errors'])} error(s) found",
248
+ fg="red",
249
+ bold=True,
250
+ )
251
+ raise SystemExit(1)
252
+
253
+
254
+ @token.command("view")
255
+ @click.option(
256
+ "--project-dir",
257
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
258
+ default=".",
259
+ help="Project root directory (default: current directory).",
260
+ )
261
+ def token_view_cmd(project_dir: Path):
262
+ """Generate an HTML preview page for design tokens.
263
+
264
+ Reads all .tokens.json files, resolves references, and generates
265
+ a visual preview at .floop/build/design-tokens.html.
266
+ """
267
+ from floop.tokens import token_view
268
+
269
+ project_dir = project_dir.resolve()
270
+ tokens_dir = project_dir / ".floop" / "tokens"
271
+
272
+ if not tokens_dir.exists():
273
+ click.secho(
274
+ "⚠ .floop/tokens/ not found. Run 'floop init' and 'floop token init' first.",
275
+ fg="yellow",
276
+ err=True,
277
+ )
278
+ raise SystemExit(1)
279
+
280
+ token_files = list(tokens_dir.glob("*.tokens.json"))
281
+ if not token_files:
282
+ click.secho(
283
+ "⚠ No .tokens.json files found. Run 'floop token init' first.",
284
+ fg="yellow",
285
+ err=True,
286
+ )
287
+ raise SystemExit(1)
288
+
289
+ build_dir = project_dir / ".floop" / "build" / "tokens"
290
+ build_dir.mkdir(parents=True, exist_ok=True)
291
+ out_path = token_view(tokens_dir, out_dir=build_dir)
292
+ css_path = build_dir / "tokens.css"
293
+ click.secho("✓ Token preview generated", fg="green", bold=True)
294
+ click.echo(f" {out_path.relative_to(project_dir)}")
295
+ click.echo(f" {css_path.relative_to(project_dir)}")
296
+
297
+
298
+ # ---------------------------------------------------------------------------
299
+ # floop preview — Local preview server
300
+ # ---------------------------------------------------------------------------
301
+
302
+
303
+ @main.command()
304
+ @click.option(
305
+ "--project-dir",
306
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
307
+ default=".",
308
+ help="Project root directory (default: current directory).",
309
+ )
310
+ @click.option(
311
+ "--port",
312
+ type=int,
313
+ default=0,
314
+ help="Port number (default: auto-assign a free port).",
315
+ )
316
+ @click.option(
317
+ "--version",
318
+ "active_version",
319
+ default="trunk",
320
+ help="Version to preview (default: trunk = current build).",
321
+ )
322
+ def preview(project_dir: Path, port: int, active_version: str):
323
+ """Start a local web server to preview floop output.
324
+
325
+ Generates a navigation index page in .floop/build/ and serves it on
326
+ a temporary local port. Open the printed URL in your browser to
327
+ browse design tokens, components, and prototype pages.
328
+
329
+ Use --version to start preview pinned to a named snapshot.
330
+
331
+ Press Ctrl+C to stop the server.
332
+ """
333
+ import http.server
334
+ import functools
335
+ import socket
336
+
337
+ from floop.preview import generate_preview_index
338
+
339
+ project_dir = project_dir.resolve()
340
+ floop_dir = project_dir / ".floop"
341
+
342
+ if not floop_dir.exists():
343
+ click.secho(
344
+ "⚠ .floop/ not found. Run 'floop init' first.", fg="yellow", err=True
345
+ )
346
+ raise SystemExit(1)
347
+
348
+ build_dir = floop_dir / "build"
349
+ build_dir.mkdir(parents=True, exist_ok=True)
350
+
351
+ # Generate (or refresh) the navigation index page
352
+ generate_preview_index(build_dir, active_version=active_version)
353
+
354
+ # Serve from .floop/ so both build/ and versions/ are reachable
355
+ handler = functools.partial(
356
+ http.server.SimpleHTTPRequestHandler, directory=str(floop_dir)
357
+ )
358
+
359
+ # Find a free port if port=0
360
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
361
+ s.bind(("127.0.0.1", port))
362
+ chosen_port = s.getsockname()[1]
363
+
364
+ server = http.server.HTTPServer(("127.0.0.1", chosen_port), handler)
365
+
366
+ click.secho("floop preview server", fg="green", bold=True)
367
+ click.echo(f" http://127.0.0.1:{chosen_port}/build/")
368
+ click.echo("\n Press Ctrl+C to stop.\n")
369
+
370
+ try:
371
+ server.serve_forever()
372
+ except KeyboardInterrupt:
373
+ pass
374
+ finally:
375
+ server.server_close()
376
+ click.echo("\nServer stopped.")
377
+
378
+
379
+ # ---------------------------------------------------------------------------
380
+ # floop prd — Product Requirements Document management
381
+ # ---------------------------------------------------------------------------
382
+
383
+
384
+ @main.group()
385
+ def prd():
386
+ """Manage product requirements document (.floop/prd.md).
387
+
388
+ \b
389
+ Commands:
390
+ floop prd init Create prd.md template
391
+ floop prd validate Validate prd.md frontmatter
392
+ """
393
+
394
+
395
+ @prd.command("init")
396
+ @click.option(
397
+ "--project-dir",
398
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
399
+ default=".",
400
+ help="Project root directory (default: current directory).",
401
+ )
402
+ def prd_init_cmd(project_dir: Path):
403
+ """Create .floop/prd.md from template.
404
+
405
+ Generates a PRD document with YAML frontmatter (product, target_users,
406
+ core_flows, css_framework, status) and Markdown sections for you to fill in.
407
+ """
408
+ from floop.prototype import prd_init
409
+
410
+ project_dir = project_dir.resolve()
411
+ try:
412
+ path = prd_init(project_dir)
413
+ except FileExistsError as exc:
414
+ click.secho(f"⚠ {exc}", fg="yellow", err=True)
415
+ raise SystemExit(1)
416
+
417
+ rel = path.relative_to(project_dir)
418
+ click.secho("✓ prd.md created", fg="green", bold=True)
419
+ click.echo(f" {rel}")
420
+
421
+
422
+ @prd.command("validate")
423
+ @click.option(
424
+ "--project-dir",
425
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
426
+ default=".",
427
+ help="Project root directory (default: current directory).",
428
+ )
429
+ def prd_validate_cmd(project_dir: Path):
430
+ """Validate .floop/prd.md frontmatter fields."""
431
+ from floop.prototype import prd_validate
432
+
433
+ project_dir = project_dir.resolve()
434
+ errors, warnings = prd_validate(project_dir)
435
+
436
+ for warn in warnings:
437
+ click.secho(f" ⚠ {warn}", fg="yellow")
438
+ for err in errors:
439
+ click.secho(f" ✗ {err}", fg="red")
440
+
441
+ if errors:
442
+ click.secho(f"✗ {len(errors)} error(s) found", fg="red", bold=True)
443
+ raise SystemExit(1)
444
+
445
+ click.secho("✓ prd.md is valid", fg="green", bold=True)
446
+
447
+
448
+ # ---------------------------------------------------------------------------
449
+ # floop sitemap — Sitemap management
450
+ # ---------------------------------------------------------------------------
451
+
452
+
453
+ @main.group()
454
+ def sitemap():
455
+ """Manage sitemap definition (.floop/sitemap.md).
456
+
457
+ \b
458
+ Commands:
459
+ floop sitemap init Create sitemap.md template
460
+ floop sitemap validate Validate sitemap.md frontmatter
461
+ """
462
+
463
+
464
+ @sitemap.command("init")
465
+ @click.option(
466
+ "--project-dir",
467
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
468
+ default=".",
469
+ help="Project root directory (default: current directory).",
470
+ )
471
+ def sitemap_init_cmd(project_dir: Path):
472
+ """Create .floop/sitemap.md from template.
473
+
474
+ Generates a sitemap document with YAML frontmatter listing pages
475
+ (id, title, file, status) for you to fill in.
476
+ """
477
+ from floop.prototype import sitemap_init
478
+
479
+ project_dir = project_dir.resolve()
480
+ try:
481
+ path = sitemap_init(project_dir)
482
+ except FileExistsError as exc:
483
+ click.secho(f"⚠ {exc}", fg="yellow", err=True)
484
+ raise SystemExit(1)
485
+
486
+ rel = path.relative_to(project_dir)
487
+ click.secho("✓ sitemap.md created", fg="green", bold=True)
488
+ click.echo(f" {rel}")
489
+
490
+
491
+ @sitemap.command("validate")
492
+ @click.option(
493
+ "--project-dir",
494
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
495
+ default=".",
496
+ help="Project root directory (default: current directory).",
497
+ )
498
+ def sitemap_validate_cmd(project_dir: Path):
499
+ """Validate .floop/sitemap.md frontmatter fields."""
500
+ from floop.prototype import sitemap_validate
501
+
502
+ project_dir = project_dir.resolve()
503
+ errors, warnings = sitemap_validate(project_dir)
504
+
505
+ for warn in warnings:
506
+ click.secho(f" ⚠ {warn}", fg="yellow")
507
+ for err in errors:
508
+ click.secho(f" ✗ {err}", fg="red")
509
+
510
+ if errors:
511
+ click.secho(f"✗ {len(errors)} error(s) found", fg="red", bold=True)
512
+ raise SystemExit(1)
513
+
514
+ click.secho("✓ sitemap.md is valid", fg="green", bold=True)
515
+
516
+
517
+ # ---------------------------------------------------------------------------
518
+ # floop component — Component library management
519
+ # ---------------------------------------------------------------------------
520
+
521
+
522
+ @main.group()
523
+ def component():
524
+ """Manage component library definition (.floop/components.yaml).
525
+
526
+ \b
527
+ Commands:
528
+ floop component init Create components.yaml template
529
+ floop component validate Validate components.yaml
530
+ """
531
+
532
+
533
+ @component.command("init")
534
+ @click.option(
535
+ "--project-dir",
536
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
537
+ default=".",
538
+ help="Project root directory (default: current directory).",
539
+ )
540
+ def component_init_cmd(project_dir: Path):
541
+ """Create .floop/components.yaml from template.
542
+
543
+ Generates a component library file with YAML structure
544
+ (id, title, status, tokens) for you to fill in.
545
+ """
546
+ from floop.prototype import component_init
547
+
548
+ project_dir = project_dir.resolve()
549
+ try:
550
+ path = component_init(project_dir)
551
+ except FileExistsError as exc:
552
+ click.secho(f"⚠ {exc}", fg="yellow", err=True)
553
+ raise SystemExit(1)
554
+
555
+ rel = path.relative_to(project_dir)
556
+ click.secho("✓ components.yaml created", fg="green", bold=True)
557
+ click.echo(f" {rel}")
558
+
559
+
560
+ @component.command("validate")
561
+ @click.option(
562
+ "--project-dir",
563
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
564
+ default=".",
565
+ help="Project root directory (default: current directory).",
566
+ )
567
+ def component_validate_cmd(project_dir: Path):
568
+ """Validate .floop/components.yaml fields."""
569
+ from floop.prototype import component_validate
570
+
571
+ project_dir = project_dir.resolve()
572
+ errors, warnings = component_validate(project_dir)
573
+
574
+ for warn in warnings:
575
+ click.secho(f" ⚠ {warn}", fg="yellow")
576
+ for err in errors:
577
+ click.secho(f" ✗ {err}", fg="red")
578
+
579
+ if errors:
580
+ click.secho(f"✗ {len(errors)} error(s) found", fg="red", bold=True)
581
+ raise SystemExit(1)
582
+
583
+ click.secho("✓ components.yaml is valid", fg="green", bold=True)
584
+
585
+
586
+ # ---------------------------------------------------------------------------
587
+ # floop prototype — Journey Map management
588
+ # ---------------------------------------------------------------------------
589
+
590
+
591
+ @main.group()
592
+ def prototype():
593
+ """Manage prototype journey map (.floop/journey-map.csv).
594
+
595
+ \b
596
+ Commands:
597
+ floop prototype init Build journey-map.csv from sitemap.md
598
+ floop prototype validate Validate journey HTMLs against journey-map.csv
599
+ """
600
+
601
+
602
+ @prototype.command("init")
603
+ @click.option(
604
+ "--project-dir",
605
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
606
+ default=".",
607
+ help="Project root directory (default: current directory).",
608
+ )
609
+ def prototype_init_cmd(project_dir: Path):
610
+ """Build .floop/journey-map.csv from sitemap.md.
611
+
612
+ Reads all pages in sitemap.md frontmatter and generates a CSV mapping
613
+ sitemap domains to HTML files. The domain is taken from each page's
614
+ optional 'domain' field; if absent it is derived from the file path
615
+ (e.g. build/journey/auth/login.html → domain 'auth').
616
+
617
+ The CSV is always regenerated — safe to re-run after updating sitemap.md.
618
+ """
619
+ from floop.prototype import prototype_init
620
+
621
+ project_dir = project_dir.resolve()
622
+ try:
623
+ path = prototype_init(project_dir)
624
+ except FileNotFoundError as exc:
625
+ click.secho(f"⚠ {exc}", fg="yellow", err=True)
626
+ raise SystemExit(1)
627
+
628
+ rel = path.relative_to(project_dir)
629
+ click.secho("✓ journey-map.csv generated", fg="green", bold=True)
630
+ click.echo(f" {rel}")
631
+
632
+
633
+ @prototype.command("validate")
634
+ @click.option(
635
+ "--project-dir",
636
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
637
+ default=".",
638
+ help="Project root directory (default: current directory).",
639
+ )
640
+ def prototype_validate_cmd(project_dir: Path):
641
+ """Validate journey HTML files against journey-map.csv and sitemap.md.
642
+
643
+ \b
644
+ Two checks:
645
+ 1. Every HTML under .floop/build/journey/ is mapped in journey-map.csv
646
+ 2. Every domain in journey-map.csv exists in sitemap.md pages
647
+ """
648
+ from floop.prototype import prototype_validate
649
+
650
+ project_dir = project_dir.resolve()
651
+ errors, warnings = prototype_validate(project_dir)
652
+
653
+ for warn in warnings:
654
+ click.secho(f" ⚠ {warn}", fg="yellow")
655
+ for err in errors:
656
+ click.secho(f" ✗ {err}", fg="red")
657
+
658
+ if errors:
659
+ click.secho(f"✗ {len(errors)} error(s) found", fg="red", bold=True)
660
+ raise SystemExit(1)
661
+
662
+ click.secho("✓ prototype is valid", fg="green", bold=True)
663
+
664
+
665
+ # ---------------------------------------------------------------------------
666
+ # floop version — Trunk-based prototype version snapshots
667
+ # ---------------------------------------------------------------------------
668
+
669
+
670
+ @main.group()
671
+ def version():
672
+ """Manage prototype versions (trunk-based snapshots).
673
+
674
+ \b
675
+ Commands:
676
+ floop version create Snapshot current build into a named version
677
+ floop version list List all versions
678
+ """
679
+
680
+
681
+ @version.command("create")
682
+ @click.argument("name")
683
+ @click.option("-m", "--message", default="", help="Version description.")
684
+ @click.option(
685
+ "--project-dir",
686
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
687
+ default=".",
688
+ help="Project root directory (default: current directory).",
689
+ )
690
+ def version_create_cmd(name: str, message: str, project_dir: Path):
691
+ """Snapshot .floop/build/ into .floop/versions/NAME/.
692
+
693
+ NAME must be unique (e.g. v1.0, v1.1-homepage-revamp).
694
+ Always run this before sharing a build with a client.
695
+ """
696
+ from floop.prototype import version_create
697
+
698
+ project_dir = project_dir.resolve()
699
+ if not (project_dir / ".floop").exists():
700
+ click.secho(
701
+ "⚠ .floop/ not found. Run 'floop init' first.", fg="yellow", err=True
702
+ )
703
+ raise SystemExit(1)
704
+
705
+ try:
706
+ version_dir = version_create(project_dir, name, message)
707
+ except (ValueError, FileNotFoundError) as exc:
708
+ click.secho(f"⚠ {exc}", fg="yellow", err=True)
709
+ raise SystemExit(1)
710
+
711
+ rel = version_dir.relative_to(project_dir)
712
+ click.secho(f"✓ Version '{name}' created", fg="green", bold=True)
713
+ click.echo(f" {rel}")
714
+
715
+
716
+ @version.command("list")
717
+ @click.option(
718
+ "--project-dir",
719
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
720
+ default=".",
721
+ help="Project root directory (default: current directory).",
722
+ )
723
+ def version_list_cmd(project_dir: Path):
724
+ """List all prototype versions."""
725
+ from floop.prototype import version_list
726
+
727
+ project_dir = project_dir.resolve()
728
+ versions = version_list(project_dir)
729
+
730
+ if not versions:
731
+ click.echo("No versions found. Run 'floop version create' to create one.")
732
+ return
733
+
734
+ for v in versions:
735
+ date = v.get("created_at", "")[:10]
736
+ msg = v.get("message", "")
737
+ suffix = f" {msg}" if msg else ""
738
+ click.echo(f" {v['version']} ({date}){suffix}")
739
+
740
+
741
+ # ---------------------------------------------------------------------------
742
+ # floop journey — Journey backward-check commands
743
+ # ---------------------------------------------------------------------------
744
+
745
+
746
+ @main.group()
747
+ def journey():
748
+ """Manage journey HTML pages.
749
+
750
+ \b
751
+ Commands:
752
+ floop journey check Backward-check a journey HTML for token/component gaps
753
+ """
754
+
755
+
756
+ @journey.command("check")
757
+ @click.argument(
758
+ "html_file",
759
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
760
+ )
761
+ @click.option(
762
+ "--project-dir",
763
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
764
+ default=".",
765
+ help="Project root directory (default: current directory).",
766
+ )
767
+ def journey_check_cmd(html_file: Path, project_dir: Path):
768
+ """Backward-check a journey HTML file for token and component gaps.
769
+
770
+ Scans HTML_FILE for missing token references, unused components,
771
+ and missing head links (tokens.css / components.js).
772
+ """
773
+ from floop.prototype import journey_check
774
+
775
+ project_dir = project_dir.resolve()
776
+ html_file = html_file.resolve()
777
+
778
+ if not (project_dir / ".floop").exists():
779
+ click.secho(
780
+ "⚠ .floop/ not found. Run 'floop init' first.", fg="yellow", err=True
781
+ )
782
+ raise SystemExit(1)
783
+
784
+ errors, warnings = journey_check(project_dir, html_file)
785
+
786
+ for warn in warnings:
787
+ click.secho(f" ⚠ {warn}", fg="yellow")
788
+ for err in errors:
789
+ click.secho(f" ✗ {err}", fg="red")
790
+
791
+ if errors:
792
+ click.secho(
793
+ f"✗ {len(errors)} error(s) found", fg="red", bold=True
794
+ )
795
+ raise SystemExit(1)
796
+
797
+ click.secho("✓ journey check passed", fg="green", bold=True)