/settings-sync: show, then apply, what the team's Claude settings (the template's layer and this project's own) add to ~/.claude/settings.json and the…

The team's Claude Code settings, kept in the repository and spread onto any machine. They live outside the repository otherwise: ~/.claude/settings.json (user) and the checkout's .claude/settings.local.json (gitignored), so a new laptop or a new teammate starts without them.
/settings-sync show what the team settings would add to yours; writes nothing
/settings-sync apply add it
The same script runs from a shell too, before Claude is set up:
python3 .claude/skills/settings-sync/sync.py check
python3 .claude/skills/settings-sync/sync.py apply
| Layer | Where | Owned by | Holds |
|---|---|---|---|
| template | .claude/skills/settings-sync/baseline/ | the copier template | settings any project made from the template shares: preferences, local dev rules |
| project | .claude/settings-sync/ | this project | what only this project needs: its connectors, its production and deploy rules, its own wording |
The project layer is applied over the template layer: where both set a value, the project's wins. Each holds user.json (for ~/.claude/settings.json), local.json (for .claude/settings.local.json), files/ (a hook's script under ~/.claude/hooks/) and connectors.json. The project layer also holds maintainer, the git email whose save writes it.
! run code or let Claude act unasked: hooks, permission rules, autoMode, env, plugins, a restored script with its sha256. apply writes.autoMode line, a hook) and a missing file. Your own values stay: a setting you have with another value is kept and the report lists both (= theme: yours "light", baseline "dark").<file>.bak-<time>. If nothing is missing, nothing is written.@HOME@, @PROJECT@ (the checkout) and @PROJECT_SLUG@ (its folder name under ~/.claude/projects).mcp__@NAME@__tool, and connectors.json declares NAME by the tools that tell its server apart ({"SHOP": ["get_order", "list_orders"]}). /settings-sync finds the server carrying them in the session and fills it in. From a shell the script skips those rules and says so; run /settings-sync apply once Claude is up.~/.claude/hooks/ that a setting calls is restored if it is missing. Nothing outside ~/.claude/hooks/ is restored, and a path with .. is refused.git pull)./settings-sync, read the ! lines, then /settings-sync apply.copier update; never by hand in a project. python3 .claude/skills/settings-sync/sync.py save
It writes the settings less what the template layer already gives. The first save names its git email the maintainer; anyone else's is refused, so nobody replaces the team settings with their own by accident. To template a connector's rules, pass its server: --connector NAME=SERVER. save leaves out what looks like a secret and says so: environment variables named like one (*KEY*, *TOKEN*, *PASSWORD*, …), any value shaped like a key or a token (sk-…, ghp_…, Bearer …, password=…, a private key) and a hook script holding one. Read the diff before you commit it: a one-off approval you would not want shared comes out by hand.
python3 -m unittest discover -s .claude/skills/settings-sync/tests -p 'test_*.py'
claude plugin validate .claude/skills/settings-sync
claude plugin test .claude/skills/settings-synchooks/register.ts 77 lines1import type { EngineInterface, ProcessRunResult, Register } from 'claude-code'
2
3// The servers among `mcp__<server>__<tool>` names that carry every one of `tools`. The desktop app
4// names a claude.ai connector by its id, which differs per account, so a layer declares a connector
5// by its tools (connectors.json) and the server is found here, in the session.
6export function serversWith(tools: readonly string[], toolNames: readonly string[]): string[] {
7 const byServer = new Map<string, Set<string>>()
8 for (const name of toolNames) {
9 const match = /^mcp__(.+)__([A-Za-z0-9_-]+)$/.exec(name)
10 if (match?.[1] === undefined || match[2] === undefined) continue
11 const held = byServer.get(match[1]) ?? new Set<string>()
12 held.add(match[2])
13 byServer.set(match[1], held)
14 }
15 return [...byServer].filter(([, held]) => tools.every(tool => held.has(tool))).map(([server]) => server)
16}
17
18// `--connector NAME=SERVER` for each declared connector found in the session
19export function connectorArgs(declared: Record<string, readonly string[]>, toolNames: readonly string[]): string[] {
20 return Object.entries(declared).flatMap(([name, tools]) =>
21 tools.length === 0 ? [] : serversWith(tools, toolNames).flatMap(server => ['--connector', `${name}=${server}`]),
22 )
23}
24
25// `/settings-sync [apply]`: with no word a check that writes nothing, since the layers' hooks and rules
26// are read before they land; null for any other word. Writing the project layer (`sync.py save`) is its
27// maintainer's, from a shell, and not offered here.
28export function mode(args: string): 'check' | 'apply' | null {
29 const word = args.trim()
30 return word === '' || word === 'check' ? 'check' : word === 'apply' ? 'apply' : null
31}
32
33function output(run: ProcessRunResult): string {
34 const text = [run.stdout.trim(), run.stderr.trim()].filter(part => part !== '').join('\n')
35 return run.exitCode === 0 ? text : `${text}\n(sync.py exited ${run.exitCode})`
36}
37
38async function declaredConnectors($: EngineInterface, script: string): Promise<Record<string, string[]>> {
39 const run = await $.process.run(['python3', script, 'connectors'], { cwd: $.plugin.root, timeoutMs: 30_000 })
40 try {
41 const parsed: unknown = JSON.parse(run.stdout)
42 return parsed !== null && typeof parsed === 'object' ? (parsed as Record<string, string[]>) : {}
43 } catch {
44 return {}
45 }
46}
47
48async function toolNames($: EngineInterface): Promise<string[]> {
49 try {
50 return (await $.tool.list()).filter(tool => tool.mcp).map(tool => tool.name)
51 } catch {
52 return []
53 }
54}
55
56export const register: Register = on => {
57 on('session.start', async ($, e, next) => {
58 const started = await next(e)
59 await $.command.register({
60 name: 'settings-sync',
61 description:
62 "Show what the team's Claude settings would add to ~/.claude/settings.json and this project's settings.local.json; `apply` adds it",
63 argumentHint: '[apply]',
64 })
65 return started
66 })
67
68 on('command.run', { command: 'settings-sync' }, async ($, e) => {
69 const word = mode(e.args)
70 if (word === null) return { text: 'Usage: /settings-sync shows what the team settings would add; /settings-sync apply adds it.' }
71 const script = `${$.plugin.root}/sync.py`
72 const connectors = connectorArgs(await declaredConnectors($, script), await toolNames($))
73 const run = await $.process.run(['python3', script, word, ...connectors], { cwd: $.plugin.root, timeoutMs: 60_000 })
74 return { text: output(run) }
75 })
76}
77