guild-cli 0.26.23__tar.gz → 0.27.0__tar.gz

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 (151) hide show
  1. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/version-bump/SKILL.md +14 -7
  2. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/version-bump/scripts/bump.py +156 -63
  3. {guild_cli-0.26.23 → guild_cli-0.27.0}/CHANGELOG.md +14 -0
  4. {guild_cli-0.26.23 → guild_cli-0.27.0}/PKG-INFO +1 -1
  5. {guild_cli-0.26.23 → guild_cli-0.27.0}/pyproject.toml +1 -1
  6. {guild_cli-0.26.23 → guild_cli-0.27.0}/tests/test_version_bump_script.py +122 -0
  7. {guild_cli-0.26.23 → guild_cli-0.27.0}/uv.lock +1 -1
  8. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/agent-config/SKILL.md +0 -0
  9. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/agent-config/data/backend-fingerprints.yaml +0 -0
  10. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/agent-config/scripts/show.sh +0 -0
  11. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/ask-colleague/SKILL.md +0 -0
  12. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/ask-colleague/prompts/explore.md +0 -0
  13. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/ask-colleague/prompts/review.md +0 -0
  14. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/ask-colleague/prompts/write.md +0 -0
  15. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/ask-colleague/scripts/ask-colleague.sh +0 -0
  16. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/assign-to-workforce/SKILL.md +0 -0
  17. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/assign-to-workforce/scripts/assign-to-workforce.sh +0 -0
  18. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/challenge/SKILL.md +0 -0
  19. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/challenge/scripts/challenge.sh +0 -0
  20. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/cicd/SKILL.md +0 -0
  21. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/cicd/scripts/_resolve-nick.sh +0 -0
  22. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/cicd/scripts/portability-lint.sh +0 -0
  23. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/cicd/scripts/pr-reply.sh +0 -0
  24. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/cicd/scripts/pr-status.sh +0 -0
  25. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/cicd/scripts/workflow.sh +0 -0
  26. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/communicate/SKILL.md +0 -0
  27. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/communicate/scripts/fetch-issues.sh +0 -0
  28. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/communicate/scripts/mesh-message.sh +0 -0
  29. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/communicate/scripts/post-comment.sh +0 -0
  30. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/communicate/scripts/post-issue.sh +0 -0
  31. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/communicate/scripts/templates/skill-new-brief.md +0 -0
  32. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/communicate/scripts/templates/skill-update-brief.md +0 -0
  33. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/deviate/SKILL.md +0 -0
  34. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/deviate/scripts/deviate.sh +0 -0
  35. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/doc-test-alignment/SKILL.md +0 -0
  36. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/doc-test-alignment/scripts/check.sh +0 -0
  37. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/guild/SKILL.md +0 -0
  38. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/guild/scripts/configure-repo.sh +0 -0
  39. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/guild/scripts/create.sh +0 -0
  40. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/guild/scripts/overview.sh +0 -0
  41. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/onboard/SKILL.md +0 -0
  42. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/onboard/scripts/onboard.sh +0 -0
  43. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/outsource/SKILL.md +0 -0
  44. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/outsource/prompts/explore.md +0 -0
  45. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/outsource/prompts/review.md +0 -0
  46. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/outsource/prompts/write.md +0 -0
  47. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/outsource/scripts/outsource.sh +0 -0
  48. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/pypi-maintainer/SKILL.md +0 -0
  49. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/pypi-maintainer/scripts/switch-source.sh +0 -0
  50. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/recall/SKILL.md +0 -0
  51. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/recall/scripts/recall.sh +0 -0
  52. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/remember/SKILL.md +0 -0
  53. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/remember/scripts/remember.sh +0 -0
  54. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/run-tests/SKILL.md +0 -0
  55. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/run-tests/scripts/test.sh +0 -0
  56. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/scope/SKILL.md +0 -0
  57. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/scope/scripts/scope.sh +0 -0
  58. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/sonarclaude/SKILL.md +0 -0
  59. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/sonarclaude/scripts/sonar.sh +0 -0
  60. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/spec-to-plan/SKILL.md +0 -0
  61. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/spec-to-plan/scripts/spec-to-plan.sh +0 -0
  62. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/summarize-delivery/SKILL.md +0 -0
  63. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/summarize-delivery/scripts/summarize-delivery.sh +0 -0
  64. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/teach/SKILL.md +0 -0
  65. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/teach/scripts/teach.sh +0 -0
  66. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/think/SKILL.md +0 -0
  67. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/think/scripts/think.sh +0 -0
  68. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/validate-delivery/SKILL.md +0 -0
  69. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills/validate-delivery/scripts/validate-delivery.sh +0 -0
  70. {guild_cli-0.26.23 → guild_cli-0.27.0}/.claude/skills.local.yaml.example +0 -0
  71. {guild_cli-0.26.23 → guild_cli-0.27.0}/.devague/current +0 -0
  72. {guild_cli-0.26.23 → guild_cli-0.27.0}/.devague/current_plan +0 -0
  73. {guild_cli-0.26.23 → guild_cli-0.27.0}/.devague/deliveries/template-supports-four-harnesses.json +0 -0
  74. {guild_cli-0.26.23 → guild_cli-0.27.0}/.devague/frames/guildmaster-ships-teach-and-onboard-two-agent-firs.json +0 -0
  75. {guild_cli-0.26.23 → guild_cli-0.27.0}/.devague/frames/template-supports-four-harnesses.json +0 -0
  76. {guild_cli-0.26.23 → guild_cli-0.27.0}/.devague/plans/guildmaster-ships-teach-and-onboard-two-agent-firs.json +0 -0
  77. {guild_cli-0.26.23 → guild_cli-0.27.0}/.devague/plans/template-supports-four-harnesses.json +0 -0
  78. {guild_cli-0.26.23 → guild_cli-0.27.0}/.eidetic/memory/culture-agent-template__public.jsonl +0 -0
  79. {guild_cli-0.26.23 → guild_cli-0.27.0}/.eidetic/memory/default__public.jsonl +0 -0
  80. {guild_cli-0.26.23 → guild_cli-0.27.0}/.eidetic/memory/guildmaster__public.jsonl +0 -0
  81. {guild_cli-0.26.23 → guild_cli-0.27.0}/.flake8 +0 -0
  82. {guild_cli-0.26.23 → guild_cli-0.27.0}/.github/workflows/publish.yml +0 -0
  83. {guild_cli-0.26.23 → guild_cli-0.27.0}/.github/workflows/tests.yml +0 -0
  84. {guild_cli-0.26.23 → guild_cli-0.27.0}/.gitignore +0 -0
  85. {guild_cli-0.26.23 → guild_cli-0.27.0}/.markdownlint-cli2.yaml +0 -0
  86. {guild_cli-0.26.23 → guild_cli-0.27.0}/AGENTS.override.md +0 -0
  87. {guild_cli-0.26.23 → guild_cli-0.27.0}/CLAUDE.md +0 -0
  88. {guild_cli-0.26.23 → guild_cli-0.27.0}/LICENSE +0 -0
  89. {guild_cli-0.26.23 → guild_cli-0.27.0}/QWEN.md +0 -0
  90. {guild_cli-0.26.23 → guild_cli-0.27.0}/README.md +0 -0
  91. {guild_cli-0.26.23 → guild_cli-0.27.0}/culture.yaml +0 -0
  92. {guild_cli-0.26.23 → guild_cli-0.27.0}/docs/cutover.md +0 -0
  93. {guild_cli-0.26.23 → guild_cli-0.27.0}/docs/deliveries/2026-09-05-template-supports-four-harnesses.md +0 -0
  94. {guild_cli-0.26.23 → guild_cli-0.27.0}/docs/onboarding/reachy-mini-mcp.json +0 -0
  95. {guild_cli-0.26.23 → guild_cli-0.27.0}/docs/plans/2026-05-24-guildmaster-ships-teach-and-onboard-two-agent-firs.md +0 -0
  96. {guild_cli-0.26.23 → guild_cli-0.27.0}/docs/plans/2026-09-05-template-supports-four-harnesses-split.md +0 -0
  97. {guild_cli-0.26.23 → guild_cli-0.27.0}/docs/plans/2026-09-05-template-supports-four-harnesses.md +0 -0
  98. {guild_cli-0.26.23 → guild_cli-0.27.0}/docs/skill-sources.md +0 -0
  99. {guild_cli-0.26.23 → guild_cli-0.27.0}/docs/specs/2026-05-24-guildmaster-ships-teach-and-onboard-two-agent-firs.md +0 -0
  100. {guild_cli-0.26.23 → guild_cli-0.27.0}/docs/specs/2026-09-05-template-supports-four-harnesses.md +0 -0
  101. {guild_cli-0.26.23 → guild_cli-0.27.0}/guild/__init__.py +0 -0
  102. {guild_cli-0.26.23 → guild_cli-0.27.0}/guild/__main__.py +0 -0
  103. {guild_cli-0.26.23 → guild_cli-0.27.0}/guild/cli/__init__.py +0 -0
  104. {guild_cli-0.26.23 → guild_cli-0.27.0}/guild/cli/_commands/__init__.py +0 -0
  105. {guild_cli-0.26.23 → guild_cli-0.27.0}/guild/cli/_commands/_broadcast.py +0 -0
  106. {guild_cli-0.26.23 → guild_cli-0.27.0}/guild/cli/_commands/_provision_template.py +0 -0
  107. {guild_cli-0.26.23 → guild_cli-0.27.0}/guild/cli/_commands/create.py +0 -0
  108. {guild_cli-0.26.23 → guild_cli-0.27.0}/guild/cli/_commands/explain.py +0 -0
  109. {guild_cli-0.26.23 → guild_cli-0.27.0}/guild/cli/_commands/harness.py +0 -0
  110. {guild_cli-0.26.23 → guild_cli-0.27.0}/guild/cli/_commands/learn.py +0 -0
  111. {guild_cli-0.26.23 → guild_cli-0.27.0}/guild/cli/_commands/onboard.py +0 -0
  112. {guild_cli-0.26.23 → guild_cli-0.27.0}/guild/cli/_commands/overview.py +0 -0
  113. {guild_cli-0.26.23 → guild_cli-0.27.0}/guild/cli/_commands/show.py +0 -0
  114. {guild_cli-0.26.23 → guild_cli-0.27.0}/guild/cli/_commands/teach.py +0 -0
  115. {guild_cli-0.26.23 → guild_cli-0.27.0}/guild/cli/_commands/whoami.py +0 -0
  116. {guild_cli-0.26.23 → guild_cli-0.27.0}/guild/cli/_errors.py +0 -0
  117. {guild_cli-0.26.23 → guild_cli-0.27.0}/guild/cli/_output.py +0 -0
  118. {guild_cli-0.26.23 → guild_cli-0.27.0}/guild/cli/_repo.py +0 -0
  119. {guild_cli-0.26.23 → guild_cli-0.27.0}/guild/scaffold/__init__.py +0 -0
  120. {guild_cli-0.26.23 → guild_cli-0.27.0}/guild/scaffold/harness.py +0 -0
  121. {guild_cli-0.26.23 → guild_cli-0.27.0}/guild/scaffold/harness_use.py +0 -0
  122. {guild_cli-0.26.23 → guild_cli-0.27.0}/guild/scaffold/instantiate.py +0 -0
  123. {guild_cli-0.26.23 → guild_cli-0.27.0}/guild/skills/__init__.py +0 -0
  124. {guild_cli-0.26.23 → guild_cli-0.27.0}/guild/skills/identity.py +0 -0
  125. {guild_cli-0.26.23 → guild_cli-0.27.0}/guild/skills/ledger.py +0 -0
  126. {guild_cli-0.26.23 → guild_cli-0.27.0}/guild/skills/render.py +0 -0
  127. {guild_cli-0.26.23 → guild_cli-0.27.0}/guild/skills/sources.py +0 -0
  128. {guild_cli-0.26.23 → guild_cli-0.27.0}/sonar-project.properties +0 -0
  129. {guild_cli-0.26.23 → guild_cli-0.27.0}/tests/__init__.py +0 -0
  130. {guild_cli-0.26.23 → guild_cli-0.27.0}/tests/test_broadcast_post.py +0 -0
  131. {guild_cli-0.26.23 → guild_cli-0.27.0}/tests/test_cli.py +0 -0
  132. {guild_cli-0.26.23 → guild_cli-0.27.0}/tests/test_cli_create.py +0 -0
  133. {guild_cli-0.26.23 → guild_cli-0.27.0}/tests/test_cli_explain.py +0 -0
  134. {guild_cli-0.26.23 → guild_cli-0.27.0}/tests/test_cli_harness.py +0 -0
  135. {guild_cli-0.26.23 → guild_cli-0.27.0}/tests/test_cli_learn.py +0 -0
  136. {guild_cli-0.26.23 → guild_cli-0.27.0}/tests/test_cli_onboard.py +0 -0
  137. {guild_cli-0.26.23 → guild_cli-0.27.0}/tests/test_cli_overview.py +0 -0
  138. {guild_cli-0.26.23 → guild_cli-0.27.0}/tests/test_cli_show.py +0 -0
  139. {guild_cli-0.26.23 → guild_cli-0.27.0}/tests/test_cli_teach.py +0 -0
  140. {guild_cli-0.26.23 → guild_cli-0.27.0}/tests/test_cli_whoami.py +0 -0
  141. {guild_cli-0.26.23 → guild_cli-0.27.0}/tests/test_configure_repo_sonar.py +0 -0
  142. {guild_cli-0.26.23 → guild_cli-0.27.0}/tests/test_create_gate.py +0 -0
  143. {guild_cli-0.26.23 → guild_cli-0.27.0}/tests/test_scaffold_harness.py +0 -0
  144. {guild_cli-0.26.23 → guild_cli-0.27.0}/tests/test_scaffold_harness_use.py +0 -0
  145. {guild_cli-0.26.23 → guild_cli-0.27.0}/tests/test_scaffold_instantiate.py +0 -0
  146. {guild_cli-0.26.23 → guild_cli-0.27.0}/tests/test_skills_convention.py +0 -0
  147. {guild_cli-0.26.23 → guild_cli-0.27.0}/tests/test_skills_identity.py +0 -0
  148. {guild_cli-0.26.23 → guild_cli-0.27.0}/tests/test_skills_ledger.py +0 -0
  149. {guild_cli-0.26.23 → guild_cli-0.27.0}/tests/test_skills_render.py +0 -0
  150. {guild_cli-0.26.23 → guild_cli-0.27.0}/tests/test_skills_sources.py +0 -0
  151. {guild_cli-0.26.23 → guild_cli-0.27.0}/tests/test_version_fallback.py +0 -0
