steppy-dev 0.3.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.
Files changed (177) hide show
  1. steppy/CHANGELOG.md +862 -0
  2. steppy/__init__.py +9 -0
  3. steppy/__main__.py +13 -0
  4. steppy/_cli_common.py +183 -0
  5. steppy/_docker/Dockerfile.installed +53 -0
  6. steppy/_parallel/__init__.py +7 -0
  7. steppy/_parallel/compose.py +349 -0
  8. steppy/_parallel/event_bus.py +418 -0
  9. steppy/_parallel/failures.py +267 -0
  10. steppy/_parallel/fingerprint.py +141 -0
  11. steppy/_parallel/image.py +380 -0
  12. steppy/_parallel/lastfailed.py +125 -0
  13. steppy/_parallel/lifecycle.py +90 -0
  14. steppy/_parallel/preauth.py +109 -0
  15. steppy/_parallel/progress.py +101 -0
  16. steppy/_parallel/queue.py +385 -0
  17. steppy/_parallel/renderer.py +489 -0
  18. steppy/_parallel/retry.py +316 -0
  19. steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
  20. steppy/_player_bundle/__init__.py +100 -0
  21. steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
  22. steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
  23. steppy/_player_bundle/steppy-player.js +5419 -0
  24. steppy/_player_bundle/steppy-player.js.map +1 -0
  25. steppy/_player_bundle/steppy-video.js +472 -0
  26. steppy/_player_bundle/steppy-video.js.map +1 -0
  27. steppy/_templating.py +117 -0
  28. steppy/assertions.py +251 -0
  29. steppy/auth.py +493 -0
  30. steppy/build.py +2798 -0
  31. steppy/build_models.py +669 -0
  32. steppy/buildid.py +134 -0
  33. steppy/bundle/__init__.py +47 -0
  34. steppy/bundle/archive.py +239 -0
  35. steppy/bundle/classify.py +329 -0
  36. steppy/bundle/create.py +490 -0
  37. steppy/bundle/hashing.py +28 -0
  38. steppy/bundle/manifest.py +334 -0
  39. steppy/bundle/results.py +546 -0
  40. steppy/bundle_cli.py +455 -0
  41. steppy/captures.py +207 -0
  42. steppy/cli.py +2489 -0
  43. steppy/cloud/__init__.py +58 -0
  44. steppy/cloud/client.py +267 -0
  45. steppy/cloud/upload.py +497 -0
  46. steppy/compat.py +109 -0
  47. steppy/config.py +231 -0
  48. steppy/config_schema.py +87 -0
  49. steppy/contracts/__init__.py +54 -0
  50. steppy/contracts/bundle.py +215 -0
  51. steppy/contracts/report.py +346 -0
  52. steppy/contracts/tours.py +505 -0
  53. steppy/coordinator.py +1352 -0
  54. steppy/coverage/__init__.py +9 -0
  55. steppy/coverage/gaps.py +190 -0
  56. steppy/coverage/manifest.py +193 -0
  57. steppy/coverage/merge.py +363 -0
  58. steppy/coverage/normalize.py +174 -0
  59. steppy/coverage/report.py +178 -0
  60. steppy/coverage/sourcemap.py +219 -0
  61. steppy/coverage/store.py +473 -0
  62. steppy/coverage/triage.py +151 -0
  63. steppy/coverage_cli.py +265 -0
  64. steppy/durations.py +48 -0
  65. steppy/exclusive.py +178 -0
  66. steppy/flaky_cli.py +169 -0
  67. steppy/generator.py +633 -0
  68. steppy/guide.py +1467 -0
  69. steppy/history.py +352 -0
  70. steppy/init_cli.py +330 -0
  71. steppy/locales.py +282 -0
  72. steppy/migrate/__init__.py +35 -0
  73. steppy/migrate/apply.py +194 -0
  74. steppy/migrate/plan.py +199 -0
  75. steppy/migrate/rules.py +465 -0
  76. steppy/migrate/verify.py +163 -0
  77. steppy/migrate_cli.py +152 -0
  78. steppy/models.py +2539 -0
  79. steppy/naming.py +37 -0
  80. steppy/parser.py +475 -0
  81. steppy/pdf.py +193 -0
  82. steppy/presets.py +284 -0
  83. steppy/record_cli.py +796 -0
  84. steppy/report/__init__.py +37 -0
  85. steppy/report/assets.py +197 -0
  86. steppy/report/build.py +261 -0
  87. steppy/report/coverage_source.py +277 -0
  88. steppy/report/model.py +423 -0
  89. steppy/report/render.py +303 -0
  90. steppy/report/source.py +238 -0
  91. steppy/report_cli.py +173 -0
  92. steppy/runmeta.py +114 -0
  93. steppy/selection.py +183 -0
  94. steppy/sitemap.py +205 -0
  95. steppy/skills/steppy-app-tours/SKILL.md +448 -0
  96. steppy/skills/steppy-ci-report/SKILL.md +567 -0
  97. steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
  98. steppy/skills/steppy-template/SKILL.md +676 -0
  99. steppy/templates/_steppy_events.py +291 -0
  100. steppy/templates/_steppy_tours.py +404 -0
  101. steppy/templates/angular/component-interactive.ts.j2 +109 -0
  102. steppy/templates/angular/component-video.ts.j2 +112 -0
  103. steppy/templates/angular/component.ts.j2 +229 -0
  104. steppy/templates/angular/content.ts.j2 +31 -0
  105. steppy/templates/angular/gitignore.j2 +3 -0
  106. steppy/templates/angular/group.component.ts.j2 +39 -0
  107. steppy/templates/angular/onboarding.component.ts.j2 +43 -0
  108. steppy/templates/angular/routes.ts.j2 +34 -0
  109. steppy/templates/ci/github-actions.yml +284 -0
  110. steppy/templates/ci/gitlab-ci.yml +178 -0
  111. steppy/templates/conftest.py.j2 +2405 -0
  112. steppy/templates/guide.book.html.j2 +145 -0
  113. steppy/templates/guide.fragment.html.j2 +257 -0
  114. steppy/templates/guide.html.j2 +301 -0
  115. steppy/templates/guide.md.j2 +18 -0
  116. steppy/templates/init/config.yaml +124 -0
  117. steppy/templates/init/credentials.yaml.example +30 -0
  118. steppy/templates/init/scenarios/example--first-flow.md +53 -0
  119. steppy/templates/report/_head.html.j2 +14 -0
  120. steppy/templates/report/_mark.html.j2 +3 -0
  121. steppy/templates/report/coverage.html.j2 +124 -0
  122. steppy/templates/report/index.html.j2 +203 -0
  123. steppy/templates/report/report.css +401 -0
  124. steppy/templates/report/scenario.html.j2 +137 -0
  125. steppy/templates/static/_headers.j2 +30 -0
  126. steppy/templates/static/_mode_links.html.j2 +22 -0
  127. steppy/templates/static/_seo_head.html.j2 +37 -0
  128. steppy/templates/static/cursor.js.j2 +10 -0
  129. steppy/templates/static/group.html.j2 +78 -0
  130. steppy/templates/static/guides.css +678 -0
  131. steppy/templates/static/index.html.j2 +79 -0
  132. steppy/templates/static/interactive.html.j2 +100 -0
  133. steppy/templates/static/onboarding.html.j2 +64 -0
  134. steppy/templates/static/page.html.j2 +145 -0
  135. steppy/templates/static/robots.txt.j2 +7 -0
  136. steppy/templates/static/video.html.j2 +135 -0
  137. steppy/templates/test_scenario.py.j2 +767 -0
  138. steppy/testids.py +166 -0
  139. steppy/totp.py +26 -0
  140. steppy/tour_spec.py +163 -0
  141. steppy/tours/__init__.py +22 -0
  142. steppy/tours/actions.py +402 -0
  143. steppy/tours/compiler.py +144 -0
  144. steppy/tours/diagnostics.py +137 -0
  145. steppy/tours/emit.py +239 -0
  146. steppy/tours/ids.py +40 -0
  147. steppy/tours/inference.py +399 -0
  148. steppy/tours/model.py +120 -0
  149. steppy/tours/paths.py +126 -0
  150. steppy/tours/pipeline.py +277 -0
  151. steppy/tours/publish.py +124 -0
  152. steppy/tours/replay.py +234 -0
  153. steppy/tours/routes.py +175 -0
  154. steppy/tours/settings.py +164 -0
  155. steppy/tours/text.py +237 -0
  156. steppy/tours_cli.py +219 -0
  157. steppy/tts/__init__.py +280 -0
  158. steppy/tts/cache.py +185 -0
  159. steppy/tts/presets.py +125 -0
  160. steppy/tts/providers/__init__.py +6 -0
  161. steppy/tts/providers/http.py +184 -0
  162. steppy/tts/providers/local.py +207 -0
  163. steppy/tts/providers/null.py +28 -0
  164. steppy/tts/registry.py +201 -0
  165. steppy/tts/track.py +158 -0
  166. steppy/tts/types.py +94 -0
  167. steppy/update_cli.py +460 -0
  168. steppy/video.py +730 -0
  169. steppy/video_cache.py +239 -0
  170. steppy/video_cli.py +271 -0
  171. steppy/worker.py +301 -0
  172. steppy_dev-0.3.0.dist-info/METADATA +1335 -0
  173. steppy_dev-0.3.0.dist-info/RECORD +177 -0
  174. steppy_dev-0.3.0.dist-info/WHEEL +4 -0
  175. steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
  176. steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
  177. steppy_dev-0.3.0.dist-info/licenses/NOTICE +13 -0
