kctl-react 0.6.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. kctl_react/__init__.py +3 -0
  2. kctl_react/__main__.py +5 -0
  3. kctl_react/cli.py +201 -0
  4. kctl_react/commands/__init__.py +0 -0
  5. kctl_react/commands/a11y.py +78 -0
  6. kctl_react/commands/affected.py +170 -0
  7. kctl_react/commands/apps.py +353 -0
  8. kctl_react/commands/build.py +376 -0
  9. kctl_react/commands/bundle_cmd.py +217 -0
  10. kctl_react/commands/cap.py +1465 -0
  11. kctl_react/commands/clean.py +76 -0
  12. kctl_react/commands/codegen.py +491 -0
  13. kctl_react/commands/compliance.py +587 -0
  14. kctl_react/commands/config_cmd.py +368 -0
  15. kctl_react/commands/dashboard.py +163 -0
  16. kctl_react/commands/deploy.py +318 -0
  17. kctl_react/commands/deps.py +792 -0
  18. kctl_react/commands/dev.py +96 -0
  19. kctl_react/commands/docker_cmd.py +73 -0
  20. kctl_react/commands/doctor.py +170 -0
  21. kctl_react/commands/e2e.py +343 -0
  22. kctl_react/commands/env.py +155 -0
  23. kctl_react/commands/i18n.py +310 -0
  24. kctl_react/commands/lint.py +306 -0
  25. kctl_react/commands/maintenance.py +308 -0
  26. kctl_react/commands/monitor_cmd.py +50 -0
  27. kctl_react/commands/observe.py +34 -0
  28. kctl_react/commands/packages.py +129 -0
  29. kctl_react/commands/perf.py +762 -0
  30. kctl_react/commands/pipeline.py +289 -0
  31. kctl_react/commands/pwa.py +193 -0
  32. kctl_react/commands/scaffold.py +323 -0
  33. kctl_react/commands/security.py +660 -0
  34. kctl_react/commands/skill_cmd.py +54 -0
  35. kctl_react/commands/state.py +254 -0
  36. kctl_react/commands/test_cmd.py +418 -0
  37. kctl_react/commands/ui_audit.py +889 -0
  38. kctl_react/core/__init__.py +0 -0
  39. kctl_react/core/analyzers.py +200 -0
  40. kctl_react/core/callbacks.py +70 -0
  41. kctl_react/core/compliance/__init__.py +3 -0
  42. kctl_react/core/compliance/api_check/__init__.py +3 -0
  43. kctl_react/core/compliance/api_check/checks/__init__.py +18 -0
  44. kctl_react/core/compliance/api_check/checks/endpoints.py +53 -0
  45. kctl_react/core/compliance/api_check/checks/envelope.py +44 -0
  46. kctl_react/core/compliance/api_check/checks/naming.py +60 -0
  47. kctl_react/core/compliance/api_check/checks/params.py +44 -0
  48. kctl_react/core/compliance/api_check/checks/requests.py +57 -0
  49. kctl_react/core/compliance/api_check/checks/types.py +55 -0
  50. kctl_react/core/compliance/api_check/hooks.py +133 -0
  51. kctl_react/core/compliance/api_check/matcher.py +55 -0
  52. kctl_react/core/compliance/api_check/schema.py +151 -0
  53. kctl_react/core/compliance/api_health/__init__.py +35 -0
  54. kctl_react/core/compliance/api_health/checks/__init__.py +9 -0
  55. kctl_react/core/compliance/api_health/checks/auth.py +72 -0
  56. kctl_react/core/compliance/api_health/checks/reachable.py +44 -0
  57. kctl_react/core/compliance/api_health/checks/response.py +55 -0
  58. kctl_react/core/compliance/api_health/checks/timing.py +38 -0
  59. kctl_react/core/compliance/api_health/client.py +99 -0
  60. kctl_react/core/compliance/api_health/sampler.py +16 -0
  61. kctl_react/core/compliance/checks/__init__.py +47 -0
  62. kctl_react/core/compliance/checks/api.py +101 -0
  63. kctl_react/core/compliance/checks/codegen.py +94 -0
  64. kctl_react/core/compliance/checks/darkmode.py +57 -0
  65. kctl_react/core/compliance/checks/errors.py +68 -0
  66. kctl_react/core/compliance/checks/features.py +66 -0
  67. kctl_react/core/compliance/checks/i18n_check.py +105 -0
  68. kctl_react/core/compliance/checks/imports.py +86 -0
  69. kctl_react/core/compliance/checks/navigation.py +62 -0
  70. kctl_react/core/compliance/checks/practices.py +122 -0
  71. kctl_react/core/compliance/checks/providers.py +85 -0
  72. kctl_react/core/compliance/checks/pwa.py +101 -0
  73. kctl_react/core/compliance/checks/responsive.py +47 -0
  74. kctl_react/core/compliance/checks/scripts.py +85 -0
  75. kctl_react/core/compliance/checks/shadcn.py +51 -0
  76. kctl_react/core/compliance/checks/structure.py +76 -0
  77. kctl_react/core/compliance/checks/testing.py +83 -0
  78. kctl_react/core/compliance/checks/theme.py +92 -0
  79. kctl_react/core/compliance/checks/ui_standard.py +185 -0
  80. kctl_react/core/compliance/checks/vite.py +83 -0
  81. kctl_react/core/compliance/engine.py +87 -0
  82. kctl_react/core/compliance/exceptions_map.py +15 -0
  83. kctl_react/core/compliance/fixes/__init__.py +33 -0
  84. kctl_react/core/compliance/fixes/codegen_fix.py +28 -0
  85. kctl_react/core/compliance/fixes/i18n_fix.py +61 -0
  86. kctl_react/core/compliance/fixes/imports_fix.py +36 -0
  87. kctl_react/core/compliance/fixes/structure_fix.py +20 -0
  88. kctl_react/core/compliance/fixes/theme_fix.py +29 -0
  89. kctl_react/core/compliance/models.py +106 -0
  90. kctl_react/core/config.py +201 -0
  91. kctl_react/core/discovery.py +185 -0
  92. kctl_react/core/exceptions.py +17 -0
  93. kctl_react/core/git.py +146 -0
  94. kctl_react/core/history.py +121 -0
  95. kctl_react/core/output.py +5 -0
  96. kctl_react/core/plugins.py +13 -0
  97. kctl_react/core/runner.py +34 -0
  98. kctl_react/py.typed +0 -0
  99. kctl_react-0.6.2.dist-info/METADATA +17 -0
  100. kctl_react-0.6.2.dist-info/RECORD +102 -0
  101. kctl_react-0.6.2.dist-info/WHEEL +4 -0
  102. kctl_react-0.6.2.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,889 @@
