Skip to content

Add shared-config plugin to share config across repos - #223

Draft
jack-nsheaps[bot] wants to merge 11 commits into
mainfrom
claude/pensive-lovelace-8fcLz
Draft

Add shared-config plugin to share config across repos#223
jack-nsheaps[bot] wants to merge 11 commits into
mainfrom
claude/pensive-lovelace-8fcLz

Conversation

@jack-nsheaps

@jack-nsheaps jack-nsheaps Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds the shared-config plugin (plugins/claude-code/shared-config): a 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 deduped shared cache, bootstraps from an org-level upstream config, and (opt-in) merges settings.json fragments.

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

How it works

  1. Resolves config via the standard 3-tier plugins.settings.yaml mechanism (under the shared-config: namespace) plus standalone + $AGENT_PLUGIN_SHARED_CONFIG_UPSTREAM overlays (sources unioned; scalars last-wins).
  2. Waits for the github-app plugin's GH_TOKEN (parsed from CLAUDE_ENV_FILE, preferring the GITHUB_TOKEN_FILE signal) so private source repos clone with the app token.
  3. Clones/updates each source once into $CLAUDE_PLUGIN_DATA/shared-configs/sources/<org>/<repo>/.
  4. Builds per-project link trees and points <project>/.claude/<type>/.shared at them; emits reloadSkills.
  5. Optional (mergeSettings) settings.json/.local.json merge with jsonnet support, project always winning, with backups.

Repo references use GitHub uses:-style org/repo[/subpath] (no @ref). A source repo can ship .claude/shared-config-roots.yaml to relocate its resources; the target can override sourceDir/targetDir via the object form.

Cross-project dedup

Every project's .shared links resolve (via realpath) to the same file in the same source clone, so Claude Code loads each shared resource once across all projects in a session.

Dependencies

plugin.json declares github-app ^0.5. Runtime needs bun + git; jsonnet optional.

Testing

bun test plugins/claude-code/shared-config/tests/sync.test.ts — hermetic (no network), 6 tests / 25 assertions: ref parsing, source normalization/dedup, env-file parsing, clone+symlink layout, source-side sourceDir override, uses:-style subpath, source union, resourceTypes honoring, idempotency, and single-realpath dedup across projects. Plugin manifest validates; prettier clean.

https://claude.ai/code/session_013jBEvz5u5jFdbgUe6rg7Lb

claude and others added 3 commits June 6, 2026 19:52
… across repos

Setup/SessionStart hook that clones shared-config source repos and symlinks
their rules/skills/commands/agents into the project through a deduped shared
cache, optionally merges settings fragments, and bootstraps from an org-level
upstream via $AGENT_PLUGIN_SHARED_CONFIG_UPSTREAM. Prefers the github-app token,
waiting briefly for it to appear in CLAUDE_ENV_FILE.

Repo references use GitHub uses:-style org/repo[/subpath] (no @ref yet).
Hermetic test (tests/sync.test.sh) covers clone+symlink layout, source-side
sourceDir override, subpath selection, settings merge, idempotency, and the
single-realpath dedup property across projects.

https://claude.ai/code/session_013jBEvz5u5jFdbgUe6rg7Lb
…al deps

- Declare dependencies on shared-lib (^1.0) and github-app (^0.5) in plugin.json
  instead of soft runtime detection.
- Read repo settings through the shared-lib plugin-config-read.sh 3-tier
  mechanism in the hook (plugin_get_config / plugin_get_config_json), passing
  the resolved config to the orchestrator as JSON with ""/[] unset sentinels.
- Ship plugin-default shared-config.settings.yaml (3rd tier).
- Python now layers upstream + standalone overlays over the shared-lib config;
  sources union, scalars last-wins with type coercion.
- Docs: dedup across projects is the documented behavior (drop hypothesis
  framing); reflect dependencies and shared-lib usage.
- Tests: add shared-lib JSON boundary + source-union coverage (16 checks).

https://claude.ai/code/session_013jBEvz5u5jFdbgUe6rg7Lb
@github-actions

github-actions Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Plugin Version Preview

