@seanyao/roll 2026.520.1 → 2026.521.2

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.
package/lib/roll-init.py CHANGED
@@ -1,8 +1,38 @@
1
1
  #!/usr/bin/env python3
2
- """roll-init — v2 terminal view for `roll init` (US-VIEW-008)."""
2
+ """roll-init — v2 terminal view for `roll init`.
3
+
4
+ Reads a single JSON document from stdin describing real step outcomes
5
+ captured by the bash flow in `bin/roll cmd_init`. Renders the v2 UI
6
+ (banner + horizontal rule + numbered steps + NEXT block) preserving the
7
+ visual style of US-VIEW-008 but reflecting what actually happened.
8
+
9
+ Input schema (single JSON object on stdin):
10
+ {
11
+ "kind": "init", # informational; UI label uses header_label
12
+ "header_label": "INIT", # banner label (e.g. "INIT" / "REINIT")
13
+ "subtitle": "项目初始化", # banner subtitle
14
+ "project_path": "/path/to/project", # right-aligned banner text
15
+ "steps": [
16
+ {"num": 1, "label": "Detect project type", "status": "ok"},
17
+ {"num": 2, "label": "Create AGENTS.md", "status": "ok",
18
+ "files": [["+", "AGENTS.md"]]},
19
+ {"num": 3, "label": "...", "status": "skip", "note": "already exists"},
20
+ {"num": 4, "label": "...", "status": "fail", "error": "permission denied"}
21
+ ],
22
+ "footer": {"status": "ok", "label": "Project ready"},
23
+ "next": [["Edit .roll/backlog.md", "open the backlog and add your first US"]]
24
+ }
25
+
26
+ `status` values: ok | skip | fail.
27
+ `files` ops: "+" created "~" merged "·" unchanged "✗" failed.
28
+
29
+ If stdin is empty or invalid JSON, exit 1 with a short message — the
30
+ renderer is no longer runnable standalone, it always renders real data.
31
+ """
3
32
  from __future__ import annotations
4
33
 
5
34
  import argparse
35
+ import json
6
36
  import os
7
37
  import sys
8
38
 
@@ -12,32 +42,6 @@ if _LIB_DIR not in sys.path:
12
42
  import roll_render
13
43
  from roll_render import c, row, COLS
14
44
 
15
- # ════════════════════════════════════════════════════════════════════════════
16
- # Demo data — 6 steps mirror cmd_init's actual phases.
17
- # AC text: detect → AGENTS.md → BACKLOG.md → docs/features/ → merge CLAUDE.md → link skills
18
- # Each entry: (label, [(op, filename)]) where op ∈ {"+", "~"}.
19
- # ════════════════════════════════════════════════════════════════════════════
20
-
21
- _DEMO_STEPS = [
22
- ("Detect project type", []),
23
- ("Create AGENTS.md", [("+", "AGENTS.md")]),
24
- ("Create .roll/backlog.md", [("+", ".roll/backlog.md")]),
25
- ("Create .roll/features/", [("+", ".roll/features/")]),
26
- ("Merge existing CLAUDE.md", [("~", "CLAUDE.md")]),
27
- ("Link skills to AI clients", [("+", "~/.claude/skills/roll-build"),
28
- ("+", "~/.claude/skills/roll-fix")]),
29
- ]
30
-
31
- _NEXT_STEPS = [
32
- ("Edit .roll/backlog.md", "open the backlog and add your first US"),
33
- ("Run roll loop now", "execute one cycle manually to test the flow"),
34
- ("Enable loop scheduling", "roll loop on — let it run hourly"),
35
- ]
36
-
37
-
38
- # ════════════════════════════════════════════════════════════════════════════
39
- # Render
40
- # ════════════════════════════════════════════════════════════════════════════
41
45
 
42
46
  def _divider(char: str = "─") -> None:
43
47
  print(c("dim", char * min(COLS, 80)))
@@ -48,53 +52,104 @@ def _op_marker(op: str) -> str:
48
52
  return c("green", "+", bold=True)
49
53
  if op == "~":
50
54
  return c("amber", "~", bold=True)
55
+ if op == "·":
56
+ return c("dim", "·")
57
+ if op == "✗":
58
+ return c("red", "✗", bold=True)
51
59
  return c("dim", op)
