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
69 changes: 69 additions & 0 deletions .claude/hooks/ask-before-risky-commands.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Claude Code PreToolUse(Bash) gate — pathscale backend service.
#
# Prompts before prod-affecting / destructive commands in ANY wrapper form
# (env VAR=val …, tool -C <dir> …, chained with ; && |, multi-line, quoted via
# bash -c '…'). For everything else it stays SILENT (exit 0, no decision), so the
# normal permission rules — permissions.allow / ask / deny in .claude/settings.json
# and the session's permission mode — decide as usual.
#
# This is one layer of defense, not a replacement for the permission system: a
# pattern match over a command string is best-effort (quoting and indirection can
# evade any blocklist). It backs up the declarative permissions.ask list in
# .claude/settings.json — KEEP THE TWO IN SYNC — and for fully autonomous runs,
# prefer OS-level sandboxing on top.
#
# Adapted from PakhomovAlexander/project-hub. Edit RISKY_WORDS for this repo;
# tooling; further branches below gate `git`/`docker push`, `git clean`, recursive
# `rm`, `find -delete`, package publishing, PR-merge/release via `gh`, and a deploy
# script run by path. Add your own command families (e.g. `ssh`, a bespoke deploy
# CLI) to RISKY_WORDS, or trim what you don't use — and mirror the change in
# permissions.ask in .claude/settings.json.
set -u

# --- the watchlist: command words that should prompt before running ----------------
RISKY_WORDS="aws|gcloud|az|kubectl|helm|terraform|terragrunt|flyctl|fly"
# -----------------------------------------------------------------------------------

# Pull the command out of the hook's stdin JSON (jq if available, else python3).
if command -v jq >/dev/null 2>&1; then
cmd="$(jq -r '.tool_input.command // ""' 2>/dev/null)"
else
cmd="$(python3 -c 'import sys, json; print(json.load(sys.stdin).get("tool_input", {}).get("command", ""))' 2>/dev/null)"
fi

# Couldn't read the command → stay neutral, let normal permission rules decide.
[ -z "$cmd" ] && exit 0

# A command word counts as "at a command position" after start-of-line, whitespace,
# ; & | ( or a quote — and ends before whitespace, a quote, ) ; & | or end-of-line —
# so `bash -c 'git push'` is still seen.
b="(^|[[:space:];&|(\"'\`])"
e="([[:space:];&|)\"'\`]|$)"
# Global options that may sit between a tool and its subcommand (git -C dir push).
opts='([[:space:]]+(-[A-Za-z-]+|[-_A-Za-z0-9]+=[^[:space:]]+|-C[[:space:]]+[^[:space:]]+))*'

re="${b}(${RISKY_WORDS})${e}"
# git push / git clean, docker push — allowing global options before the subcommand.
re="$re|${b}git${opts}[[:space:]]+(push|clean)${e}"
re="$re|${b}docker${opts}[[:space:]]+push${e}"
# recursive rm (-r / -R / -fr / --recursive), with flags/paths in any order.
re="$re|${b}rm([[:space:]]+[^[:space:]]+)*[[:space:]]+(-[A-Za-z]*[rR]|--recursive)"
# find … -delete — irreversible bulk delete.
re="$re|${b}find([[:space:]]+[^[:space:]]+)*[[:space:]]+-delete${e}"
# package publishing — ships artifacts to a registry.
re="$re|${b}(npm|pnpm|yarn|bun|cargo|gem)([[:space:]]+[^[:space:]]+)*[[:space:]]+publish${e}"
re="$re|${b}twine([[:space:]]+[^[:space:]]+)*[[:space:]]+upload${e}"
# gh mutations that merge, ship, or destroy.
re="$re|${b}gh[[:space:]]+(pr[[:space:]]+merge|repo[[:space:]]+delete|release[[:space:]]+(create|delete))${e}"
re="$re|${b}gh[[:space:]]+api([[:space:]]+[^[:space:]]+)*[[:space:]]+(-X|--method)[[:space:]]+(POST|PUT|PATCH|DELETE)${e}"
# a deploy script invoked by path, e.g. ./scripts/deploy.sh — a word-list can't see it.
re="$re|${b}([./A-Za-z0-9_-]*/)?deploy(\.[A-Za-z]+)?${e}"
# WorkTable data migrations and endpoint regeneration rewrite committed artifacts.
re="$re|${b}([./A-Za-z0-9_-]*/)?regenerate_endpoints(\.[A-Za-z]+)?${e}"

if printf '%s\n' "$cmd" | grep -Eq "$re"; then
printf '%s' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"Prod-affecting / destructive command — confirm before running."}}'
fi
# No match → no output: fall through to the normal permission flow.
exit 0
44 changes: 44 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"permissions": {
"allow": [
"Bash(cargo build:*)",
"Bash(cargo check:*)",
"Bash(cargo test:*)",
"Bash(cargo fmt:*)",
"Bash(cargo clippy:*)",
"Bash(cargo tree:*)",
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)"
],
"ask": [
"Bash(git push:*)",
"Bash(cargo publish:*)",
"Bash(npm publish:*)",
"Bash(bun publish:*)",
"Bash(docker push:*)",
"Bash(gh pr merge:*)",
"Bash(gh release:*)",
"Bash(aws:*)",
"Bash(gcloud:*)",
"Bash(az:*)",
"Bash(kubectl:*)",
"Bash(helm:*)",
"Bash(terraform:*)",
"Bash(flyctl:*)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/ask-before-risky-commands.sh\""
}
]
}
]
}
}
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,3 @@ examples/minimal_project/config/generated/*
.vscode

.idea/
CLAUDE.md
88 changes: 88 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Working agreement — endpointgen

The operating contract for **any** coding agent working in this repository. This file is
the single source of truth for the rules: Codex, Cursor and Gemini CLI read `AGENTS.md`
natively, and Claude Code loads it through the `@AGENTS.md` import in
[`CLAUDE.md`](CLAUDE.md). **Never fork these rules into a per-vendor file.**

**Rust binary** (`endpoint-gen`).

## Invariants (don't break these)

- **Keep `cargo fmt` and `cargo clippy --all-targets` clean.** Lint failures are part of the build here, not advisory.
- **Publishing to crates.io is irreversible.** A version number can never be reused, and yanking does not delete. Run `cargo publish --dry-run` first, publish from the merged default branch, and tag the release.
- **A pre-release version (`-alpha`, `-beta`) needs an exact dependency pin.** A plain `"2.0"` requirement will not match `2.0.0-alpha.1`, so consumers must be bumped deliberately.
- **Docs describe what is true now.** If you change behaviour, update the README and any affected doc in the same change.

## Build & test

```bash
cargo build
cargo test
cargo fmt && cargo clippy --all-targets # run after every change
```

## Verification

Run what you build before reporting it done. Type-checks and tests verify code correctness,
not feature correctness — **if you can't run it, say so explicitly** rather than implying
success.

- Compare against the base branch rather than asserting: a pre-existing failing test or lint
error is not something you introduced, and saying so requires checking.
- A build that finishes suspiciously fast was cached, not rebuilt. Force a real rebuild when
the rebuild is the thing you're verifying.

## PR discipline

**Always paste the full PR URL** (`https://github.com/pathscale/endpointgen/pull/<n>`), not just the number, so it's
clickable.

<!-- DORMANT — CI-green gating. Do not follow this rule yet; re-enable it as its own project.

Why it's off: CI here does not reliably attach checks to pull requests, so
`statusCheckRollup` comes back empty and "wait for green" would teach an agent to wait on
nothing. Verify per repo before switching this on.

To enable: ensure the workflow runs on `pull_request:`, confirm checks attach to a PR, then
uncomment the rule below.

After any push or PR, **check CI and don't call it done until it's green**:

```bash
gh pr view <number> --repo pathscale/endpointgen --json statusCheckRollup
```

CI running → wait and recheck. CI failed → read the logs, fix, push, wait for green.
-->

## Keeping docs honest

Hit a factual error here — a stale path, a wrong command, a moved status? Fix it in the same
change. Don't open cosmetic rewording PRs.

Learned something durable — a gotcha, a decision, a constraint? It belongs **in this repo's
docs**, not in your agent's private memory. Repo docs are versioned, reviewable, and visible
to every agent and human; private memory dies with your machine.

## Git workflow

- **Always specify the branch when pushing**: `git push origin branch-name`
- **Branch naming**: `fix/issue-description` or `feat/issue-description`
- **Force-push your own branch freely.** Rebasing a feature branch onto a moved
base, or amending before review, is normal and correct — use
`--force-with-lease` so you don't clobber someone else's push.
- **Never force-push the default branch** (`main`/`master`). That is the history
everyone else builds on, and it is protected server-side for a reason.

## Guardrails

[`.claude/settings.json`](.claude/settings.json) and [`.claude/hooks/`](.claude/hooks/) make
Claude Code prompt a human before prod-affecting or destructive commands — pushes, publishing
to a registry, `gh pr merge`, cloud CLIs, recursive deletes, deploy scripts.

**Other agents don't get that net automatically.** Apply the same rule yourself: ask before
running any command family listed in
[`.claude/hooks/ask-before-risky-commands.sh`](.claude/hooks/ask-before-risky-commands.sh).
It is one layer of defence, not a guarantee — a pattern match over a command string is
best-effort.
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
@AGENTS.md

# Claude Code notes — endpointgen

The import above is binding: [`AGENTS.md`](AGENTS.md) is the **working agreement** for this
repository, and every Claude Code session loads it automatically. Don't copy rules here —
one source of truth, no drift. Only genuinely Claude-specific wiring belongs below.

- Guardrails live in [`.claude/settings.json`](.claude/settings.json): safe read-only
commands are pre-allowed; pushes, publishing, `gh pr merge`, cloud CLIs and deploys prompt
first (`permissions.ask` plus the `PreToolUse` hook in [`.claude/hooks/`](.claude/hooks/)).
- Keep the hook's `RISKY_WORDS` and `permissions.ask` **in sync** — they back each other up.
Loading