@@ -45,12 +45,17 @@ python3 .claude/skills/version-bump/scripts/bump.py show
45
45
  ## Changelog JSON Format
46
46
 
47
47
  Pass via stdin. All fields are optional — only non-empty sections are rendered.
48
+ The only keys are `added`, `changed` and `fixed`, each a list of strings.
48
49
 
49
- Stdin is read until it closes, however slowly the JSON arrives. If it stays
50
- open with no new input for `BUMP_STDIN_IDLE_SECONDS` (default 5), the bump
51
- stops with an error and changes nothing, instead of hanging or dropping your
52
- entries. `--no-stdin` (or `BUMP_NO_STDIN=1`) skips stdin and writes the empty
53
- stub.
50
+ Stdin is read until it closes. Any pause longer than `BUMP_STDIN_IDLE_SECONDS`
51
+ (default 5), **including before the first byte**, stops the bump with an
52
+ error; raise it for a slow producer (an LLM call, a cold `git log`).
53
+ `--no-stdin` (or `BUMP_NO_STDIN=1`) skips stdin and writes the empty stub.
54
+
55
+ Invalid JSON, an unknown key (e.g. `removed`), a value that isn't a list of
56
+ strings, or bytes that don't decode are errors too, never a silent empty
57
+ stub. Every check runs before any file is written, so a failed bump changes
58
+ nothing; fix the input and run it again.
54
59
 
55
60
  ```json
56
61
  {
@@ -68,8 +73,10 @@ stub.
68
73
  - `CHANGELOG.md` — inserts a new `## [x.y.z] - YYYY-MM-DD` entry above the
69
74
  first `## [` release heading (headings inside fenced code blocks don't
70
75
  count). A leading `## [Unreleased]` section stays on top, untouched; its
71
- content is never moved into the release for you. A CHANGELOG with no release
72
- heading yet gets the entry after its Unreleased section, or at the end.
76
+ content is never moved into the release for you. A CHANGELOG with no `##`
77
+ heading yet gets the entry at the end, or after an Unreleased-only section.
78
+ One whose `##` headings aren't `## [...]` release headings (e.g. `## 1.2.3`)
79
+ stops the bump with an error.
73
80
 
74
81
  The script does the rest. Pick a bump type from the diff (patch for fixes,
75
82
  minor for new features, major for breaking changes), summarize the diff into
@@ -21,22 +21,28 @@ Changelog entries are passed via stdin as a JSON object:
21
21
  }
22
22
 
23
23
  If stdin is a terminal or empty, or --no-stdin (or BUMP_NO_STDIN=1) is given,
24
- an empty stub is inserted. Stdin is read until it closes; if it stays open
25
- with no new input for BUMP_STDIN_IDLE_SECONDS (default 5), the bump stops with
26
- an error and changes nothing, rather than hanging or dropping entries.
24
+ an empty stub is inserted. Otherwise stdin is read until it closes. Any pause
25
+ longer than BUMP_STDIN_IDLE_SECONDS (default 5), including before the first
26
+ byte, stops the bump with an error; raise it for a slow producer. Stdin that
27
+ isn't valid JSON in the shape above (an object whose keys are added / changed /
28
+ fixed, each a list of strings) is an error too. Every check runs before any
29
+ file is written, so a failed bump changes nothing.
27
30
 
