agex-cli 0.11.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 (132) hide show
  1. agex_cli-0.11.0/.flake8 +8 -0
  2. agex_cli-0.11.0/.github/workflows/docs.yml +130 -0
  3. agex_cli-0.11.0/.github/workflows/publish.yml +376 -0
  4. agex_cli-0.11.0/.github/workflows/test.yml +106 -0
  5. agex_cli-0.11.0/.gitignore +213 -0
  6. agex_cli-0.11.0/.python-version +1 -0
  7. agex_cli-0.11.0/CHANGELOG.md +289 -0
  8. agex_cli-0.11.0/CLAUDE.md +92 -0
  9. agex_cli-0.11.0/LICENSE +21 -0
  10. agex_cli-0.11.0/PKG-INFO +56 -0
  11. agex_cli-0.11.0/README.md +29 -0
  12. agex_cli-0.11.0/docs/.gitignore +6 -0
  13. agex_cli-0.11.0/docs/Gemfile +8 -0
  14. agex_cli-0.11.0/docs/_config.yml +39 -0
  15. agex_cli-0.11.0/docs/_sass/color_schemes/anthropic.scss +34 -0
  16. agex_cli-0.11.0/docs/_sass/color_schemes/dark-terminal.scss +40 -0
  17. agex_cli-0.11.0/docs/_sass/custom/custom.scss +348 -0
  18. agex_cli-0.11.0/docs/commands/explain.md +27 -0
  19. agex_cli-0.11.0/docs/commands/gamify.md +32 -0
  20. agex_cli-0.11.0/docs/commands/hook.md +32 -0
  21. agex_cli-0.11.0/docs/commands/index.md +10 -0
  22. agex_cli-0.11.0/docs/commands/learn.md +22 -0
  23. agex_cli-0.11.0/docs/commands/overview.md +32 -0
  24. agex_cli-0.11.0/docs/getting-started.md +39 -0
  25. agex_cli-0.11.0/docs/index.md +22 -0
  26. agex_cli-0.11.0/docs/superpowers/plans/2026-04-18-agex-v0.1.md +3920 -0
  27. agex_cli-0.11.0/docs/superpowers/specs/2026-04-18-agex-design.md +442 -0
  28. agex_cli-0.11.0/pyproject.toml +60 -0
  29. agex_cli-0.11.0/scripts/sync_skill_md.py +80 -0
  30. agex_cli-0.11.0/sonar-project.properties +46 -0
  31. agex_cli-0.11.0/src/agent_experience/__init__.py +3 -0
  32. agex_cli-0.11.0/src/agent_experience/__main__.py +4 -0
  33. agex_cli-0.11.0/src/agent_experience/backends/__init__.py +0 -0
  34. agex_cli-0.11.0/src/agent_experience/backends/acp/__init__.py +0 -0
  35. agex_cli-0.11.0/src/agent_experience/backends/acp/probe.py +9 -0
  36. agex_cli-0.11.0/src/agent_experience/backends/capabilities/acp.yaml +7 -0
  37. agex_cli-0.11.0/src/agent_experience/backends/capabilities/claude-code.yaml +4 -0
  38. agex_cli-0.11.0/src/agent_experience/backends/capabilities/codex.yaml +7 -0
  39. agex_cli-0.11.0/src/agent_experience/backends/capabilities/copilot.yaml +7 -0
  40. agex_cli-0.11.0/src/agent_experience/backends/claude_code/__init__.py +0 -0
  41. agex_cli-0.11.0/src/agent_experience/backends/claude_code/probe.py +97 -0
  42. agex_cli-0.11.0/src/agent_experience/backends/codex/__init__.py +0 -0
  43. agex_cli-0.11.0/src/agent_experience/backends/codex/probe.py +16 -0
  44. agex_cli-0.11.0/src/agent_experience/backends/copilot/__init__.py +0 -0
  45. agex_cli-0.11.0/src/agent_experience/backends/copilot/probe.py +9 -0
  46. agex_cli-0.11.0/src/agent_experience/cli.py +170 -0
  47. agex_cli-0.11.0/src/agent_experience/commands/__init__.py +0 -0
  48. agex_cli-0.11.0/src/agent_experience/commands/explain/SKILL.md +26 -0
  49. agex_cli-0.11.0/src/agent_experience/commands/explain/__init__.py +0 -0
  50. agex_cli-0.11.0/src/agent_experience/commands/explain/assets/topics/agex.md +35 -0
  51. agex_cli-0.11.0/src/agent_experience/commands/explain/references/.gitkeep +0 -0
  52. agex_cli-0.11.0/src/agent_experience/commands/explain/scripts/__init__.py +0 -0
  53. agex_cli-0.11.0/src/agent_experience/commands/explain/scripts/explain.py +63 -0
  54. agex_cli-0.11.0/src/agent_experience/commands/gamify/SKILL.md +31 -0
  55. agex_cli-0.11.0/src/agent_experience/commands/gamify/__init__.py +0 -0
  56. agex_cli-0.11.0/src/agent_experience/commands/gamify/assets/hooks/claude-code.json +28 -0
  57. agex_cli-0.11.0/src/agent_experience/commands/gamify/references/.gitkeep +0 -0
  58. agex_cli-0.11.0/src/agent_experience/commands/gamify/scripts/__init__.py +0 -0
  59. agex_cli-0.11.0/src/agent_experience/commands/gamify/scripts/install.py +196 -0
  60. agex_cli-0.11.0/src/agent_experience/commands/hook/SKILL.md +31 -0
  61. agex_cli-0.11.0/src/agent_experience/commands/hook/__init__.py +0 -0
  62. agex_cli-0.11.0/src/agent_experience/commands/hook/assets/table.md.j2 +17 -0
  63. agex_cli-0.11.0/src/agent_experience/commands/hook/references/.gitkeep +0 -0
  64. agex_cli-0.11.0/src/agent_experience/commands/hook/scripts/__init__.py +0 -0
  65. agex_cli-0.11.0/src/agent_experience/commands/hook/scripts/read.py +38 -0
  66. agex_cli-0.11.0/src/agent_experience/commands/hook/scripts/write.py +24 -0
  67. agex_cli-0.11.0/src/agent_experience/commands/learn/SKILL.md +21 -0
  68. agex_cli-0.11.0/src/agent_experience/commands/learn/__init__.py +0 -0
  69. agex_cli-0.11.0/src/agent_experience/commands/learn/assets/menu.md.j2 +7 -0
  70. agex_cli-0.11.0/src/agent_experience/commands/learn/assets/topics/gamify/SKILL.md +35 -0
  71. agex_cli-0.11.0/src/agent_experience/commands/learn/assets/topics/gamify/assets/skill-template/claude-code/SKILL.md +22 -0
  72. agex_cli-0.11.0/src/agent_experience/commands/learn/assets/topics/introspect/SKILL.md +41 -0
  73. agex_cli-0.11.0/src/agent_experience/commands/learn/assets/topics/introspect/assets/skill-template/claude-code/SKILL.md +22 -0
  74. agex_cli-0.11.0/src/agent_experience/commands/learn/assets/topics/levelup/SKILL.md +31 -0
  75. agex_cli-0.11.0/src/agent_experience/commands/learn/assets/topics/levelup/assets/skill-template/claude-code/SKILL.md +22 -0
  76. agex_cli-0.11.0/src/agent_experience/commands/learn/assets/topics/visualize/SKILL.md +27 -0
  77. agex_cli-0.11.0/src/agent_experience/commands/learn/assets/topics/visualize/assets/skill-template/claude-code/SKILL.md +19 -0
  78. agex_cli-0.11.0/src/agent_experience/commands/learn/references/.gitkeep +0 -0
  79. agex_cli-0.11.0/src/agent_experience/commands/learn/scripts/__init__.py +0 -0
  80. agex_cli-0.11.0/src/agent_experience/commands/learn/scripts/learn.py +72 -0
  81. agex_cli-0.11.0/src/agent_experience/commands/overview/SKILL.md +31 -0
  82. agex_cli-0.11.0/src/agent_experience/commands/overview/__init__.py +0 -0
  83. agex_cli-0.11.0/src/agent_experience/commands/overview/assets/backends/acp.yaml +7 -0
  84. agex_cli-0.11.0/src/agent_experience/commands/overview/assets/backends/claude-code.yaml +7 -0
  85. agex_cli-0.11.0/src/agent_experience/commands/overview/assets/backends/codex.yaml +7 -0
  86. agex_cli-0.11.0/src/agent_experience/commands/overview/assets/backends/copilot.yaml +7 -0
  87. agex_cli-0.11.0/src/agent_experience/commands/overview/assets/sections.md.j2 +52 -0
  88. agex_cli-0.11.0/src/agent_experience/commands/overview/references/.gitkeep +0 -0
  89. agex_cli-0.11.0/src/agent_experience/commands/overview/scripts/__init__.py +0 -0
  90. agex_cli-0.11.0/src/agent_experience/commands/overview/scripts/overview.py +40 -0
  91. agex_cli-0.11.0/src/agent_experience/core/__init__.py +0 -0
  92. agex_cli-0.11.0/src/agent_experience/core/backend.py +16 -0
  93. agex_cli-0.11.0/src/agent_experience/core/capabilities.py +44 -0
  94. agex_cli-0.11.0/src/agent_experience/core/config.py +42 -0
  95. agex_cli-0.11.0/src/agent_experience/core/hook_io.py +95 -0
  96. agex_cli-0.11.0/src/agent_experience/core/paths.py +26 -0
  97. agex_cli-0.11.0/src/agent_experience/core/render.py +27 -0
  98. agex_cli-0.11.0/src/agent_experience/core/skill_loader.py +36 -0
  99. agex_cli-0.11.0/tester-agents/claude/.claude/settings.json +5 -0
  100. agex_cli-0.11.0/tester-agents/claude/CLAUDE.md +30 -0
  101. agex_cli-0.11.0/tester-agents/claude/README.md +23 -0
  102. agex_cli-0.11.0/tester-agents/claude/culture.yaml +18 -0
  103. agex_cli-0.11.0/tests/__init__.py +0 -0
  104. agex_cli-0.11.0/tests/backends/__init__.py +0 -0
  105. agex_cli-0.11.0/tests/backends/test_claude_code_probe.py +63 -0
  106. agex_cli-0.11.0/tests/backends/test_stub_probes.py +21 -0
  107. agex_cli-0.11.0/tests/commands/__init__.py +0 -0
  108. agex_cli-0.11.0/tests/commands/test_explain.py +33 -0
  109. agex_cli-0.11.0/tests/commands/test_gamify.py +173 -0
  110. agex_cli-0.11.0/tests/commands/test_hook.py +63 -0
  111. agex_cli-0.11.0/tests/commands/test_learn.py +57 -0
  112. agex_cli-0.11.0/tests/commands/test_overview.py +55 -0
  113. agex_cli-0.11.0/tests/core/__init__.py +0 -0
  114. agex_cli-0.11.0/tests/core/test_backend.py +27 -0
  115. agex_cli-0.11.0/tests/core/test_capabilities.py +35 -0
  116. agex_cli-0.11.0/tests/core/test_config.py +25 -0
  117. agex_cli-0.11.0/tests/core/test_hook_io.py +138 -0
  118. agex_cli-0.11.0/tests/core/test_paths.py +31 -0
  119. agex_cli-0.11.0/tests/core/test_render.py +21 -0
  120. agex_cli-0.11.0/tests/core/test_skill_loader.py +45 -0
  121. agex_cli-0.11.0/tests/fixtures/claude-code/empty/.gitkeep +0 -0
  122. agex_cli-0.11.0/tests/fixtures/claude-code/malformed/.claude/hooks.json +1 -0
  123. agex_cli-0.11.0/tests/fixtures/claude-code/malformed/.claude/settings.json +1 -0
  124. agex_cli-0.11.0/tests/fixtures/claude-code/malformed/.claude/skills/bad/SKILL.md +1 -0
  125. agex_cli-0.11.0/tests/fixtures/claude-code/malformed/.claude/skills/broken-yaml/SKILL.md +6 -0
  126. agex_cli-0.11.0/tests/fixtures/claude-code/typical/.claude/hooks.json +5 -0
  127. agex_cli-0.11.0/tests/fixtures/claude-code/typical/.claude/settings.json +5 -0
  128. agex_cli-0.11.0/tests/fixtures/claude-code/typical/.claude/skills/example/SKILL.md +6 -0
  129. agex_cli-0.11.0/tests/fixtures/claude-code/typical/CLAUDE.md +3 -0
  130. agex_cli-0.11.0/tests/test_cli_errors.py +150 -0
  131. agex_cli-0.11.0/tests/test_cli_smoke.py +11 -0
  132. agex_cli-0.11.0/tests/test_skill_md_consistency.py +80 -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,130 @@
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 OriNachum/agex 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
+ run: |
72
+ raw="${{ github.head_ref }}"
73
+ safe=$(echo "$raw" | sed 's/[^a-zA-Z0-9-]/-/g; s/--*/-/g; s/^-//; s/-$//')
74
+ echo "name=$safe" >> "$GITHUB_OUTPUT"
75
+
76
+ # Deploy to Cloudflare Pages.
77
+ # - push / workflow_dispatch -> `--branch=main` = production
78
+ # - pull_request -> `--branch=<sanitized-head-ref>` = preview
79
+ # The project `agex` is a Direct Upload project, so CF won't auto-
80
+ # build on its own; this step IS the build+deploy pipeline.
81
+ - name: Deploy to Cloudflare Pages
82
+ id: deploy
83
+ uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3
84
+ with:
85
+ apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
86
+ accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
87
+ command: pages deploy docs/_site --project-name=agex --branch=${{ github.event_name == 'pull_request' && steps.branch.outputs.name || 'main' }}
88
+
89
+ # Post the preview URL back on the PR so reviewers can click through
90
+ # without digging into the workflow logs. Uses a marker
91
+ # (`<!-- agex-docs-preview -->`) to update the existing comment on
92
+ # subsequent pushes instead of spamming the PR thread with a new
93
+ # comment per workflow run. `gh` is preinstalled on GitHub runners
94
+ # so no extra SHA-pinned action is needed. Runs only on PR events.
95
+ - name: Post (or update) preview URL comment
96
+ if: github.event_name == 'pull_request' && steps.deploy.outputs.deployment-url != ''
97
+ env:
98
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
99
+ PR_NUMBER: ${{ github.event.pull_request.number }}
100
+ DEPLOY_URL: ${{ steps.deploy.outputs.deployment-url }}
101
+ BRANCH: ${{ steps.branch.outputs.name }}
102
+ REPO: ${{ github.repository }}
103
+ run: |
104
+ set -e
105
+ MARKER='<!-- agex-docs-preview -->'
106
+ ALIAS_URL="https://${BRANCH}.agex.pages.dev"
107
+ # Two URLs intentionally:
108
+ # * deploy URL (hash) -- immutable pin for THIS deploy, ideal for
109
+ # review bookmarks that should not drift.
110
+ # * branch-alias URL -- stays the same as you push new commits;
111
+ # always reflects the latest preview.
112
+ 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)_"
113
+ # `gh api --paginate` streams one JSON array per page; `jq -s`
114
+ # ("slurp") collects them into `[[page1], [page2], ...]`, `add`
115
+ # flattens to a single array, then the filter picks our marker.
116
+ # `gh api --slurp --jq` itself is mutually exclusive (see
117
+ # https://github.com/cli/cli/issues/8615), so the slurp has to
118
+ # happen inside jq. Without the pagination + slurp, PRs with
119
+ # more than 30 comments would miss the marker and post
120
+ # duplicate comments on each run.
121
+ EXISTING=$(gh api --paginate "repos/$REPO/issues/$PR_NUMBER/comments" \
122
+ | jq -rs "add | [.[] | select(.body | contains(\"$MARKER\")) | .id] | first // empty")
123
+ if [ -n "$EXISTING" ]; then
124
+ gh api --method PATCH "repos/$REPO/issues/comments/$EXISTING" \
125
+ -f body="$BODY" > /dev/null
126
+ echo "Updated existing preview comment #$EXISTING"
127
+ else
128
+ gh pr comment "$PR_NUMBER" --body "$BODY"
129
+ echo "Created new preview comment"
130
+ fi
@@ -0,0 +1,376 @@
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
+ # Workflow-level default: least-privilege read-only. Publish jobs ADD
58
+ # `id-token: write` for OIDC on top; `contents: read` propagates so
59
+ # `actions/checkout` and `actions/download-artifact` both have the scopes
60
+ # they need. Setting `permissions:` at job level REPLACES the default
61
+ # entirely -- the workflow-level block ensures the replacement starts
62
+ # from a usable baseline rather than from nothing.
63
+ permissions:
64
+ contents: read
65
+
66
+ jobs:
67
+ # --------------------------------------------------------------------------
68
+ # Shared build job for the main-push path. Produces a single `dist`
69
+ # artifact consumed by publish-testpypi (canary) and publish-pypi (real
70
+ # release, inline after autotag). Skipped on pull_request -- the PR path
71
+ # has its own build inside `publish-pr-testpypi` because it needs to
72
+ # rewrite the version before building.
73
+ # --------------------------------------------------------------------------
74
+ build:
75
+ if: github.event_name == 'push'
76
+ runs-on: ubuntu-latest
77
+ steps:
78
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
79
+ - uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3
80
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
81
+ with:
82
+ python-version: "3.11"
83
+
84
+ # Build sdist + wheel via PEP 517. `build` is the canonical frontend;
85
+ # we avoid pulling in full dev extras here since the build backend
86
+ # (hatchling) reads everything it needs from pyproject.toml.
87
+ - run: uv venv
88
+ - run: uv pip install build
89
+ - run: uv run python -m build
90
+
91
+ - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
92
+ with:
93
+ name: dist
94
+ path: dist/
95
+
96
+ # --------------------------------------------------------------------------
97
+ # Per-PR dev-version publish to TestPyPI. Rewrites pyproject.toml to a
98
+ # `.devN` version before building so each PR gets its own unique TestPyPI
99
+ # release, then posts (or updates) a sticky PR comment with the install
100
+ # command for reviewers.
101
+ # --------------------------------------------------------------------------
102
+ publish-pr-testpypi:
103
+ # Fork-PR guard: GitHub masks secrets + denies OIDC to workflow runs
104
+ # from forks, so the deploy would fail anyway -- and more importantly,
105
+ # letting an untrusted fork run a privileged publish step is a real
106
+ # supply-chain hazard (exfiltrating TestPyPI credentials, pushing a
107
+ # malicious preview under the agex-cli name, etc.). Skip cleanly for
108
+ # forks; internal PRs take the fast path.
109
+ if: >-
110
+ github.event_name == 'pull_request'
111
+ && github.event.pull_request.head.repo.full_name == github.repository
112
+ runs-on: ubuntu-latest
113
+ environment: testpypi
114
+ permissions:
115
+ id-token: write # OIDC -- Trusted Publishing
116
+ contents: read # actions/checkout
117
+ pull-requests: write # sticky comment with the install command
118
+ steps:
119
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
120
+ - uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3
121
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
122
+ with:
123
+ python-version: "3.11"
124
+ - run: uv venv
125
+ - run: uv pip install build
126
+
127
+ # Rewrite `[project].version` from `X.Y.Z` to `X.Y.Z.devN` where
128
+ # N = github.run_number (monotonic across the workflow's lifetime).
129
+ # run_attempt is intentionally NOT encoded: PEP 440's dev segment is
130
+ # a single integer with no internal dots allowed, so concatenating
131
+ # would be ambiguous (`dev71` could mean run_number=7 + attempt=1 OR
132
+ # run_number=71). `skip-existing: true` on the TestPyPI upload below
133
+ # handles rerun idempotency instead. Parsing via tomllib + re-emitting
134
+ # only the `[project]` table's `version` key is more robust than
135
+ # `sed` -- if a future `[tool.*]` section ever introduces its own
136
+ # `version` key the regex approach would silently rewrite the wrong
137
+ # one.
138
+ - name: Set dev version
139
+ id: devver
140
+ run: |
141
+ set -e
142
+ DEV_VERSION=$(uv run python -c "
143
+ import re, tomllib
144
+ data = open('pyproject.toml').read()
145
+ base = tomllib.loads(data)['project']['version']
146
+ dev = f'{base}.dev${{ github.run_number }}'
147
+ # Fail-closed: bound the final version to PEP 440's safe shape
148
+ # before substituting into pyproject.toml (defense in depth against
149
+ # a hostile value sneaking into the [project].version field).
150
+ assert re.fullmatch(r'[0-9]+(\.[0-9]+)*(\.dev[0-9]+)?', dev), dev
151
+ out = re.sub(r'(?m)^version = .+', f'version = \"{dev}\"', data, count=1)
152
+ open('pyproject.toml', 'w').write(out)
153
+ print(dev)
154
+ ")
155
+ echo "dev_version=${DEV_VERSION}" >> "$GITHUB_OUTPUT"
156
+ echo "Publishing agex-cli ${DEV_VERSION} to TestPyPI"
157
+
158
+ - run: uv run python -m build
159
+
160
+ - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
161
+ with:
162
+ repository-url: https://test.pypi.org/legacy/
163
+ # Belt-and-braces -- the dev-version scheme above already produces
164
+ # a unique version per run_id+run_attempt, so collisions shouldn't
165
+ # happen. If they ever do (e.g. a retry after a partial upload),
166
+ # skip-existing makes the workflow idempotent instead of failing.
167
+ skip-existing: true
168
+
169
+ # Sticky PR comment with the install command. Uses an
170
+ # `<!-- agex-pypi-preview -->` marker to edit the existing comment on
171
+ # reruns (same pattern as .github/workflows/docs.yml). `--paginate
172
+ # --slurp` ensures the marker is found even on PRs with >30 comments
173
+ # (default gh api page size); otherwise the workflow would post a
174
+ # duplicate comment on late-stage PRs.
175
+ - name: Post (or update) install-command comment
176
+ if: success()
177
+ env:
178
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
179
+ PR_NUMBER: ${{ github.event.pull_request.number }}
180
+ DEV_VERSION: ${{ steps.devver.outputs.dev_version }}
181
+ REPO: ${{ github.repository }}
182
+ run: |
183
+ set -e
184
+ MARKER='<!-- agex-pypi-preview -->'
185
+ INSTALL="pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ agex-cli==${DEV_VERSION}"
186
+ BODY="$MARKER"$'\n'"📦 **TestPyPI preview:** \`agex-cli==${DEV_VERSION}\`"$'\n\n'"Install this PR's wheel to try it locally:"$'\n\n'"\`\`\`bash"$'\n'"$INSTALL"$'\n'"\`\`\`"
187
+ # `gh api --paginate` streams one JSON array per page; `jq -s`
188
+ # ("slurp") collects them into `[[page1], [page2], ...]`, `add`
189
+ # flattens to a single array of all comments, then the filter
190
+ # picks our marker. `gh api --slurp --jq` itself is mutually
191
+ # exclusive (see https://github.com/cli/cli/issues/8615), so
192
+ # the slurp has to happen inside jq.
193
+ EXISTING=$(gh api --paginate "repos/$REPO/issues/$PR_NUMBER/comments" \
194
+ | jq -rs "add | [.[] | select(.body | contains(\"$MARKER\")) | .id] | first // empty")
195
+ if [ -n "$EXISTING" ]; then
196
+ gh api --method PATCH "repos/$REPO/issues/comments/$EXISTING" \
197
+ -f body="$BODY" > /dev/null
198
+ echo "Updated existing preview comment #$EXISTING"
199
+ else
200
+ gh pr comment "$PR_NUMBER" --body "$BODY"
201
+ echo "Created new preview comment"
202
+ fi
203
+
204
+ publish-testpypi:
205
+ needs: build
206
+ if: github.ref == 'refs/heads/main'
207
+ runs-on: ubuntu-latest
208
+ environment: testpypi
209
+ permissions:
210
+ id-token: write # OIDC; no API token needed
211
+ contents: read # actions/download-artifact needs this
212
+ steps:
213
+ - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
214
+ with:
215
+ name: dist
216
+ path: dist/
217
+ - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
218
+ with:
219
+ repository-url: https://test.pypi.org/legacy/
220
+ # See header comment -- idempotent on repeat pushes of the same version.
221
+ skip-existing: true
222
+
223
+ # --------------------------------------------------------------------------
224
+ # Ensures an annotated `v<version>` tag exists on origin so the PyPI
225
+ # release and the GitHub Release have a matching git anchor. Runs AFTER
226
+ # the TestPyPI canary so we don't tag a version that failed to build.
227
+ #
228
+ # Output semantics: `tag_present = true` when the tag exists on origin
229
+ # AFTER this job finishes, regardless of whether we had to create it or
230
+ # it was already there. Downstream jobs gate on that, not on "did this
231
+ # specific run push the tag", so reruns after a transient PyPI or
232
+ # Release failure can complete the publish instead of getting stuck in
233
+ # a "tag exists but release didn't complete" dead-end. Idempotency for
234
+ # the actual publish/release steps is handled in those jobs (skip-existing
235
+ # on PyPI, `gh release view` probe before `gh release create`).
236
+ #
237
+ # Tag existence is checked against origin via `git ls-remote`, not the
238
+ # local ref store -- actions/checkout's default tag-fetching behavior
239
+ # varies with fetch-depth / options, and a false negative here would
240
+ # cause `git push origin v$VERSION` to fail on an already-pushed tag.
241
+ #
242
+ # GITHUB_TOKEN-pushed tags do not retrigger workflows (documented GitHub
243
+ # loop-prevention). That's fine -- publish-pypi runs inline in the same
244
+ # workflow run, keyed off `tag_present`.
245
+ # --------------------------------------------------------------------------
246
+ autotag:
247
+ needs: publish-testpypi
248
+ if: github.ref == 'refs/heads/main' && github.event_name == 'push'
249
+ runs-on: ubuntu-latest
250
+ permissions:
251
+ contents: write # git push tag
252
+ outputs:
253
+ version: ${{ steps.version.outputs.version }}
254
+ tag_present: ${{ steps.tag.outputs.tag_present }}
255
+ steps:
256
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
257
+ with:
258
+ fetch-depth: 0
259
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
260
+ with:
261
+ python-version: "3.11"
262
+ - name: Read version from pyproject.toml
263
+ id: version
264
+ run: |
265
+ VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
266
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
267
+ echo "Detected version: $VERSION"
268
+ - name: Ensure tag exists on origin
269
+ id: tag
270
+ env:
271
+ VERSION: ${{ steps.version.outputs.version }}
272
+ run: |
273
+ set -e
274
+ if git ls-remote --exit-code --tags origin "refs/tags/v$VERSION" >/dev/null 2>&1; then
275
+ echo "Tag v$VERSION already present on origin — downstream jobs will run idempotently."
276
+ else
277
+ git config user.name "github-actions[bot]"
278
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
279
+ git tag -a "v$VERSION" -m "Release v$VERSION"
280
+ git push origin "v$VERSION"
281
+ echo "Created and pushed tag v$VERSION"
282
+ fi
283
+ echo "tag_present=true" >> "$GITHUB_OUTPUT"
284
+
285
+ # --------------------------------------------------------------------------
286
+ # Real PyPI publish. Runs inline on main after `autotag` -- the dist
287
+ # artifact produced by `build` is the same wheel we already canaried on
288
+ # TestPyPI, so there's no rebuild.
289
+ #
290
+ # `skip-existing: true` is set so reruns after a transient failure (or
291
+ # after a partial-success run where the tag got pushed but a later step
292
+ # failed) converge cleanly instead of failing on "file already exists".
293
+ # Combined with the `autotag` gate, the normal case is a real upload;
294
+ # the abnormal case is a clear no-op log line.
295
+ # --------------------------------------------------------------------------
296
+ publish-pypi:
297
+ needs: [build, autotag]
298
+ if: >-
299
+ github.event_name == 'push'
300
+ && github.ref == 'refs/heads/main'
301
+ && needs.autotag.outputs.tag_present == 'true'
302
+ runs-on: ubuntu-latest
303
+ environment: pypi
304
+ permissions:
305
+ id-token: write # OIDC; no API token needed
306
+ contents: read # actions/download-artifact needs this
307
+ steps:
308
+ - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
309
+ with:
310
+ name: dist
311
+ path: dist/
312
+ - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
313
+ with:
314
+ skip-existing: true
315
+
316
+ # --------------------------------------------------------------------------
317
+ # Creates a GitHub Release at the tag `autotag` ensured exists, with
318
+ # body pulled from the matching `## [X.Y.Z]` section of CHANGELOG.md.
319
+ # If the section is empty (or missing), falls back to a stub rather than
320
+ # failing the workflow -- the release anchor is more important than
321
+ # perfect notes.
322
+ #
323
+ # Idempotent: if a Release for this tag already exists (e.g. a rerun
324
+ # after a partial failure), `gh release view` succeeds and we skip
325
+ # creation. Otherwise we create it. No "release already exists" error
326
+ # blocks reruns.
327
+ # --------------------------------------------------------------------------
328
+ github-release:
329
+ needs: [autotag, publish-pypi]
330
+ if: needs.autotag.outputs.tag_present == 'true'
331
+ runs-on: ubuntu-latest
332
+ permissions:
333
+ contents: write # gh release create
334
+ steps:
335
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
336
+ with:
337
+ ref: v${{ needs.autotag.outputs.version }}
338
+ fetch-depth: 1
339
+ - name: Extract CHANGELOG section
340
+ env:
341
+ VERSION: ${{ needs.autotag.outputs.version }}
342
+ run: |
343
+ set -e
344
+ # Pull the section from `## [X.Y.Z]` up to (but not including)
345
+ # the next `## [` header. The em-dash variant in existing entries
346
+ # (`— 2026-04-19`) means we match on the bracketed version only,
347
+ # not the full header line.
348
+ awk -v ver="$VERSION" '
349
+ /^## \[/ {
350
+ if (capturing) exit
351
+ if ($0 ~ "^## \\[" ver "\\]") { capturing=1; next }
352
+ }
353
+ capturing { print }
354
+ ' CHANGELOG.md > release-notes.md
355
+ # Strip leading/trailing blank lines for tidy rendering. `\s` is
356
+ # not portable in sed (GNU/BSD differ); use the POSIX class.
357
+ sed -i -e '/./,$!d' -e :a -e '/^[[:space:]]*$/{$d;N;ba' -e '}' release-notes.md || true
358
+ if [ ! -s release-notes.md ]; then
359
+ echo "See CHANGELOG.md for details." > release-notes.md
360
+ fi
361
+ echo "--- release-notes.md ---"
362
+ cat release-notes.md
363
+ echo "------------------------"
364
+ - name: Create GitHub Release (or skip if it already exists)
365
+ env:
366
+ GH_TOKEN: ${{ github.token }}
367
+ VERSION: ${{ needs.autotag.outputs.version }}
368
+ run: |
369
+ set -e
370
+ if gh release view "v$VERSION" >/dev/null 2>&1; then
371
+ echo "Release v$VERSION already exists — skipping creation."
372
+ else
373
+ gh release create "v$VERSION" \
374
+ --title "v$VERSION" \
375
+ --notes-file release-notes.md
376
+ fi
@@ -0,0 +1,106 @@
1
+ name: test
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ${{ matrix.os }}
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ os: [ubuntu-latest, macos-latest, windows-latest]
15
+ python: ["3.10", "3.11", "3.12", "3.13"]
16
+ steps:
17
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
18
+ - uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3
19
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
20
+ with:
21
+ python-version: ${{ matrix.python }}
22
+ - run: uv venv
23
+ - run: uv pip install -e ".[dev]"
24
+ - run: uv run pytest
25
+
26
+ # --------------------------------------------------------------------------
27
+ # Enforces that every PR touching code (src/, tests/, pyproject.toml) also
28
+ # bumps the version in pyproject.toml. Without this, a merge to main that
29
+ # forgot to bump would silently try to republish the existing PyPI release
30
+ # and fail deep inside the publish workflow. Catching it at PR-time turns
31
+ # that failure mode into a single sticky comment pointing at /version-bump.
32
+ #
33
+ # Docs-only PRs skip this check automatically (no code diff = no bump
34
+ # needed). See culture/.github/workflows/tests.yml:23-78 for the source
35
+ # pattern this was ported from.
36
+ # --------------------------------------------------------------------------
37
+ version-check:
38
+ if: github.event_name == 'pull_request'
39
+ runs-on: ubuntu-latest
40
+ permissions:
41
+ contents: read
42
+ pull-requests: write # sticky "forgot to bump" comment
43
+ steps:
44
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
45
+ with:
46
+ fetch-depth: 0
47
+ - run: git fetch origin main
48
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
49
+ with:
50
+ python-version: "3.11"
51
+
52
+ - name: Check if code changed
53
+ id: changes
54
+ run: |
55
+ CODE_CHANGED=$(git diff --name-only origin/main...HEAD -- 'src/' 'pyproject.toml' 'tests/' | head -1)
56
+ if [ -z "$CODE_CHANGED" ]; then
57
+ echo "skip=true" >> "$GITHUB_OUTPUT"
58
+ echo "Only docs/config changed — skipping version check"
59
+ else
60
+ echo "skip=false" >> "$GITHUB_OUTPUT"
61
+ fi
62
+
63
+ - name: Check version bump
64
+ if: steps.changes.outputs.skip != 'true'
65
+ env:
66
+ GH_TOKEN: ${{ github.token }}
67
+ PR_NUMBER: ${{ github.event.pull_request.number }}
68
+ REPO: ${{ github.repository }}
69
+ run: |
70
+ set -e
71
+ PR_VERSION=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
72
+ MAIN_VERSION=$(git show origin/main:pyproject.toml | python3 -c "import sys,tomllib; print(tomllib.loads(sys.stdin.read())['project']['version'])")
73
+
74
+ MARKER='<!-- version-check -->'
75
+
76
+ # Find any existing sticky comment. Paginate so the lookup is
77
+ # correct on long-lived PRs with >30 comments (same pattern as
78
+ # docs.yml / publish.yml's agex-pypi-preview comment). `gh api
79
+ # --slurp --jq` can't be combined (cli#8615), so the slurp has
80
+ # to happen inside jq.
81
+ EXISTING=$(gh api --paginate "repos/$REPO/issues/$PR_NUMBER/comments" \
82
+ | jq -rs "add | [.[] | select(.body | contains(\"$MARKER\")) | .id] | first // empty")
83
+
84
+ if [ "$PR_VERSION" = "$MAIN_VERSION" ]; then
85
+ BODY="⚠️ **Version not bumped** — \`pyproject.toml\` still has \`$PR_VERSION\` (same as main). Run \`/version-bump patch\` (or minor/major) before merging to avoid a failed PyPI publish.
86
+
87
+ $MARKER"
88
+
89
+ if [ -n "$EXISTING" ]; then
90
+ gh api "repos/$REPO/issues/comments/$EXISTING" \
91
+ -X PATCH -f body="$BODY" > /dev/null
92
+ else
93
+ gh pr comment "$PR_NUMBER" --body "$BODY" || true
94
+ fi
95
+
96
+ echo "::error::Version $PR_VERSION matches main. Bump before merging."
97
+ exit 1
98
+ else
99
+ echo "Version bumped: $MAIN_VERSION -> $PR_VERSION"
100
+ # Clean up a stale "not bumped" warning from an earlier failing
101
+ # run so the PR reflects the current (green) state.
102
+ if [ -n "$EXISTING" ]; then
103
+ gh api "repos/$REPO/issues/comments/$EXISTING" -X DELETE > /dev/null
104
+ echo "Removed stale version-check comment #$EXISTING"
105
+ fi
106
+ fi