Architecture Guide¶
Purpose¶
This guide explains how AgentSync moves local agent configuration into an encrypted vault and back again so contributors can reason about the system without reverse-engineering file paths and control flow.
High-level model¶
AgentSync has three main layers:
- CLI commands under
src/commands/that orchestrate user-facing workflows. - Agent adapters under
src/agents/that snapshot local config into vault artifacts and apply vault artifacts back onto the machine. - Core services under
src/core/andsrc/config/that handle encryption, sanitization, Git operations, IPC, tar archives, watchers, and platform path resolution.
Core concepts¶
- Vault: A Git repository containing encrypted
.ageand.tar.ageartifacts. - Snapshot: The read side that turns local config into
Artifact[]plus warnings. - Apply: The write side that decrypts vault artifacts and upserts them onto the local machine.
- Recipient: An age public key listed in
agentsync.toml. The vault is encrypted for all configured recipients. - Never-sync patterns: Hard exclusions in
src/core/sanitizer.tsthat block sensitive files before encryption. - Redaction: Secret detection that aborts the push if literal credentials appear in supported config content.
- Reconciliation policy: A shared fast-forward-only Git rule in
src/core/git.tsthat decides whether sync work may continue.
Reconciliation flow¶
flowchart TD
InitNode[Command resolves runtime and vault repo]:::step
RemoteNode{Does remote branch<br/>already exist}:::decision
EmptyNode[Allow first-machine bootstrap<br/>and push upstream]:::step
JoinNode[Fetch remote branch and<br/>align local history first]:::step
SafeNode{Can local branch<br/>fast-forward or match remote}:::decision
WorkNode[Run init pull push key<br/>or daemon work]:::step
StopNode[Stop with recovery guidance<br/>and no success footer]:::error
InitNode --> RemoteNode
RemoteNode -- no --> EmptyNode --> WorkNode
RemoteNode -- yes --> JoinNode --> SafeNode
SafeNode -- yes --> WorkNode
SafeNode -- no --> StopNode
classDef step fill:#2c3e50,color:#ffffff,stroke:#1a252f,stroke-width:1.5px;
classDef decision fill:#78350f,color:#ffffff,stroke:#451a03,stroke-width:1.5px;
classDef error fill:#7f1d1d,color:#ffffff,stroke:#7f1d1d,stroke-width:1.5px;
init uses this flow to distinguish first-machine bootstrap from second-machine join behavior.
pull, push, key add, key rotate, and daemon-triggered sync all reuse the same reconciliation check before they apply, encrypt, or rewrite vault content.
Main flow¶
Push¶
src/commands/push.tsresolves runtime paths and loadsagentsync.toml.- It snapshots enabled agents via the registry in
src/agents/registry.ts. - It aborts early if snapshot warnings show literal secrets.
- It encrypts each artifact with all configured recipients.
- It reconciles with the remote using the shared fast-forward-only rule in
src/core/git.ts. - It commits and pushes the resulting vault changes through
src/core/git.ts.
Pull¶
src/commands/pull.tsresolves runtime paths, loads config, and reads the private key.- It reconciles the local vault branch with the remote using the shared fast-forward-only rule.
- It dispatches agent apply functions through the registry.
- Each agent decrypts and writes only its own artifact set.
If the local vault diverged from the remote, the command flow stops before any apply or encryption work begins.
Status and doctor¶
statuscompares local snapshot content with decrypted vault files to show drift.doctorchecks key presence, config validity, remote reachability, vault hygiene, and daemon installation state.
Skills sync flow¶
AgentSync treats per-user skills — directories under ~/.claude/skills/, ~/.codex/skills/, ~/.cursor/skills/, and ~/.copilot/skills/ — as first-class artifacts. They ride the vault through a single shared module: the skills walker at src/agents/skills-walker.ts.
Every skill-bearing agent adapter calls collectSkillArtifacts(agent, skillsDir) and inherits the same five gates in the same order. Each gate is a safety rule that maps back to a specific requirement:
- Dot-skip — any entry name starting with
.is skipped silently. Protects vendor bundles like Codex's.system/directory (FR-017). - Root-symlink rejection — if the skills root itself is a symlink, the walker returns an empty result with no warnings. Protects against a vendored-pool tree being pointed at the skills directory by accident (FR-016 outer tier).
- Sentinel verification — a skill directory must contain a real
SKILL.mdfile.lstatis used so a symlinked sentinel fails naturally without a special case (FR-002 + FR-016 sentinel). - Never-sync interior scan — every file inside the skill is run through
shouldNeverSyncfromsrc/core/sanitizer.ts. Any hit emits anever-sync inside skill: <path>warning whichsrc/commands/push.tsescalates to a fatal abort, so the entire push fails before any encryption work begins (FR-006). - Symlink-filtered tar — the surviving skill tree is archived through
archiveDirectory(dir, { skipSymlinks: true }). Symlinked files inside the skill are filtered out of the tar; the real files around them are archived normally (FR-016 inner tier).
The filter's opt-in flag keeps the pre-existing Copilot agent-tarball code path bit-for-bit unchanged.
flowchart TD
LocalDir["Local skills dir<br/>~/.agent/skills"]:::action
RootCheck{"Root is a<br/>real directory"}:::decision
DotCheck{"Entry name<br/>starts with dot"}:::decision
Sentinel{"Real SKILL.md<br/>sentinel present"}:::decision
NeverSync{"Interior matches<br/>never-sync pattern"}:::decision
TarStep["archiveDirectory<br/>skipSymlinks true"]:::keep
EncStep["encryptString<br/>age recipients"]:::keep
VaultEntry["Vault namespace<br/>agent/skills/name.tar.age"]:::vault
Pull["pull command<br/>decrypts artifact"]:::keep
Restore["extractArchive<br/>into ~/.agent/skills/name"]:::keep
SkipSilent["Skipped silently<br/>FR-016 outer or FR-017"]:::skip
SkipSentinel["Skipped silently<br/>FR-002 sentinel missing"]:::skip
Abort["Fatal abort<br/>push gate escalates"]:::fail
LocalDir --> RootCheck
RootCheck -- no --> SkipSilent
RootCheck -- yes --> DotCheck
DotCheck -- yes --> SkipSilent
DotCheck -- no --> Sentinel
Sentinel -- no --> SkipSentinel
Sentinel -- yes --> NeverSync
NeverSync -- yes --> Abort
NeverSync -- no --> TarStep --> EncStep --> VaultEntry --> Pull --> Restore
classDef action fill:#1e3a8a,color:#ffffff,stroke:#0f1f4d,stroke-width:1.5px;
classDef decision fill:#78350f,color:#ffffff,stroke:#451a03,stroke-width:1.5px;
classDef keep fill:#14532d,color:#ffffff,stroke:#0a2d18,stroke-width:1.5px;
classDef vault fill:#3730a3,color:#ffffff,stroke:#1e1b6e,stroke-width:1.5px;
classDef skip fill:#78350f,color:#ffffff,stroke:#451a03,stroke-width:1.5px;
classDef fail fill:#7f1d1d,color:#ffffff,stroke:#7f1d1d,stroke-width:1.5px;
Vault-removal flow¶
Skills are additive-by-default across the whole pipeline. A local delete never removes the vault entry — the only way to take a skill out of the vault is the explicit agentsync skill remove <agent> <name> verb at src/commands/skill.ts. That verb enforces two invariants: it only touches the vault file, never any local skill directory (FR-012); and any subsequent pull on another machine leaves that machine's local skill directory untouched because applyXxxVault is extract-only (FR-013).
flowchart TD
UserReq["User runs<br/>agentsync skill remove agent name"]:::action
ValidateAgent{"Agent is<br/>claude cursor codex copilot"}:::decision
Reconcile["GitClient<br/>reconcileWithRemote"]:::vault
StatFile{"Vault file<br/>agent/skills/name.tar.age exists"}:::decision
Unlink["unlink vault file<br/>commit with skill remove message"]:::vault
Push["git push origin branch"]:::vault
LocalA["Local skills on machine A<br/>untouched FR-012"]:::local
MachineB["Machine B runs<br/>agentsync pull"]:::action
ApplyExtract["applyXxxVault<br/>extract-only no unlink"]:::vault
LocalB["Local skill on machine B<br/>still present FR-013"]:::local
NotFound["Exit code 1<br/>Skill not found"]:::fail
UnknownAgent["Exit code 1<br/>Unknown agent rejected"]:::fail
UserReq --> ValidateAgent
ValidateAgent -- no --> UnknownAgent
ValidateAgent -- yes --> Reconcile --> StatFile
StatFile -- no --> NotFound
StatFile -- yes --> Unlink --> Push
Push --> LocalA
Push --> MachineB --> ApplyExtract --> LocalB
classDef action fill:#1e3a8a,color:#ffffff,stroke:#0f1f4d,stroke-width:1.5px;
classDef decision fill:#78350f,color:#ffffff,stroke:#451a03,stroke-width:1.5px;
classDef vault fill:#3730a3,color:#ffffff,stroke:#1e1b6e,stroke-width:1.5px;
classDef local fill:#14532d,color:#ffffff,stroke:#0a2d18,stroke-width:1.5px;
classDef fail fill:#7f1d1d,color:#ffffff,stroke:#7f1d1d,stroke-width:1.5px;
This two-flow model is why AgentSync can add and remove skills independently on different machines without any central coordination — every removal is an intentional user action, and every pull is extract-only.
Claude plugin sync flow¶
Claude Code organises optional capabilities — commands, sub-agents, hooks, MCP servers, and bundled skills — under ~/.claude/plugins/<name>/. AgentSync rides each plugin through the vault as a self-contained subtree at claude/plugins/<name>/, so installing a plugin on one machine and pulling on another reproduces every artifact under the same plugin namespace.
The plugin walker collectClaudePlugins at src/agents/claude-plugins.ts mirrors the skills-walker contract: it skips the plugins root if it is missing or a symlink, skips dot-prefixed entries silently, and rejects any entry whose name fails validatePluginName (the same defence that guards skill names against .., separators, control characters, and the ./.. reserved names). A plugin must contain a real .claude-plugin/plugin.json file (lstat-checked so symlinked manifests are rejected) before any of its assets are emitted.
Once a plugin is admitted, snapshotClaude emits per-artifact entries:
plugin.json.age— sanitised throughsanitizeClaudePluginManifest(full-tree secret redaction, structure preserved).commands/<file>.md.ageandagents/<file>.md.age— markdown bundles with sanitiser warnings surfacing redacted secrets.hooks/<file>.json.age— sanitised through the existing hooks-only allowlist.mcp.json.age— sanitised throughsanitizeClaudePluginMcp(full structure preserved, secret literals redacted, walker warnings escalated to push aborts).skills/<name>.tar.age— tar bundles produced by reusingcollectSkillArtifactsagainst the plugin's ownskills/dir, then re-namespaced into the plugin path.
The opt-in claudePlugins.syncMarketplace flag in agentsync.toml adds marketplace.json.age (sanitised manifest of the global ~/.claude/.claude-plugin/marketplace.json). The flag is off by default because the catalog can pin third-party sources — teams must opt in explicitly.
On pull, applyClaudePluginsDir walks claude/plugins/ in the decrypted vault, validates every directory name before any path.join, and routes each artifact back to its disk equivalent. Vault entries with names like .. are rejected with a warning and never reach the filesystem.
flowchart TD
LocalPlugins["Local plugins<br/>~/.claude/plugins/<name>"]:::action
WalkerGate{"Real dir + valid name<br/>+ real plugin.json"}:::decision
Manifest["plugin.json<br/>sanitizeClaudePluginManifest"]:::keep
Surfaces["commands agents hooks mcp<br/>per-file sanitisers"]:::keep
PluginSkills["plugin-local skills<br/>collectSkillArtifacts reused"]:::keep
Marketplace{"claudePlugins<br/>syncMarketplace true"}:::decision
MarketArt["marketplace.json.age<br/>opt-in only"]:::keep
Vault["Vault namespace<br/>claude/plugins/<name>/..."]:::vault
Pull["pull command<br/>applyClaudePluginsDir"]:::keep
NameGate{"validatePluginName<br/>before any path.join"}:::decision
Apply["restore manifest commands agents<br/>hooks mcp skills"]:::keep
SkipSilent["Skipped silently<br/>missing root or invalid"]:::skip
Reject["Warning emitted<br/>traversal name rejected"]:::fail
LocalPlugins --> WalkerGate
WalkerGate -- no --> SkipSilent
WalkerGate -- yes --> Manifest --> Vault
WalkerGate --> Surfaces --> Vault
WalkerGate --> PluginSkills --> Vault
Marketplace -- yes --> MarketArt --> Vault
Vault --> Pull --> NameGate
NameGate -- no --> Reject
NameGate -- yes --> Apply
classDef action fill:#1e3a8a,color:#ffffff,stroke:#0f1f4d,stroke-width:1.5px;
classDef decision fill:#78350f,color:#ffffff,stroke:#451a03,stroke-width:1.5px;
classDef keep fill:#14532d,color:#ffffff,stroke:#0a2d18,stroke-width:1.5px;
classDef vault fill:#3730a3,color:#ffffff,stroke:#1e1b6e,stroke-width:1.5px;
classDef skip fill:#78350f,color:#ffffff,stroke:#451a03,stroke-width:1.5px;
classDef fail fill:#7f1d1d,color:#ffffff,stroke:#7f1d1d,stroke-width:1.5px;
Security boundaries¶
src/core/encryptor.tsis the boundary for age identity generation, recipient derivation, and string/file encryption.src/core/sanitizer.tsis the single source of truth for secret detection and never-sync path rules.src/core/tar.tsexists because some agent assets are directory-shaped and need archive transport rather than line-by-line file sync.- Private keys stay on disk in the local runtime directory and must never be committed or logged.
Daemon model¶
src/daemon/index.tsruns the background process.- It exposes
status,push, andpullover the newline-delimited IPC protocol insrc/core/ipc.ts. - It watches selected agent directories and auto-pushes after a debounce window.
- It also runs periodic pull on the configured interval.
- Platform installers in
src/daemon/installer-macos.ts,src/daemon/installer-linux.ts, andsrc/daemon/installer-windows.tscreate the service wrapper appropriate for each OS.
Platform-specific paths¶
Path differences are centralized in src/config/paths.ts. That file maps supported agent locations and runtime paths for macOS, Linux, and Windows, including:
- Claude config and command directories
- Cursor MCP config and rules field location
- Codex home and rule directories
- Copilot instructions, prompts, skills, and agents directories
- VS Code MCP config path
- AgentSync runtime home and daemon socket path
Support-state reminder¶
The current repo supports the local CLI and daemon model. It does not provide a hosted sync service, web administration surface, or conflict-resolution UI outside the command flow.
Related docs¶
- development.md for local setup and validation
- command-reference.md for user-facing command behavior
- maintenance.md for update rules and documentation gates
- troubleshooting.md for setup and daemon failures