SLOPSHOPPER

rx-attribution

Drops the Claude trailer and footer from commits and pull requests, then checks each opened or edited PR body and the new commit's author.

newguardtoastprocess
A shopper browsing a rack in a slop shop
README

<img src="https://cdn.jsdelivr.net/npm/@rxova/brand@1/assets/rxova-logo-256.png" width="128" alt="rxova logo" />

<h1 align="center">rxova/shared</h1>

<a href="https://github.com/rxova/shared/actions/workflows/ci.yml"><img src="https://github.com/rxova/shared/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI status" /></a> <img src="https://img.shields.io/badge/Node.js-%E2%89%A522.13-5fa04e?logo=nodedotjs&logoColor=white" alt="Node.js 22.13 or newer" /> <img src="https://img.shields.io/badge/pnpm%20%2B%20Turborepo-%E2%9C%93-f69220" alt="Made for pnpm and Turborepo" /> <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license" /></a>

<a href="#packages">Packages</a> · <a href="#ci-building-blocks">CI building blocks</a> · <a href="#using-it-in-an-rxova-repo">Using it</a> · <a href="#developing-this-repo">Developing</a> · <a href="#docs">Docs</a>

Every repository ends up with the same helpers, the same verify gate, the same CI jobs and the same Renovate rules. Copied, they drift. Here they live once: repositories depend on the packages and call the actions and workflows by path, so a fix lands everywhere on the next install or run.

Packages

PackagenpmWhat it is
@rxova/ts-utilsnpmSmall, dependency-free runtime helpers, inlined at build time, plus a /react entry.
@rxova/repo-confignpmThe rxova-repo-config bin (verify gate, changesets, scope, tarball and docs checks) and presets for ESLint, Prettier, tsconfig, Vitest and more.
@rxova/agent-kitnpm10 hooks, 51 skills and 25 agents for Claude Code and OpenCode, installed in profiles by rxova-agent-kit.
@rxova/docs-kitnpmMarkdown twins, llms.txt and the check-md-routes build check for an Astro Starlight docs site.

Each package README lists every export, command and option.

CI building blocks

Composite actions

- uses: rxova/shared/actions/setup-pnpm@main
ActionWhat it does
setup-pnpmpnpm at the packageManager version and Node, with the store cached.
turbo-cacheRestores and saves the local Turbo cache across runs.
turbo-remote-cachePoints Turbo at a remote cache backed by the Actions cache.
setup-playwrightResolves, caches and installs Playwright browsers.
pin-reactPins one React at the root and in the given packages, and proves only that one resolves.
require-jobsFails unless every job in needs passed or was skipped: the one check to require.
notify-websiteTells the rxova.dev website to publish this run's docs build.

Inputs and examples: actions/README.md.

Reusable workflows

jobs:
  commitlint:
    uses: rxova/shared/.github/workflows/commit-messages.yml@main
WorkflowWhat it does
commit-messages.ymlThe root CI job: lints commits and outputs code-changed, docs-only and docs-changed.
docs-checks.ymlThe light job a documentation-only range runs instead of the build and test matrix.
lint-pr-title.ymlLints the PR title, which becomes the squash subject.
changeset-gate.ymlRequires a changeset when a pull request changes a published package.
react-minimum-version.ymlBuilds, tests and typechecks against the oldest React a peer range allows.
node-floor-smoke.ymlInstalls each packed package on exactly the oldest Node its engines promises.
changesets-release.ymlThe version pull request, then publishing with npm trusted publishing and provenance.
snapshot-release.ymlPublishes a prerelease under a dist-tag other than latest, to try a change in another repo.

Inputs, outputs and a full CI graph: .github/workflows/README.md.

Renovate preset

{ extends: ["github>rxova/shared//renovate/default.json5"] }

The org rules live in renovate/default.json5; a repository keeps only its own.

Using it in an rxova repo

Tooling packages go in the root package.json only, never in a workspace package:

pnpm add -D -w @rxova/repo-config @rxova/ts-utils

Point each tool at its preset:

// eslint.config.js
import { rxova } from "@rxova/repo-config/eslint";

export default rxova({ tsconfigRootDir: import.meta.dirname, node: true, tests: true });
// .prettierrc
"@rxova/repo-config/prettier"
// tsconfig.json
{ "extends": "@rxova/repo-config/tsconfig.react.json", "include": ["src"] }