28
31
  The new section goes above the first ``## [`` release heading, ignoring
29
32
  headings inside fenced code blocks. A leading ``## [Unreleased]`` section stays
30
- on top, untouched; a CHANGELOG with no release heading yet gets the section
31
- after its Unreleased section, or at the end.
33
+ on top, untouched. A CHANGELOG with no ``##`` heading yet gets the section at
34
+ the end, or after an Unreleased-only section; one whose ``##`` headings aren't
35
+ ``## [...]`` release headings (e.g. ``## 1.2.3``) is an error.
32
36
  """
33
37
 
38
+ import argparse
34
39
  import json
35
40
  import math
36
41
  import os
42
+ import queue
37
43
  import re
38
- import select
39
44
  import sys
45
+ import threading
40
46
  from datetime import date
41
47
  from pathlib import Path
42
48
 
@@ -91,6 +97,7 @@ def write_version(path: Path, old: str, new: str) -> None:
91
97
 
92
98
 
93
99
  STDIN_IDLE_SECONDS_DEFAULT = 5.0
100
+ SECTIONS = ("added", "changed", "fixed")
94
101
 
95
102
 
96
103
  def _env_flag(name: str) -> bool:
@@ -114,21 +121,85 @@ def _stdin_idle_seconds() -> float:
114
121
  return value
115
122
 
116
123
 
117
- def _read_stdin_until_eof(idle: float) -> str | None:
118
- """Read stdin to EOF; None if it stays open with no new input for ``idle`` s."""
119
- fd = sys.stdin.fileno()
120
- chunks = []
124
+ def _fail(message: str) -> None:
125
+ print(f"ERROR: {message}; nothing was changed.", file=sys.stderr)
126
+ sys.exit(1)
127
+
128
+
129
+ def _stdin_encoding() -> str:
130
+ return getattr(sys.stdin, "encoding", None) or "utf-8"
131
+
132
+
133
+ def _pump_stdin(out: queue.Queue) -> None:
134
+ """Thread body: put stdin's chunks (b"" at EOF) or the read error on ``out``.
135
+
136
+ Reads the raw fd with os.read, not the buffered sys.stdin, so a thread
137
+ still blocked in a read at exit can't hold the stdin buffer's lock during
138
+ interpreter shutdown.
139
+ """
140
+ try:
141
+ fd = sys.stdin.fileno()
142
+ except (OSError, ValueError): # a text-only stdin, e.g. io.StringIO
143
+ encoding = _stdin_encoding()
144
+
145
+ def read() -> bytes:
146
+ return sys.stdin.read(65536).encode(encoding)
147
+
148
+ else:
149
+
150
+ def read() -> bytes:
151
+ return os.read(fd, 65536)
152
+
153
+ try:
154
+ while True:
155
+ chunk = read()
156
+ out.put(chunk)
157
+ if not chunk:
158
+ return
159
+ except (OSError, ValueError) as exc:
160
+ out.put(exc)
161
+
162
+
163
+ def _read_stdin_until_eof(idle: float) -> bytes | None:
164
+ """Read stdin to EOF; None if no new input arrives within ``idle`` s."""
165
+ chunks: queue.Queue = queue.Queue()
166
+ threading.Thread(target=_pump_stdin, args=(chunks,), daemon=True).start()
167
+ data = []
121
168
  while True:
122
169
  try:
123
- ready, _, _ = select.select([fd], [], [], idle)
124
- except (OSError, ValueError):
125
- return sys.stdin.read() # select() can't poll this stdin (e.g. Windows): read as before
126
- if not ready:
170
+ chunk = chunks.get(timeout=idle)
171
+ except queue.Empty:
127
172
  return None
128
- chunk = os.read(fd, 65536)
173
+ if isinstance(chunk, Exception):
174
+ _fail(f"could not read stdin ({chunk})")
129
175
  if not chunk:
130
- return b"".join(chunks).decode()
131
- chunks.append(chunk)
176
+ return b"".join(data)
177
+ data.append(chunk)
178
+
179
+
180
+ def _parse_entries(raw: bytes) -> dict:
181
+ """Decode and validate changelog JSON; an error rather than a dropped entry."""
182
+ try:
183
+ text = raw.decode(_stdin_encoding()).strip()
184
+ except (UnicodeDecodeError, LookupError) as exc:
185
+ _fail(f"could not decode stdin as {_stdin_encoding()} ({exc})")
186
+ if not text:
187
+ return {}
188
+ try:
189
+ entries = json.loads(text)
190
+ except json.JSONDecodeError as exc:
191
+ _fail(f"stdin is not valid changelog JSON ({exc})")
192
+ if not isinstance(entries, dict):
193
+ _fail('changelog JSON must be an object like {"fixed": ["..."]}')
194
+ unknown = sorted(set(entries) - set(SECTIONS))
195
+ if unknown:
196
+ _fail(
197
+ f"unknown changelog key(s) {', '.join(map(repr, unknown))}; use {', '.join(SECTIONS)}"
198
+ )
199
+ for key, items in entries.items():
200
+ if not isinstance(items, list) or not all(isinstance(i, str) for i in items):
201
+ _fail(f"changelog key {key!r} must be a list of strings")
202
+ return entries
132
203
 
133
204
 
134
205
  def read_changelog_entries(no_stdin: bool = False) -> dict:
@@ -138,23 +209,13 @@ def read_changelog_entries(no_stdin: bool = False) -> dict:
138
209
  idle = _stdin_idle_seconds()
139
210
  raw = _read_stdin_until_eof(idle)
140
211
  if raw is None:
141
- print(
142
- f"ERROR: stdin stayed open with no input for {idle:g}s and never closed; nothing "
143
- "was changed. Pipe the changelog JSON (echo '{...}' | bump.py patch), or pass "
144
- "--no-stdin (or set BUMP_NO_STDIN=1) for an empty stub.",
145
- file=sys.stderr,
146
- )
147
- sys.exit(1)
148
- raw = raw.strip()
149
- if not raw:
150
- return {}
151
- try:
152
- return json.loads(raw)
153
- except (json.JSONDecodeError, ValueError):
154
- print(
155
- "WARNING: could not parse changelog JSON from stdin, using empty stub", file=sys.stderr
212
+ _fail(
213
+ f"stdin stayed open with no input for {idle:g}s and never closed. Pipe the "
214
+ "changelog JSON (echo '{...}' | bump.py patch), raise BUMP_STDIN_IDLE_SECONDS "
215
+ "for a slow producer, or pass --no-stdin (or set BUMP_NO_STDIN=1) for an "
216
+ "empty stub"
156
217
  )
157
- return {}
218
+ return _parse_entries(raw)
158
219
 
159
220
 
160
221
  def format_changelog_section(new: str, entries: dict) -> str:
@@ -167,35 +228,50 @@ def format_changelog_section(new: str, entries: dict) -> str:
167
228
  today = date.today().isoformat()
168
229
  chunks = [f"## [{new}] - {today}\n"]
169
230
 
170
- for section in ("added", "changed", "fixed"):
231
+ for section in SECTIONS:
171
232
  items = entries.get(section, [])
172
233
  if items:
173
234
  chunks.append(f"\n### {section.capitalize()}\n\n")
174
235
  chunks.extend(f"- {item}\n" for item in items)
175
236
 
176
237
  # If no entries at all, emit empty section stubs.
177
- if not any(entries.get(s) for s in ("added", "changed", "fixed")):
238
+ if not any(entries.get(s) for s in SECTIONS):
178
239
  chunks.append("\n### Added\n\n### Changed\n\n### Fixed\n")
179
240
 
180
241
  chunks.append("\n") # blank line before the next existing entry
181
242
  return "".join(chunks)
182
243
 
183
244
 
245
+ _FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$")
246
+
247
+
184
248
  def _section_headings(lines: list[str]) -> list[tuple[int, str]]:
185
- """``## `` headings outside fenced code blocks, as (line index, line)."""
186
- headings, fenced = [], False
249
+ """``## `` headings outside fenced code blocks, as (line index, line).
250
+
251
+ A fence opens with 3+ backticks or tildes (a backtick fence's info string
252
+ can't contain a backtick, so ```` ```x``` text ```` is inline code, not a
253
+ fence) and closes only on a line of the same character, at least as long,
254
+ with nothing after it.
255
+ """
256
+ headings, fence = [], None
187
257
  for i, line in enumerate(lines):