1
+ """UI component audit — detect shadcn violations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ import re
8
+ import shutil
9
+ import tempfile
10
+ from pathlib import Path
11
+ from typing import Annotated
12
+
13
+ import typer
14
+
15
+ from kctl_react.core.analyzers import find_raw_html_elements, scan_imports
16
+ from kctl_react.core.callbacks import AppContext
17
+ from kctl_react.core.exceptions import CommandError
18
+ from kctl_react.core.runner import run
19
+
20
+ app = typer.Typer(help="UI component audit and shadcn compliance.")
21
+
22
+ # React anti-patterns to detect
23
+ _ANTI_PATTERNS: list[tuple[str, str, str]] = [
24
+ # (pattern, name, fix)
25
+ (r"style=\{\{", "inline style", "Use Tailwind utility classes instead"),
26
+ (r'from\s+["\'][^"\']+\.module\.css["\']', "CSS module import", "Use Tailwind v4 utility classes"),
27
+ (r'from\s+["\']styled-components["\']', "styled-components import", "Use Tailwind v4 utility classes"),
28
+ (r"document\.getElementById\(", "direct DOM access", "Use React refs (useRef) instead"),
29
+ (
30
+ r'from\s+["\'][^"\']*tailwind\.config["\']',
31
+ "tailwind.config import",
32
+ "Use CSS @theme in index.css (Tailwind v4)",
33
+ ),
34
+ ]
35
+
36
+
37
+ def _scan_anti_patterns(src_dir: Path) -> list[dict]:
38
+ """Scan all TSX/TS files in src_dir for React anti-patterns."""
39
+ results: list[dict] = []
40
+ for f in src_dir.rglob("*.ts"):
41
+ _check_file_anti_patterns(f, results, src_dir)
42
+ for f in src_dir.rglob("*.tsx"):
43
+ _check_file_anti_patterns(f, results, src_dir)
44
+ return results
45
+
46
+
47
+ def _check_file_anti_patterns(file_path: Path, results: list[dict], base_dir: Path) -> None:
48
+ try:
49
+ lines = file_path.read_text(errors="ignore").splitlines()
50
+ except OSError:
51
+ return
52
+ rel = str(file_path.relative_to(base_dir.parent.parent))
53
+ for line_num, line in enumerate(lines, start=1):
54
+ for pattern, name, fix in _ANTI_PATTERNS:
55
+ if re.search(pattern, line):
56
+ results.append(
57
+ {
58
+ "file": rel,
59
+ "line": line_num,
60
+ "pattern": name,
61
+ "fix": fix,
62
+ "code": line.strip()[:80],
63
+ }
64
+ )
65
+
66
+
67
+ @app.command()
68
+ def audit(ctx: typer.Context, app_name: Annotated[str, typer.Argument(help="App name")]) -> None:
69
+ """Find raw HTML elements that should be shadcn components."""
70
+ actx: AppContext = ctx.obj
71
+ out = actx.output
72
+ actx.validate_app(app_name)
73
+ src_dir = actx.get_app_dir(app_name) / "src"
74
+ violations: list[dict] = []
75
+ for f in src_dir.rglob("*.tsx"):
76
+ for v in find_raw_html_elements(f):
77
+ # Normalise file path to be relative to project root
78
+ v = dict(v)
79
+ with contextlib.suppress(ValueError):
80
+ v["file"] = str(Path(str(v["file"])).relative_to(actx.project_root))
81
+ violations.append(v)
82
+ if not violations:
83
+ out.success("No shadcn violations found")
84
+ return
85
+ rows = [
86
+ [v["file"], str(v["line"]), v["element"], f"Use <{v['replacement'].split('+')[0].strip()}>"]
87
+ for v in violations[:50]
88
+ ]
89
+ out.table(
90
+ f"UI Violations ({len(violations)} found)",
91
+ [("File", "red"), ("Line", "dim"), ("Element", "yellow"), ("Fix", "green")],
92
+ rows,
93
+ data_for_json=violations[:50],
94
+ )
95
+ if len(violations) > 50:
96
+ out.warn(f"Showing 50/{len(violations)} — use --json for full list")
97
+
98
+
99
+ @app.command()
100
+ def compliance(ctx: typer.Context, app_name: Annotated[str, typer.Argument(help="App name")]) -> None:
101
+ """Score shadcn compliance as % of clean TSX files (no violations)."""
102
+ actx: AppContext = ctx.obj
103
+ out = actx.output
104
+ actx.validate_app(app_name)
105
+ src_dir = actx.get_app_dir(app_name) / "src"
106
+
107
+ total_files = 0
108
+ clean_files = 0
109
+ total_violations = 0
110
+ violation_by_file: dict[str, int] = {}
111
+
112
+ for f in src_dir.rglob("*.tsx"):
113
+ total_files += 1
114
+ file_violations = find_raw_html_elements(f)
115
+ count = len(file_violations)
116
+ total_violations += count
117
+ if count == 0:
118
+ clean_files += 1
119
+ else:
120
+ rel = str(f.relative_to(actx.project_root))
121
+ violation_by_file[rel] = count
122
+
123
+ if total_files == 0:
124
+ out.warn("No TSX files found")
125
+ return
126
+
127
+ score = round(clean_files / total_files * 100, 1)
128
+ status = "✓ PASS" if score >= 80 else "✗ FAIL"
129
+
130
+ rows = [
131
+ [
132
+ app_name,
133
+ f"{score}%",
134
+ str(total_files),
135
+ str(clean_files),
136
+ str(total_violations),
137
+ status,
138
+ ]
139
+ ]
140
+ json_data = [
141
+ {
142
+ "app": app_name,
143
+ "score_pct": score,
144
+ "total_files": total_files,
145
+ "clean_files": clean_files,
146
+ "violation_count": total_violations,
147
+ "status": status,
148
+ }
149
+ ]
150
+ out.table(
151
+ f"shadcn Compliance — {app_name}",
152
+ [
153
+ ("App", "cyan"),
154
+ ("Score", "green" if score >= 80 else "red"),
155
+ ("Total Files", ""),
156
+ ("Clean Files", ""),
157
+ ("Violations", "yellow"),
158
+ ("Status", ""),
159
+ ],
160
+ rows,
161
+ data_for_json=json_data,
162
+ )
163
+
164
+ if violation_by_file:
165
+ top = sorted(violation_by_file.items(), key=lambda x: -x[1])[:10]
166
+ detail_rows = [[f, str(c)] for f, c in top]
167
+ out.table(
168
+ "Top Violating Files",
169
+ [("File", "red"), ("Violations", "yellow")],
170
+ detail_rows,
171
+ data_for_json=[{"file": f, "violations": c} for f, c in top],
172
+ )
173
+
174
+
175
+ @app.command("anti-patterns")
176
+ def anti_patterns(ctx: typer.Context, app_name: Annotated[str, typer.Argument(help="App name")]) -> None:
177
+ """Detect React anti-patterns: inline styles, CSS modules, styled-components, direct DOM access."""
178
+ actx: AppContext = ctx.obj
179
+ out = actx.output
180
+ actx.validate_app(app_name)
181
+ src_dir = actx.get_app_dir(app_name) / "src"
182
+ issues = _scan_anti_patterns(src_dir)
183
+ if not issues:
184
+ out.success("No React anti-patterns detected")
185
+ return
186
+ rows = [[v["file"], str(v["line"]), v["pattern"], v["fix"]] for v in issues[:50]]
187
+ out.table(
188
+ f"React Anti-Patterns ({len(issues)} found)",
189
+ [("File", "red"), ("Line", "dim"), ("Pattern", "yellow"), ("Fix", "green")],
190
+ rows,
191
+ data_for_json=issues[:50],
192
+ )
193
+ if len(issues) > 50:
194
+ out.warn(f"Showing 50/{len(issues)} — use --json for full list")
195
+
196
+
197
+ @app.command()
198
+ def components(ctx: typer.Context, app_name: Annotated[str, typer.Argument(help="App name")]) -> None:
199
+ """List shadcn components used by the app."""
200
+ actx: AppContext = ctx.obj
201
+ out = actx.output
202
+ actx.validate_app(app_name)
203
+ src_dir = actx.get_app_dir(app_name) / "src"
204
+ imports: dict[str, int] = {}
205
+ for f in src_dir.rglob("*.tsx"):
206
+ for name in scan_imports(f, "@kodemeio/ui"):
207
+ imports[name] = imports.get(name, 0) + 1
208
+ if not imports:
209
+ out.warn("No @kodemeio/ui imports found")
210
+ return
211
+ rows = [[comp, str(count)] for comp, count in sorted(imports.items(), key=lambda x: -x[1])]
212
+ out.table(
213
+ f"shadcn Components — {app_name}",
214
+ [("Component", "cyan"), ("Imports", "")],
215
+ rows,
216
+ data_for_json=[{"component": c, "imports": n} for c, n in sorted(imports.items(), key=lambda x: -x[1])],
217
+ )
218
+
219
+
220
+ @app.command("theme-check")
221
+ def theme_check(ctx: typer.Context, app_name: Annotated[str, typer.Argument(help="App name")]) -> None:
222
+ """Validate theme CSS variable references and Tailwind v4 patterns."""
223
+ actx: AppContext = ctx.obj
224
+ out = actx.output
225
+ actx.validate_app(app_name)
226
+ theme_file = actx.project_root / "packages" / "tailwind-config" / "themes" / f"{app_name}.css"
227
+ if not theme_file.exists():
228
+ out.error(f"No theme file: {theme_file}")
229
+ raise typer.Exit(1)
230
+ theme_content = theme_file.read_text()
231
+ defined_vars = set(re.findall(r"--([a-z0-9-]+)\s*:", theme_content))
232
+ # Check source for var(--xxx) references
233
+ src_dir = actx.get_app_dir(app_name) / "src"
234
+ used_vars: set[str] = set()
235
+ for f in src_dir.rglob("*.tsx"):
236
+ content = f.read_text(errors="ignore")
237
+ used_vars.update(re.findall(r"var\(--([a-z0-9-]+)\)", content))
238
+ missing = used_vars - defined_vars
239
+ if missing:
240
+ out.warn(f"{len(missing)} CSS variable(s) used but not defined in theme:")
241
+ for v in sorted(missing):
242
+ out.text(f" --{v}")
243
+ else:
244
+ out.success("All CSS variables properly defined")
245
+
246
+ # Tailwind v4 pattern checks
247
+ issues: list[str] = []
248
+ index_css = src_dir / "index.css"
249
+ if index_css.exists():
250
+ css_content = index_css.read_text()
251
+ if '@import "tailwindcss"' not in css_content and "@import 'tailwindcss'" not in css_content:
252
+ issues.append("index.css missing '@import \"tailwindcss\"' (Tailwind v4 pattern)")
253
+ else:
254
+ issues.append("src/index.css not found")
255
+
256
+ vite_cfg = actx.get_app_dir(app_name) / "vite.config.ts"
257
+ if vite_cfg.exists():
258
+ vite_content = vite_cfg.read_text()
259
+ if "@tailwindcss/vite" not in vite_content:
260
+ issues.append("vite.config.ts missing '@tailwindcss/vite' plugin (Tailwind v4 pattern)")
261
+ else:
262
+ issues.append("vite.config.ts not found")
263
+
264
+ if issues:
265
+ out.warn(f"{len(issues)} Tailwind v4 issue(s):")
266
+ for issue in issues:
267
+ out.text(f" • {issue}")
268
+ else:
269
+ out.success("Tailwind v4 patterns verified")
270
+
271
+
272
+ # ── shadcn CLI wrapper commands ──────────────────────────────────────
273
+
274
+ # Known custom components (not from the shadcn registry)
275
+ _CUSTOM_COMPONENTS = frozenset(
276
+ {
277
+ "data-table",
278
+ "file-upload",
279
+ "image-gallery",
280
+ "signature-pad",
281
+ "timeline",
282
+ "tour-overlay",
283
+ }
284
+ )
285
+
286
+
287
+ @app.command()
288
+ def add(
289
+ ctx: typer.Context,
290
+ component: Annotated[str | None, typer.Argument(help="Component name (e.g. button, card)")] = None,
291
+ all_components: Annotated[bool, typer.Option("--all", "-a", help="Install all components")] = False,
292
+ overwrite: Annotated[bool, typer.Option("--overwrite", "-o", help="Overwrite existing")] = False,
293
+ dry_run: Annotated[bool, typer.Option("--dry-run", help="Preview changes")] = False,
294
+ ) -> None:
295
+ """Install a shadcn component into @kodemeio/ui."""
296
+ actx: AppContext = ctx.obj
297
+ out = actx.output
298
+ if not component and not all_components:
299
+ out.error("Specify a component name or use --all")
300
+ raise typer.Exit(1)
301
+ cmd = ["npx", "shadcn@latest", "add"]
302
+ if component:
303
+ cmd.append(component)
304
+ if all_components:
305
+ cmd.append("-a")
306
+ cmd.extend(["-c", "packages/ui", "-y"])
307
+ if overwrite:
308
+ cmd.append("-o")
309
+ if dry_run:
310
+ cmd.append("--dry-run")
311
+ try:
312
+ run(cmd, cwd=actx.project_root, capture=False)
313
+ except CommandError as exc:
314
+ out.error(str(exc))
315
+ raise typer.Exit(1) from exc
316
+
317
+
318
+ @app.command("diff")
319
+ def diff_cmd(
320
+ ctx: typer.Context,
321
+ component: Annotated[str, typer.Argument(help="Component name")],
322
+ ) -> None:
323
+ """Show diff for a shadcn component update."""
324
+ actx: AppContext = ctx.obj
325
+ out = actx.output
326
+ cmd = ["npx", "shadcn@latest", "add", component, "--diff", "-c", "packages/ui"]
327
+ try:
328
+ run(cmd, cwd=actx.project_root, capture=False)
329
+ except CommandError as exc:
330
+ out.error(str(exc))
331
+ raise typer.Exit(1) from exc
332
+
333
+
334
+ @app.command()
335
+ def search(
336
+ ctx: typer.Context,
337
+ query: Annotated[str, typer.Argument(help="Search query")],
338
+ limit: Annotated[int, typer.Option("--limit", "-n", help="Max results")] = 20,
339
+ ) -> None:
340
+ """Search the shadcn component registry."""
341
+ actx: AppContext = ctx.obj
342
+ out = actx.output
343
+ cmd = ["npx", "shadcn@latest", "search", "@shadcn", "-q", query, "-l", str(limit)]
344
+ try:
345
+ run(cmd, cwd=actx.project_root, capture=False)
346
+ except CommandError as exc:
347
+ out.error(str(exc))
348
+ raise typer.Exit(1) from exc
349
+
350
+
351
+ @app.command()
352
+ def docs(
353
+ ctx: typer.Context,
354
+ component: Annotated[str, typer.Argument(help="Component name")],
355
+ ) -> None:
356
+ """Get shadcn component documentation and API reference."""
357
+ actx: AppContext = ctx.obj
358
+ out = actx.output
359
+ cmd = ["npx", "shadcn@latest", "docs", component, "-c", "packages/ui"]
360
+ try:
361
+ run(cmd, cwd=actx.project_root, capture=False)
362
+ except CommandError as exc:
363
+ out.error(str(exc))
364
+ raise typer.Exit(1) from exc
365
+
366
+
367
+ _VALID_APPS = frozenset({"sfa", "lfa", "shop", "wms", "bia", "eam", "mrp", "hrm", "tpm", "dms", "saas"})
368
+
369
+
370
+ def _extract_css_blocks(css: str) -> dict[str, str]:
371
+ """Extract structured CSS blocks from shadcn-generated index.css.
372
+
373
+ Returns a dict with keys: ":root", ".dark", "@theme", "@layer".
374
+ Values are the inner content of each block (without the braces).
375
+ """
376
+ blocks: dict[str, str] = {}
377
+
378
+ # Extract @theme inline { ... }
379
+ m = re.search(r"@theme\s+inline\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}", css, re.DOTALL)
380
+ if m:
381
+ blocks["@theme"] = m.group(1).strip()
382
+
383
+ # Extract top-level :root { ... } (not inside @layer)
384
+ # Find :root blocks that are NOT preceded by @layer
385
+ for m in re.finditer(r"(?<!\w):root\s*\{([^}]*)\}", css, re.DOTALL):
386
+ # Check it's not inside @layer by looking at the preceding text
387
+ before = css[: m.start()]
388
+ # Count open/close braces to see if we're inside something
389
+ open_braces = before.count("{") - before.count("}")
390
+ if open_braces == 0:
391
+ blocks[":root"] = m.group(1).strip()
392
+ break
393
+
394
+ # Extract top-level .dark { ... }
395
+ for m in re.finditer(r"\.dark\s*\{([^}]*)\}", css, re.DOTALL):
396
+ before = css[: m.start()]
397
+ open_braces = before.count("{") - before.count("}")
398
+ if open_braces == 0:
399
+ blocks[".dark"] = m.group(1).strip()
400
+ break
401
+
402
+ # Extract @layer base { ... } — may contain nested braces
403
+ m = re.search(r"@layer\s+base\s*\{(.*)\}", css, re.DOTALL)
404
+ if m:
405
+ # Greedy match gets the outermost @layer base block; trim to balanced braces
406
+ content = m.group(1)
407
+ # Find the balanced closing brace
408
+ depth = 0
409
+ end = len(content)
410
+ for i, ch in enumerate(content):
411
+ if ch == "{":
412
+ depth += 1
413
+ elif ch == "}":
414
+ if depth == 0:
415
+ end = i
416
+ break
417
+ depth -= 1
418
+ blocks["@layer"] = content[:end].strip()
419
+
420
+ return blocks
421
+
422
+
423
+ def _indent_block(content: str) -> str:
424
+ """Ensure consistent 4-space indentation for all lines in a CSS block."""
425
+ lines = content.splitlines()
426
+ result: list[str] = []
427
+ for line in lines:
428
+ stripped = line.strip()
429
+ if stripped:
430
+ result.append(f" {stripped}")
431
+ else:
432
+ result.append("")
433
+ return "\n".join(result)
434
+
435
+
436
+ # CSS variables that shadcn presets don't include but our apps need.
437
+ _CUSTOM_THEME_INLINE_VARS = """ --color-destructive-foreground: var(--destructive-foreground);
438
+ --color-success: var(--success);
439
+ --color-success-foreground: var(--success-foreground);
440
+ --color-warning: var(--warning);
441
+ --color-warning-foreground: var(--warning-foreground);"""
442
+
443
+
444
+ def _ensure_custom_theme_vars(theme_block: str) -> str:
445
+ """Append our custom color mappings if the preset didn't include them."""
446
+ for var in ("--color-destructive-foreground", "--color-success", "--color-warning"):
447
+ if var not in theme_block:
448
+ theme_block = theme_block.rstrip() + "\n" + _CUSTOM_THEME_INLINE_VARS
449
+ break
450
+ return theme_block
451
+
452
+
453
+ def _build_updated_globals(
454
+ old_globals: str,
455
+ new_blocks: dict[str, str],
456
+ *,
457
+ colors_in_target: bool = False,
458
+ ) -> str:
459
+ """Rebuild globals.css replacing @theme inline and @layer base blocks.
460
+
461
+ When *colors_in_target* is True (--target APP), :root/.dark color tokens
462
+ are NOT written into globals.css — they go to the theme file instead.
463
+ globals.css stays structural-only.
464
+ """
465
+ lines = old_globals.splitlines(keepends=True)
466
+ result_parts: list[str] = []
467
+ i = 0
468
+
469
+ # Phase 1: Collect imports and pre-@theme content
470
+ while i < len(lines):
471
+ line = lines[i]
472
+ if re.match(r"\s*@theme\s+inline\s*\{", line):
473
+ break
474
+ if re.match(r"(?<!\w):root\s*\{", line.strip()):
475
+ break
476
+ result_parts.append(line)
477
+ i += 1
478
+
479
+ # Phase 2: Write new structural blocks
480
+ if "@theme" in new_blocks:
481
+ themed = _indent_block(new_blocks["@theme"])
482
+ themed = _ensure_custom_theme_vars(themed)
483
+ result_parts.append("@theme inline {\n")
484
+ result_parts.append(themed + "\n")
485
+ result_parts.append("}\n")
486
+ result_parts.append("\n")
487
+
488
+ # Only write :root/.dark into globals when colors are NOT going to a target theme file
489
+ if not colors_in_target:
490
+ if ":root" in new_blocks:
491
+ result_parts.append(":root {\n")
492
+ result_parts.append(_indent_block(new_blocks[":root"]) + "\n")
493
+ result_parts.append("}\n")
494
+ result_parts.append("\n")
495
+
496
+ if ".dark" in new_blocks:
497
+ result_parts.append(".dark {\n")
498
+ result_parts.append(_indent_block(new_blocks[".dark"]) + "\n")
499
+ result_parts.append("}\n")
500
+ result_parts.append("\n")
501
+
502
+ if "@layer" in new_blocks:
503
+ result_parts.append("@layer base {\n")
504
+ result_parts.append(_indent_block(new_blocks["@layer"]) + "\n")
505
+ result_parts.append("}\n")
506
+
507
+ # Phase 3: Skip over old managed blocks in original, collect custom content
508
+ custom_lines: list[str] = []
509
+ while i < len(lines):
510
+ line = lines[i]
511
+ stripped = line.strip()
512
+
513
+ # Always skip these managed block types
514
+ skip_patterns = [
515
+ r"\s*@theme\s+inline\s*\{",
516
+ r":root\s*\{",
517
+ r"\.dark\s*\{",
518
+ r"@layer\s+base\s*\{",
519
+ ]
520
+ skipped = False
521
+ for pattern in skip_patterns:
522
+ if re.match(pattern, stripped):
523
+ depth = 1
524
+ i += 1
525
+ while i < len(lines) and depth > 0:
526
+ for ch in lines[i]:
527
+ if ch == "{":
528
+ depth += 1
529
+ elif ch == "}":
530
+ depth -= 1
531
+ i += 1
532
+ skipped = True
533
+ break
534
+ if skipped:
535
+ continue
536
+
537
+ # Skip @custom-variant (managed by preset)
538
+ if stripped.startswith("@custom-variant"):
539
+ i += 1
540
+ continue
541
+
542
+ # Everything else is custom content — keep it
543
+ custom_lines.append(lines[i])
544
+ i += 1
545
+
546
+ # Phase 4: Append custom content (strip leading blank lines)
547
+ while custom_lines and custom_lines[0].strip() == "":
548
+ custom_lines.pop(0)
549
+ if custom_lines:
550
+ result_parts.append("\n")
551
+ result_parts.extend(custom_lines)
552
+
553
+ return "".join(result_parts)
554
+
555
+
556
+ def _build_theme_css(root_content: str, dark_content: str, *, with_globals_import: bool = False) -> str:
557
+ """Build a theme CSS file with :root and .dark blocks.
558
+
559
+ When *with_globals_import* is True, adds ``@import "../globals.css"`` at top
560
+ so the theme file can be the single import in an app's index.css.
561
+ """
562
+ parts: list[str] = []
563
+ if with_globals_import:
564
+ parts.append('@import "../globals.css";\n\n')
565
+ parts.extend(
566
+ [
567
+ ":root {\n",
568
+ _indent_block(root_content) + "\n",
569
+ "}\n",
570
+ "\n",
571
+ ".dark {\n",
572
+ _indent_block(dark_content) + "\n",
573
+ "}\n",
574
+ ]
575
+ )
576
+ return "".join(parts)
577
+
578
+
579
+ @app.command()
580
+ def preset(
581
+ ctx: typer.Context,
582
+ name: Annotated[str, typer.Argument(help="Preset ID from ui.shadcn.com/themes (e.g. bIkeymG)")],
583
+ reinstall: Annotated[
584
+ bool, typer.Option("--reinstall", help="Re-install existing components with new style")
585
+ ] = False,
586
+ dry_run: Annotated[bool, typer.Option("--dry-run", help="Show what would change without writing")] = False,
587
+ target: Annotated[
588
+ str,
589
+ typer.Option("--target", "-t", help="Target: 'base' for base-neutral.css or APP_NAME for theme file"),
590
+ ] = "base",
591
+ ) -> None:
592
+ """Apply a shadcn theme preset — extract CSS vars into the monorepo theme system.
593
+
594
+ Creates a temp Vite project, runs shadcn init --preset, extracts the
595
+ generated CSS variables and @theme inline block, then updates:
596
+
597
+ \b
598
+ - globals.css — @theme inline, :root, .dark, @layer base
599
+ - base-neutral.css (or themes/{app}.css) — :root and .dark color vars
600
+
601
+ Get preset IDs from https://ui.shadcn.com/themes — click a theme,
602
+ copy the preset code from the URL or init command.
603
+
604
+ Examples:
605
+ kctl-react ui preset b1D0dv72 # Apply to base-neutral.css
606
+ kctl-react ui preset b1D0dv72 --target sfa # Apply to themes/sfa.css
607
+ kctl-react ui preset b1D0dv72 --dry-run # Preview changes
608
+ kctl-react ui preset b1D0dv72 --reinstall # Apply + reinstall components
609
+ """
610
+ actx: AppContext = ctx.obj
611
+ out = actx.output
612
+
613
+ # Validate target
614
+ if target != "base" and target not in _VALID_APPS:
615
+ out.error(f"Invalid target '{target}'. Use 'base' or an app name: {', '.join(sorted(_VALID_APPS))}")
616
+ raise typer.Exit(1)
617
+
618
+ tc_src = actx.project_root / "packages" / "tailwind-config" / "src"
619
+ globals_path = tc_src / "globals.css"
620
+ target_path = tc_src / "base-neutral.css" if target == "base" else tc_src / "themes" / f"{target}.css"
621
+
622
+ ui_components_json = actx.project_root / "packages" / "ui" / "components.json"
623
+
624
+ # Step 1: Create temp directory with minimal Vite scaffolding
625
+ tmp_dir = tempfile.mkdtemp(prefix="kctl-preset-")
626
+ try:
627
+ out.info(f"Creating temp Vite project in {tmp_dir}...")
628
+
629
+ # package.json
630
+ pkg = {
631
+ "name": "preset-tmp",
632
+ "private": True,
633
+ "dependencies": {
634
+ "vite": "*",
635
+ "react": "*",
636
+ "react-dom": "*",
637
+ "tailwindcss": "*",
638
+ "@tailwindcss/vite": "*",
639
+ },
640
+ }
641
+ Path(tmp_dir, "package.json").write_text(json.dumps(pkg, indent=2))
642
+
643
+ # vite.config.js
644
+ Path(tmp_dir, "vite.config.js").write_text(
645
+ 'import { defineConfig } from "vite"\n'
646
+ 'import tailwindcss from "@tailwindcss/vite"\n'
647
+ "export default defineConfig({ plugins: [tailwindcss()] })\n"
648
+ )
649
+
650
+ # tsconfig.json
651
+ Path(tmp_dir, "tsconfig.json").write_text(
652
+ json.dumps(
653
+ {
654
+ "compilerOptions": {
655
+ "baseUrl": ".",
656
+ "paths": {"@/*": ["./src/*"]},
657
+ }
658
+ },
659
+ indent=2,
660
+ )
661
+ )
662
+
663
+ # components.json — copy from packages/ui
664
+ if ui_components_json.exists():
665
+ shutil.copy2(ui_components_json, Path(tmp_dir, "components.json"))
666
+ else:
667
+ Path(tmp_dir, "components.json").write_text(
668
+ json.dumps(
669
+ {
670
+ "$schema": "https://ui.shadcn.com/schema.json",
671
+ "style": "new-york",
672
+ "rsc": False,
673
+ "tsx": True,
674
+ "aliases": {"components": "src/components", "utils": "src/lib/utils"},
675
+ },
676
+ indent=2,
677
+ )
678
+ )
679
+
680
+ # src/index.css
681
+ src_dir = Path(tmp_dir, "src")
682
+ src_dir.mkdir()
683
+ (src_dir / "index.css").write_text('@import "tailwindcss";\n')
684
+
685
+ # Step 2: Install deps and run preset
686
+ out.info("Installing temp dependencies (npm)...")
687
+ try:
688
+ run(["npm", "install", "--silent"], cwd=Path(tmp_dir), capture=True, timeout=120)
689
+ except CommandError as exc:
690
+ out.error(f"npm install failed: {exc}")
691
+ raise typer.Exit(1) from exc
692
+
693
+ out.info(f"Running shadcn init --preset {name}...")
694
+ try:
695
+ run(
696
+ ["npx", "shadcn@latest", "init", "--preset", name, "--force", "--no-reinstall", "-y"],
697
+ cwd=Path(tmp_dir),
698
+ capture=True,
699
+ timeout=120,
700
+ )
701
+ except CommandError as exc:
702
+ out.error(f"shadcn init --preset failed: {exc}")
703
+ raise typer.Exit(1) from exc
704
+
705
+ # Step 3: Extract CSS from generated index.css
706
+ generated_css_path = src_dir / "index.css"
707
+ if not generated_css_path.exists():
708
+ out.error("shadcn did not generate src/index.css")
709
+ raise typer.Exit(1)
710
+
711
+ generated_css = generated_css_path.read_text()
712
+ blocks = _extract_css_blocks(generated_css)
713
+
714
+ if ":root" not in blocks:
715
+ out.error("Could not extract :root block from generated CSS")
716
+ raise typer.Exit(1)
717
+
718
+ # Step 4: Extract style from generated components.json
719
+ new_style: str | None = None
720
+ generated_cj = Path(tmp_dir, "components.json")
721
+ if generated_cj.exists():
722
+ try:
723
+ cj_data = json.loads(generated_cj.read_text())
724
+ new_style = cj_data.get("style")
725
+ except (json.JSONDecodeError, OSError):
726
+ pass
727
+
728
+ # ── Summary / Dry-run ───────────────────────────────────────
729
+ out.info("Extracted CSS blocks:")
730
+ for key in ("@theme", ":root", ".dark", "@layer"):
731
+ if key in blocks:
732
+ line_count = blocks[key].count("\n") + 1
733
+ out.text(f" {key}: {line_count} lines")
734
+
735
+ if new_style:
736
+ out.text(f" style: {new_style}")
737
+
738
+ if dry_run:
739
+ out.info("[dry-run] Would update:")
740
+ if globals_path.exists():
741
+ out.text(f" {globals_path.relative_to(actx.project_root)}")
742
+ out.text(f" {target_path.relative_to(actx.project_root) if target_path.exists() else target_path.name}")
743
+ if new_style and ui_components_json.exists():
744
+ out.text(f" {ui_components_json.relative_to(actx.project_root)} (style -> {new_style})")
745
+ out.success("[dry-run] No files modified")
746
+ return
747
+
748
+ # ── Step 5: Update globals.css ──────────────────────────────
749
+ changes: list[str] = []
750
+ colors_in_target = target != "base"
751
+
752
+ if globals_path.exists() and blocks:
753
+ old_globals = globals_path.read_text()
754
+ new_globals = _build_updated_globals(old_globals, blocks, colors_in_target=colors_in_target)
755
+ globals_path.write_text(new_globals)
756
+ changes.append(str(globals_path.relative_to(actx.project_root)))
757
+
758
+ # ── Step 6: Update target theme file ────────────────────────
759
+ root_content = blocks.get(":root", "")
760
+ dark_content = blocks.get(".dark", "")
761
+ if root_content:
762
+ target_path.parent.mkdir(parents=True, exist_ok=True)
763
+ # App themes get @import "../globals.css"; base-neutral does not
764
+ target_path.write_text(_build_theme_css(root_content, dark_content, with_globals_import=colors_in_target))
765
+ changes.append(str(target_path.relative_to(actx.project_root)))
766
+
767
+ # ── Step 7: Update components.json style ────────────────────
768
+ if new_style and ui_components_json.exists():
769
+ try:
770
+ cj = json.loads(ui_components_json.read_text())
771
+ old_style = cj.get("style", "")
772
+ if old_style != new_style:
773
+ cj["style"] = new_style
774
+ ui_components_json.write_text(json.dumps(cj, indent=2) + "\n")
775
+ changes.append(
776
+ f"{ui_components_json.relative_to(actx.project_root)} (style: {old_style} -> {new_style})"
777
+ )
778
+ except (json.JSONDecodeError, OSError):
779
+ out.warn("Could not update components.json")
780
+
781
+ for c in changes:
782
+ out.text(f" Updated: {c}")
783
+ out.success(f"Preset '{name}' applied successfully")
784
+
785
+ finally:
786
+ shutil.rmtree(tmp_dir, ignore_errors=True)
787
+
788
+ # ── Step 8: Optional reinstall ──────────────────────────────────
789
+ if reinstall:
790
+ out.info("Re-installing components with new style...")
791
+ try:
792
+ run(
793
+ ["npx", "shadcn@latest", "init", "--force", "--reinstall", "-c", "packages/ui"],
794
+ cwd=actx.project_root,
795
+ capture=False,
796
+ )
797
+ out.success("Components reinstalled with new style")
798
+ except CommandError as exc:
799
+ out.warn(f"Reinstall failed (components may need manual update): {exc}")
800
+
801
+
802
+ @app.command()
803
+ def info(ctx: typer.Context) -> None:
804
+ """Show shadcn project configuration from packages/ui."""
805
+ actx: AppContext = ctx.obj
806
+ out = actx.output
807
+ cmd = ["npx", "shadcn@latest", "info", "-c", "packages/ui"]
808
+ try:
809
+ run(cmd, cwd=actx.project_root, capture=False)
810
+ except CommandError as exc:
811
+ out.error(str(exc))
812
+ raise typer.Exit(1) from exc
813
+
814
+
815
+ @app.command()
816
+ def installed(ctx: typer.Context) -> None:
817
+ """List all installed shadcn components in @kodemeio/ui."""
818
+ actx: AppContext = ctx.obj
819
+ out = actx.output
820
+ components_dir = actx.project_root / "packages" / "ui" / "src" / "components"
821
+ if not components_dir.exists():
822
+ out.warn("No components directory found at packages/ui/src/components")
823
+ return
824
+ files = sorted(components_dir.glob("*.tsx"))
825
+ if not files:
826
+ out.warn("No .tsx component files found")
827
+ return
828
+ rows: list[list[str]] = []
829
+ json_data: list[dict[str, str]] = []
830
+ for f in files:
831
+ name = f.stem
832
+ comp_type = "custom" if name in _CUSTOM_COMPONENTS else "shadcn"
833
+ rel_path = str(f.relative_to(actx.project_root))
834
+ rows.append([name, comp_type, rel_path])
835
+ json_data.append({"component": name, "type": comp_type, "path": rel_path})
836
+ out.table(
837
+ f"Installed Components ({len(files)})",
838
+ [("Component", "cyan"), ("Type", "yellow"), ("Path", "dim")],
839
+ rows,
840
+ data_for_json=json_data,
841
+ )
842
+
843
+
844
+ @app.command()
845
+ def unused(ctx: typer.Context) -> None:
846
+ """Find installed components not used by any app."""
847
+ actx: AppContext = ctx.obj
848
+ out = actx.output
849
+ components_dir = actx.project_root / "packages" / "ui" / "src" / "components"
850
+ if not components_dir.exists():
851
+ out.warn("No components directory found at packages/ui/src/components")
852
+ return
853
+ comp_names = {f.stem for f in components_dir.glob("*.tsx")}
854
+ if not comp_names:
855
+ out.warn("No .tsx component files found")
856
+ return
857
+ # Scan all apps for imports from @kodemeio/ui
858
+ usage: dict[str, set[str]] = {name: set() for name in comp_names}
859
+ for app_name in actx.app_names:
860
+ app_dir = actx.get_app_dir(app_name)
861
+ src_dir = app_dir / "src"
862
+ if not src_dir.is_dir():
863
+ continue
864
+ app_name = app_dir.name
865
+ for tsx_file in src_dir.rglob("*.tsx"):
866
+ try:
867
+ content = tsx_file.read_text(errors="ignore")
868
+ except OSError:
869
+ continue
870
+ for comp in comp_names:
871
+ # Check if the component name (PascalCase or kebab-case) appears
872
+ # in an import from @kodemeio/ui
873
+ pascal = comp.replace("-", " ").title().replace(" ", "")
874
+ if pascal in content or f"/{comp}" in content:
875
+ usage[comp].add(app_name)
876
+ rows: list[list[str]] = []
877
+ json_data: list[dict] = []
878
+ for comp in sorted(comp_names):
879
+ apps_using = usage[comp]
880
+ status = "used" if apps_using else "unused"
881
+ count = len(apps_using)
882
+ rows.append([comp, status, str(count)])
883
+ json_data.append({"component": comp, "status": status, "used_by": count})
884
+ out.table(
885
+ f"Component Usage ({len(comp_names)} total)",
886
+ [("Component", "cyan"), ("Status", "green"), ("Used By", "")],
887
+ rows,
888
+ data_for_json=json_data,
889
+ )