Preview only — plugin versions and marketplace.json are bumped automatically on merge to main, not in this PR. Manual bumps to higher versions are preserved. See the file annotations for the pending change on each plugin.json.

Plugin Base Current Action
shared-config 0.1.0 0.1.1 Already bumped

- Replace the Python orchestrator + bash hook wrapper with a single
  src/index.ts run directly by bun (hooks.json -> bun src/index.ts). Zero
  external deps: Bun.YAML, node:fs/node:crypto, and the git binary.
- Config read in TS (3-tier plugins.settings.yaml + standalone + upstream
  overlays); github-app token resolved by parsing CLAUDE_ENV_FILE in TS
  (export + recursive source), preferring the GITHUB_TOKEN_FILE signal.
- Port the hermetic test to bun test (tests/sync.test.ts): 6 tests / 25
  assertions covering ref parsing, normalization/dedup, env-file parsing,
  link layout, sourceDir override, uses:-style subpath, source union,
  resourceTypes, idempotency, and single-realpath cross-project dedup.
- Drop the shared-lib dependency (config now in TS); keep github-app dep.
- Add tsconfig.json; update README/design for the TS implementation.

https://claude.ai/code/session_013jBEvz5u5jFdbgUe6rg7Lb
External binaries the hook spawns are resolved by toolArgv(): used directly if
on PATH, otherwise run via `mise exec <tool>@latest -- <tool>` so they are
auto-installed/delegated through mise instead of failing (matching the ai-mktpl
plugins' tooling convention). Applies to git (always) and jsonnet (opt-in
settings merge; still skips gracefully if neither PATH nor mise has it). bun
itself is provided by the repo's mise.toml like the other bun-based plugins.

https://claude.ai/code/session_013jBEvz5u5jFdbgUe6rg7Lb
nsheaps and others added 2 commits June 9, 2026 11:53
…branch

Bun.which() uses a startup PATH snapshot and ignores live process.env.PATH, so
toolArgv() could miss tools added during the session and its mise-delegation
branch was effectively untestable. Resolve via Bun.which(name, { PATH }) so the
current PATH is respected. Add a regression test exercising all three branches
(PATH hit, mise delegation, bare fallback). 7 tests / 28 assertions.