188
- if line.lstrip().startswith(("```", "~~~")):
189
- fenced = not fenced
190
- elif not fenced and line.startswith("## "):
258
+ match = _FENCE.match(line.rstrip("\r\n"))
259
+ if fence:
260
+ marker, info = match.groups() if match else ("", "x")
261
+ if marker[:1] == fence[0] and len(marker) >= len(fence) and not info.strip():
262
+ fence = None
263
+ elif match and not (match.group(1)[0] == "`" and "`" in match.group(2)):
264
+ fence = match.group(1)
265
+ elif line.startswith("## "):
191
266
  headings.append((i, line))
192
267
  return headings
193
268
 
194
269
 
195
- def _insertion_index(lines: list[str]) -> int:
270
+ def _insertion_index(lines: list[str]) -> int | None:
196
271
  """Line index for the new section: above the first ``## [`` release heading
197
272
  that isn't Unreleased, so a leading Unreleased section stays on top; after
198
- an Unreleased-only section, or at the end of a file with neither."""
273
+ an Unreleased-only section, or at the end of a file with no ``##`` heading.
274
+ None for ``##`` headings this script doesn't recognise (e.g. ``## 1.2.3``)."""
199
275
  headings = _section_headings(lines)
200
276
  unreleased = re.compile(r"^## \[Unreleased\]", re.IGNORECASE)
