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.

<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.
| Package | npm | What it is |
|---|---|---|
@rxova/ts-utils | Small, dependency-free runtime helpers, inlined at build time, plus a /react entry. | |
@rxova/repo-config | The rxova-repo-config bin (verify gate, changesets, scope, tarball and docs checks) and presets for ESLint, Prettier, tsconfig, Vitest and more. | |
@rxova/agent-kit | 10 hooks, 51 skills and 25 agents for Claude Code and OpenCode, installed in profiles by rxova-agent-kit. | |
@rxova/docs-kit | Markdown 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.
- uses: rxova/shared/actions/setup-pnpm@main
| Action | What it does |
|---|---|
setup-pnpm | pnpm at the packageManager version and Node, with the store cached. |
turbo-cache | Restores and saves the local Turbo cache across runs. |
turbo-remote-cache | Points Turbo at a remote cache backed by the Actions cache. |
setup-playwright | Resolves, caches and installs Playwright browsers. |
pin-react | Pins one React at the root and in the given packages, and proves only that one resolves. |
require-jobs | Fails unless every job in needs passed or was skipped: the one check to require. |
notify-website | Tells the rxova.dev website to publish this run's docs build. |
Inputs and examples: actions/README.md.
jobs:
commitlint:
uses: rxova/shared/.github/workflows/commit-messages.yml@main
| Workflow | What it does |
|---|---|
commit-messages.yml | The root CI job: lints commits and outputs code-changed, docs-only and docs-changed. |
docs-checks.yml | The light job a documentation-only range runs instead of the build and test matrix. |
lint-pr-title.yml | Lints the PR title, which becomes the squash subject. |
changeset-gate.yml | Requires a changeset when a pull request changes a published package. |
react-minimum-version.yml | Builds, tests and typechecks against the oldest React a peer range allows. |
node-floor-smoke.yml | Installs each packed package on exactly the oldest Node its engines promises. |
changesets-release.yml | The version pull request, then publishing with npm trusted publishing and provenance. |
snapshot-release.yml | Publishes 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.
{ extends: ["github>rxova/shared//renovate/default.json5"] }
The org rules live in renovate/default.json5; a repository keeps only its own.
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.
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
main, Changesets opens a chore: version packages pull request; merging it publishes to npm with trusted publishing and provenance.docs checks job instead; the docs site still builds when apps/docs changed. 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.
Every README in the repository:
packages/ts-utils/README.md: @rxova/ts-utilspackages/repo-config/README.md: @rxova/repo-configpackages/agent-kit/README.md: @rxova/agent-kitpackages/docs-kit/README.md: @rxova/docs-kitactions/README.md: the composite actions.github/workflows/README.md: the reusable workflows.changeset/README.md: adding a changeset and how it is releasedThe documentation site's sources are in apps/docs; pnpm docs serves it locally.
hooks/register.ts 107 lines1import 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};
107hooks/attribution-lines.ts 6 lines1import { 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);
6hooks/commits.ts 4 lines1/** Whether a shell command runs git commit. */
2export const commits = (command: string): boolean =>
3 /\bgit\s+(?:-[cC]\s+\S+\s+)*commit\b/.test(command);
4hooks/git-dir.ts 9 lines1/** 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};
9hooks/opens-or-edits-pr.ts 4 lines1/** 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);
4hooks/pr-url.ts 5 lines1const 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];
5hooks/without-attribution.ts 11 lines1import { 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();
11hooks/carries-attribution.ts 13 lines1const 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