Add shared-config plugin to share config across repos - #223
Add shared-config plugin to share config across repos#223jack-nsheaps[bot] wants to merge 11 commits into
Conversation
… 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
Plugin Version PreviewPreview only — plugin versions and
|
- 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
…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", |
There was a problem hiding this comment.
doesn't this create a circular dependency? What's the dependency here?
There was a problem hiding this comment.
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_FILEenv vars that github-app writes toCLAUDE_ENV_FILE, purely to clone private source repos. It degrades gracefully without it (public repos, an ambientGH_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.
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.
Strengths
Follow-upsP1 — test coverage regressed vs the prior implementation P2 — P2 — concurrency on the shared cache ( P2 — token visible in process args ( P2 — nits
Not blockingThe synchronous token wait ( |
…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
|
Review findings addressed in
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
Re-review — PR #223 (after fixes, head
|
…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
Summary
Adds the
shared-configplugin (plugins/claude-code/shared-config): aSetup/SessionStarthook that shares Claude Code config across repos. It clones one or more "source" repos and symlinks theirrules/,skills/,commands/, andagents/into the current project through a deduped shared cache, bootstraps from an org-level upstream config, and (opt-in) mergessettings.jsonfragments.Implemented in Bun/TypeScript (
src/index.ts), run directly bybunfrom the hook — no build step, zero external deps (usesBun.YAML,node:fs/node:crypto, and thegitbinary).How it works
plugins.settings.yamlmechanism (under theshared-config:namespace) plus standalone +$AGENT_PLUGIN_SHARED_CONFIG_UPSTREAMoverlays (sources unioned; scalars last-wins).GH_TOKEN(parsed fromCLAUDE_ENV_FILE, preferring theGITHUB_TOKEN_FILEsignal) so private source repos clone with the app token.$CLAUDE_PLUGIN_DATA/shared-configs/sources/<org>/<repo>/.<project>/.claude/<type>/.sharedat them; emitsreloadSkills.mergeSettings) settings.json/.local.json merge with jsonnet support, project always winning, with backups.Repo references use GitHub
uses:-styleorg/repo[/subpath](no@ref). A source repo can ship.claude/shared-config-roots.yamlto relocate its resources; the target can overridesourceDir/targetDirvia the object form.Cross-project dedup
Every project's
.sharedlinks resolve (viarealpath) 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.jsondeclaresgithub-app ^0.5. Runtime needsbun+git;jsonnetoptional.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-sidesourceDiroverride,uses:-style subpath, source union,resourceTypeshonoring, idempotency, and single-realpath dedup across projects. Plugin manifest validates; prettier clean.https://claude.ai/code/session_013jBEvz5u5jFdbgUe6rg7Lb