And CI at the shared workflows, pinned to @main:

jobs:
  commitlint:
    uses: rxova/shared/.github/workflows/commit-messages.yml@main
  changeset:
    needs: [commitlint]
    uses: rxova/shared/.github/workflows/changeset-gate.yml@main

The @rxova/repo-config quick start covers the rest: the verify script, the pre-push hook, lint-staged, commitlint, tsdown and Vitest.

Developing this repo

Node 22.13 or newer, and pnpm through Corepack:

corepack enable
pnpm install        # dependencies and git hooks
pnpm test           # unit tests, coverage enforced per file
pnpm docs           # the docs site (apps/docs) on a local dev server
pnpm run verify     # everything CI runs, in CI's order (also the pre-push hook)
pnpm changeset      # record a change to a published package
  • Releases. A change to a published package needs a changeset (see .changeset/README.md). Once CI is green on main, Changesets opens a chore: version packages pull request; merging it publishes to npm with trusted publishing and provenance.
  • Docs-only pull requests. When a range changes only Markdown (and changesets), CI skips the build, test and package jobs and runs the light docs checks job instead; the docs site still builds when apps/docs changed.
  • Releasing a README-only change. A package README change needs no changeset, but npm shows the README of the published version. To ship it, add a patch changeset; the range stays docs-only:
  pnpm exec rxova-repo-config add-changeset ts-utils patch "Rewrite the readme"

See CONTRIBUTING.md for hooks and commit rules, SECURITY.md to report a vulnerability, and SUPPORT.md for help.

Docs

Every README in the repository:

The documentation site's sources are in apps/docs; pnpm docs serves it locally.

License

MIT

