agent-devex 0.15.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 (150) hide show
  1. agent_devex-0.15.0/.flake8 +8 -0
  2. agent_devex-0.15.0/.github/workflows/docs.yml +146 -0
  3. agent_devex-0.15.0/.github/workflows/publish.yml +449 -0
  4. agent_devex-0.15.0/.github/workflows/test.yml +106 -0
  5. agent_devex-0.15.0/.gitignore +214 -0
  6. agent_devex-0.15.0/.python-version +1 -0
  7. agent_devex-0.15.0/CHANGELOG.md +501 -0
  8. agent_devex-0.15.0/CLAUDE.md +92 -0
  9. agent_devex-0.15.0/LICENSE +21 -0
  10. agent_devex-0.15.0/PKG-INFO +56 -0
  11. agent_devex-0.15.0/README.md +29 -0
  12. agent_devex-0.15.0/docs/.gitignore +6 -0
  13. agent_devex-0.15.0/docs/404.md +16 -0
  14. agent_devex-0.15.0/docs/Gemfile +8 -0
  15. agent_devex-0.15.0/docs/_config.yml +71 -0
  16. agent_devex-0.15.0/docs/_includes/head_custom.html +8 -0
  17. agent_devex-0.15.0/docs/_sass/color_schemes/anthropic.scss +34 -0
  18. agent_devex-0.15.0/docs/_sass/color_schemes/dark-terminal.scss +40 -0
  19. agent_devex-0.15.0/docs/_sass/custom/custom.scss +348 -0
  20. agent_devex-0.15.0/docs/assets/images/apple-touch-icon.png +0 -0
  21. agent_devex-0.15.0/docs/assets/images/favicon-16x16.png +0 -0
  22. agent_devex-0.15.0/docs/assets/images/favicon-32x32.png +0 -0
  23. agent_devex-0.15.0/docs/assets/images/favicon.ico +0 -0
  24. agent_devex-0.15.0/docs/assets/images/og-agex.png +0 -0
  25. agent_devex-0.15.0/docs/assets/images/og-culture.png +0 -0
  26. agent_devex-0.15.0/docs/commands/explain.md +27 -0
  27. agent_devex-0.15.0/docs/commands/gamify.md +32 -0
  28. agent_devex-0.15.0/docs/commands/hook.md +32 -0
  29. agent_devex-0.15.0/docs/commands/index.md +10 -0
  30. agent_devex-0.15.0/docs/commands/learn.md +22 -0
  31. agent_devex-0.15.0/docs/commands/overview.md +32 -0
  32. agent_devex-0.15.0/docs/getting-started.md +39 -0
  33. agent_devex-0.15.0/docs/index.md +29 -0
  34. agent_devex-0.15.0/docs/superpowers/plans/2026-04-18-agex-v0.1.md +3920 -0
  35. agent_devex-0.15.0/docs/superpowers/specs/2026-04-18-agex-design.md +442 -0
  36. agent_devex-0.15.0/docs/superpowers/specs/2026-04-26-agex-doctor.md +89 -0
  37. agent_devex-0.15.0/pyproject.toml +60 -0
  38. agent_devex-0.15.0/scripts/sync_skill_md.py +80 -0
  39. agent_devex-0.15.0/sonar-project.properties +46 -0
  40. agent_devex-0.15.0/src/agent_experience/__init__.py +23 -0
  41. agent_devex-0.15.0/src/agent_experience/__main__.py +4 -0
  42. agent_devex-0.15.0/src/agent_experience/backends/__init__.py +0 -0
  43. agent_devex-0.15.0/src/agent_experience/backends/acp/__init__.py +0 -0
  44. agent_devex-0.15.0/src/agent_experience/backends/acp/probe.py +9 -0
  45. agent_devex-0.15.0/src/agent_experience/backends/capabilities/acp.yaml +7 -0
  46. agent_devex-0.15.0/src/agent_experience/backends/capabilities/claude-code.yaml +4 -0
  47. agent_devex-0.15.0/src/agent_experience/backends/capabilities/codex.yaml +7 -0
  48. agent_devex-0.15.0/src/agent_experience/backends/capabilities/copilot.yaml +7 -0
  49. agent_devex-0.15.0/src/agent_experience/backends/claude_code/__init__.py +0 -0
  50. agent_devex-0.15.0/src/agent_experience/backends/claude_code/probe.py +97 -0
  51. agent_devex-0.15.0/src/agent_experience/backends/codex/__init__.py +0 -0
  52. agent_devex-0.15.0/src/agent_experience/backends/codex/probe.py +16 -0
  53. agent_devex-0.15.0/src/agent_experience/backends/copilot/__init__.py +0 -0
  54. agent_devex-0.15.0/src/agent_experience/backends/copilot/probe.py +9 -0
  55. agent_devex-0.15.0/src/agent_experience/cli.py +186 -0
  56. agent_devex-0.15.0/src/agent_experience/commands/__init__.py +0 -0
  57. agent_devex-0.15.0/src/agent_experience/commands/doctor/SKILL.md +41 -0
  58. agent_devex-0.15.0/src/agent_experience/commands/doctor/__init__.py +0 -0
  59. agent_devex-0.15.0/src/agent_experience/commands/doctor/assets/report.md.j2 +39 -0
  60. agent_devex-0.15.0/src/agent_experience/commands/doctor/references/design.md +36 -0
  61. agent_devex-0.15.0/src/agent_experience/commands/doctor/scripts/__init__.py +0 -0
  62. agent_devex-0.15.0/src/agent_experience/commands/doctor/scripts/doctor.py +391 -0
  63. agent_devex-0.15.0/src/agent_experience/commands/explain/SKILL.md +26 -0
  64. agent_devex-0.15.0/src/agent_experience/commands/explain/__init__.py +0 -0
  65. agent_devex-0.15.0/src/agent_experience/commands/explain/assets/topics/agex.md +37 -0
  66. agent_devex-0.15.0/src/agent_experience/commands/explain/references/.gitkeep +0 -0
  67. agent_devex-0.15.0/src/agent_experience/commands/explain/scripts/__init__.py +0 -0
  68. agent_devex-0.15.0/src/agent_experience/commands/explain/scripts/explain.py +63 -0
  69. agent_devex-0.15.0/src/agent_experience/commands/gamify/SKILL.md +31 -0
  70. agent_devex-0.15.0/src/agent_experience/commands/gamify/__init__.py +0 -0
  71. agent_devex-0.15.0/src/agent_experience/commands/gamify/assets/hooks/claude-code.json +28 -0
  72. agent_devex-0.15.0/src/agent_experience/commands/gamify/references/.gitkeep +0 -0
  73. agent_devex-0.15.0/src/agent_experience/commands/gamify/scripts/__init__.py +0 -0
  74. agent_devex-0.15.0/src/agent_experience/commands/gamify/scripts/install.py +201 -0
  75. agent_devex-0.15.0/src/agent_experience/commands/hook/SKILL.md +31 -0
  76. agent_devex-0.15.0/src/agent_experience/commands/hook/__init__.py +0 -0
  77. agent_devex-0.15.0/src/agent_experience/commands/hook/assets/table.md.j2 +17 -0
  78. agent_devex-0.15.0/src/agent_experience/commands/hook/references/.gitkeep +0 -0
  79. agent_devex-0.15.0/src/agent_experience/commands/hook/scripts/__init__.py +0 -0
  80. agent_devex-0.15.0/src/agent_experience/commands/hook/scripts/read.py +38 -0
  81. agent_devex-0.15.0/src/agent_experience/commands/hook/scripts/write.py +24 -0
  82. agent_devex-0.15.0/src/agent_experience/commands/learn/SKILL.md +21 -0
  83. agent_devex-0.15.0/src/agent_experience/commands/learn/__init__.py +0 -0
  84. agent_devex-0.15.0/src/agent_experience/commands/learn/assets/menu.md.j2 +7 -0
  85. agent_devex-0.15.0/src/agent_experience/commands/learn/assets/topics/gamify/SKILL.md +35 -0
  86. agent_devex-0.15.0/src/agent_experience/commands/learn/assets/topics/gamify/assets/skill-template/claude-code/SKILL.md +22 -0
  87. agent_devex-0.15.0/src/agent_experience/commands/learn/assets/topics/introspect/SKILL.md +41 -0
  88. agent_devex-0.15.0/src/agent_experience/commands/learn/assets/topics/introspect/assets/skill-template/claude-code/SKILL.md +22 -0
  89. agent_devex-0.15.0/src/agent_experience/commands/learn/assets/topics/levelup/SKILL.md +31 -0
  90. agent_devex-0.15.0/src/agent_experience/commands/learn/assets/topics/levelup/assets/skill-template/claude-code/SKILL.md +22 -0
  91. agent_devex-0.15.0/src/agent_experience/commands/learn/assets/topics/visualize/SKILL.md +27 -0
  92. agent_devex-0.15.0/src/agent_experience/commands/learn/assets/topics/visualize/assets/skill-template/claude-code/SKILL.md +19 -0
  93. agent_devex-0.15.0/src/agent_experience/commands/learn/references/.gitkeep +0 -0
  94. agent_devex-0.15.0/src/agent_experience/commands/learn/scripts/__init__.py +0 -0
  95. agent_devex-0.15.0/src/agent_experience/commands/learn/scripts/learn.py +72 -0
  96. agent_devex-0.15.0/src/agent_experience/commands/overview/SKILL.md +31 -0
  97. agent_devex-0.15.0/src/agent_experience/commands/overview/__init__.py +0 -0
  98. agent_devex-0.15.0/src/agent_experience/commands/overview/assets/backends/acp.yaml +7 -0
  99. agent_devex-0.15.0/src/agent_experience/commands/overview/assets/backends/claude-code.yaml +7 -0
  100. agent_devex-0.15.0/src/agent_experience/commands/overview/assets/backends/codex.yaml +7 -0
  101. agent_devex-0.15.0/src/agent_experience/commands/overview/assets/backends/copilot.yaml +7 -0
  102. agent_devex-0.15.0/src/agent_experience/commands/overview/assets/sections.md.j2 +52 -0
  103. agent_devex-0.15.0/src/agent_experience/commands/overview/references/.gitkeep +0 -0
  104. agent_devex-0.15.0/src/agent_experience/commands/overview/scripts/__init__.py +0 -0
  105. agent_devex-0.15.0/src/agent_experience/commands/overview/scripts/overview.py +40 -0
  106. agent_devex-0.15.0/src/agent_experience/core/__init__.py +0 -0
  107. agent_devex-0.15.0/src/agent_experience/core/backend.py +16 -0
  108. agent_devex-0.15.0/src/agent_experience/core/capabilities.py +44 -0
  109. agent_devex-0.15.0/src/agent_experience/core/config.py +42 -0
  110. agent_devex-0.15.0/src/agent_experience/core/hook_io.py +95 -0
  111. agent_devex-0.15.0/src/agent_experience/core/paths.py +26 -0
  112. agent_devex-0.15.0/src/agent_experience/core/render.py +31 -0
  113. agent_devex-0.15.0/src/agent_experience/core/skill_loader.py +36 -0
  114. agent_devex-0.15.0/tester-agents/claude/.claude/settings.json +5 -0
  115. agent_devex-0.15.0/tester-agents/claude/CLAUDE.md +30 -0
  116. agent_devex-0.15.0/tester-agents/claude/README.md +23 -0
  117. agent_devex-0.15.0/tester-agents/claude/culture.yaml +18 -0
  118. agent_devex-0.15.0/tests/__init__.py +0 -0
  119. agent_devex-0.15.0/tests/backends/__init__.py +0 -0
  120. agent_devex-0.15.0/tests/backends/test_claude_code_probe.py +63 -0
  121. agent_devex-0.15.0/tests/backends/test_stub_probes.py +21 -0
  122. agent_devex-0.15.0/tests/commands/__init__.py +0 -0
  123. agent_devex-0.15.0/tests/commands/test_doctor.py +183 -0
  124. agent_devex-0.15.0/tests/commands/test_explain.py +33 -0
  125. agent_devex-0.15.0/tests/commands/test_gamify.py +173 -0
  126. agent_devex-0.15.0/tests/commands/test_hook.py +63 -0
  127. agent_devex-0.15.0/tests/commands/test_learn.py +57 -0
  128. agent_devex-0.15.0/tests/commands/test_overview.py +55 -0
  129. agent_devex-0.15.0/tests/core/__init__.py +0 -0
  130. agent_devex-0.15.0/tests/core/test_backend.py +27 -0
  131. agent_devex-0.15.0/tests/core/test_capabilities.py +35 -0
  132. agent_devex-0.15.0/tests/core/test_config.py +25 -0
  133. agent_devex-0.15.0/tests/core/test_hook_io.py +138 -0
  134. agent_devex-0.15.0/tests/core/test_paths.py +31 -0
  135. agent_devex-0.15.0/tests/core/test_render.py +21 -0
  136. agent_devex-0.15.0/tests/core/test_skill_loader.py +45 -0
  137. agent_devex-0.15.0/tests/core/test_version_lookup.py +67 -0
  138. agent_devex-0.15.0/tests/fixtures/claude-code/empty/.gitkeep +0 -0
  139. agent_devex-0.15.0/tests/fixtures/claude-code/malformed/.claude/hooks.json +1 -0
  140. agent_devex-0.15.0/tests/fixtures/claude-code/malformed/.claude/settings.json +1 -0
  141. agent_devex-0.15.0/tests/fixtures/claude-code/malformed/.claude/skills/bad/SKILL.md +1 -0
  142. agent_devex-0.15.0/tests/fixtures/claude-code/malformed/.claude/skills/broken-yaml/SKILL.md +6 -0
  143. agent_devex-0.15.0/tests/fixtures/claude-code/typical/.claude/hooks.json +5 -0
  144. agent_devex-0.15.0/tests/fixtures/claude-code/typical/.claude/settings.json +5 -0
  145. agent_devex-0.15.0/tests/fixtures/claude-code/typical/.claude/skills/example/SKILL.md +6 -0
  146. agent_devex-0.15.0/tests/fixtures/claude-code/typical/CLAUDE.md +3 -0
  147. agent_devex-0.15.0/tests/test_cli_errors.py +150 -0
  148. agent_devex-0.15.0/tests/test_cli_smoke.py +11 -0
  149. agent_devex-0.15.0/tests/test_skill_md_consistency.py +80 -0
  150. agent_devex-0.15.0/uv.lock +816 -0
@@ -0,0 +1,8 @@
1
+ [flake8]
2
+ # Match pyproject.toml's [tool.black] line-length (100) and skip E203
3
+ # ("whitespace before ':'") because black's preferred slice style conflicts
4
+ # with PEP 8 on that one rule. The combination is the documented "black +
5
+ # flake8" compatibility settings per black's own docs.
6
+ max-line-length = 100
7
+ extend-ignore =
8
+ E203,
@@ -0,0 +1,146 @@
1
+ name: docs
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ paths:
7
+ - "docs/**"
8
+ - "src/agent_experience/commands/**/SKILL.md"
9
+ - "src/agent_experience/core/skill_loader.py"
10
+ - "scripts/sync_skill_md.py"
11
+ - ".github/workflows/docs.yml"
12
+ pull_request:
13
+ paths:
14
+ - "docs/**"
15
+ - "src/agent_experience/commands/**/SKILL.md"
16
+ - "src/agent_experience/core/skill_loader.py"
17
+ - "scripts/sync_skill_md.py"
18
+ - ".github/workflows/docs.yml"
19
+ workflow_dispatch:
20
+
21
+ permissions:
22
+ contents: read
23
+ deployments: write
24
+ pull-requests: write # for posting the preview URL comment on PRs
25
+
26
+ jobs:
27
+ build-and-deploy:
28
+ # Pull requests from forks cannot see the Cloudflare secrets (GitHub
29
+ # masks them for security), so the deploy step would fail. Skip the
30
+ # whole job for fork PRs and let reviewers rely on local `bundle exec
31
+ # jekyll serve`. This repo is single-owner today, so in practice
32
+ # every PR comes from a branch within agentculture/agex-cli and hits the
33
+ # fast path.
34
+ if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
35
+ runs-on: ubuntu-latest
36
+ steps:
37
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
38
+
39
+ # Refresh docs/commands/*.md from the ground-truth SKILL.md files so
40
+ # the deployed site cannot drift from what the CLI actually ships.
41
+ - uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3
42
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
43
+ with:
44
+ python-version: "3.11"
45
+ # Match the install pattern used by .github/workflows/build.yml -- the
46
+ # repo does not commit a uv.lock, so `uv venv` + `uv pip install -e .`
47
+ # is the reproducible shape across every workflow.
48
+ - run: uv venv
49
+ - run: uv pip install -e "."
50
+ - run: uv run python scripts/sync_skill_md.py
51
+
52
+ # Jekyll build.
53
+ - uses: ruby/setup-ruby@7372622e62b60b3cb750dcd2b9e32c247ffec26a # v1
54
+ with:
55
+ ruby-version: "3.2"
56
+ bundler-cache: true
57
+ working-directory: docs
58
+ - run: bundle exec jekyll build
59
+ working-directory: docs
60
+
61
+ # Compute the Cloudflare Pages branch name from the PR's head ref.
62
+ # CF Pages uses the branch name as a subdomain component, so only
63
+ # `[a-zA-Z0-9-]` are safe: `/`, `.`, whitespace, and other chars
64
+ # produce a failing branch-alias URL. Replace unsafe chars with `-`,
65
+ # collapse consecutive dashes, and strip leading/trailing dashes.
66
+ # Only used on pull_request events -- push and workflow_dispatch
67
+ # always deploy `--branch=main` (the production branch).
68
+ - name: Compute preview branch
69
+ id: branch
70
+ if: github.event_name == 'pull_request'
71
+ env:
72
+ # `github.head_ref` is attacker-controllable (branch name on a PR
73
+ # from a fork). Pass it through an env var rather than expanding
74
+ # it inline into the `run:` script -- env values are not re-parsed
75
+ # by the shell, so the sed sanitiser can no longer be bypassed by
76
+ # crafted branch names containing shell metacharacters.
77
+ HEAD_REF: ${{ github.head_ref }}
78
+ PR_NUMBER: ${{ github.event.pull_request.number }}
79
+ run: |
80
+ safe=$(printf '%s' "$HEAD_REF" | sed 's/[^a-zA-Z0-9-]/-/g; s/--*/-/g; s/^-//; s/-$//')
81
+ # Defence-in-depth: a head_ref of "---" (or other input that is
82
+ # fully stripped by the sanitiser) would yield an empty `safe`,
83
+ # and the deploy step's `... && steps.branch.outputs.name ||
84
+ # 'main'` fallback would then resolve to `'main'` -- publishing
85
+ # PR content to the production alias. Substitute a deterministic
86
+ # per-PR name instead.
87
+ if [ -z "$safe" ]; then
88
+ safe="pr-${PR_NUMBER}"
89
+ fi
90
+ echo "name=$safe" >> "$GITHUB_OUTPUT"
91
+
92
+ # Deploy to Cloudflare Pages.
93
+ # - push / workflow_dispatch -> `--branch=main` = production
94
+ # - pull_request -> `--branch=<sanitized-head-ref>` = preview
95
+ # The project `agex` is a Direct Upload project, so CF won't auto-
96
+ # build on its own; this step IS the build+deploy pipeline.
97
+ - name: Deploy to Cloudflare Pages
98
+ id: deploy
99
+ uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3
100
+ with:
101
+ apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
102
+ accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
103
+ command: pages deploy docs/_site --project-name=agex --branch=${{ github.event_name == 'pull_request' && steps.branch.outputs.name || 'main' }}
104
+
105
+ # Post the preview URL back on the PR so reviewers can click through
106
+ # without digging into the workflow logs. Uses a marker
107
+ # (`<!-- agex-docs-preview -->`) to update the existing comment on
108
+ # subsequent pushes instead of spamming the PR thread with a new
109
+ # comment per workflow run. `gh` is preinstalled on GitHub runners
110
+ # so no extra SHA-pinned action is needed. Runs only on PR events.
111
+ - name: Post (or update) preview URL comment
112
+ if: github.event_name == 'pull_request' && steps.deploy.outputs.deployment-url != ''
113
+ env:
114
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
115
+ PR_NUMBER: ${{ github.event.pull_request.number }}
116
+ DEPLOY_URL: ${{ steps.deploy.outputs.deployment-url }}
117
+ BRANCH: ${{ steps.branch.outputs.name }}
118
+ REPO: ${{ github.repository }}
119
+ run: |
120
+ set -e
121
+ MARKER='<!-- agex-docs-preview -->'
122
+ ALIAS_URL="https://${BRANCH}.agex.pages.dev"
123
+ # Two URLs intentionally:
124
+ # * deploy URL (hash) -- immutable pin for THIS deploy, ideal for
125
+ # review bookmarks that should not drift.
126
+ # * branch-alias URL -- stays the same as you push new commits;
127
+ # always reflects the latest preview.
128
+ BODY="$MARKER"$'\n'"📘 **docs preview** for this PR:"$'\n\n'"- **Latest deploy:** $DEPLOY_URL _(immutable — this specific commit)_"$'\n'"- **Branch alias:** $ALIAS_URL _(always latest — updates on each push)_"
129
+ # `gh api --paginate` streams one JSON array per page; `jq -s`
130
+ # ("slurp") collects them into `[[page1], [page2], ...]`, `add`
131
+ # flattens to a single array, then the filter picks our marker.
132
+ # `gh api --slurp --jq` itself is mutually exclusive (see
133
+ # https://github.com/cli/cli/issues/8615), so the slurp has to
134
+ # happen inside jq. Without the pagination + slurp, PRs with
135
+ # more than 30 comments would miss the marker and post
136
+ # duplicate comments on each run.
137
+ EXISTING=$(gh api --paginate "repos/$REPO/issues/$PR_NUMBER/comments" \
138
+ | jq -rs "add | [.[] | select(.body | contains(\"$MARKER\")) | .id] | first // empty")
139
+ if [ -n "$EXISTING" ]; then
140
+ gh api --method PATCH "repos/$REPO/issues/comments/$EXISTING" \
141
+ -f body="$BODY" > /dev/null
142
+ echo "Updated existing preview comment #$EXISTING"
143
+ else
144
+ gh pr comment "$PR_NUMBER" --body "$BODY"
145
+ echo "Created new preview comment"
146
+ fi
@@ -0,0 +1,449 @@
1
+ name: publish
2
+
3
+ # Publishes sdist + wheel with the shape:
4
+ #
5
+ # - PR → TestPyPI (dev version -- per-PR installable preview)
6
+ # - Push to main → TestPyPI (stable version, canary)
7
+ # ↓ if v<version> tag doesn't exist yet
8
+ # → autotag (creates + pushes the tag)
9
+ # ↓
10
+ # → PyPI (real release, only when autotag succeeded)
11
+ # ↓
12
+ # → GitHub Release with the matching CHANGELOG section
13
+ #
14
+ # Manual tagging is NOT required -- the `autotag` job turns the version field
15
+ # in `pyproject.toml` into the release signal. Forgetting to bump is caught
16
+ # on the PR by the `version-check` job in test.yml, which fails with a sticky
17
+ # comment pointing at `/version-bump`.
18
+ #
19
+ # The `v*` tag trigger is deliberately absent: tags are created by this
20
+ # workflow (via GITHUB_TOKEN, which by design does not retrigger workflows
21
+ # to prevent loops), and PyPI publish runs inline in the same workflow run.
22
+ # A tag pushed manually from a laptop would therefore not fire a publish --
23
+ # if you ever need that, use `gh workflow run` on this file's main branch
24
+ # or add a `workflow_dispatch:` trigger.
25
+ #
26
+ # Trusted Publishing (OIDC) is used in every publish job -- no API tokens.
27
+ # One-time setup on pypi.org / test.pypi.org and in the repo's Environments
28
+ # (`pypi`, `testpypi`) is documented in the original header below:
29
+ #
30
+ # - PyPI project `agex-cli` → Publishing → Add a GitHub pending publisher
31
+ # Owner: OriNachum Repo: agex
32
+ # Workflow: publish.yml Environment: pypi
33
+ #
34
+ # - TestPyPI project `agex-cli` → same shape, Environment: testpypi
35
+ #
36
+ # - GitHub repo → Settings → Environments → create `pypi` and `testpypi`
37
+ # (any required reviewers / wait timers live there)
38
+ #
39
+ # The PR job rewrites `version = "X.Y.Z"` to `"X.Y.Z.devN"` where N is
40
+ # the workflow run number. PEP 440's dev segment is a single integer
41
+ # (internal dots aren't allowed), so run_attempt is NOT encoded in the
42
+ # version -- instead `skip-existing: true` on the TestPyPI upload makes
43
+ # reruns idempotent. PEP 440 orders `.devN` strictly below the real
44
+ # release, so dev wheels never overshadow stable ones on TestPyPI. The
45
+ # PR job comments the exact install command back on the PR (sticky:
46
+ # edits the comment in place on reruns).
47
+
48
+ on:
49
+ push:
50
+ branches: [main]
51
+ pull_request:
52
+ paths:
53
+ - "pyproject.toml"
54
+ - "src/**"
55
+ - ".github/workflows/publish.yml"
56
+
57
+ # Permissions are declared per-job (least privilege). There is no
58
+ # workflow-level `permissions:` block -- a job-level block fully replaces
59
+ # any workflow-level default anyway, so declaring at job level keeps the
60
+ # scope each job actually needs visible in one place.
61
+
62
+ jobs:
63
+ # --------------------------------------------------------------------------
64
+ # Shared build job for the main-push path. Produces one `dist-<dist_name>`
65
+ # artifact per matrix entry, consumed by publish-testpypi (canary) and
66
+ # publish-pypi (real release, inline after autotag). Skipped on
67
+ # pull_request -- the PR path has its own build inside `publish-pr-testpypi`
68
+ # because it needs to rewrite the version before building.
69
+ #
70
+ # The matrix exists so we can publish the same code under two PyPI
71
+ # distribution names: `agex-cli` (canonical) and `agent-devex` (alias).
72
+ # Each leg rewrites `[project].name` in pyproject.toml before building so
73
+ # each wheel is uploaded under its own Trusted Publishing project.
74
+ # --------------------------------------------------------------------------
75
+ build:
76
+ if: github.event_name == 'push'
77
+ runs-on: ubuntu-latest
78
+ strategy:
79
+ fail-fast: false
80
+ matrix:
81
+ dist_name: [agex-cli, agent-devex]
82
+ permissions:
83
+ contents: read # actions/checkout
84
+ steps:
85
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
86
+ - uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3
87
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
88
+ with:
89
+ python-version: "3.11"
90
+
91
+ - run: uv venv
92
+ - run: uv pip install build
93
+
94
+ # Rewrite `[project].name` to the matrix dist name. For `agex-cli`
95
+ # (the canonical dist) the regex still has to match so we re-emit
96
+ # the line; for `agent-devex` it produces an alias dist with
97
+ # identical contents. The post-rewrite re-parse is a fail-closed
98
+ # check: if a future formatting change breaks the regex match (or
99
+ # `[project].name` ends up wrong for any reason), the job aborts
100
+ # before uploading mis-named artifacts.
101
+ - name: Set dist name
102
+ env:
103
+ DIST_NAME: ${{ matrix.dist_name }}
104
+ run: |
105
+ set -e
106
+ uv run python -c "
107
+ import os, re, tomllib
108
+ dist = os.environ['DIST_NAME']
109
+ assert re.fullmatch(r'[a-z][a-z0-9-]*', dist), dist
110
+ data = open('pyproject.toml').read()
111
+ parsed = tomllib.loads(data)['project']
112
+ out, n = re.subn(r'(?m)^name = .+', f'name = \"{dist}\"', data, count=1)
113
+ assert n == 1, f'expected one [project].name match, got {n}'
114
+ rewritten = tomllib.loads(out)['project']['name']
115
+ assert rewritten == dist, f'rewrite produced name={rewritten!r}, expected {dist!r}'
116
+ open('pyproject.toml', 'w').write(out)
117
+ print(f'Building dist {dist} version {parsed[\"version\"]}')
118
+ "
119
+
120
+ # Build sdist + wheel via PEP 517. `build` is the canonical frontend;
121
+ # we avoid pulling in full dev extras here since the build backend
122
+ # (hatchling) reads everything it needs from pyproject.toml.
123
+ - run: uv run python -m build
124
+
125
+ - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
126
+ with:
127
+ name: dist-${{ matrix.dist_name }}
128
+ path: dist/
129
+
130
+ # --------------------------------------------------------------------------
131
+ # Per-PR dev-version publish to TestPyPI. Rewrites pyproject.toml to a
132
+ # `.devN` version before building so each PR gets its own unique TestPyPI
133
+ # release, then posts (or updates) a sticky PR comment with the install
134
+ # command for reviewers.
135
+ # --------------------------------------------------------------------------
136
+ publish-pr-testpypi:
137
+ # Fork-PR guard: GitHub masks secrets + denies OIDC to workflow runs
138
+ # from forks, so the deploy would fail anyway -- and more importantly,
139
+ # letting an untrusted fork run a privileged publish step is a real
140
+ # supply-chain hazard (exfiltrating TestPyPI credentials, pushing a
141
+ # malicious preview under the agex-cli name, etc.). Skip cleanly for
142
+ # forks; internal PRs take the fast path.
143
+ #
144
+ # Matrixed across both distribution names so each PR gets a per-package
145
+ # TestPyPI preview (and a per-package sticky install-command comment).
146
+ if: >-
147
+ github.event_name == 'pull_request'
148
+ && github.event.pull_request.head.repo.full_name == github.repository
149
+ runs-on: ubuntu-latest
150
+ strategy:
151
+ fail-fast: false
152
+ matrix:
153
+ dist_name: [agex-cli, agent-devex]
154
+ environment: testpypi
155
+ permissions:
156
+ id-token: write # OIDC -- Trusted Publishing
157
+ contents: read # actions/checkout
158
+ pull-requests: write # sticky comment with the install command
159
+ steps:
160
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
161
+ - uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3
162
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
163
+ with:
164
+ python-version: "3.11"
165
+ - run: uv venv
166
+ - run: uv pip install build
167
+
168
+ # Rewrite `[project].name` to the matrix dist name. Same fail-closed
169
+ # check as the build job: re-parse the result and confirm
170
+ # `[project].name` matches DIST_NAME before continuing.
171
+ - name: Set dist name
172
+ env:
173
+ DIST_NAME: ${{ matrix.dist_name }}
174
+ run: |
175
+ set -e
176
+ uv run python -c "
177
+ import os, re, tomllib
178
+ dist = os.environ['DIST_NAME']
179
+ assert re.fullmatch(r'[a-z][a-z0-9-]*', dist), dist
180
+ data = open('pyproject.toml').read()
181
+ out, n = re.subn(r'(?m)^name = .+', f'name = \"{dist}\"', data, count=1)
182
+ assert n == 1, f'expected one [project].name match, got {n}'
183
+ rewritten = tomllib.loads(out)['project']['name']
184
+ assert rewritten == dist, f'rewrite produced name={rewritten!r}, expected {dist!r}'
185
+ open('pyproject.toml', 'w').write(out)
186
+ print(f'Dist name set to {dist}')
187
+ "
188
+
189
+ # Rewrite `[project].version` from `X.Y.Z` to `X.Y.Z.devN` where
190
+ # N = github.run_number (monotonic across the workflow's lifetime).
191
+ # run_attempt is intentionally NOT encoded: PEP 440's dev segment is
192
+ # a single integer with no internal dots allowed, so concatenating
193
+ # would be ambiguous (`dev71` could mean run_number=7 + attempt=1 OR
194
+ # run_number=71). `skip-existing: true` on the TestPyPI upload below
195
+ # handles rerun idempotency instead. Parsing via tomllib + re-emitting
196
+ # only the `[project]` table's `version` key is more robust than
197
+ # `sed` -- if a future `[tool.*]` section ever introduces its own
198
+ # `version` key the regex approach would silently rewrite the wrong
199
+ # one.
200
+ - name: Set dev version
201
+ id: devver
202
+ run: |
203
+ set -e
204
+ DEV_VERSION=$(uv run python -c "
205
+ import re, tomllib
206
+ data = open('pyproject.toml').read()
207
+ base = tomllib.loads(data)['project']['version']
208
+ dev = f'{base}.dev${{ github.run_number }}'
209
+ # Fail-closed: bound the final version to PEP 440's safe shape
210
+ # before substituting into pyproject.toml (defense in depth against
211
+ # a hostile value sneaking into the [project].version field).
212
+ assert re.fullmatch(r'[0-9]+(\.[0-9]+)*(\.dev[0-9]+)?', dev), dev
213
+ out = re.sub(r'(?m)^version = .+', f'version = \"{dev}\"', data, count=1)
214
+ open('pyproject.toml', 'w').write(out)
215
+ print(dev)
216
+ ")
217
+ echo "dev_version=${DEV_VERSION}" >> "$GITHUB_OUTPUT"
218
+ echo "Publishing ${{ matrix.dist_name }} ${DEV_VERSION} to TestPyPI"
219
+
220
+ - run: uv run python -m build
221
+
222
+ - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
223
+ with:
224
+ repository-url: https://test.pypi.org/legacy/
225
+ # Belt-and-braces -- the dev-version scheme above already produces
226
+ # a unique version per run_id+run_attempt, so collisions shouldn't
227
+ # happen. If they ever do (e.g. a retry after a partial upload),
228
+ # skip-existing makes the workflow idempotent instead of failing.
229
+ skip-existing: true
230
+
231
+ # Sticky PR comment with the install command. Uses an
232
+ # `<!-- agex-pypi-preview-<dist_name> -->` marker (one sticky per
233
+ # matrix leg) to edit the existing comment on reruns. The
234
+ # per-dist marker means the two matrix legs don't race on a shared
235
+ # comment -- each leg is self-contained. `--paginate --slurp`
236
+ # ensures the marker is found even on PRs with >30 comments
237
+ # (default gh api page size); otherwise the workflow would post a
238
+ # duplicate comment on late-stage PRs.
239
+ - name: Post (or update) install-command comment
240
+ if: success()
241
+ env:
242
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
243
+ PR_NUMBER: ${{ github.event.pull_request.number }}
244
+ DEV_VERSION: ${{ steps.devver.outputs.dev_version }}
245
+ DIST_NAME: ${{ matrix.dist_name }}
246
+ REPO: ${{ github.repository }}
247
+ run: |
248
+ set -e
249
+ MARKER="<!-- agex-pypi-preview-${DIST_NAME} -->"
250
+ INSTALL="pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ ${DIST_NAME}==${DEV_VERSION}"
251
+ BODY="$MARKER"$'\n'"📦 **TestPyPI preview:** \`${DIST_NAME}==${DEV_VERSION}\`"$'\n\n'"Install this PR's wheel to try it locally:"$'\n\n'"\`\`\`bash"$'\n'"$INSTALL"$'\n'"\`\`\`"
252
+ # `gh api --paginate` streams one JSON array per page; `jq -s`
253
+ # ("slurp") collects them into `[[page1], [page2], ...]`, `add`
254
+ # flattens to a single array of all comments, then the filter
255
+ # picks our marker. `gh api --slurp --jq` itself is mutually
256
+ # exclusive (see https://github.com/cli/cli/issues/8615), so
257
+ # the slurp has to happen inside jq.
258
+ EXISTING=$(gh api --paginate "repos/$REPO/issues/$PR_NUMBER/comments" \
259
+ | jq -rs "add | [.[] | select(.body | contains(\"$MARKER\")) | .id] | first // empty")
260
+ if [ -n "$EXISTING" ]; then
261
+ gh api --method PATCH "repos/$REPO/issues/comments/$EXISTING" \
262
+ -f body="$BODY" > /dev/null
263
+ echo "Updated existing preview comment #$EXISTING"
264
+ else
265
+ gh pr comment "$PR_NUMBER" --body "$BODY"
266
+ echo "Created new preview comment"
267
+ fi
268
+
269
+ publish-testpypi:
270
+ needs: build
271
+ if: github.ref == 'refs/heads/main'
272
+ runs-on: ubuntu-latest
273
+ strategy:
274
+ fail-fast: false
275
+ matrix:
276
+ dist_name: [agex-cli, agent-devex]
277
+ environment: testpypi
278
+ permissions:
279
+ id-token: write # OIDC; no API token needed
280
+ contents: read # actions/download-artifact needs this
281
+ steps:
282
+ - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
283
+ with:
284
+ name: dist-${{ matrix.dist_name }}
285
+ path: dist/
286
+ - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
287
+ with:
288
+ repository-url: https://test.pypi.org/legacy/
289
+ # See header comment -- idempotent on repeat pushes of the same version.
290
+ skip-existing: true
291
+
292
+ # --------------------------------------------------------------------------
293
+ # Ensures an annotated `v<version>` tag exists on origin so the PyPI
294
+ # release and the GitHub Release have a matching git anchor. Runs AFTER
295
+ # the TestPyPI canary so we don't tag a version that failed to build.
296
+ #
297
+ # Output semantics: `tag_present = true` when the tag exists on origin
298
+ # AFTER this job finishes, regardless of whether we had to create it or
299
+ # it was already there. Downstream jobs gate on that, not on "did this
300
+ # specific run push the tag", so reruns after a transient PyPI or
301
+ # Release failure can complete the publish instead of getting stuck in
302
+ # a "tag exists but release didn't complete" dead-end. Idempotency for
303
+ # the actual publish/release steps is handled in those jobs (skip-existing
304
+ # on PyPI, `gh release view` probe before `gh release create`).
305
+ #
306
+ # Tag existence is checked against origin via `git ls-remote`, not the
307
+ # local ref store -- actions/checkout's default tag-fetching behavior
308
+ # varies with fetch-depth / options, and a false negative here would
309
+ # cause `git push origin v$VERSION` to fail on an already-pushed tag.
310
+ #
311
+ # GITHUB_TOKEN-pushed tags do not retrigger workflows (documented GitHub
312
+ # loop-prevention). That's fine -- publish-pypi runs inline in the same
313
+ # workflow run, keyed off `tag_present`.
314
+ # --------------------------------------------------------------------------
315
+ autotag:
316
+ needs: publish-testpypi
317
+ if: github.ref == 'refs/heads/main' && github.event_name == 'push'
318
+ runs-on: ubuntu-latest
319
+ permissions:
320
+ contents: write # git push tag
321
+ outputs:
322
+ version: ${{ steps.version.outputs.version }}
323
+ tag_present: ${{ steps.tag.outputs.tag_present }}
324
+ steps:
325
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
326
+ with:
327
+ fetch-depth: 0
328
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
329
+ with:
330
+ python-version: "3.11"
331
+ - name: Read version from pyproject.toml
332
+ id: version
333
+ run: |
334
+ VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
335
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
336
+ echo "Detected version: $VERSION"
337
+ - name: Ensure tag exists on origin
338
+ id: tag
339
+ env:
340
+ VERSION: ${{ steps.version.outputs.version }}
341
+ run: |
342
+ set -e
343
+ if git ls-remote --exit-code --tags origin "refs/tags/v$VERSION" >/dev/null 2>&1; then
344
+ echo "Tag v$VERSION already present on origin — downstream jobs will run idempotently."
345
+ else
346
+ git config user.name "github-actions[bot]"
347
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
348
+ git tag -a "v$VERSION" -m "Release v$VERSION"
349
+ git push origin "v$VERSION"
350
+ echo "Created and pushed tag v$VERSION"
351
+ fi
352
+ echo "tag_present=true" >> "$GITHUB_OUTPUT"
353
+
354
+ # --------------------------------------------------------------------------
355
+ # Real PyPI publish. Runs inline on main after `autotag` -- the dist
356
+ # artifact produced by `build` is the same wheel we already canaried on
357
+ # TestPyPI, so there's no rebuild.
358
+ #
359
+ # `skip-existing: true` is set so reruns after a transient failure (or
360
+ # after a partial-success run where the tag got pushed but a later step
361
+ # failed) converge cleanly instead of failing on "file already exists".
362
+ # Combined with the `autotag` gate, the normal case is a real upload;
363
+ # the abnormal case is a clear no-op log line.
364
+ # --------------------------------------------------------------------------
365
+ publish-pypi:
366
+ needs: [build, autotag]
367
+ if: >-
368
+ github.event_name == 'push'
369
+ && github.ref == 'refs/heads/main'
370
+ && needs.autotag.outputs.tag_present == 'true'
371
+ runs-on: ubuntu-latest
372
+ strategy:
373
+ fail-fast: false
374
+ matrix:
375
+ dist_name: [agex-cli, agent-devex]
376
+ environment: pypi
377
+ permissions:
378
+ id-token: write # OIDC; no API token needed
379
+ contents: read # actions/download-artifact needs this
380
+ steps:
381
+ - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
382
+ with:
383
+ name: dist-${{ matrix.dist_name }}
384
+ path: dist/
385
+ - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
386
+ with:
387
+ skip-existing: true
388
+
389
+ # --------------------------------------------------------------------------
390
+ # Creates a GitHub Release at the tag `autotag` ensured exists, with
391
+ # body pulled from the matching `## [X.Y.Z]` section of CHANGELOG.md.
392
+ # If the section is empty (or missing), falls back to a stub rather than
393
+ # failing the workflow -- the release anchor is more important than
394
+ # perfect notes.
395
+ #
396
+ # Idempotent: if a Release for this tag already exists (e.g. a rerun
397
+ # after a partial failure), `gh release view` succeeds and we skip
398
+ # creation. Otherwise we create it. No "release already exists" error
399
+ # blocks reruns.
400
+ # --------------------------------------------------------------------------
401
+ github-release:
402
+ needs: [autotag, publish-pypi]
403
+ if: needs.autotag.outputs.tag_present == 'true'
404
+ runs-on: ubuntu-latest
405
+ permissions:
406
+ contents: write # gh release create
407
+ steps:
408
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
409
+ with:
410
+ ref: v${{ needs.autotag.outputs.version }}
411
+ fetch-depth: 1
412
+ - name: Extract CHANGELOG section
413
+ env:
414
+ VERSION: ${{ needs.autotag.outputs.version }}
415
+ run: |
416
+ set -e
417
+ # Pull the section from `## [X.Y.Z]` up to (but not including)
418
+ # the next `## [` header. The em-dash variant in existing entries
419
+ # (`— 2026-04-19`) means we match on the bracketed version only,
420
+ # not the full header line.
421
+ awk -v ver="$VERSION" '
422
+ /^## \[/ {
423
+ if (capturing) exit
424
+ if ($0 ~ "^## \\[" ver "\\]") { capturing=1; next }
425
+ }
426
+ capturing { print }
427
+ ' CHANGELOG.md > release-notes.md
428
+ # Strip leading/trailing blank lines for tidy rendering. `\s` is
429
+ # not portable in sed (GNU/BSD differ); use the POSIX class.
430
+ sed -i -e '/./,$!d' -e :a -e '/^[[:space:]]*$/{$d;N;ba' -e '}' release-notes.md || true
431
+ if [ ! -s release-notes.md ]; then
432
+ echo "See CHANGELOG.md for details." > release-notes.md
433
+ fi
434
+ echo "--- release-notes.md ---"
435
+ cat release-notes.md
436
+ echo "------------------------"
437
+ - name: Create GitHub Release (or skip if it already exists)
438
+ env:
439
+ GH_TOKEN: ${{ github.token }}
440
+ VERSION: ${{ needs.autotag.outputs.version }}
441
+ run: |
442
+ set -e
443
+ if gh release view "v$VERSION" >/dev/null 2>&1; then
444
+ echo "Release v$VERSION already exists — skipping creation."
445
+ else
446
+ gh release create "v$VERSION" \
447
+ --title "v$VERSION" \
448
+ --notes-file release-notes.md
449
+ fi