Post #5 in the Claude Code Toolkit series. Earlier posts: nf-agents, nf-dream, nf-git-workflow, nf-memory. This post is about the skill that moves config between machines without an accidental overwrite.

I run Claude Code on two machines. A laptop I drive day-to-day, and a homelab server I SSH into for long sessions. Both keep config under ~/.claude/. Both expect that config to evolve.

The naive way to keep them in sync is one ~/.claude/ repo on a single shared branch, with each machine pulling and pushing as if they were teammates. That falls apart the first time both machines edit settings.json on the same day. The merge picks one machine’s terminal flags, drops the other’s environment variables, and you find out two days later when a hook stops firing.

nf-cc-sync is the skill I wrote to replace that workflow with one branch per machine, pull-only sync, and per-key merge for the one file where machines genuinely diverge.

The problem this skill solves

A ~/.claude/ repo grows three kinds of content:

  • Truly shared: rules, skills, agent definitions, references. Edit on machine-a, want it on machine-b within minutes.
  • Per-machine: the statusLine shell snippet, hooks that touch local paths, OS-specific env vars.
  • Sort-of-shared but conflict-prone: settings.json. Some keys are global (permissions.deny, includeCoAuthoredBy). Others are local (theme, defaultMode).

Treating all three the same way is what causes pain. Sync everything wholesale and machine-specific keys collide constantly. Sync only what is obviously shared and you forget to copy the new skill you wrote yesterday.

nf-cc-sync picks one rule per category. Shared content uses prefer-newer with a 24-hour conflict window. Per-machine content is skipped by default (opt in via --include-machine-config). settings.json gets per-key merge with explicit policy for each known key. Once those rules are encoded, “sync from the other machine” stops being a thing I think about. It is one slash command and a short report.

What it does

  1. Resolves the OTHER machine’s branch automatically (one candidate from a small allowlist) or asks if there are several.
  2. Auto-commits any dirty working tree on the current branch first, so the sync is reproducible.
  3. Computes a per-file change set from merge-base to origin/<other>, applies a category policy per path.
  4. Merges settings.json at the key level using jq, with explicit rules per key.
  5. Bumps the memory/ submodule pointer separately (the submodule has its own single-branch lifecycle).
  6. Commits the resulting changes on the current branch and pushes, with a structured report at the end.
  7. Never auto-deletes a file on the receiving side. Never pushes to the OTHER branch.

How to invoke

/nf-cc-sync                          # default policy, skip machine-config
/nf-cc-sync --include-machine-config # also sync hooks/, statusline, channels/
/nf-cc-sync --dry-run                # compute the plan, no writes

Run from ~/.claude/. The skill refuses to run anywhere else, because every path it touches is relative to that root.

A walkthrough

You are on machine-a, the laptop. Branch is also machine-a. Yesterday you were on machine-b, wrote a new rule, adjusted settings.json. You want those changes on the laptop.

$ cd ~/.claude
$ /nf-cc-sync

Pre-flight:
  OS:        Darwin
  Branch:    machine-a
  Status:    clean

Memory submodule:
  Local:     ab12cd3
  Remote:    ab12cd3
  Action:    up to date

OTHER candidates: machine-b
Auto-selected:    machine-b

Computing change set from merge-base 7f3a91 -> origin/machine-b:

  rules/code-style.md           (OTHER modified, CURRENT unchanged) fast-forward
  skills/nf-blog/SKILL.md       (OTHER added)                        fast-forward
  settings.json                 (both modified)                      key-merge
  hooks/stop-notify-telegram.sh (OTHER modified)                     skip (machine-config)
  statusline-command.sh         (OTHER modified)                     skip (machine-config)

Settings.json key changes:
  permissions.allow: 2 new entries (npm scripts)
  env.ENABLE_PROMPT_CACHING_1H: kept CURRENT value
  permissions.deny: union (no removals)

Apply? Yes / No
> Yes

Wrote: rules/code-style.md
Wrote: skills/nf-blog/SKILL.md
Wrote: settings.json (3 keys changed)

Commit: chore(machine-a): sync from machine-b, 3 files
Push:   ok

Summary:
  Fast-forward:  2 files
  Settings.json: env, permissions.allow, permissions.deny
  Skipped:       2 (machine-config policy)
  Conflicts:     0

What did not happen: the laptop did not push anything to machine-b. It did not switch branches to machine-b. It did not delete a file even if machine-b deleted one in its branch. The skill is strictly pull-only.

