Skip to content
Draft
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
25 changes: 25 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,31 @@
"review-dispatch"
]
},
{
"name": "shared-config",
"description": "Setup/SessionStart hook that clones shared-config source repos and symlinks their rules/skills/commands/agents into the project (deduped across projects via a shared cache), optionally merges settings files, and bootstraps from an org-level upstream config via $AGENT_PLUGIN_SHARED_CONFIG_UPSTREAM. Prefers the github-app token when available.",
"version": "0.1.1",
"author": {
"name": "Nathan Heaps"
},
"source": "./plugins/claude-code/shared-config",
"category": "utility",
"tags": [
"utility"
],
"keywords": [
"claude-code",
"shared-config",
"symlink",
"rules",
"skills",
"commands",
"agents",
"settings-merge",
"hook",
"multi-project"
]
},
{
"name": "task-utils",
"description": "Task discipline hooks (0-or-1 in_progress invariant, write-tool gating, validation-steps + RESULT cites) + task-manage skill for atomic-task workflow. Blocking behaviour is configurable via plugins.settings.yaml.",
Expand Down
24 changes: 24 additions & 0 deletions plugins/claude-code/shared-config/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "shared-config",
"version": "0.1.1",

Check notice on line 3 in plugins/claude-code/shared-config/.claude-plugin/plugin.json

View workflow job for this annotation

GitHub Actions / version-preview

Pending version bump

shared-config: already bumped to 0.1.1 in this PR (kept on merge to main)
"description": "Setup/SessionStart hook that clones shared-config source repos and symlinks their rules/skills/commands/agents into the project (deduped across projects via a shared cache), optionally merges settings files, and bootstraps from an org-level upstream config via $AGENT_PLUGIN_SHARED_CONFIG_UPSTREAM. Prefers the github-app token when available.",
"author": {
"name": "Nathan Heaps",
"email": "nsheaps@gmail.com",
"url": "https://github.com/nsheaps"
},
"homepage": "https://github.com/nsheaps/agents/tree/main/plugins/claude-code/shared-config",
"repository": "https://github.com/nsheaps/agents",
"keywords": [
"claude-code",
"shared-config",
"symlink",
"rules",
"skills",
"commands",
"agents",
"settings-merge",
"hook",
"multi-project"
]
}
9 changes: 9 additions & 0 deletions plugins/claude-code/shared-config/.release-it.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
module.exports = {
extends: "../../.release-it.base.json",
plugins: {
"@release-it/bumper": {
in: ".claude-plugin/plugin.json",
out: ".claude-plugin/plugin.json",
},
},
};
154 changes: 154 additions & 0 deletions plugins/claude-code/shared-config/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# shared-config

Setup/SessionStart hook that **shares Claude Code config across repos**. It
clones one or more "source" repos and symlinks their `rules/`, `skills/`,
`commands/`, and `agents/` into the current project through a shared cache, so
the same shared resources can be reused across every project in a session
without duplicating them. It can also bootstrap from an org-level upstream
config and (opt-in) merge `settings.json` fragments.

## How it works

Implemented in Bun/TypeScript (`src/index.ts`), run directly by `bun` from the
hook — no build step and zero external deps (uses `Bun.YAML`, `node:fs`,
`node:crypto`, and the `git` binary). On `Setup` and `SessionStart`:

1. **Reads the github-app token.** Waits briefly for the `github-app` plugin to
publish `GH_TOKEN` via `CLAUDE_ENV_FILE`, preferring that token over ambient
creds so private source repos clone correctly. Falls back to any ambient
`GH_TOKEN`, or tokenless for public repos.
2. **Resolves settings** (see below), including `$AGENT_PLUGIN_SHARED_CONFIG_UPSTREAM`.
3. **Clones/updates each source** into a shared cache:
`$CLAUDE_PLUGIN_DATA/shared-configs/sources/<org>/<repo>/` — one clone per
repo, fetched and hard-reset to the remote on each run.
4. **Builds a per-project link tree** and points the project at it:

```
<project>/.claude/rules/.shared
└─> $CLAUDE_PLUGIN_DATA/shared-configs/<project-slug>/.claude/rules/
├─ acme__shared-a ─> …/sources/acme/shared-a/rules
└─ acme__shared-b ─> …/sources/acme/shared-b/.claude/rules
```

(same for `skills`, `commands`, `agents`). `reloadSkills` is emitted so newly
linked skills load in the same session.

### Why the indirection — cross-project dedup