52
60
 
53
61
 
54
- def render_demo(project_path: str = "~/myproject") -> None:
55
- left = " " + c("blue", "INIT", bold=True) + c("dim", " · ") + c("dim", "项目初始化")
62
+ def _step_icon(status: str) -> str:
63
+ if status == "ok":
64
+ return c("green", "✓")
65
+ if status == "skip":
66
+ return c("amber", "↷")
67
+ if status == "fail":
68
+ return c("red", "✗", bold=True)
69
+ return c("dim", "·")
70
+
71
+
72
+ def _file_color(op: str) -> str:
73
+ if op == "+":
74
+ return "green"
75
+ if op == "~":
76
+ return "amber"
77
+ if op == "✗":
78
+ return "red"
79
+ return "dim"
80
+
81
+
82
+ def render(payload: dict) -> None:
83
+ header_label = payload.get("header_label", "INIT")
84
+ subtitle = payload.get("subtitle", "项目初始化")
85
+ project_path = payload.get("project_path", "")
86
+
87
+ left = " " + c("blue", header_label, bold=True) + c("dim", " · ") + c("dim", subtitle)
56
88
  right = c("dim", project_path) + " "
57
89
  print(row(left, right))
58
90
  _divider()
59
91
  print()
60
92
 
61
- for i, (label, files) in enumerate(_DEMO_STEPS, start=1):
62
- num = c("dim", f" {i}.")
63
- icon = c("green", "")
93
+ for step in payload.get("steps", []):
94
+ num = c("dim", f" {step.get('num', 0)}.")
95
+ icon = _step_icon(step.get("status", "ok"))
96
+ label = step.get("label", "")
64
97
  print(f"{num} {icon} {label}")
65
- for op, fname in files:
98
+ for entry in step.get("files", []) or []:
99
+ op, fname = entry[0], entry[1]
66
100
  mark = _op_marker(op)
67
- color = "green" if op == "+" else "amber"
101
+ color = _file_color(op)
68
102
  print(" " + mark + " " + c(color, fname))
103
+ note = step.get("note") or step.get("error")
104
+ if note:
105
+ tone = "red" if step.get("status") == "fail" else "dim"
106
+ print(" " + c(tone, str(note)))
69
107
 
70
108
  print()
71
109
  _divider()
72
- print(" " + c("green", "✓") + " " + c("green", "Project ready", bold=True))
73
- print()
74
- print(" " + c("pink", "NEXT", bold=True) + c("dim", " · 下一步"))
75
- for i, (label, hint) in enumerate(_NEXT_STEPS, start=1):
76
- num = c("dim", f" {i}.")
77
- print(f"{num} {c('fg', label, bold=True)}")
78
- print(" " + c("dim", hint))
110
+
111
+ footer = payload.get("footer") or {}
112
+ f_status = footer.get("status", "ok")
113
+ f_label = footer.get("label", "Done")
114
+ icon_color = "green" if f_status == "ok" else "red"
115
+ icon = "✓" if f_status == "ok" else "✗"
116
+ print(" " + c(icon_color, icon) + " " + c(icon_color, f_label, bold=True))
117
+
118
+ next_items = payload.get("next") or []
119
+ if next_items:
120
+ print()
121
+ print(" " + c("pink", "NEXT", bold=True) + c("dim", " · 下一步"))
122
+ for i, entry in enumerate(next_items, start=1):
123
+ label = entry[0]
124
+ hint = entry[1] if len(entry) > 1 else ""
125
+ num = c("dim", f" {i}.")
126
+ print(f"{num} {c('fg', label, bold=True)}")
127
+ if hint:
128
+ print(" " + c("dim", hint))
79
129
  _divider("═")
80
130
 
81
131
 
82
- # ════════════════════════════════════════════════════════════════════════════
83
- # Entry point
84
- # ════════════════════════════════════════════════════════════════════════════
132
+ def _read_payload() -> dict:
133
+ raw = sys.stdin.read()
134
+ if not raw.strip():
135
+ sys.stderr.write("roll-init.py: expected JSON on stdin\n")
136
+ sys.exit(1)
137
+ try:
138
+ return json.loads(raw)
139
+ except json.JSONDecodeError as exc:
140
+ sys.stderr.write(f"roll-init.py: invalid JSON on stdin: {exc}\n")
141
+ sys.exit(1)
142
+
85
143
 