Decision 1: Pull-only, never push to OTHER

The temptation to “push my new skill to the server from the laptop” is real. The skill refuses by design.

The reason is that the OTHER branch belongs to the OTHER machine. Only that machine knows which files on it are committable. The local-only statusline-command.sh, the OS-specific hook paths, the half-edited skill someone left in wip/. None of those belong on the laptop. Pushing from machine-a to origin/machine-b would force the server to either accept the laptop’s view of its own state or reject the push and clean up by hand.

The contract: each machine pushes its own work; the skill only pulls. Two machines stay in eventual consistency without either one writing into the other’s branch.

Side benefit: this contract scales. Adding a third machine is one line in the allowlist. Adding a fourth is also one line. The pull-only direction makes the topology a star, not a mesh, so the number of merge directions is N, not N×N.

Decision 2: Key-level merge for settings.json, not file-level

The hard problem in cross-machine config is settings.json. It has shared keys (permissions.deny is safety-critical and global), per-machine keys (theme, defaultMode), and additive keys (permissions.allow).

A normal file-level merge picks one machine’s version of the whole file. Either the laptop’s theme survives and the server’s permissions.deny additions are dropped, or vice versa. Both are wrong half the time.

The skill merges at the key level with jq:

jq -s '
  .[0] as $cur | .[1] as $oth |
  $cur
  | .env = ($oth.env // {}) + ($cur.env // {})
  | .permissions.allow = (((($cur.permissions.allow // []) + ($oth.permissions.allow // [])) | unique))
  | .permissions.deny  = (((($cur.permissions.deny  // []) + ($oth.permissions.deny  // [])) | unique))
  | .hooks = (($oth.hooks // {}) * ($cur.hooks // {}))
  | .enabledPlugins = (($oth.enabledPlugins // {}) * ($cur.enabledPlugins // {}))
' settings.json /tmp/other-settings.json

jq’s * operator is recursive merge with right-hand precedence, which is why CURRENT is on the right for hooks and enabledPlugins. That detail bit me twice during the rewrite.

The per-key policy is the value here. It encodes “machine-keys are sticky, deny is union, hooks are local”. You set it once in the skill. Every subsequent sync uses it. The theme you picked on the laptop never gets overwritten by the server’s theme even though both edited the file.

Gotchas

  • The 24-hour conflict window catches genuine collisions. If both machines edited the same rule yesterday, the skill stops and asks. The first time this triggered for me, I was about to silently overwrite a fresh edit on my laptop with a stale edit from the server.
  • memory/ is a separate submodule. The submodule has its own single-branch lifecycle. The skill pulls it independently and bumps the parent’s pinned SHA as part of the same sync commit. If you want to share memory content, work in the submodule directly.
  • Auto-commit before sync. The skill commits any dirty tree on the current branch before pulling. That avoids “I forgot I had unsaved edits and they got clobbered”, but it means stray edits get committed under a chore(<branch>): wip auto-commit before nf-cc-sync message. Review your commits if that bothers you.
  • Never auto-delete. If the OTHER branch deleted a file, the skill logs the deletion and skips. The receiving machine might still need that file. Manual delete is fine; the skill will not do it silently.

What you should keep even if you never use this skill

Three patterns generalize:

  1. One branch per machine, not one shared branch. Removes the entire class of “two machines wrote the same file” merge conflicts. Each machine pushes its own; sync pulls.
  2. Pull-only sync direction. Avoids the temptation to push from one machine to the other’s branch. The topology becomes a star, easier to reason about.
  3. Key-level merge for any config file with mixed-scope keys. File-level merge is the wrong tool. jq handles JSON cleanly; for YAML, yq works similarly. The cost of writing the per-key policy once is paid back every sync.

Get the skill

White-labeled in the claude-skills-toolkit repo alongside the rest of this series.

git clone https://github.com/llawliet11/claude-skills-toolkit.git
cp -r claude-skills-toolkit/nf-cc-sync ~/.claude/skills/

Folder reference: claude-skills-toolkit/nf-cc-sync (publishes alongside this post).

Tweak the machine allowlist at the top of SKILL.md to your own branch names. The rest of the logic is generic. Verify in a new session: type /nf-cc-sync from ~/.claude/ and the skill should appear in the slash-command list. The first run will probably reveal a key in settings.json you forgot was machine-specific. After that, sync becomes one command.