Claude Code on the web loads `rules`/`skills`/etc. from **all** projects in a
session, not just the cwd. Because every project's `.shared` links ultimately
resolve (via `realpath`) to the **same** file in the **same** source clone,
Claude Code deduplicates identical resources and loads each one once — so the
same shared rule referenced by five projects is loaded a single time, not five.

The hermetic test (`tests/sync.test.ts`) asserts the filesystem half of this:
the link tree is built correctly and a resource reachable from two projects
resolves to one realpath inside a single source clone.

## Configuration

Config follows the standard 3-tier `plugins.settings.yaml` resolution
(project > user > plugin default) under the `shared-config:` namespace, plus two
overlays. Resolution, low -> high precedence (`sources` lists are **unioned**
across every layer; other keys are last-wins):

- `$AGENT_PLUGIN_SHARED_CONFIG_UPSTREAM` — org bootstrap (lowest)
- `${CLAUDE_PLUGIN_ROOT}/shared-config.settings.yaml` — plugin defaults
- `~/.claude/plugins.settings.yaml` `shared-config:` — user
- `~/.claude/shared-config.settings.yaml` — standalone overlay (user)
- `<project>/.claude/plugins.settings.yaml` `shared-config:` — project
- `<project>/.claude/shared-config.settings.yaml` — standalone overlay (project,
highest)

The standalone `shared-config.settings.yaml` overlay lets an org sync just this
one file without syncing all plugin settings.

```yaml
# shared-config.settings.yaml (or nested under `shared-config:` in plugins.settings.yaml)
enabled: true
resourceTypes: [rules, skills, commands, agents] # which dirs to link
defaultSourceDir: "." # base in a source repo holding the {rules,skills,…} dirs
targetBaseDir: ".claude" # where in the project the .shared links go
waitForTokenTimeoutSeconds: 15 # 0 disables the github-app token wait
mergeSettings: false # opt-in, experimental (see below)
sources:
- acme/shared-config # whole repo, resources at the repo root
- acme/org-rules/.claude # uses:-style subpath -> source dir = .claude
- repo: acme/special # object form
sourceDir: configs/claude # override base dir in the source repo
targetDir: .claude # override where links land in this project
```

### Repo references

GitHub-Actions `uses:`-style, **without** a `@ref` (refs are not supported yet):

- `org/repo` — whole repo; resources at the default source dir
- `org/repo/sub/path` — `sub/path` is the source dir (base of `{rules,skills,…}`)

### Source-side override

A source repo can ship `.claude/shared-config-roots.yaml` to declare where its
resources live (e.g. under `.claude/` instead of the repo root):

```yaml
sourceDir: .claude # base dir holding {rules,skills,commands,agents}
# or per-type overrides (each is the exact dir, relative to the repo root):
roots:
rules: .claude/rules
skills: skills
```

Precedence for a source's resource dir: target `roots`/`sourceDir` override →
source repo's `shared-config-roots.yaml` → `defaultSourceDir`.

### `$AGENT_PLUGIN_SHARED_CONFIG_UPSTREAM`

Points at an org-level config (a repo + optional path), e.g.
`nsheaps/shared-config/config`. The hook clones it and loads
`shared-config.settings.yaml` (or `plugins.settings.yaml`) from the given path,
using it **in addition to** anything defined in the repo (its `sources` are
unioned in; its scalars are overridden by repo-level settings).

### Settings merge (opt-in)

With `mergeSettings: true`, source repos may provide
`<sourceDir>/settings/settings.json`, `settings.local.json`, or `*.jsonnet`
fragments. They are deep-merged together, then merged into the project's
`.claude/settings.json` / `settings.local.json` with **project values winning**;
the originals are backed up to `*.shared-config.bak`. `.jsonnet` fragments are
evaluated only if `jsonnet` is available (on `PATH` or via `mise`), otherwise
skipped.

## Dependencies / requirements

This plugin has **no hard plugin dependencies** — it works on its own.

Optional companion:

- **github-app** — if installed, it publishes `GH_TOKEN` into `CLAUDE_ENV_FILE`;
shared-config opportunistically reads that token (via the `GITHUB_TOKEN_FILE`
signal) to clone **private** source repos. It is _not_ required: shared-config
imports no github-app code and degrades gracefully (public repos, an ambient
`GH_TOKEN`, or tokenless). It is therefore intentionally not declared in
`plugin.json` `dependencies` — that would force installing a whole
cross-marketplace auth stack just to read an optional env var.

Runtime tools:

- **bun** runs the hook — provided via `mise` (the repo's `mise.toml` pins it),
matching the other bun-based plugins.
- **git** (and **jsonnet**, only for jsonnet settings fragments) are resolved at
runtime: used directly if on `PATH`, otherwise auto-run via `mise exec
<tool>@latest` so they're installed/delegated through mise rather than failing.

## Testing

```bash
bun test plugins/claude-code/shared-config/tests/sync.test.ts
```

Hermetic (no network) — builds local fixture repos and asserts the clone +
symlink layout, source-side `sourceDir` override, `uses:`-style subpath, source
union, `resourceTypes` honoring, idempotency, env-file parsing, and that
cross-project paths resolve to one realpath.
109 changes: 109 additions & 0 deletions plugins/claude-code/shared-config/docs/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# shared-config — design notes

## Goal

Share Claude Code config (rules, skills, commands, agents, and select
settings files) across repositories, with cross-project deduplication, so an
org can maintain one source of truth and have it appear in every project.

## Implementation

Single Bun/TypeScript entrypoint `src/index.ts`, invoked directly by `bun` from
the hook (`hooks.json` → `bun "${CLAUDE_PLUGIN_ROOT}/src/index.ts"`). No build
step and zero external deps — it uses `Bun.YAML.parse`, `node:fs`/`node:crypto`,
and spawns the `git` binary. Functions are exported so `bun test` can drive them.

## Hook process (Setup + SessionStart)

1. Resolve the local config layers (no network) to learn
`waitForTokenTimeoutSeconds`.
2. Wait (bounded by that timeout; default 15, `0` disables) for the
**github-app** plugin to publish `GH_TOKEN` into `CLAUDE_ENV_FILE`, preferring
it (via the `GITHUB_TOKEN_FILE` signal) over ambient creds. The env file is
parsed in TS (`export K=V` + recursive `source` lines).
3. Resolve settings across layers (low → high): `$AGENT_PLUGIN_SHARED_CONFIG_UPSTREAM`
bootstrap → plugin defaults (`${CLAUDE_PLUGIN_ROOT}/shared-config.settings.yaml`)
→ user `plugins.settings.yaml` → user standalone → project
`plugins.settings.yaml` → project standalone. `sources` are unioned; other
keys last-wins (with string→type coercion).
4. Clone/update each source into `…/shared-configs/sources/<org>/<repo>/`
(one clone per repo; fetch + hard reset each run; tokened via the github-app
token using `http.extraheader`, never persisted to the repo config).
5. Rebuild `…/shared-configs/<slug>/<targetBase>/<type>/<org>__<repo>` →
`<clone>/<resolved type dir>` (slug derived from the project's git origin, or
a hash of its realpath).
6. Point `<project>/<targetBase>/<type>/.shared` at the intermediate dir.
7. If `mergeSettings`, deep-merge source `settings/*.json|*.jsonnet` fragments
into the project's settings (project wins; backups written).
8. Emit SessionStart JSON with `reloadSkills: true`.

Setup/SessionStart hooks must never break the session: `main()` wraps the run in
try/catch and always emits JSON to stdout, exiting 0 even on error.

> Config is read directly in TypeScript (replicating the standard 3-tier
> `plugins.settings.yaml` semantics) rather than sourcing the shared-lib bash
> `plugin-config-read.sh`, because the entrypoint is pure Bun/TS with no shell.

### Concurrency & safety

- **Cross-process lock** (`withLock`): the clone/build/merge phase runs under an
atomic `mkdir` lock at `…/shared-configs/.sync.lock` (stale-lock takeover after
3 min), so an overlapping `Setup` + `SessionStart` (or two projects) can't race
the shared cache. A run that can't get the lock in 30s skips (the other run
does the same work). The token wait happens _before_ the lock so it isn't held
during the wait.
- **Token never on argv**: the github-app token is passed to `git` via
`GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_n`/`GIT_CONFIG_VALUE_n` env vars (git ≥2.31),
not `-c …` on the command line, so it isn't visible in `ps`. It's also never
persisted to a repo's `.git/config`.
- **Orphan cleanup**: the set of `targetBase`s linked each run is persisted to
`…/shared-configs/<slug>.targetbases.json`; the next run cleans up `.shared`
links under any base no longer referenced (e.g. a removed custom-`targetDir`
source). `ensureSymlink` also replaces dangling links rather than throwing.

## Cross-project dedup

```
projectA/.claude/rules/.shared ─> …/<slugA>/.claude/rules/ ─> …/sources/<org>/<repo>/rules
projectB/.claude/rules/.shared ─> …/<slugB>/.claude/rules/ ─> …/sources/<org>/<repo>/rules
```