86
144
  def main() -> None:
87
145
  ap = argparse.ArgumentParser(add_help=False)
88
- ap.add_argument("--demo", action="store_true")
89
146
  ap.add_argument("--no-color", dest="no_color", action="store_true")
90
- ap.add_argument("--en", action="store_true")
91
- ap.add_argument("--zh", action="store_true")
92
147
  args, _ = ap.parse_known_args()
93
148
 
94
149
  if args.no_color or os.environ.get("NO_COLOR") or not sys.stdout.isatty():
95
150
  roll_render.USE_COLOR = False
96
151
 
97
- render_demo(project_path=os.getcwd())
152
+ render(_read_payload())
98
153
 
99
154
 
100
155
  if __name__ == "__main__":
@@ -17,7 +17,7 @@ Usage:
17
17
  python3 lib/roll-loop-status.py --days 7
18
18
  python3 lib/roll-loop-status.py --no-color
19
19
  python3 lib/roll-loop-status.py --en | --zh # collapse bilingual rows
20
- python3 lib/roll-loop-status.py --demo # render with fixture data
20
+ ROLL_RENDER_FIXTURE=1 python3 lib/roll-loop-status.py # render with fixture data (test only)
21
21
 
22
22
  Wire it in bin/roll under `loop status` (replace _loop_status with a call to
23
23
  this script).
@@ -206,9 +206,19 @@ def aggregate(events: List[Dict[str, Any]], cron: List[Dict[str, Any]]) -> List[
206
206
  elif stage == "usage":
207
207
  # US-LOOP-004: loop-fmt emits this with full token / cost data.
208
208
  # Detail is a dict (not the legacy string form).
209
+ # US-VIEW-010: token counts are per-turn deltas — sum across events
210
+ # so list-price cost computed from totals matches actual API usage.
211
+ # Non-additive fields (model, cost_reported_usd, duration_ms) take
212
+ # the last value seen.
209
213
  d = e.get("detail") or {}
210
214
  if isinstance(d, dict):
211
- cy["usage_event"] = d
215
+ prev = cy.get("usage_event") or {}
216
+ merged = dict(prev)
217
+ merged.update(d)
218
+ for k in ("input_tokens", "output_tokens",
219
+ "cache_creation_tokens", "cache_read_tokens"):
220
+ merged[k] = int(prev.get(k) or 0) + int(d.get(k) or 0)
221
+ cy["usage_event"] = merged
212
222
  elif stage in ("test", "build") and e.get("outcome") == "fail":
213
223
  cy["fail_detail"] = detail or stage
214
224
 
@@ -315,8 +325,7 @@ def backfill_usage_from_claude_sessions(cycles: List[Dict[str, Any]], slug: str)
315
325
  for cy in cycles:
316
326
  # Path 1: usage event written by loop-fmt at result time.
317
327
  ue = cy.get("usage_event")
318
- if isinstance(ue, dict) and (ue.get("input_tokens") or ue.get("output_tokens")
319
- or ue.get("cost_reported_usd")):
328
+ if isinstance(ue, dict) and (ue.get("input_tokens") or ue.get("output_tokens")):
320
329
  cy["tokens"] = mp.total_tokens(
321
330
  input_tokens=ue.get("input_tokens", 0),
322
331
  output_tokens=ue.get("output_tokens", 0),
@@ -324,20 +333,18 @@ def backfill_usage_from_claude_sessions(cycles: List[Dict[str, Any]], slug: str)
324
333
  cache_read_tokens=ue.get("cache_read_tokens", 0),
325
334
  )
326
335
  cy["model"] = ue.get("model")
327
- # FIX-060: cost_reported_usd is the authoritative cumulative session
328
- # cost written by loop-fmt; use it directly instead of recomputing
329
- # from the last individual API-call token counts (which would only
330
- # reflect one small call and badly undercount the true cycle cost).
331
- if ue.get("cost_reported_usd"):
332
- cy["cost_list"] = float(ue["cost_reported_usd"])
333
- else:
334
- cy["cost_list"] = mp.compute_list_cost(
335
- ue.get("model"),
336
- input_tokens=ue.get("input_tokens", 0),
337
- output_tokens=ue.get("output_tokens", 0),
338
- cache_creation_tokens=ue.get("cache_creation_tokens", 0),
339
- cache_read_tokens=ue.get("cache_read_tokens", 0),
340
- )
336
+ # US-VIEW-010: aggregate now sums per-turn usage tokens, so the
337
+ # totals in `ue` reflect the whole cycle. Always compute cost at
338
+ # list price for cross-account comparability supersedes FIX-060
339
+ # which preferred cost_reported_usd as a workaround for
340
+ # last-event-only token counts (that root cause is now gone).
341
+ cy["cost_list"] = mp.compute_list_cost(
342
+ ue.get("model"),
343
+ input_tokens=ue.get("input_tokens", 0),
344
+ output_tokens=ue.get("output_tokens", 0),
345
+ cache_creation_tokens=ue.get("cache_creation_tokens", 0),
346
+ cache_read_tokens=ue.get("cache_read_tokens", 0),
347
+ )
341
348
  if ue.get("duration_ms") and not cy.get("duration_s"):
342
349
  cy["duration_s"] = int(ue["duration_ms"] / 1000)
343
350
  continue
@@ -699,7 +706,7 @@ def render(events, cron, state, backlog, *, days=3, lang="both", now=None,
699
706
  c("muted", " ") +
700
707
  c("dim", "watch ") + c("blue", "roll loop --watch") +
701
708
  c("muted", " ") +
702
- c("dim", "more ") + c("blue", "roll loop --days 7"))
709
+ c("dim", "more ") + c("blue", "roll loop status --days 7"))
703
710
 