Source 8 files
hooks/register.ts 107 lines
1import type { EngineInterface, Register } from "claude-code";
2
3import { attributionLines } from "./attribution-lines";
4import { commits } from "./commits";
5import { gitDir } from "./git-dir";
6import { opensOrEditsPr } from "./opens-or-edits-pr";
7import { prUrl } from "./pr-url";
8import { withoutAttribution } from "./without-attribution";
9
10/**
11 * Reads a pull request's body and, when it credits Claude, removes those lines with gh pr edit
12 * (or only reports them when `isFixing` is off). Answers the note for the model, if any.
13 */
14const checkPr = async (
15  $: EngineInterface,
16  url: string,
17  isFixing: boolean,
18): Promise<string | undefined> => {
19  const view = await $.process.run(["gh", "pr", "view", url, "--json", "body", "-q", ".body"]);
20  if (view.exitCode !== 0) {
21    return `rx-attribution: could not read ${url} to check its body (${view.stderr.trim()}).`;
22  }
23
24  const found = attributionLines(view.stdout);
25  if (found.length === 0) {
26    return undefined;
27  }
28
29  if (!isFixing) {
30    return `rx-attribution: ${url} carries attribution: ${found.join(" | ")}. Remove it with gh pr edit.`;
31  }
32
33  const edit = await $.process.run(["gh", "pr", "edit", url, "--body-file", "-"], {
34    stdin: withoutAttribution(view.stdout),
35  });
36  if (edit.exitCode !== 0) {
37    return `rx-attribution: ${url} carries attribution (${found.join(" | ")}) and gh pr edit failed: ${edit.stderr.trim()}`;
38  }
39
40  $.ui.toast(`rx-attribution: removed ${found.length} attribution line(s) from ${url}`);
41
42  return `rx-attribution: removed attribution from ${url}'s body: ${found.join(" | ")}.`;
43};
44
45/**
46 * Reads the newest commit and answers a note for the model when its author or committer is not
47 * `email` (an empty `email` skips that) or its message credits Claude.
48 */
49const checkCommit = async (
50  $: EngineInterface,
51  cwd: string | undefined,
52  email: string,
53): Promise<string | undefined> => {
54  const log = await $.process.run(
55    ["git", "log", "-1", "--pretty=%an <%ae>%n%cn <%ce>%n%B"],
56    cwd === undefined ? undefined : { cwd },
57  );
58  if (log.exitCode !== 0) {
59    return undefined;
60  }
61
62  const [author = "", committer = "", ...message] = log.stdout.split("\n");
63  const problems: string[] = [];
64  if (email !== "" && !author.endsWith(`<${email}>`)) {
65    problems.push(`author is ${author}`);
66  }
67  if (email !== "" && !committer.endsWith(`<${email}>`)) {
68    problems.push(`committer is ${committer}`);
69  }
70  const found = attributionLines(message.join("\n"));
71  if (found.length > 0) {
72    problems.push(`message carries ${found.join(" | ")}`);
73  }
74
75  return problems.length === 0
76    ? undefined
77    : `rx-attribution: the new commit is wrong: ${problems.join("; ")}. Amend it before pushing.`;
78};
79
80export const register: Register = (on, options) => {
81  const email = typeof options.authorEmail === "string" ? options.authorEmail.trim() : "";
82  const isFixing = options.fixPrBody !== false;
83
84  on("attribution.text", { kind: "commit" }, () => ({ text: "" }));
85  on("attribution.text", { kind: "pr" }, () => ({ text: "" }));
86
87  on("tool.call", { tool: "Bash" }, async ($, e, next) => {
88    const ran = await next(e);
89    if (ran.deny !== undefined || ran.isError === true) {
90      return ran;
91    }
92
93    const notes: string[] = [];
94    if (opensOrEditsPr(e.command)) {
95      const url = prUrl(ran.text ?? "");
96      const note = url === undefined ? undefined : await checkPr($, url, isFixing);
97      if (note !== undefined) notes.push(note);
98    }
99    if (commits(e.command)) {
100      const note = await checkCommit($, gitDir(e.command), email);
101      if (note !== undefined) notes.push(note);
102    }
103
104    return notes.length === 0 ? ran : { ...ran, context: [...(ran.context ?? []), ...notes] };
105  }).catch(($, e, next) => next(e));
106};
107
hooks/attribution-lines.ts 6 lines
1import { carriesAttribution } from "./carries-attribution";
2
3/** The lines of a text that credit Claude. */
4export const attributionLines = (text: string): string[] =>
5  text.split("\n").filter(carriesAttribution);
6
hooks/commits.ts 4 lines
1/** Whether a shell command runs git commit. */
2export const commits = (command: string): boolean =>
3  /\bgit\s+(?:-[cC]\s+\S+\s+)*commit\b/.test(command);
4
hooks/git-dir.ts 9 lines
1/** The directory a shell command runs git in, from `git -C <dir>` or a leading `cd <dir> &&`. */
2export const gitDir = (command: string): string | undefined => {
3  const word =
4    /\bgit\s+-C\s+("[^"]+"|'[^']+'|\S+)/.exec(command)?.[1] ??
5    /^\s*cd\s+("[^"]+"|'[^']+'|\S+)\s*&&/.exec(command)?.[1];
6
7  return word?.replace(/^["']|["']$/g, "");
8};
9
hooks/opens-or-edits-pr.ts 4 lines
1/** Whether a shell command runs gh pr create or gh pr edit. */
2export const opensOrEditsPr = (command: string): boolean =>
3  /\bgh\s+pr\s+(create|edit)\b/.test(command);
4
hooks/pr-url.ts 5 lines
1const PR_URL = /https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+/;
2
3/** The first GitHub pull request URL in a text, as gh pr create and gh pr edit print it. */
4export const prUrl = (text: string): string | undefined => PR_URL.exec(text)?.[0];
5
hooks/without-attribution.ts 11 lines
1import { carriesAttribution } from "./carries-attribution";
2
3/** The text with every line that credits Claude removed, and the blank runs that leaves collapsed. */
4export const withoutAttribution = (text: string): string =>
5  text
6    .split("\n")
7    .filter((line) => !carriesAttribution(line))
8    .join("\n")
9    .replace(/\n{3,}/g, "\n\n")
10    .trimEnd();
11
hooks/carries-attribution.ts 13 lines
1const ATTRIBUTION = [
2  /^\s*co-authored-by:.*\b(claude|anthropic)\b/i,
3  /^\s*claude-session:/i,
4  /generated with \[?claude/i,
5  /🤖/u,
6  /claude\.ai\/code/i,
7  /\bsession_[A-Za-z0-9]{6,}/,
8];
9
10/** Whether a line credits Claude: a co-author trailer, a session link or the generated-with footer. */
11export const carriesAttribution = (line: string): boolean =>
12  ATTRIBUTION.some((pattern) => pattern.test(line));
13