Claude Code (on the web, with multiple projects in scope) recurses into the
linked dirs, follows the symlinks, and dedups by realpath, so a rule shared by
both projects loads exactly once. The shared cache + indirection exist to make
every project resolve identical resources to one realpath inside a single clone.

`tests/sync.test.ts` (`bun test`, 13 tests) covers: ref parsing, source
normalization/dedup, env-file parsing, `toolArgv` (PATH/mise/bare), link-tree
construction, source-side `sourceDir` override, `uses:`-style subpath selection,
source union, `resourceTypes` honoring, idempotency, single-realpath dedup across
projects, the `$AGENT_PLUGIN_SHARED_CONFIG_UPSTREAM` bootstrap, per-source
`targetDir` override, `mergeSettings` (project-wins + backup), `resolveToken`
reading `CLAUDE_ENV_FILE`, orphaned-link cleanup, and dangling-symlink handling.

## Dependencies & tool resolution

**No hard plugin dependencies.** `github-app` is an _optional_ companion, not a
declared dependency: shared-config imports none of its code — it only reads the
`GH_TOKEN`/`GITHUB_TOKEN_FILE` env vars github-app writes to `CLAUDE_ENV_FILE`,
and degrades gracefully without a token (public repos / ambient token /
tokenless). Declaring it would force a cross-marketplace auth stack
(github-app → shared-lib, plus 1pass for creds) just to read an optional env
var. The dependency graph would not be circular (`github-app → shared-lib`, with
nothing pointing back at shared-config), but the coupling is simply too loose to
model as a package dependency.

`bun` runs the hook and is provided via `mise` (pinned in the repo `mise.toml`),
like the other bun-based plugins. External binaries the code spawns are resolved
by `toolArgv()`: used directly if on `PATH`, else delegated to `mise exec
<tool>@latest -- <tool>` so they auto-install/run through mise. This covers
`git` (always needed) and `jsonnet` (only for jsonnet settings fragments, which
otherwise skip gracefully).

## Open questions / follow-ups

- The standalone `shared-config.settings.yaml` overlay and the settings-merge
feature are implemented and unit-tested but still to be exercised against real
org sync flows.
- If a resource type needs a flatter on-disk layout than
`.claude/<type>/.shared/<repo>/…`, adjust the link tree accordingly.
29 changes: 29 additions & 0 deletions plugins/claude-code/shared-config/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"description": "shared-config hooks: Setup + SessionStart sync (clone shared-config source repos, symlink rules/skills/commands/agents into the project via a deduped shared cache, optionally merge settings). Prefers the github-app token; waits briefly for it to appear in CLAUDE_ENV_FILE. Implemented in Bun/TypeScript.",
"hooks": {
"Setup": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "bun \"${CLAUDE_PLUGIN_ROOT}/src/index.ts\"",
"timeout": 120
}
]
}
],
"SessionStart": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "bun \"${CLAUDE_PLUGIN_ROOT}/src/index.ts\"",
"timeout": 120
}
]
}
]
}
}
28 changes: 28 additions & 0 deletions plugins/claude-code/shared-config/shared-config.settings.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# shared-config plugin — default settings (3rd tier).
#
# Resolved via the standard 3-tier plugins.settings.yaml mechanism:
# 1. project: ${CLAUDE_PROJECT_DIR}/.claude/plugins.settings.yaml
# 2. user: ~/.claude/plugins.settings.yaml
# 3. plugin: this file
# All keys live under the `shared-config:` namespace. Per-project config should
# set the `shared-config:` section in plugins.settings.yaml (or use the
# standalone shared-config.settings.yaml overlay — see README).

shared-config:
enabled: true
# Resource dirs to link from each source into the project.
resourceTypes: [rules, skills, commands, agents]
# Base dir within a source repo that holds the {rules,skills,…} subdirs.
defaultSourceDir: '.'
# Where in the project the `.shared` links are placed.
targetBaseDir: '.claude'
# Seconds to wait for the github-app token to appear in CLAUDE_ENV_FILE
# (0 disables the wait).
waitForTokenTimeoutSeconds: 15
# Opt-in: merge source-provided settings.json fragments into the project.
mergeSettings: false
# List of source repos. Each is a GitHub uses:-style ref (no @ref):
# - org/repo
# - org/repo/sub/path # sub/path = source dir
# - { repo: org/repo, sourceDir: configs, targetDir: .claude }
sources: []
Loading
Loading