201
277
  releases = [i for i, line in headings if line.startswith("## [") and not unreleased.match(line)]
@@ -204,46 +280,60 @@ def _insertion_index(lines: list[str]) -> int:
204
280
  after = [i for i, line in headings if unreleased.match(line)]
205
281
  if after:
206
282
  return next((i for i, _ in headings if i > after[0]), len(lines))
207
- return len(lines)
283
+ return None if headings else len(lines)
208
284
 
209
285
 
210
- def update_changelog(project_root: Path, new: str, entries: dict) -> None:
211
- """Insert a new changelog entry into CHANGELOG.md."""
286
+ def plan_changelog(project_root: Path, new: str, entries: dict) -> tuple[Path, str] | None:
287
+ """The CHANGELOG path and its new text, without writing; None if there is none."""
212
288
  changelog = project_root / "CHANGELOG.md"
213
289
  if not changelog.exists():
214
- print("No CHANGELOG.md found — skipping")
215
- return
216
-
290
+ return None
217
291
  lines = changelog.read_text().splitlines(keepends=True)
218
292
  idx = _insertion_index(lines)
293
+ if idx is None:
294
+ _fail(
295
+ "CHANGELOG.md has ## headings but no '## [x.y.z]' release heading to insert "
296
+ "above (Keep a Changelog format)"
297
+ )
219
298
  before, after = "".join(lines[:idx]), "".join(lines[idx:])
220
299
  new_entry = format_changelog_section(new, entries)
221
300
  if not after: # appending at the end of the file
222
301
  before = before.rstrip("\n") + "\n\n" if before.strip() else ""
223
302
  new_entry = new_entry.rstrip("\n") + "\n"
224
- changelog.write_text(before + new_entry + after)
225
- print(f"Updated CHANGELOG.md with [{new}]")
303
+ return changelog, before + new_entry + after
304
+
305
+
306
+ def _parse_args(argv: list[str]) -> argparse.Namespace:
307
+ parser = argparse.ArgumentParser(
308
+ prog="bump.py",
309
+ description=__doc__,
310
+ allow_abbrev=False,
311
+ formatter_class=argparse.RawDescriptionHelpFormatter,
312
+ )
313
+ parser.add_argument("part", type=str.lower, choices=("major", "minor", "patch", "show"))
314
+ parser.add_argument(
315
+ "--no-stdin", action="store_true", help="don't read stdin; insert the empty stub"
316
+ )
317
+ if not argv:
318
+ parser.print_help()
319
+ sys.exit(0)
320
+ return parser.parse_args(argv)
226
321
 
227
322
 
228
323
  def main():
229
- args = [a for a in sys.argv[1:] if a != "--no-stdin"]
230
- no_stdin = len(args) < len(sys.argv) - 1 or _env_flag("BUMP_NO_STDIN")
231
- if not args or args[0] in ("-h", "--help"):
232
- print(__doc__.strip())
233
- sys.exit(0)
234
-
235
- part = args[0].lower()
324
+ args = _parse_args(sys.argv[1:])
236
325
  path = find_pyproject()