steppy/history.py ADDED
@@ -0,0 +1,352 @@
1
+ """Append-only run history — the substrate for ``steppy flaky``.
2
+
3
+ A single run's summary tells you what broke today. It cannot tell you that
4
+ ``checkout-with-coupon`` has failed in seven of the last twenty runs while
5
+ everything around it is stable. That ranking is what actually directs a fix,
6
+ and it needs one thing the codebase did not have: memory.
7
+
8
+ Storage is ``.steppy/history.jsonl`` — one JSON object per (run, scenario),
9
+ appended, never rewritten. Deliberately **not** under ``output/``: that tree is
10
+ wiped by cleanup, and history that evaporates on a clean build is worse than no
11
+ history because it looks authoritative. This mirrors the coverage store's
12
+ reasoning for living at ``.steppy/coverage.db``.
13
+
14
+ JSONL rather than sqlite because the access pattern is "read the tail, group in
15
+ memory" — a few thousand lines is nothing, and a text file survives partial
16
+ writes, concurrent appends and manual inspection in a way a binary store does
17
+ not.
18
+
19
+ This is a leaf module: stdlib only, no imports from ``cli``, ``coordinator`` or
20
+ ``_parallel``. ``steppy.flaky_cli`` and the coordinator both use it, and the
21
+ no-parallel-import guard depends on it staying that way.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import os
28
+ import statistics
29
+ import time
30
+ from dataclasses import dataclass
31
+ from pathlib import Path
32
+ from typing import Any, Iterable, Optional
33
+
34
+
35
+ HISTORY_DIRNAME = ".steppy"
36
+ HISTORY_FILENAME = "history.jsonl"
37
+
38
+ # Bumped if the record shape changes incompatibly. Readers skip records whose
39
+ # version they don't understand rather than crashing on an old file.
40
+ RECORD_SCHEMA_VERSION = 1
41
+
42
+ # Outcomes a record can carry.
43
+ OUTCOME_PASSED = "passed"
44
+ OUTCOME_FLAKY = "flaky"
45
+ OUTCOME_FAILED = "failed"
46
+
47
+
48
+ def history_path(project_root: Path) -> Path:
49
+ return Path(project_root) / HISTORY_DIRNAME / HISTORY_FILENAME
50
+
51
+
52
+ # ── Writing ─────────────────────────────────────────────────────────────────
53
+
54
+
55
+ def append_run(
56
+ *,
57
+ project_root: Path,
58
+ run_id: str,
59
+ workers: int,
60
+ passes: Iterable[Any],
61
+ verdict: Optional[Any] = None,
62
+ bus: Optional[Any] = None,
63
+ started_at: Optional[float] = None,
64
+ ) -> Optional[Path]:
65
+ """Append one record per scenario for a just-finished parallel run.
66
+
67
+ ``passes`` are the :class:`~steppy._parallel.retry.PassResult` objects
68
+ (attempt 1 first). ``verdict`` is the classification when a retry pass ran;
69
+ without one every scenario is simply passed/failed.
70
+
71
+ Returns the path written, or ``None`` on any failure — history is
72
+ telemetry, and the caller's run has already produced its real outcome. This
73
+ function never raises.
74
+ """
75
+ try:
76
+ passes = list(passes)
77
+ records = _build_records(
78
+ run_id=run_id,
79
+ workers=workers,
80
+ passes=passes,
81
+ verdict=verdict,
82
+ bus=bus,
83
+ started_at=started_at,
84
+ )
85
+ if not records:
86
+ return None
87
+ path = history_path(project_root)
88
+ path.parent.mkdir(parents=True, exist_ok=True)
89
+ # One open/append per run keeps the write atomic enough for our
90
+ # purposes: concurrent runs interleave whole lines, never partial ones.
91
+ with path.open("a", encoding="utf-8") as f:
92
+ for record in records:
93
+ f.write(json.dumps(record, sort_keys=True) + "\n")
94
+ _ensure_gitignored(path.parent)
95
+ return path
96
+ except (OSError, TypeError, ValueError, AttributeError):
97
+ return None
98
+
99
+
100
+ def _build_records(*, run_id, workers, passes, verdict, bus, started_at) -> list[dict]:
101
+ """One record per scenario, reflecting its FINAL outcome across attempts."""
102
+ if not passes:
103
+ return []
104
+ now = time.time()
105
+ started = started_at
106
+ if started is None and bus is not None:
107
+ started = getattr(bus.snapshot(), "wall_clock_started", None)
108
+ started = started if started is not None else now
109
+
110
+ flaky = set(getattr(verdict, "flaky", ()) or ())
111
+ failed = set(getattr(verdict, "failed", ()) or ())
112
+
113
+ # Last attempt wins for the per-scenario metadata (worker, duration).
114
+ last_result: dict[str, Any] = {}
115
+ attempts_used: dict[str, int] = {}
116
+ for p in passes:
117
+ for r in p.results:
118
+ last_result[r.slug] = r
119
+ attempts_used[r.slug] = p.attempt
120
+ for slugs in p.orphans.values():
121
+ for slug in slugs:
122
+ attempts_used.setdefault(slug, p.attempt)
123
+ last_result.setdefault(slug, None)
124
+
125
+ fingerprints = _fingerprints_by_slug(passes)
126
+
127
+ records: list[dict] = []
128
+ for slug in sorted(last_result):
129
+ result = last_result[slug]
130
+ if verdict is not None:
131
+ if slug in failed:
132
+ outcome = OUTCOME_FAILED
133
+ elif slug in flaky:
134
+ outcome = OUTCOME_FLAKY
135
+ else:
136
+ outcome = OUTCOME_PASSED
137
+ else:
138
+ outcome = OUTCOME_PASSED if (result is not None and result.exit_code == 0) else OUTCOME_FAILED
139
+ records.append(
140
+ {
141
+ "v": RECORD_SCHEMA_VERSION,
142
+ "run_id": run_id,
143
+ "started_at": round(started, 3),
144
+ "mode": "parallel",
145
+ "workers": workers,
146
+ "slug": slug,
147
+ "outcome": outcome,
148
+ "attempts": attempts_used.get(slug, 1),
149
+ "duration_s": round(result.duration_s, 3) if result is not None else None,
150
+ "worker_id": result.worker_id if result is not None else None,
151
+ "fingerprint": fingerprints.get(slug),
152
+ }
153
+ )
154
+ return records
155
+
156
+
157
+ def _fingerprints_by_slug(passes) -> dict[str, str]:
158
+ """Failure fingerprint per scenario, from the last attempt that produced a
159
+ tail. Lets ``steppy flaky`` say "these five always fail the same way"."""
160
+ try:
161
+ from ._parallel.fingerprint import fingerprint
162
+ except ImportError:
163
+ return {}
164
+ out: dict[str, str] = {}
165
+ for p in passes:
166
+ bus = getattr(p, "bus", None)
167
+ if bus is None:
168
+ continue
169
+ try:
170
+ tests = bus.snapshot().tests
171
+ except AttributeError:
172
+ continue
173
+ for t in tests:
174
+ if t.outcome in ("failed", "error", "worker_died") and t.fail_tail and t.scenario_slug:
175
+ out[t.scenario_slug] = fingerprint(t.fail_tail)
176
+ return out
177
+
178
+
179
+ def _ensure_gitignored(steppy_dir: Path) -> None:
180
+ """``.steppy/`` is already gitignored in scaffolded projects; add a local
181
+ guard for projects that predate it so history never lands in a commit."""
182
+ marker = steppy_dir / ".gitignore"
183
+ if marker.exists():
184
+ return
185
+ try:
186
+ marker.write_text("# Created by steppy automatically.\n*\n", encoding="utf-8")
187
+ except OSError:
188
+ pass
189
+
190
+
191
+ # ── Reading ─────────────────────────────────────────────────────────────────
192
+
193
+
194
+ def load(project_root: Path, *, last_runs: Optional[int] = None) -> list[dict]:
195
+ """Read history records, oldest first.
196
+
197
+ ``last_runs`` keeps only the N most recent runs (by first appearance), which
198
+ is what makes "flaky over the last 20 runs" cheap. Malformed or
199
+ future-versioned lines are skipped, never fatal — a corrupt tail from a
200
+ killed process must not make the command unusable.
201
+ """
202
+ path = history_path(project_root)
203
+ if not path.is_file():
204
+ return []
205
+ records: list[dict] = []
206
+ try:
207
+ with path.open("r", encoding="utf-8", errors="replace") as f:
208
+ for line in f:
209
+ line = line.strip()
210
+ if not line:
211
+ continue
212
+ try:
213
+ record = json.loads(line)
214
+ except json.JSONDecodeError:
215
+ continue
216
+ if not isinstance(record, dict) or record.get("v") != RECORD_SCHEMA_VERSION:
217
+ continue
218
+ if record.get("slug"):
219
+ records.append(record)
220
+ except OSError:
221
+ return []
222
+
223
+ if last_runs is not None and last_runs > 0:
224
+ seen: list[str] = []
225
+ for record in records:
226
+ rid = record.get("run_id")
227
+ if rid not in seen:
228
+ seen.append(rid)
229
+ keep = set(seen[-last_runs:])
230
+ records = [r for r in records if r.get("run_id") in keep]
231
+ return records
232
+
233
+
234
+ @dataclass(frozen=True)
235
+ class FlakeStat:
236
+ """Aggregated behaviour of one scenario over a window of runs."""
237
+
238
+ slug: str
239
+ runs: int
240
+ failures: int # runs that ended failed
241
+ flakes: int # runs that needed a retry to pass
242
+ median_s: Optional[float]
243
+ p95_s: Optional[float]
244
+ last_outcome: str
245
+ fingerprints: list[str]
246
+
247
+ @property
248
+ def flake_rate(self) -> float:
249
+ """Fraction of runs where this scenario did not pass first try.
250
+
251
+ Flakes and outright failures both count: from the reader's point of
252
+ view "this scenario cost me attention" is the signal, and a scenario
253
+ that fails outright half the time is not healthier than one that
254
+ flakes half the time."""
255
+ return (self.failures + self.flakes) / self.runs if self.runs else 0.0
256
+
257
+
258
+ def flake_stats(records: Iterable[dict]) -> list[FlakeStat]:
259
+ """Aggregate records per scenario, worst first.
260
+
261
+ Ordering is by flake rate, then by absolute count, then slug — so a
262
+ scenario that failed 7/10 outranks one that failed 1/2 despite the lower
263
+ ratio being computed from more evidence.
264
+ """
265
+ by_slug: dict[str, list[dict]] = {}
266
+ for record in records:
267
+ by_slug.setdefault(record["slug"], []).append(record)
268
+
269
+ stats: list[FlakeStat] = []
270
+ for slug, rows in by_slug.items():
271
+ durations = [r["duration_s"] for r in rows if isinstance(r.get("duration_s"), (int, float))]
272
+ fps = []
273
+ for r in rows:
274
+ fp = r.get("fingerprint")
275
+ if fp and fp not in fps:
276
+ fps.append(fp)
277
+ stats.append(
278
+ FlakeStat(
279
+ slug=slug,
280
+ runs=len(rows),
281
+ failures=sum(1 for r in rows if r.get("outcome") == OUTCOME_FAILED),
282
+ flakes=sum(1 for r in rows if r.get("outcome") == OUTCOME_FLAKY),
283
+ median_s=round(statistics.median(durations), 1) if durations else None,
284
+ p95_s=round(_percentile(durations, 0.95), 1) if durations else None,
285
+ last_outcome=rows[-1].get("outcome", "unknown"),
286
+ fingerprints=fps,
287
+ )
288
+ )
289
+ stats.sort(key=lambda s: (-s.flake_rate, -(s.failures + s.flakes), s.slug))
290
+ return stats
291
+
292
+
293
+ def _percentile(values: list[float], q: float) -> float:
294
+ """Nearest-rank percentile. Avoids a numpy dependency for one number."""
295
+ if not values:
296
+ return 0.0
297
+ ordered = sorted(values)
298
+ index = max(0, min(len(ordered) - 1, int(round(q * (len(ordered) - 1)))))
299
+ return ordered[index]
300
+
301
+
302
+ def median_durations(project_root: Path, *, last_runs: int = 10) -> dict[str, float]:
303
+ """Median duration per scenario, for longest-first queue ordering.
304
+
305
+ Returns ``{}`` when there is no history — the caller falls back to whatever
306
+ order it already had.
307
+ """
308
+ durations: dict[str, list[float]] = {}
309
+ for record in load(project_root, last_runs=last_runs):
310
+ value = record.get("duration_s")
311
+ if isinstance(value, (int, float)) and value > 0:
312
+ durations.setdefault(record["slug"], []).append(float(value))
313
+ return {slug: statistics.median(values) for slug, values in durations.items()}
314
+
315
+
316
+ def order_by_duration_desc(slugs: list[str], durations: dict[str, float]) -> list[str]:
317
+ """Longest-first (LPT) ordering, preserving the caller's order for ties.
318
+
319
+ Scenarios with no recorded duration go last: an unknown is most likely a
320
+ newly added scenario, and guessing it is slow would push a genuinely slow
321
+ known one behind it. Unknown-last also makes the no-history case a no-op.
322
+ """
323
+ if not durations:
324
+ return list(slugs)
325
+ known = [s for s in slugs if s in durations]
326
+ unknown = [s for s in slugs if s not in durations]
327
+ known.sort(key=lambda s: -durations[s])
328
+ return known + unknown
329
+
330
+
331
+ def prune(project_root: Path, *, keep_runs: int) -> int:
332
+ """Rewrite the history file keeping only the newest ``keep_runs`` runs.
333
+
334
+ Returns the number of records dropped (0 on any error). Uses a temp file +
335
+ replace so an interrupted prune can't truncate the history."""
336
+ path = history_path(project_root)
337
+ if not path.is_file():
338
+ return 0
339
+ before = load(project_root)
340
+ kept = load(project_root, last_runs=keep_runs)
341
+ if len(kept) == len(before):
342
+ return 0
343
+ tmp = path.with_suffix(".jsonl.tmp")
344
+ try:
345
+ with tmp.open("w", encoding="utf-8") as f:
346
+ for record in kept:
347
+ f.write(json.dumps(record, sort_keys=True) + "\n")
348
+ os.replace(tmp, path)
349
+ except OSError:
350
+ tmp.unlink(missing_ok=True)
351
+ return 0
352
+ return len(before) - len(kept)
steppy/init_cli.py ADDED
@@ -0,0 +1,330 @@
1
+ """``steppy init`` — scaffold a new project (FLAWS §7.1).
2
+
3
+ Zero-to-first-test is otherwise ~7 non-trivial steps, two of which (authoring
4
+ ``config.yaml`` and a first scenario) have no scaffolding at all. ``init`` writes
5
+ a commented neutral ``config.yaml`` (with a ``$schema`` header for editor
6
+ autocomplete), a runnable starter scenario, ``credentials.yaml.example``, a
7
+ config JSON Schema, and a ``.steppy/scaffold.json`` manifest, and appends the
8
+ right ``.gitignore`` entries. ``--ci`` also drops in the GitHub Actions workflow,
9
+ and ``--app-dir`` puts the app-side skill in the app's own repository.
10
+
11
+ Re-running it is safe, which is also how a project set up by hand adopts the
12
+ scaffold: existing files are kept, the starter scenario is left out when
13
+ ``scenarios/`` already holds some, and the manifest is merged, not replaced.
14
+
15
+ A leaf module: imports only from ``_cli_common`` + stdlib (no cycle with
16
+ ``cli``). Registered via :func:`register_init` at the bottom of ``cli.py``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import importlib.resources
23
+ import json
24
+ import os
25
+ from pathlib import Path
26
+ from typing import Optional
27
+
28
+ import typer
29
+
30
+ from . import __version__
31
+ from ._cli_common import _cli_errors, _error, console
32
+ from .config_schema import schema_json
33
+
34
+ # Files init lays down, resolved relative to the target project root. Value is
35
+ # the bundled source path under steppy/templates/ (None ⇒ generated in code).
36
+ _SCHEMA_FILENAME = "steppy-config.schema.json"
37
+
38
+ # Packaged Claude skills installed into <project>/.claude/skills/<name>/SKILL.md
39
+ # (FLAWS §7.1). Each name matches the skill's frontmatter `name:` and its
40
+ # bundled source dir under steppy/skills/<name>/.
41
+ SKILL_NAMES = ("steppy-scenario-author", "steppy-template", "steppy-ci-report", "steppy-app-tours")
42
+
43
+ # The skills that also belong in the app's own repository (``--app-dir``). The
44
+ # code they walk an assistant through lives there, and an assistant only sees
45
+ # the skills of the tree it works in.
46
+ APP_SKILL_NAMES = ("steppy-app-tours",)
47
+
48
+ # Skill folders people copied in by hand before Steppy packaged its skills, and
49
+ # what replaced each. init never wrote them, so no manifest has their hash and
50
+ # nothing can tell a pristine copy from an edited one: they are reported, never
51
+ # removed. Left in place, an assistant loads them beside their replacement.
52
+ LEGACY_SKILLS = {
53
+ "steppy": "steppy-scenario-author",
54
+ "steppy-angular-export": "steppy-scenario-author",
55
+ }
56
+
57
+ # .gitignore lines init ensures are present (idempotent append). The negation
58
+ # un-ignores the committed scaffold manifest that lives under the ignored
59
+ # .steppy/ dir.
60
+ _GITIGNORE_LINES = (
61
+ ".auth/",
62
+ "output/",
63
+ ".steppy/",
64
+ "credentials.yaml",
65
+ "!.steppy/scaffold.json",
66
+ )
67
+
68
+
69
+ def _bundled(*parts: str) -> Path:
70
+ """Absolute path to a bundled asset under ``steppy/templates/``."""
71
+ return Path(importlib.resources.files("steppy") / "templates" / Path(*parts))
72
+
73
+
74
+ def _bundled_skill(*parts: str) -> Path:
75
+ """Absolute path to a bundled asset under ``steppy/skills/``."""
76
+ return Path(importlib.resources.files("steppy") / "skills" / Path(*parts))
77
+
78
+
79
+ def _sha256(text: str) -> str:
80
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
81
+
82
+
83
+ def _rel(root: Path, path: Path) -> str:
84
+ """``path`` as the manifest records it: relative to ``root``, ``../`` included.
85
+
86
+ An app repository given to ``--app-dir`` usually sits beside the project.
87
+ On another Windows drive no relative path exists, so the path stays absolute.
88
+ """
89
+ try:
90
+ return Path(os.path.relpath(path, root)).as_posix()
91
+ except ValueError:
92
+ return path.as_posix()
93
+
94
+
95
+ def _write(target: Path, content: str, written: dict, skipped: list) -> None:
96
+ """Write ``content`` to ``target`` unless it already exists.
97
+
98
+ Records the file (relative to the project root, recorded by the caller) so
99
+ the scaffold manifest can hash exactly what init produced. Never clobbers a
100
+ user file — an existing path is reported and skipped.
101
+ """
102
+ if target.exists():
103
+ skipped.append((target, "already exists"))
104
+ return
105
+ target.parent.mkdir(parents=True, exist_ok=True)
106
+ target.write_text(content, encoding="utf-8", newline="\n")
107
+ written[target] = _sha256(content)
108
+
109
+
110
+ def _ensure_gitignore(root: Path, written: dict) -> None:
111
+ """Append missing ignore lines idempotently (never duplicates a line)."""
112
+ gitignore = root / ".gitignore"
113
+ existing = ""
114
+ if gitignore.exists():
115
+ existing = gitignore.read_text(encoding="utf-8")
116
+ present = {line.strip() for line in existing.splitlines()}
117
+ to_add = [line for line in _GITIGNORE_LINES if line not in present]
118
+ if not to_add:
119
+ return
120
+ block = ""
121
+ if existing and not existing.endswith("\n"):
122
+ block += "\n"
123
+ if existing:
124
+ block += "\n"
125
+ block += "# Steppy\n" + "\n".join(to_add) + "\n"
126
+ with open(gitignore, "a", encoding="utf-8", newline="\n") as f:
127
+ f.write(block)
128
+ # The manifest records the resulting file's hash so `update` can tell a
129
+ # pristine .gitignore from a user-edited one.
130
+ written[gitignore] = _sha256(gitignore.read_text(encoding="utf-8"))
131
+
132
+
133
+ def _write_manifest(root: Path, written: dict) -> None:
134
+ """Write ``.steppy/scaffold.json`` = {steppy_version, files:{rel: sha256}}.
135
+
136
+ Must ship from the first ``init`` — modification detection (§7.7 ``steppy
137
+ update``) can't be retrofitted onto files whose original hashes were never
138
+ recorded.
139
+
140
+ A re-run merges into the manifest it finds. Replacing it would forget every
141
+ file an earlier run recorded, and ``update`` would stop managing them. The
142
+ recorded ``steppy_version`` is kept too: ``update`` shows the changelog since
143
+ that version, and the older baselines still date from it.
144
+ """
145
+ manifest_path = root / ".steppy" / "scaffold.json"
146
+ manifest = {"steppy_version": __version__, "files": {}}
147
+ if manifest_path.is_file():
148
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
149
+ files = dict(manifest.get("files", {}))
150
+ files.update({_rel(root, path): digest for path, digest in written.items()})
151
+ manifest["files"] = dict(sorted(files.items()))
152
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
153
+ manifest_path.write_text(
154
+ json.dumps(manifest, indent=2, sort_keys=True) + "\n",
155
+ encoding="utf-8",
156
+ newline="\n",
157
+ )
158
+
159
+
160
+ def _legacy_skills(root: Path) -> list[tuple[str, str]]:
161
+ """The :data:`LEGACY_SKILLS` folders present under ``root``, with each one's replacement."""
162
+ return [
163
+ (old, new) for old, new in LEGACY_SKILLS.items() if (root / ".claude" / "skills" / old / "SKILL.md").is_file()
164
+ ]
165
+
166
+
167
+ def _report_legacy_skills(root: Path) -> bool:
168
+ """Print one line per legacy skill folder; returns whether there was any."""
169
+ found = _legacy_skills(root)
170
+ for old, new in found:
171
+ console.print(
172
+ f" [yellow]legacy[/yellow] .claude/skills/{old}/ — an old copy, superseded by {new}; "
173
+ "delete the folder once any local edits are carried over"
174
+ )
175
+ return bool(found)
176
+
177
+
178
+ def _run_init(
179
+ directory: Path,
180
+ ci: bool,
181
+ no_skills: bool = False,
182
+ ci_gitlab: bool = False,
183
+ app_dir: Optional[Path] = None,
184
+ ) -> None:
185
+ root = directory.resolve()
186
+ app_root = app_dir.resolve() if app_dir is not None else None
187
+ if app_root is not None and not app_root.is_dir():
188
+ _error(f"--app-dir: {app_dir} is not a directory")
189
+ raise typer.Exit(1)
190
+ root.mkdir(parents=True, exist_ok=True)
191
+
192
+ written: dict[Path, str] = {}
193
+ skipped: list[tuple[Path, str]] = []
194
+
195
+ # Core scaffold assets (bundled templates + the generated schema).
196
+ _write(
197
+ root / "config.yaml",
198
+ _bundled("init", "config.yaml").read_text(encoding="utf-8"),
199
+ written,
200
+ skipped,
201
+ )
202
+ # The starter scenario is for an empty project. In one that already has
203
+ # scenarios, it would be generated and run against the real app.
204
+ starter = root / "scenarios" / "example--first-flow.md"
205
+ if not starter.exists() and any((root / "scenarios").rglob("*.md")):
206
+ skipped.append((starter, "scenarios/ already has scenarios"))
207
+ else:
208
+ _write(
209
+ starter,
210
+ _bundled("init", "scenarios", "example--first-flow.md").read_text(encoding="utf-8"),
211
+ written,
212
+ skipped,
213
+ )
214
+ _write(
215
+ root / "credentials.yaml.example",
216
+ _bundled("init", "credentials.yaml.example").read_text(encoding="utf-8"),
217
+ written,
218
+ skipped,
219
+ )
220
+ _write(root / _SCHEMA_FILENAME, schema_json(), written, skipped)
221
+
222
+ if ci:
223
+ _write(
224
+ root / ".github" / "workflows" / "steppy.yml",
225
+ _bundled("ci", "github-actions.yml").read_text(encoding="utf-8"),
226
+ written,
227
+ skipped,
228
+ )
229
+
230
+ # A separate flag rather than `--ci <vendor>`: `--ci` is a boolean people
231
+ # already type, and giving it a value would break every existing invocation.
232
+ # Writing both files unconditionally would leave a stray .github/ in a GitLab
233
+ # project, which is noise in a scaffold meant to be read.
234
+ if ci_gitlab:
235
+ _write(
236
+ root / ".gitlab-ci.yml",
237
+ _bundled("ci", "gitlab-ci.yml").read_text(encoding="utf-8"),
238
+ written,
239
+ skipped,
240
+ )
241
+
242
+ # Packaged Claude skills — installed by default so the project's AI
243
+ # assistant is a Steppy expert on day one (FLAWS §7.1). Routed through
244
+ # _write so they land in the manifest and never clobber user edits.
245
+ if not no_skills:
246
+ for name in SKILL_NAMES:
247
+ _write(
248
+ root / ".claude" / "skills" / name / "SKILL.md",
249
+ _bundled_skill(name, "SKILL.md").read_text(encoding="utf-8"),
250
+ written,
251
+ skipped,
252
+ )
253
+
254
+ # The app-side skills, in the app's repository. Recorded in this project's
255
+ # manifest (as ../<app>/…), so `steppy update` refreshes them with the rest.
256
+ # When the app is this directory, the loop above already installed them.
257
+ app_skills: list[Path] = []
258
+ if app_root is not None and (app_root != root or no_skills):
259
+ for name in APP_SKILL_NAMES:
260
+ target = app_root / ".claude" / "skills" / name / "SKILL.md"
261
+ _write(target, _bundled_skill(name, "SKILL.md").read_text(encoding="utf-8"), written, skipped)
262
+ if target in written:
263
+ app_skills.append(target)
264
+
265
+ _ensure_gitignore(root, written)
266
+ _write_manifest(root, written)
267
+
268
+ # Report.
269
+ for path in sorted(written):
270
+ console.print(f" [green]create[/green] {_rel(root, path)}")
271
+ for path, reason in sorted(skipped):
272
+ console.print(f" [yellow]skip[/yellow] {_rel(root, path)} ({reason})")
273
+ _report_legacy_skills(root)
274
+
275
+ if skipped and not written:
276
+ console.print("\n[yellow]Nothing to do[/yellow] — all scaffold files already exist.")
277
+ return
278
+
279
+ if app_skills:
280
+ console.print(
281
+ "\nCommit the skill in the app's repository: it is what an AI assistant working on the app reads."
282
+ )
283
+
284
+ console.print("\n[bold]Next steps:[/bold]")
285
+ console.print(" 1. Edit config.yaml — set your app URLs, roles, and credentials.")
286
+ console.print(" 2. steppy validate # check your scenarios")
287
+ console.print(" 3. steppy generate # produce the Playwright tests")
288
+ console.print(" 4. steppy run # run them")
289
+
290
+
291
+ def register_init(app: typer.Typer) -> None:
292
+ """Register the ``init`` command on the root Typer app."""
293
+
294
+ @app.command("init")
295
+ def init(
296
+ directory: Path = typer.Argument(
297
+ Path("."),
298
+ help="Directory to scaffold (default: current directory).",
299
+ ),
300
+ ci: bool = typer.Option(
301
+ False,
302
+ "--ci",
303
+ help="Also write a GitHub Actions workflow (.github/workflows/steppy.yml).",
304
+ ),
305
+ ci_gitlab: bool = typer.Option(
306
+ False,
307
+ "--ci-gitlab",
308
+ help="Also write a GitLab CI pipeline (.gitlab-ci.yml).",
309
+ ),
310
+ no_skills: bool = typer.Option(
311
+ False,
312
+ "--no-skills",
313
+ help="Do not install the packaged Claude skills into .claude/skills/.",
314
+ ),
315
+ app_dir: Optional[Path] = typer.Option(
316
+ None,
317
+ "--app-dir",
318
+ help="Also install the app-side skill (steppy-app-tours: wiring in-app tours into the app's code) "
319
+ "into this app repository's .claude/skills/. `steppy update` keeps it current.",
320
+ ),
321
+ ):
322
+ """Scaffold a new Steppy project (config, example scenario, schema, gitignore).
323
+
324
+ Installs the packaged Claude skills into .claude/skills/ by default so the
325
+ project's AI assistant knows the Steppy scenario format (use --no-skills to
326
+ opt out). Safe to re-run in an existing project: it only adds what is
327
+ missing, and leaves the starter scenario out when scenarios/ has some.
328
+ """
329
+ with _cli_errors():
330
+ _run_init(directory, ci, no_skills, ci_gitlab=ci_gitlab, app_dir=app_dir)