704
711
  def _read_plist_loop_minute() -> int:
705
712
  """FIX-063: read actual loop Minute from launchd plist (truth source).
@@ -733,9 +740,9 @@ def _next_cron_hint(state: Dict[str, str], zh: bool = False) -> str:
733
740
  return nxt.strftime("%H:%M") + f" · in {mins}m {secs:02d}s"
734
741
 
735
742
  # ════════════════════════════════════════════════════════════════════════════
736
- # Demo fixture lets you preview the output without real data
743
+ # Fixture data (test-only; opt in via ROLL_RENDER_FIXTURE=1)
737
744
  # ════════════════════════════════════════════════════════════════════════════
738
- def _demo_data():
745
+ def _fixture_data():
739
746
  now = datetime.now(timezone.utc)
740
747
  events, cron = [], []
741
748
  cycle_id = 0
@@ -788,7 +795,6 @@ def main(argv=None):
788
795
  p.add_argument("--no-color", action="store_true", help="strip ANSI (also honors NO_COLOR=1)")
789
796
  p.add_argument("--en", action="store_true", help="EN rows only")
790
797
  p.add_argument("--zh", action="store_true", help="ZH rows only")
791
- p.add_argument("--demo", action="store_true", help="render with fixture data")
792
798
  args = p.parse_args(argv)
793
799
 
794
800
  roll_render.USE_COLOR = (not args.no_color
@@ -797,10 +803,12 @@ def main(argv=None):
797
803
 
798
804
  lang = "en" if args.en else ("zh" if args.zh else "both")
799
805
 
800
- if args.demo:
801
- events, cron, state, backlog = _demo_data()
806
+ use_fixture = bool(os.environ.get("ROLL_RENDER_FIXTURE"))
807
+ if use_fixture:
808
+ events, cron, state, backlog = _fixture_data()
802
809
  runs = {}
803
810
  git_merges = {}
811
+ slug = None
804
812
  else:
805
813
  slug = project_slug()
806
814
  events = load_events(slug, args.days)
@@ -812,7 +820,7 @@ def main(argv=None):
812
820
 
813
821
  render(events, cron, state, backlog, days=args.days, lang=lang,
814
822
  runs=runs, git_merges=git_merges,
815
- claude_slug=None if args.demo else slug)
823
+ claude_slug=slug)
816
824
 
817
825
  if __name__ == "__main__":
818
826
  try:
package/lib/roll-peer.py CHANGED
@@ -50,10 +50,11 @@ def _agent_c(name: str) -> str:
50
50
 
51
51
 
52
52
  # ════════════════════════════════════════════════════════════════════════════
53
- # Demo data — illustrative cross-agent review: claude proposes, codex reviews
53
+ # Fixture data (test-only; opt in via ROLL_RENDER_FIXTURE=1)
54
+ # Illustrative cross-agent review: claude proposes, codex reviews
54
55
  # ════════════════════════════════════════════════════════════════════════════
55
56
 
56
- _DEMO_SUBJECT = {
57
+ _FIXTURE_SUBJECT = {
57
58
  "story": "US-AUTH-014",
58
59
  "title": "Session refresh fallback when refresh-token API 5xx",
59
60
  "pr": "#412",
@@ -63,7 +64,7 @@ _DEMO_SUBJECT = {
63
64
  "reviewer": "codex",
64
65
  }
65
66
 
66
- _DEMO_ROUNDS = [
67
+ _FIXTURE_ROUNDS = [
67
68
  {
68
69
  "n": 1,
69
70
  "hint": "first pass — proposer ships, reviewer probes",
@@ -93,13 +94,13 @@ _DEMO_ROUNDS = [
93
94
  },
94
95
  ]
95
96
 
96
- _DEMO_VERDICT = {
97
+ _FIXTURE_VERDICT = {
97
98
  "outcome": "approved",
98
99
  "reason": "2 rounds · 5 turns · all blocks resolved",
99
100
  }
100
101
 
101
- _DEMO_ARTIFACT = "~/.roll/.peer-state/logs/20260519_213700_claude_codex.md"
102
- _DEMO_NEXT = [
102
+ _FIXTURE_ARTIFACT = "~/.roll/.peer-state/logs/20260519_213700_claude_codex.md"
103
+ _FIXTURE_NEXT = [
103
104
  ("Continue execution", "claude resumes work on US-AUTH-014"),
104
105
  ("Inspect log", "open the artifact above to replay the transcript"),
105
106
  ]
@@ -205,19 +206,19 @@ def _footer(artifact: str, next_steps: list) -> None:
205
206
  # Top-level render
206
207
  # ════════════════════════════════════════════════════════════════════════════
207
208
 
208
- def render_demo() -> None:
209
- _eyebrow(_DEMO_SUBJECT["trigger"])
209
+ def render_fixture() -> None:
210
+ _eyebrow(_FIXTURE_SUBJECT["trigger"])
210
211
  _divider()
211
212
  print()
212
- _subject(_DEMO_SUBJECT)
213
+ _subject(_FIXTURE_SUBJECT)
213
214
  print()
214
- _pair_overview(_DEMO_SUBJECT)
215
- for rd in _DEMO_ROUNDS:
215
+ _pair_overview(_FIXTURE_SUBJECT)
216
+ for rd in _FIXTURE_ROUNDS:
216
217
  _round_header(rd["n"], rd["hint"])
217
218
  for agent, weight, body in rd["turns"]:
218
219
  _turn(agent, weight, body)
219
- _verdict(_DEMO_VERDICT)
220
- _footer(_DEMO_ARTIFACT, _DEMO_NEXT)
220
+ _verdict(_FIXTURE_VERDICT)
221
+ _footer(_FIXTURE_ARTIFACT, _FIXTURE_NEXT)
221
222
 
222
223
 
223
224
  # ════════════════════════════════════════════════════════════════════════════
@@ -226,7 +227,6 @@ def render_demo() -> None:
226
227
 
227
228
  def main() -> None:
228
229
  ap = argparse.ArgumentParser(add_help=False)
229
- ap.add_argument("--demo", action="store_true")
230
230
  ap.add_argument("--no-color", dest="no_color", action="store_true")
231
231
  ap.add_argument("--en", action="store_true")
232
232
  ap.add_argument("--zh", action="store_true")
@@ -235,7 +235,17 @@ def main() -> None:
235
235
  if args.no_color or os.environ.get("NO_COLOR") or not sys.stdout.isatty():
236
236
  roll_render.USE_COLOR = False
237
237
 
238
- render_demo()
238
+ # FIX-076: this standalone entrypoint only knows how to render the fixture
239
+ # transcript (for UI tests). Real peer review is orchestrated by bin/roll
240
+ # and never invokes this main(). Require an explicit opt-in so a stray
241
+ # `python3 lib/roll-peer.py` invocation can't masquerade as live output.
242
+ if not os.environ.get("ROLL_RENDER_FIXTURE"):
243
+ print("Error: lib/roll-peer.py only renders fixture data; "
244
+ "set ROLL_RENDER_FIXTURE=1 to use it (test-only).",
245
+ file=sys.stderr)
246
+ sys.exit(2)
247
+
248
+ render_fixture()
239
249
 
240
250
 
241
251
  if __name__ == "__main__":
package/lib/roll-setup.py CHANGED
@@ -1,61 +1,101 @@
1
1
  #!/usr/bin/env python3
2
- """roll-setup — v2 terminal view for `roll setup`."""
2
+ """roll-setup — v2 terminal view for `roll setup`.
3
+
4
+ Reads a single JSON document from stdin describing the real outcomes of
5
+ `bin/roll cmd_setup`'s step sequence (detect platform / install skills /
6
+ sync conventions / etc). Renders the v2 UI preserving the visual style
7
+ of US-VIEW-007 while reflecting actual results.
8
+
9
+ Input schema matches `lib/roll-init.py` (see that file for details).
10
+ """
3
11
  from __future__ import annotations
4
12
 
13
+ import argparse
14
+ import json
5
15
  import os
6
16
  import sys
7
17
 
8
18
  _LIB_DIR = os.path.dirname(os.path.realpath(__file__))
9
19
  if _LIB_DIR not in sys.path:
10
20
  sys.path.insert(0, _LIB_DIR)
21
+ import roll_render
11
22
  from roll_render import c, row, COLS
12
23
 
13
- # ════════════════════════════════════════════════════════════════════════════
14
- # Demo data
15
- # ════════════════════════════════════════════════════════════════════════════
16
-
17
- _DEMO_STEPS = [
18
- "Detect platform & shell",
19
- "Fetch latest roll version",
20
- "Install skills to ~/.claude",
21
- "Symlink bin/roll to PATH",
22
- "Check for config drift",
23
- "Apply convention templates",
24
- ]
25
-
26
- # ════════════════════════════════════════════════════════════════════════════
27
- # Rendering
28
- # ════════════════════════════════════════════════════════════════════════════
29
24
 
30
25
  def _divider(char: str = "─") -> None:
31
26
  print(c("dim", char * min(COLS, 80)))
32
27
 
33
28
 
34
- def render_demo() -> None:
35
- left = " " + c("blue", "SETUP", bold=True) + c("dim", " · ") + c("dim", "初始化")
36
- right = c("dim", "--demo")
37
- print(row(left, " " + right))
29
+ def _step_icon(status: str) -> str:
30
+ if status == "ok":
31
+ return c("green", "")
32
+ if status == "skip":
33
+ return c("amber", "↷")
34
+ if status == "forced":
35
+ return c("blue", "~")
36
+ if status == "fail":
37
+ return c("red", "✗", bold=True)
38
+ return c("dim", "·")
39
+
40
+
41
+ def render(payload: dict) -> None:
42
+ header_label = payload.get("header_label", "SETUP")
43
+ subtitle = payload.get("subtitle", "初始化")
44
+ right_text = payload.get("project_path") or payload.get("right", "")
45
+
46
+ left = " " + c("blue", header_label, bold=True) + c("dim", " · ") + c("dim", subtitle)
47
+ right = c("dim", right_text) + " " if right_text else ""
48
+ print(row(left, right))
38
49
  _divider()
39
50
  print()
40
51
 
41
- for i, label in enumerate(_DEMO_STEPS, start=1):
42
- num = c("dim", f" {i}.")
43
- icon = c("green", "")
52
+ for step in payload.get("steps", []):
53
+ num = c("dim", f" {step.get('num', 0)}.")
54
+ icon = _step_icon(step.get("status", "ok"))
55
+ label = step.get("label", "")
44
56
  print(f"{num} {icon} {label}")
57
+ note = step.get("note") or step.get("error")
58
+ if note:
59
+ tone = "red" if step.get("status") == "fail" else "dim"
60
+ print(" " + c(tone, str(note)))
45
61
 
46
62
  print()
47
63
  _divider()
48
- msg = c("green", "Setup complete")
49
- print(f" {msg} — run {c('fg', 'roll init', bold=True)} inside a project to begin")
64
+
65
+ footer = payload.get("footer") or {}
66
+ f_status = footer.get("status", "ok")
67
+ f_label = footer.get("label", "Setup complete")
68
+ icon_color = "green" if f_status == "ok" else "red"
69
+ msg = c(icon_color, f_label)
70
+ next_hint = footer.get("hint")
71
+ if next_hint:
72
+ print(f" {msg} — {next_hint}")
73
+ else:
74
+ print(f" {msg}")
50
75
  _divider("═")
51
76
 
52
77
 
53
- # ════════════════════════════════════════════════════════════════════════════
54
- # Entry point
55
- # ════════════════════════════════════════════════════════════════════════════
78
+ def _read_payload() -> dict:
79
+ raw = sys.stdin.read()
80
+ if not raw.strip():
81
+ sys.stderr.write("roll-setup.py: expected JSON on stdin\n")
82
+ sys.exit(1)
83
+ try:
84
+ return json.loads(raw)
85
+ except json.JSONDecodeError as exc:
86
+ sys.stderr.write(f"roll-setup.py: invalid JSON on stdin: {exc}\n")
87
+ sys.exit(1)
88
+
56
89
 
57
90
  def main() -> None:
58
- render_demo()
91
+ ap = argparse.ArgumentParser(add_help=False)
92
+ ap.add_argument("--no-color", dest="no_color", action="store_true")
93
+ args, _ = ap.parse_known_args()
94
+
95
+ if args.no_color or os.environ.get("NO_COLOR") or not sys.stdout.isatty():
96
+ roll_render.USE_COLOR = False
97
+
98
+ render(_read_payload())
59
99
 
60
100
 
61
101
  if __name__ == "__main__":
@@ -8,7 +8,7 @@ project templates, and this-project metrics.
8
8
  Usage:
9
9
  python3 lib/roll-status.py # live data
10
10
  python3 lib/roll-status.py --no-color
11
- python3 lib/roll-status.py --demo # render with fixture data
11
+ ROLL_RENDER_FIXTURE=1 python3 lib/roll-status.py # render with fixture data (test only)
12
12
  """