237
326
  current = read_version(path)
238
327
 
239
- if part == "show":
328
+ if args.part == "show":
240
329
  print(current)
241
330
  sys.exit(0)
242
331
 
243
- # Read changelog entries before bumping
244
- entries = read_changelog_entries(no_stdin)
332
+ # Every check runs before any write, so a failed bump changes nothing.
333
+ new = bump(current, args.part)
334
+ entries = read_changelog_entries(args.no_stdin or _env_flag("BUMP_NO_STDIN"))
335
+ changelog = plan_changelog(path.parent, new, entries)
245
336
 
246
- new = bump(current, part)
247
337
  write_version(path, current, new)
248
338
 
249
339
  # Also update __init__.py if it has __version__
@@ -262,8 +352,11 @@ def main():
262
352
  )
263
353
  print(f"Updated {init.relative_to(path.parent)}")
264
354
 
265
- # Update changelog
266
- update_changelog(path.parent, new, entries)
355
+ if changelog is None:
356
+ print("No CHANGELOG.md found — skipping")
357
+ else:
358
+ changelog[0].write_text(changelog[1])
359
+ print(f"Updated CHANGELOG.md with [{new}]")
267
360
 
268
361
  print(f"{current} -> {new}")
269
362
 
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
5
5
  Format follows [Keep a Changelog](https://keepachangelog.com/). This project
6
6
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.27.0] - 2026-10-30
9
+
10
+ ### Changed
11
+
12
+ - `version-bump`: every check runs before any file is written, so a failed bump changes nothing. That covers the bump type, stdin, the changelog JSON and where the new CHANGELOG section goes. A mistyped bump type now fails at once with argparse's "invalid choice" instead of first waiting on stdin, and unknown options such as `--nostdin` or abbreviations such as `--no` are rejected.
13
+ - `version-bump`: invalid changelog JSON, a non-object, an unknown key (e.g. `removed`), a value that isn't a list of strings, and stdin bytes that don't decode now stop the bump with an error instead of a warning plus an empty stub, so entries are never dropped silently.
14
+ - `version-bump`: SKILL.md now says plainly that a pause longer than `BUMP_STDIN_IDLE_SECONDS`, including before the first byte, is an error, and to raise it for a slow producer.
15
+
16
+ ### Fixed
17
+
18
+ - `version-bump`: fenced code blocks are recognised properly. A line like ```` ```bash``` one-liner ```` no longer opens a fence, a different or shorter fence marker no longer closes one, and the new section can no longer end up appended below the oldest release (regression in 0.26.23).
19
+ - `version-bump`: a CHANGELOG whose `##` headings aren't `## [...]` release headings (e.g. `## 1.2.3`) stops the bump with an error before `pyproject.toml` is touched. 0.26.23 appended the section at the end of the file; before that, the script warned but still bumped `pyproject.toml`.
20
+ - `version-bump`: stdin is read by a background thread with the same idle timeout instead of `select()`, so the no-hang behaviour also holds on Windows and for a text-only stdin such as `io.StringIO`, which used to crash on `fileno()`.
21
+
8
22
  ## [0.26.23] - 2026-10-30
9
23
 
10
24
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: guild-cli
3
- Version: 0.26.23
3
+ Version: 0.27.0
4
4
  Summary: guildmaster — an agent and CLI that manages skills for the AgentCulture mesh.
5
5
  Project-URL: Homepage, https://github.com/agentculture/guildmaster
6
6
  Project-URL: Issues, https://github.com/agentculture/guildmaster/issues
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "guild-cli"
3
- version = "0.26.23"
3
+ version = "0.27.0"
4
4
  description = "guildmaster — an agent and CLI that manages skills for the AgentCulture mesh."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -222,3 +222,125 @@ def test_changelog_without_a_version_heading_gets_the_section_appended(tmp_path)
222
222
  assert (repo / "CHANGELOG.md").read_text() == (
223
223
  f"# Changelog\n\nAll notable changes.\n\n## [0.1.1] - {TODAY}\n\n### Fixed\n\n- Bug\n"
224
224
  )
225
+
226
+
227
+ # ---- review follow-up: every failure changes nothing --------------------------
228
+
229
+
230
+ def _assert_rejected(result, *needles):
231
+ assert result.returncode != 0
232
+ for needle in needles:
233
+ assert needle in result.stderr
234
+ assert "nothing was changed" in result.stderr
235
+
236
+
237
+ @pytest.mark.parametrize(
238
+ "fenced",
239
+ [
240
+ "```bash``` is inline code, not a fence\n\n",
241
+ "```\n~~~\n## [9.9.9] - inside\n```\n\n",
242
+ "````\n```\n## [9.9.9] - inside\n```\n````\n\n",
243
+ "~~~~\n## [9.9.9] - inside\n~~~\n~~~~\n\n",
244
+ ],
245
+ ids=["inline-backticks", "tilde-in-backtick", "longer-fence", "short-close"],
246
+ )
247
+ def test_fences_never_hide_the_release_heading(tmp_path, fenced):
248
+ repo = _repo(tmp_path, changelog=f"# Changelog\n\n{fenced}## [0.1.0] - 2026-01-01\n")
249
+ assert _bump(repo, '{"fixed": ["Bug"]}').returncode == 0
250
+ text = (repo / "CHANGELOG.md").read_text()
251
+ assert text.index(f"## [0.1.1] - {TODAY}") < text.index("## [0.1.0]")
252
+ assert text.endswith("## [0.1.0] - 2026-01-01\n")
253
+
254
+
255
+ def test_unrecognised_release_headings_stop_the_bump(tmp_path):
256
+ changelog = "# Changelog\n\n## 0.1.0 - 2026-01-01\n\n- old\n"
257
+ repo = _repo(tmp_path, changelog=changelog)
258
+ _assert_rejected(_bump(repo, '{"fixed": ["Bug"]}'), "## [x.y.z]")
259
+ assert _unchanged(repo, changelog)
260
+
261
+
262
+ @pytest.mark.parametrize(
263
+ "stdin, needle",
264
+ [
265
+ ('{"fixed": ["unterminated"', "not valid changelog JSON"),
266
+ ('["fixed"]', "must be an object"),
267
+ ('{"removed": ["Old flag"]}', "unknown changelog key(s) 'removed'"),
268
+ ('{"fixed": "not a list"}', "must be a list of strings"),
269
+ ('{"fixed": [1]}', "must be a list of strings"),
270
+ ],
271
+ )
272
+ def test_bad_changelog_json_stops_the_bump(tmp_path, stdin, needle):
273
+ repo = _repo(tmp_path)
274
+ _assert_rejected(_bump(repo, stdin), needle)
275
+ assert _unchanged(repo)
276
+
277
+
278
+ def test_undecodable_stdin_stops_the_bump(tmp_path):
279
+ repo = _repo(tmp_path)
280
+ result = subprocess.run(
281
+ [sys.executable, str(BUMP), "patch"],
282
+ cwd=repo,
283
+ input='{"fixed": ["caf\xe9"]}'.encode("latin-1"),
284
+ capture_output=True,
285
+ timeout=30,
286
+ env=_env(PYTHONIOENCODING="utf-8"),
287
+ )
288
+ assert result.returncode != 0
289
+ assert b"could not decode stdin" in result.stderr
290
+ assert b"Traceback" not in result.stderr
291
+ assert _unchanged(repo)
292
+
293
+
294
+ def test_unknown_bump_type_fails_before_reading_stdin(tmp_path):
295
+ repo = _repo(tmp_path)
296
+ start = time.monotonic()
297
+ proc = subprocess.Popen(
298
+ [sys.executable, str(BUMP), "pach"],
299
+ cwd=repo,
300
+ stdin=subprocess.PIPE,
301
+ stderr=subprocess.PIPE,
302
+ text=True,
303
+ env=_env(BUMP_STDIN_IDLE_SECONDS="60"),
304
+ )
305
+ try:
306
+ proc.wait(timeout=30)
307
+ err = proc.stderr.read()
308
+ except subprocess.TimeoutExpired:
309
+ proc.kill()
310
+ raise
311
+ finally:
312
+ proc.stdin.close()
313
+ proc.stderr.close()
314
+ assert proc.returncode != 0
315
+ assert "invalid choice: 'pach'" in err
316
+ assert time.monotonic() - start < 20
317
+ assert _unchanged(repo)
318
+
319
+
320
+ @pytest.mark.parametrize("bad", ["--nostdin", "--no-stdin=1", "--no_stdin", "--no"])
321
+ def test_unknown_options_are_rejected(tmp_path, bad):
322
+ repo = _repo(tmp_path)
323
+ result = _bump(repo, "", bad)
324
+ assert result.returncode == 2
325
+ assert "bump.py: error:" in result.stderr
326
+ assert _unchanged(repo)
327
+
328
+
329
+ def test_no_arguments_prints_help(tmp_path):
330
+ result = subprocess.run(
331
+ [sys.executable, str(BUMP)], cwd=_repo(tmp_path), capture_output=True, text=True, timeout=30
332
+ )
333
+ assert result.returncode == 0
334
+ assert "Bump the version in pyproject.toml" in result.stdout
335
+
336
+
337
+ def test_text_only_stdin_is_read_in_process(monkeypatch):
338
+ # Review of culture#503: fileno() on an io.StringIO stdin used to crash.
339
+ import importlib.util
340
+ import io
341
+
342
+ spec = importlib.util.spec_from_file_location("bump_under_test", BUMP)
343
+ module = importlib.util.module_from_spec(spec)
344
+ spec.loader.exec_module(module)
345
+ monkeypatch.setattr(sys, "stdin", io.StringIO('{"fixed": ["In process"]}'))
346
+ assert module.read_changelog_entries() == {"fixed": ["In process"]}
@@ -179,7 +179,7 @@ wheels = [
179
179
 
180
180
  [[package]]
181
181
  name = "guild-cli"
182
- version = "0.26.23"
182
+ version = "0.27.0"
183
183
  source = { editable = "." }
184
184
  dependencies = [
185
185
  { name = "pyyaml" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes