Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 98 additions & 72 deletions .github/workflows/api-sync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,107 +55,133 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile

- name: Apply changes with Claude Code
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
claude_args: '--model claude-opus-5 --allowedTools "Bash(*),Read,Edit,Write,Glob,Grep"'
prompt: |
You are updating this CLI to match BlindPay API changes.

Read CLAUDE.md in this repo first — it describes the file
layout, command naming conventions, output patterns, and the
decision rules for translating API changes into CLI changes.

The changelog is at /tmp/api-sync/changelog.md. It is the
authoritative list of API changes since the last sync.

Process:
1. Read CLAUDE.md thoroughly.
2. Read /tmp/api-sync/changelog.md.
3. For each change, apply the decision rules in CLAUDE.md
(section "Sync workflow conventions"). Skip changes that
don't map to any CLI surface — silence is fine.
4. Implement the chosen changes:
- New commands: add the action function to
src/commands/resources.ts and wire it into
src/index.ts under the right group banner.
- New flags: add to the option list in src/index.ts and
pass through to the action in src/commands/resources.ts.
- Removed endpoints: remove the command and the action.
- Enum changes: only update help text in src/index.ts.
- Dynamic / arbitrary-object request bodies (e.g.
`Record<string, any>` or `z.record(z.string(),
z.any())`): do NOT ship the command with an empty
`{}` body and a TODO. Accept the body as a single
`--body <json>` flag, parse with `JSON.parse`, and
call `exitWithError` on a parse failure. Use
`--body` (not `--response`, `--payload`, etc.) for
consistency across commands. See the
"Dynamic request bodies" section in CLAUDE.md for
the exact pattern.
- Do NOT leave `// TODO(api-sync):` markers in shipped
commands. Use the JSON-string fallback above instead
of TODOs. TODOs are only acceptable for low-signal
cleanups (e.g. column tuning).
5. Update src/__tests__/resources.test.ts to match. For every
action you added: add at least one happy-path test
asserting the URL, method, and body. For every action you
modified: update the existing test's expected body/URL.
For every action you removed: remove its test. Follow the
existing pattern (setupTestEnv/teardownTestEnv, lastCall(),
mockResponse.body). See the "Testing" section of CLAUDE.md.
**Place new tests inside the existing `describe(...)`
block that matches the command's top-level CLI group** —
a `receivers submit_rfi` test goes in
`describe('Receivers', ...)`, not in a new
`describe('RFI', ...)`. Only create a new describe block
when introducing a brand-new top-level CLI group.
6. Bump the `version` field in package.json (patch for
additive changes, minor if anything was removed).
CLI_VERSION is derived from package.json at build time —
do not edit src/utils/constants.ts for version bumps.
7. Run `bun run typecheck`, `bun run lint:fix`, and
`bun run test`. Fix any errors until all three are clean.
8. Do NOT touch .github/workflows/.
9. Do NOT create commits — leave changes in the working tree.

If a change is ambiguous, leave a TODO comment with
`// TODO(api-sync):` so a human reviewer can address it.
# The entire "what changed, and can this repo express it without a
# human" decision is a pure script: scripts/api-sync/generate.ts. It
# parses the changelog, classifies every change as either mechanically
# applicable (an additive request-body field on a known resource path)
# or needs-human, applies only the former, and bumps package.json's
# version. No LLM, no best-guessing: an unrecognized changelog shape
# makes the parser throw and this step fails the run.
- name: Run deterministic generator
id: generate
run: |
set -o pipefail
bun scripts/api-sync/generate.ts \
--changelog /tmp/api-sync/changelog.md \
--repo-root . \
| tee /tmp/api-sync/generate.log

SUMMARY_JSON=$(grep '^SUMMARY_JSON:' /tmp/api-sync/generate.log | tail -1 | sed 's/^SUMMARY_JSON://')
echo "$SUMMARY_JSON" > /tmp/api-sync/summary.json
echo "summary_path=/tmp/api-sync/summary.json" >> "$GITHUB_OUTPUT"
echo "has_changes=$(jq -r '.hasChanges' /tmp/api-sync/summary.json)" >> "$GITHUB_OUTPUT"
echo "can_automerge=$(jq -r '.canAutoMerge' /tmp/api-sync/summary.json)" >> "$GITHUB_OUTPUT"
echo "needs_human_count=$(jq -r '.needsHumanCount' /tmp/api-sync/summary.json)" >> "$GITHUB_OUTPUT"
echo "bump_type=$(jq -r '.bumpType' /tmp/api-sync/summary.json)" >> "$GITHUB_OUTPUT"

# A changelog can contain ONLY changes the generator cannot express
# (new endpoint, removed field, enum-only, etc.) with nothing
# mechanically applicable. There is then no code diff to commit and no
# PR to open — but staying silent would bury a real API change a human
# needs to see, so this opens an issue instead.
- name: Open an issue when nothing was applicable but something needs a human
if: steps.generate.outputs.has_changes == 'false' && steps.generate.outputs.needs_human_count != '0'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
{
echo "The latest API changelog contains changes this repo's deterministic"
echo "api-sync generator (\`scripts/api-sync/\`) cannot express. Nothing was"
echo "applied or committed. A human needs to decide the CLI surface for:"
echo
jq -r '.needsHuman[] | "- " + .' /tmp/api-sync/summary.json
} > /tmp/api-sync/issue-body.md
gh issue create \
--title "api-sync: API changes need human review (no automatic changes applied)" \
--body-file /tmp/api-sync/issue-body.md \
--label api-sync

- name: Run CI checks against the generated changes
if: steps.generate.outputs.has_changes == 'true'
run: |
bun run typecheck
bun run lint
bun test
bun run build

- name: Commit and push
id: commit
if: steps.generate.outputs.has_changes == 'true'
run: |
git remote set-url origin "https://x-access-token:${{ secrets.SDK_SYNC_PAT }}@github.com/${{ github.repository }}.git"
git checkout -- .github/workflows/ 2>/dev/null || true
git add -A
git reset HEAD .github/workflows/ 2>/dev/null || true
if git diff --staged --quiet; then
echo "No changes to commit"
echo "has_changes=false" >> $GITHUB_OUTPUT
echo "committed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "has_changes=true" >> $GITHUB_OUTPUT
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git commit -m "feat: sync CLI with API changes"
git push --force-with-lease origin api-sync
echo "committed=true" >> "$GITHUB_OUTPUT"

- name: Write PR body
id: pr-body
if: steps.commit.outputs.committed == 'true'
run: |
{
echo "Automated, script-generated CLI update from API changes."
echo "No LLM was involved in producing this diff — see \`scripts/api-sync/\` in this repo."
echo
echo "### Applied automatically"
jq -r '.applied[] | "- " + .' /tmp/api-sync/summary.json
NEEDS_HUMAN_COUNT=$(jq -r '.needsHumanCount' /tmp/api-sync/summary.json)
if [ "$NEEDS_HUMAN_COUNT" != "0" ]; then
echo
echo "### Needs a human — NOT applied, NOT auto-merged"
jq -r '.needsHuman[] | "- " + .' /tmp/api-sync/summary.json
fi
echo
echo "Version bump: \`$(jq -r '.bumpType' /tmp/api-sync/summary.json)\`"
} > /tmp/api-sync/pr-body.md

- name: Create or update PR
if: steps.commit.outputs.has_changes == 'true'
id: pr
if: steps.commit.outputs.committed == 'true'
env:
GH_TOKEN: ${{ secrets.SDK_SYNC_PAT }}
run: |
EXISTING_PR="${{ steps.check-pr.outputs.existing_pr }}"

if [ -n "$EXISTING_PR" ]; then
echo "Updating existing PR #$EXISTING_PR"
gh pr comment "$EXISTING_PR" --body "Updated with latest API changes."
gh pr comment "$EXISTING_PR" --body-file /tmp/api-sync/pr-body.md
PR_NUMBER="$EXISTING_PR"
else
gh pr create \
--title "feat: sync CLI with API changes" \
--body "Automated CLI update from API changes." \
--body-file /tmp/api-sync/pr-body.md \
--base main \
--head api-sync \
--label api-sync
PR_NUMBER=$(gh pr list --head api-sync --json number --jq '.[0].number')
fi
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"

# Auto-merge only when the generator reports nothing was left for a
# human to decide. A needs-human PR stays open for manual review and is
# never auto-merged — this is the honest-failure path, not an error.
- name: Auto-merge or flag for human review
if: steps.commit.outputs.committed == 'true'
env:
GH_TOKEN: ${{ secrets.SDK_SYNC_PAT }}
run: |
PR_NUMBER="${{ steps.pr.outputs.pr_number }}"
if [ "${{ steps.generate.outputs.can_automerge }}" = "true" ]; then
gh pr merge "$PR_NUMBER" --auto --squash
else
gh pr comment "$PR_NUMBER" --body "Auto-merge NOT enabled: this changelog contains ${{ steps.generate.outputs.needs_human_count }} change(s) the generator cannot express (see the needs-human section above). A human needs to review and merge this manually."
fi
125 changes: 90 additions & 35 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,70 @@ This CLI is auto-synced with the BlindPay API. When SDK-eligible
from the OpenAPI spec diff and pushes it to this repo's
`api-sync-data` branch at `.api-sync/changelog.md`. It then fires a
`repository_dispatch` `api-sync` event.
2. `api-sync.yml` consumes the event, runs Claude with the changelog as
input, and asks Claude to read this CLAUDE.md plus the codebase to
decide what (if any) CLI changes are needed.
3. A PR is opened/updated on the `api-sync` branch for human review.
2. `api-sync.yml` consumes the event and runs a pure script,
`scripts/api-sync/generate.ts` — no LLM, no `claude-code-action`. See
"The deterministic api-sync pipeline" below for what it does and does
not touch.
3. If everything the changelog contains was mechanically applicable, the
PR auto-merges once CI passes. If any change needed a human, the PR
(or, if there was nothing applicable at all, a plain issue) stays open
for manual review and is never auto-merged.

Note: not every API change needs a CLI change. The CLI is hand-curated
UX — only commands a human would actually want to run from a terminal.

## The deterministic api-sync pipeline

`scripts/api-sync/generate.ts` is the only thing that runs in CI for a
sync. It is a straight pipeline, in `scripts/api-sync/`:

- `parse-changelog.ts` — parses the exact markdown format blindpay-v2's
`scripts/spec-diff.ts` emits into structured events (field added/removed,
enum changed, endpoint/method/schema added/removed). This is a format
parser, not a heuristic: an unrecognized bullet makes it throw rather
than silently drop content.
- `known-resources.ts` — the generator's entire "what can I touch" map:
each entry pairs a `schema.ts` resource name with the OpenAPI path(s)
and the exact `src/commands/resources.ts` function name that build its
create/update request body. A path NOT listed here is unmappable by
construction. **Extending this map to a genuinely new resource is a
hand-written change to make deliberately** — the generator will never
infer it from a changelog.
- `classify.ts` — splits parsed events into `applicable` (CAN be expressed
by the generator) and `needsHuman` (cannot). Today the CAN-express
surface is deliberately narrow: **an additive, optional field on the
REQUEST body of a create/update path listed in `known-resources.ts`.**
Everything else — removed fields, enum value changes, response-only
field changes, new/removed endpoints, methods, or schemas — is routed
to `needsHuman` with a plain-English reason, even where a human
historically handled it mechanically too (see the comment at the top of
`classify.ts` for why each of those categories isn't safe to script
today).
- `apply.ts` — applies one field addition: adds `<field>?: <type>` to the
matching function's options type in `resources.ts` (anchored on its
`json?: boolean` prop), a pass-through statement before its
`apiPost</apiPut<` call, a new `--<field-kebab> <value>` option in
`index.ts` (anchored on that command's `--json` option), and a mirrored
`FieldDef` in `schema.ts`. Every insertion is anchor-based and
idempotent — re-running it against already-patched source is a no-op,
which is what makes the pipeline safe to re-run and byte-identical
across runs of the same input.
- `version-bump.ts` — minor if the changelog added/removed any
endpoint, method, or enum value; patch otherwise. Scripted from the
parsed events, never guessed.
- `generate.ts` — orchestrates all of the above, writes the patched
files plus the bumped `package.json` version, and prints a
`SUMMARY_JSON:` line the workflow reads to decide whether to run CI,
open a PR, and whether that PR is eligible for auto-merge.

If the changelog contains ONLY changes the generator can't express, no
files change and the workflow opens a plain GitHub issue listing them
instead of a PR — there's no code diff to review, but staying silent
would bury a real API change.

Tests for the pipeline itself live in `scripts/api-sync/__tests__/` and
run via the same `bun test` CI uses for the CLI's own tests.

## Project structure

```
Expand Down Expand Up @@ -192,34 +248,33 @@ recorded `url`/`method`/`body`. Error-path tests assert that the action
throws `__test_exit__<code>` (the stubbed `process.exit` re-throws so
the test runner sees the exit code).

## Sync workflow conventions

When responding to an api-sync event:

1. Read `.api-sync/changelog.md`. It lists every API change since the
last sync.
2. For each change, decide:
- **New endpoint** → Add a CLI command only if a terminal user is
plausibly going to run it. Usually yes for CRUD-style endpoints,
no for internal/read-only diagnostics. When in doubt, add it.
- **New field on an input** → Add a corresponding `--<field>` flag
to the command's option list and pass it through.
- **New field on an output** → Update the default `columns` array if
the field is interesting; don't add columns for low-signal fields.
- **Removed endpoint/field** → Remove the corresponding command/flag.
- **Enum value added** → Update help text only (CLI doesn't validate
enum values client-side).
3. Add or update tests in `src/__tests__/resources.test.ts` for every
action you added or modified. New action → new happy-path test
(URL + method + body). Modified body shape → update the matching
test's expected body. Removed action → remove its test. See the
"Testing" section above for the helper pattern.
4. Bump the `version` field in `package.json` — patch for additive
changes, minor if you removed anything. `CLI_VERSION` is derived
from `package.json` at build time; don't edit `constants.ts`.
5. Run `bun run typecheck`, `bun run lint:fix`, and `bun run test`. Fix any errors.
6. Do NOT touch `.github/workflows/`.
7. Do NOT create commits — leave changes in the working tree.

If a change in the changelog doesn't map to any CLI surface (e.g. a
schema-only change with no field added), skip it silently.
## Reviewing a needs-human api-sync PR or issue

`scripts/api-sync/generate.ts` (see "The deterministic api-sync
pipeline" above) already applied everything it safely could. What's left
in the PR/issue body's "Needs a human" section is exactly what it
couldn't express. When picking one up by hand:

- **New endpoint** → Add a CLI command only if a terminal user is
plausibly going to run it. Usually yes for CRUD-style endpoints, no
for internal/read-only diagnostics. When in doubt, add it.
- **New field on an input, on a path not in `known-resources.ts`** →
Add a corresponding `--<field>` flag to the command's option list and
pass it through. Consider also adding the path to
`scripts/api-sync/known-resources.ts` so future additions on it are
handled automatically.
- **New field on an output** → Update the default `columns` array if
the field is interesting; don't add columns for low-signal fields.
- **Removed endpoint/field** → Remove the corresponding command/flag.
- **Enum value added** → Update help text only (CLI doesn't validate
enum values client-side).
- Add or update tests in `src/__tests__/resources.test.ts` for every
action you add, modify, or remove by hand. See "Testing" above.
- Bump the `version` field in `package.json` if you're adding to a PR
the generator already bumped — patch for additive changes, minor if
you removed anything.
- Run `bun run typecheck`, `bun run lint:fix`, and `bun run test`.

If a change in the changelog doesn't map to any CLI surface at all
(e.g. a schema-only change with no field added), the generator already
skipped it silently — that's expected, not a bug to report.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@
"dev": "bun run src/index.ts",
"build": "bun build src/index.ts --outdir dist --target node --minify && node -e \"const fs=require('fs');const f='dist/index.js';const c=fs.readFileSync(f,'utf8');fs.writeFileSync(f,'#!/usr/bin/env node\\n'+c);fs.chmodSync(f,0o755)\"",
"typecheck": "tsc --noEmit",
"lint": "oxlint -c oxlint.json src/",
"lint:fix": "oxlint -c oxlint.json --fix src/",
"lint": "oxlint -c oxlint.json src/ scripts/",
"lint:fix": "oxlint -c oxlint.json --fix src/ scripts/",
"test": "bun test",
"prepublishOnly": "bun run build"
},
Expand Down
Loading
Loading