13
13
 
14
14
  from __future__ import annotations
@@ -149,9 +149,9 @@ def _launchd_state(service: str, slug: str) -> str:
149
149
  return "installed-off"
150
150
 
151
151
  # ════════════════════════════════════════════════════════════════════════════
152
- # Demo fixture
152
+ # Fixture data (test-only; opt in via ROLL_RENDER_FIXTURE=1)
153
153
  # ════════════════════════════════════════════════════════════════════════════
154
- def _demo_data() -> Dict[str, Any]:
154
+ def _fixture_data() -> Dict[str, Any]:
155
155
  return dict(
156
156
  conventions=[
157
157
  ("AGENTS.md", True), ("CLAUDE.md", True), ("GEMINI.md", False),
@@ -347,7 +347,6 @@ def _live_data() -> Dict[str, Any]:
347
347
  # ════════════════════════════════════════════════════════════════════════════
348
348
  def main() -> None:
349
349
  ap = argparse.ArgumentParser(add_help=False)
350
- ap.add_argument("--demo", action="store_true")
351
350
  ap.add_argument("--no-color", dest="no_color", action="store_true")
352
351
  ap.add_argument("--en", action="store_true")
353
352
  ap.add_argument("--zh", action="store_true")
@@ -356,7 +355,7 @@ def main() -> None:
356
355
  if args.no_color or os.environ.get("NO_COLOR") or not sys.stdout.isatty():
357
356
  roll_render.USE_COLOR = False
358
357
 
359
- d = _demo_data() if args.demo else _live_data()
358
+ d = _fixture_data() if os.environ.get("ROLL_RENDER_FIXTURE") else _live_data()
360
359
 
361
360
  _render_health(d)
362
361
  _render_global_conventions(d["conventions"])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanyao/roll",
3
- "version": "2026.520.1",
3
+ "version": "2026.521.2",
4
4
  "description": "Roll — Roll out features with AI agents",
5
5
  "scripts": {
6
6
  "test": "bash tests/run.sh"