https://claude.ai/code/session_013jBEvz5u5jFdbgUe6rg7Lb
],
"dependencies": [
{
"name": "github-app",

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doesn't this create a circular dependency? What's the dependency here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — it's not circular, but you're right that it shouldn't be a dependency at all, so I've removed it (370a064).

  • Graph (no cycle): shared-config → github-app → shared-lib, and nothing in ai-mktpl points back at shared-config.
  • What the "dependency" actually was: nothing in code. shared-config imports zero github-app code — it only opportunistically reads the GH_TOKEN/GITHUB_TOKEN_FILE env vars that github-app writes to CLAUDE_ENV_FILE, purely to clone private source repos. It degrades gracefully without it (public repos, an ambient GH_TOKEN, or tokenless).

Declaring it as a hard dep forced a whole cross-marketplace auth stack (github-app → shared-lib, plus 1pass for creds) just to read one optional env var. So I dropped the dependencies entry and documented github-app as an optional companion in the README/design instead. dependencies is now null; manifest still validates.

@jack-nsheaps

jack-nsheaps Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor Author

Code review — PR #223 (shared-config plugin)

Verdict: 🟡 COMMENT — no blocking issues; CI is green and the design is sound. Findings below are follow-ups, not merge blockers.

Transparency: this review was produced by the same session that authored the PR, using the scm-utils review skills. Treat it as a self-review/checklist, not an independent second pair of eyes.

Strengths

  • Clean Bun/TS, zero runtime deps (Bun.YAML + node:fs/node:crypto + git), runs directly with no build step.
  • Hook is fail-safe: main() always emits SessionStart JSON and exits 0 (src/index.ts:737).
  • Tool resolution delegates to mise when a binary is missing, and correctly resolves PATH at call time (onPath/toolArgv, src/index.ts:147) — a real bug here (Bun.which startup-snapshot) was found and fixed with a regression test.
  • Path-escape guard on source sourceDir (resolveTypeDir, src/index.ts:269); token passed via http.extraheader, never persisted to repo config.

Follow-ups

P1 — test coverage regressed vs the prior implementation
The deleted Python test exercised upstream-bootstrap and settings-merge; the Bun port (tests/sync.test.ts) covers parsing/dedup/link-layout/toolArgv but has no tests for loadUpstream (src/index.ts:325), mergeSettings (src/index.ts:581), resolveToken (src/index.ts:445), or per-source targetDir override. These are non-trivial paths (precedence, project-wins merge, backup files) and should be covered before the feature is relied on.

P2 — ensureSymlink throws EEXIST on a dangling symlink (latent) (src/index.ts:279)
If linkPath is a dangling symlink, realpathSync(linkPath) throws, the catch treats it as "doesn't exist," then symlinkSync fails with EEXIST (verified empirically). Current buildLinks call sites recreate the target dir before linking, so it isn't reached in normal flow — but it's a fragile helper, and ephemeral-container data-dir wiping makes dangling links plausible. Harden the catch to unlink a leftover symlink before re-creating.

P2 — concurrency on the shared cache (src/index.ts:487, :499)
Both Setup and SessionStart run this hook and may overlap on first launch. buildLinks does rmSync(slugRoot) then rebuilds, and clones into a shared sources/ dir; two concurrent runs could race (transient missing links or a clone/rm collision). Consider a per-project/per-source lock or atomic rename.

P2 — token visible in process args (src/index.ts:138, :164)
The app token is passed as git -c http...extraheader=AUTHORIZATION: basic <b64> on argv, so it's visible via ps to other local users. Low risk in a single-user session, but actions/checkout keeps it out of argv via a config file — worth matching if this runs on shared hosts.

P2 — nits

  • mergeLayers stores NormalizedSource[] into a field typed SourceEntry[], then runSync casts as unknown as NormalizedSource[] (src/index.ts:383, :676) — a separate "resolved settings" type would remove the unsafe cast.
  • PR title is 73 chars (guideline is ≤70); consider trimming. No labels applied.
  • A source removed mid-life whose only reference used a custom targetDir leaves an orphaned .shared link (its targetBase drops out of targetBases, so neither relink nor cleanup runs).

Not blocking

The synchronous token wait (sleepSync, up to waitForTokenTimeoutSeconds) blocks the hook by design; fine for a short-lived Setup/SessionStart script.

…anup, tests)

- Cross-process lock (withLock, atomic mkdir + stale takeover) around the
  clone/build/merge phase so overlapping Setup/SessionStart (or two projects)
  don't race the shared cache; token wait happens outside the lock.
- Pass the github-app token to git via GIT_CONFIG_* env (git >=2.31) instead of
  `-c ...` on argv, so it isn't visible in `ps`.
- ensureSymlink replaces a dangling symlink instead of throwing EEXIST.
- Persist linked targetBases per project and clean up orphaned `.shared` links
  when a base (e.g. a removed custom-targetDir source) drops out of config.
- Remove the unsafe `sources as unknown as NormalizedSource[]` cast by making
  Settings.sources the resolved type.
- Tests: add upstream bootstrap, per-source targetDir, mergeSettings (project
  wins + backup), resolveToken, orphan cleanup, and dangling-symlink cases.
  Now 13 tests / 44 assertions.

https://claude.ai/code/session_013jBEvz5u5jFdbgUe6rg7Lb
@jack-nsheaps jack-nsheaps Bot changed the title Add shared-config plugin: share rules/skills/commands/agents across repos Add shared-config plugin to share config across repos Jun 9, 2026
@jack-nsheaps jack-nsheaps Bot added the enhancement New feature or request label Jun 9, 2026
@jack-nsheaps

jack-nsheaps Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor Author

Review findings addressed in 5e0522f:

  • P1 — coverage: added tests for the upstream bootstrap, per-source targetDir, mergeSettings (project-wins + backup), resolveToken, orphan cleanup, and dangling-symlink handling → 13 tests / 44 assertions.
  • P2 — ensureSymlink EEXIST: now replaces a dangling symlink instead of throwing (covered by a test).
  • P2 — concurrency: clone/build/merge runs under an atomic mkdir lock (withLock, stale takeover at 3 min); the token wait stays outside the lock.
  • P2 — token on argv: moved to git's GIT_CONFIG_* env vars (git ≥2.31), so it's no longer visible via ps and never persisted to repo config.
  • P2 nits: removed the unsafe sources cast (Settings.sources is now the resolved type); orphaned .shared links are cleaned up via a persisted per-project targetBases state; PR title trimmed to ≤70 chars; enhancement label added.

The only deliberately-unchanged item is the synchronous token wait, which is by-design for a short-lived Setup/SessionStart hook.

…lock paths

Re-review of the prior fixes surfaced a new edge case: in withLock, if statSync
on the lock dir threw while the lock still existed, the inner catch did
`continue`, skipping the deadline check and sleepSync — a potential tight
no-backoff loop until the hook timed out. Restructure so any non-takeover path
always hits the deadline/backoff. Make the lock wait configurable
(SHARED_CONFIG_LOCK_WAIT_MS) and add tests for stale-lock takeover and
skip-when-held. 15 tests / 47 assertions.

https://claude.ai/code/session_013jBEvz5u5jFdbgUe6rg7Lb
@jack-nsheaps

jack-nsheaps Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor Author

Re-review — PR #223 (after fixes, head bbb3c51)

Verdict: 🟢 APPROVE (self-review caveat below). All prior findings are resolved and verified; CI is green.

Same-session self-review — a checklist, not independent eyes.

Prior findings — status

  • P1 coverage ✅ added tests for upstream bootstrap, per-source targetDir, mergeSettings (project-wins + backup), resolveToken, orphan cleanup, dangling symlink — plus the lock paths below. Now 15 tests / 47 assertions.
  • P2 ensureSymlink EEXIST ✅ replaces a dangling link instead of throwing (src/index.ts:279), with a test that reproduces the old failure.
  • P2 concurrencywithLock (atomic mkdir + stale takeover) serializes the clone/build/merge phase (src/index.ts:700); token wait stays outside the lock.
  • P2 token on argv ✅ now passed via GIT_CONFIG_* env (gitAuthEnv, src/index.ts:142), not -c on argv.
  • P2 unsafe castSettings.sources is the resolved NormalizedSource[]; cast removed.
  • P2 nits ✅ orphan cleanup via persisted targetBases state; title trimmed (53 chars); enhancement label added.

New finding this pass (found and fixed in bbb3c51)

  • withLock tight-loop on statSync failure (was src/index.ts lock code): if statSync(lockDir) threw while the lock still existed, the inner catch did continue, bypassing the deadline check + sleepSync — a potential no-backoff spin until the hook timed out. Restructured so every non-takeover path hits the deadline/backoff; made the wait configurable (SHARED_CONFIG_LOCK_WAIT_MS) and added stale-takeover + skip-when-held tests.

Remaining (accepted, non-blocking)

  • The github-app GIT_CONFIG_* auth path can't be exercised by the hermetic file:// fixtures (no auth needed); it mirrors actions/checkout's documented mechanism and was confirmed working against the live API when minting the review token.
  • withLock is best-effort: a process killed mid-run leaves the lock until stale-takeover (>3 min), during which concurrent runs skip. Acceptable given the 120s hook timeout.
  • Synchronous token wait is by design for a short-lived hook.

Nothing outstanding blocks merge.

…a dep)

Per review feedback: github-app isn't a real package dependency — the plugin
imports none of its code and only opportunistically reads the GH_TOKEN it
publishes via CLAUDE_ENV_FILE, degrading gracefully without it (public repos /
ambient token / tokenless). Declaring it forced a cross-marketplace auth stack
(github-app -> shared-lib, + 1pass) just to read an optional env var. Remove the
dependencies entry; document github-app as an optional companion. Not circular,
just too loose to model as a dependency.

https://claude.ai/code/session_013jBEvz5u5jFdbgUe6rg7Lb
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants