Component map
A package-by-package index of the codebase. Each entry lists the package’s responsibility, its key exported symbols, and which internal packages it depends on. For how the pieces fit together, read the architecture; this page is the directory.
cmd/agentsync/ # main(): inject version ldflags, call cli.Execute()internal/├── cli/ # cobra command tree (entry layer)├── source/ # the canonical model + loaders/writers ← the schema├── secrets/ # ${secret:}/${env:} resolve · re-reference · mask├── project/ # .agentsync/ tree overlay discovery + merge├── adapter/ # the per-agent Adapter interface + registry│ ├── claude/ opencode/ codex/ # 10 deep adapters (agent-specific,│ ├── cursor/ gemini/ continuedev/ # often bidirectional; claude is│ ├── grok/ windsurf/ roo/ cline/ # the reference implementation)│ ├── generic/ # data-driven breadth tier (22 agents, specs.go)│ └── noop/ # placeholder for unimplemented agents├── render/ # the apply pipeline: plan · write · report├── capture/ # the single dest▶source write-back funnel├── drift/ # the 3-way classifier (pure, no IO)├── state/ # targets.json (last-applied hashes)├── marketplace/ # fetch marketplaces/plugins · project components├── git/ # leaf go-git wrapper: local dir rollback history (issue #118)├── iox/ # atomic write + file lock├── jsonkeys/ # per-key JSON-pointer merge (preserve foreign keys)├── paths/ # AGENTSYNC_HOME / TARGET_ROOT / HOME resolution├── ui/ # presentation: Printer · color · glyphs · diagnostics · WarnWriter├── log/ # slog setup (installs ui.SlogHandler as the default)└── testenv/ # hermetic-container test guardEntry layer
Section titled “Entry layer”cmd/agentsync
Section titled “cmd/agentsync”The binary’s main. Injects Version/Commit/Date via -ldflags and calls
cli.Execute(). Nothing else lives here.
internal/cli
Section titled “internal/cli”Wires every cobra subcommand into the root tree and dispatches to handlers; this is the only package that depends on nearly all the others.
- Key:
NewRoot() *cobra.Command,Execute() int(returns the process exit code and owns the terminal✗ ERRORline),Version/Commit/Date;runApplyPipeline— the single apply pipeline (load-projected → resolve secrets → plan → git baseline → write → record state → checkpoint → report), shared byapplyand the re-apply tail ofplugin upgradeso the two cannot diverge (#231); itsapplyOptszero value is a real, all-agents apply, not opted out of git backup (the[destination_directory_git_backup]mode still governs it);walkPlanItems— the single plan→state→destination drift walk behindstatus,diff,reconcileandexplain(planwalk.go); itsplanItemis deliberately unexported field-for-field so it can never become a--jsonsurface, because a plan built fromsecrets.SubstituteCanonicalcarries resolved cleartext inop.Content;reconcileSession— oneagentsync reconcilepass (printer, scripted input, registry, loaded state, redaction map) plus its run-scoped bookkeeping, so the two prompts, the--auto-*dispatch, the action switch and the run’s tail (finish) are separately testable methods rather than fivegotos over seven loose locals (#232);finishhas exactly one call site and is never deferred, because it writes and it returns the run’s error;destReadPath/readDestText(destread.go) — the whole-file destination readers that carry the symlink policy (AGENTSYNC_ALLOW_SYMLINK_DEST), mirroringiox.AtomicWrite’s;readPluginTOML+pluginLifecycleBase/pluginInstallRefresh(plugin.go) —plugins/<id>.tomlhas ONE shape, the canonicalsource.Plugin, and a re-install carries the whole existing entry forward and overwrites only the re-fetchedID/Version/ManifestSHA(backfilling just the default an omitted or empty key means), so a field added tosource.PluginSpecis preserved by default rather than dropped by default (#234; the CLI’s private duplicate of the struct, and the field-by-field merge that went with it, are gone);enabledAgentNames(cfg)(agents_flag.go) — the one “which agents may this run touch” extraction, returning the exact([]string, map[string]bool)pairselectAgentstakes, SORTED (eight commands open-coded it, in three different shapes, before #235);matchImportable(import.go) — the head five of the six per-component importers share (filter by name, drop plugin-provided entries, refuse a named miss; hooks keep their own because their join key is an opaque signature), while the tail stays per-component because the five genuinely disagree about id validation and write funnel. The convention for a command body is a NAMED run: the four that still carried their whole implementation as an anonymousRunE(diffRun,checkRun,doctorRun,revertRun) joinedagentAddRun/mcpAddRun/importRunin #235, so acobra.Commandliteral declares the surface and the body is a function you can read and stack-trace by name; flag values reach such a run in a by-value opts struct rather than through a closure it could write back into. (Other commands still run inline literals —status’s is the largest — and are candidates for the same lift.) - Commands:
init,agent {add,remove,list,enable,disable},apply,revert,status,diff,reconcile,import,doctor,check,mcp {add,remove,list,enable,disable},plugin {add,outdated,upgrade,enable,disable,remove,list,explain},marketplace {add,remove,list},secret {edit,get,set,list,remove},{skill,subagent,command,hook,lsp} list,migrate subagents,explain <path>,version. - Depends on: adapter, source, state, secrets, paths, render, marketplace, project, drift, git, iox, jsonkeys, ui, log.
- Files:
root.go+ one file per command group + shared helpers named for what they hold rather than for the command that first needed them — among themdestread.go(the destination-read gate AND the dest decodersimport.goused to own),hash.go(content/file hashing + the opaque shape/symlink sentinels),statekey.go(the twostate.Keyconstructors),scope.go(scope resolution, project discovery, the scope prompt),scope_flags.go(the root’s--scope/--projectdeclaration and every command’s scope stance,markScopeAware/markScopeUnaware),load.go(loadProjectedForScopeand the project overlay),planwalk.go.
Core model
Section titled “Core model”internal/source — the schema
Section titled “internal/source — the schema”Loads and represents ~/.agentsync/. The TOML-tagged structs here are the
canonical model that adapters render from; also provides write-back helpers and
memory-fragment expansion.
- Key:
Canonical(the root model:Config,MCPServers,Skills,Subagents,Commands,Hooks,LSPServers,Plugins,Marketplaces,Memory,Project);Load(fs, home);ParseFrontmatter; theWrite*family (WriteMCP,WriteLSP,WritePlugin,WriteMarketplace,WriteSkill,WriteSubagent,WriteCommand,WriteHooks,WriteMemory);ReadMCP/ReadLSP(carry source-only fields);ExpandMemoryImports;RenderManagedMemory/StripManagedBanner(inject / strip the managed-file banner — seedocs/architecture.md);NamespacedComponentName/PluginTargetsAgent/AgentTargeted(plugin provenance + theagents/native_agentsgates);FilterForAgent(the ONE narrowing of a canonical to what one agent renders, used byrender.Planviasecrets.Resolved.ForAgent— its only caller. import’s capture-refusal filter deliberately does NOT narrow the same way; its refusal set is WIDER than the render set, because the destination can still hold un-reclaimed output from before a deferral was recorded. Do not restore symmetry between them — seedocs/architecture.md);MigrateSubagentTree/MigratedSourceID(the retiredagents/→subagents/move, beside the directory names, the legacy-file lister and the pending-migration error that define the condition it resolves — the CLI keeps the prompt, the lock and the state rewrite);SpliceTOMLTable+TableOutsideUnchanged(the ONE comment-preserving rewriter behindagent add/remove/enable/disableand the git-backup mode setter: replace one table’s run inagentsync.tomland leave every other table’s bytes alone, then refuse the write unless a re-parse proves nothing outside that table changed.SpliceOptions.IncludeSubtablesis what tells[agents], which also owns[agents.<name>], from a table that does not). - Depends on: iox, jsonkeys, untrusted.
- Files:
schema.go,loader.go,writer.go,memory.go,provenance.go,targeting.go,subagentdir.go,tomltable.go.
internal/secrets
Section titled “internal/secrets”Resolves ${secret:dotted.key} and ${env:NAME} at apply time; re-references
cleartext back to ${secret:…} for write-back; masks resolved values for display.
The Resolved wrapper type is the load-bearing leak guard.
- Key:
Resolver(interface);Resolved(resolved-model wrapper);SubstituteCanonical(→Resolved);ReReferenceCanonical;CollectResolved;UnresolvedSecretRefs;SecretRefsByComponent(per-component REFERENCES, forexplain);MaskResolved;AgeBackend/EnvBackend/NopResolver;SelectBackend;NormalizeBackend(the one backend-name folding, shared withSelectBackend);ValidateConfig→[]Finding(the single definition of a valid[secrets]block — at most one severity-ranked finding per field, for an error-or-nil surface and a report surface to render the same contract from);RequireAgeVault(thesecretgroup’s age-vault precondition, sharingNormalizeBackend);Resolved.ForAgent(per-agent narrowing at the render waist, delegating tosource.FilterForAgent);Vault(NewVault,AgeFile,IdentityFile,Load,Save,WriteVerified) plusSetNestedKey/GetNestedKey/DeleteNestedKey/FlattenKeys— the age vault’s own read-modify-write, which thesecretsubcommands used to carry: decrypt to a map, mutate by dotted key refusing destructive type changes, validate against apply’s flatten contract, encrypt, verify the identity can read the result back, roll the previous store in if it cannot. It is the vault FILE, the input side of resolution — it touches nosource.Write*and unwraps noResolved; and the single field listwalkSecretFields(inwalk.go). - Depends on: source, iox, untrusted.
- Files:
secrets.go,age.go,resolved.go,substitute.go,rereference.go,mask.go,refs.go,walk.go,secretpaths.go,validate.go(theValidateConfigcontract),leakscan.go(theResidualSecretCleartextbackstop),vault.go(the age vault’s read-modify-write),runtime.go.
internal/project
Section titled “internal/project”Discovers a repo’s project-scope source tree — a .agentsync/ directory
(the same on-disk layout as the user-scope ~/.agentsync/) found by walking up
from the cwd — and overlays its canonical (project agents, MCP/LSP/skills/
subagents/commands/hooks, extra memory) onto the base user canonical. The retired
M5 single-file .agentsync.toml marker is no longer read: Discover surfaces a
migration error if it finds one with no .agentsync/ tree.
- Key:
DirName(.agentsync);LegacyMarkerFile(.agentsync.toml, migration-only);Home(root);Discover(start) (root, found, err);Merge(base, proj) source.Canonical. - Depends on: source.
- Files:
project.go.
Translation layer
Section titled “Translation layer”internal/adapter
Section titled “internal/adapter”Declares the per-agent Adapter contract and a registry; the DestWriter
interface funnels all destination writes through the foreign-collision backup.
An optional VersionedDirs extension lets an adapter declare the on-disk
directories the apply tail should git-back-up for local rollback — an adapter
implements VersionRoots(scope, project) to return its config dir plus any
shared cross-agent dir it writes into, and MUST return nil at project scope (see
architecture § VersionedDirs).
- Key:
Adapter(interface);PathKeyMerger,MergeStrategyForPath;DestWriter(interface);VersionedDirs(optional interface,VersionRoots);NonEmptyDirs(helper);MCPSpecIngester(optional interface, READ-ONLY —IngestMCPSpec(raw map[string]any) source.MCPServerSpec: the native→canonical inverse for ONE server entry of a key-merge MCP object, in the rendering adapter’s own dialect. It is how reconcile’s key-level write-back picks a translator without a pointer-root allowlist — root keys collide (/mcpServersis six deep adapters plus eleven breadth agents;/mcpis OpenCode and Crush), so only the adapter that wrote the bytes can invert them. See architecture § MCPSpecIngester);Scope(ScopeUser/ScopeProject);FileOp(with typedAction— zero valueActionWrite— andOpKind;NewCleanupOpbuilds the oneOpCleanupop);Skip(withSkipKind);Registry(NewRegistry,Register,Lookup,Names). Component support is expressed by whatRenderemits — an unsupported component yields aSkip, not an absent capability flag.PluginIngester(optional, READ-ONLY — see architecture § PluginIngester) brings the three reports that interrogate it —NativePluginOwners(which agents install a plugin themselves — what seedsnative_agentsat import),DuplicatedNativePlugins(declared AND natively installed AND projected there — every component would land twice),UndeclaredNativePlugins(thestatus/doctornudge) — plusDeclaredPlugins, the non-disabled declared set thatDuplicatedNativePluginsandstatus’s--agentsscoping note must agree on (a pure filter over the canonical; it asks no ingester anything). They live beside the interface because there is no Render-side counterpart to ask the same question of. - Files:
adapter.go,registry.go,nativeplugins.go,merge_strategy.go.
internal/adapter/claude
Section titled “internal/adapter/claude”The reference adapter — MCP, memory, skills, subagents, commands, and hooks, with per-key merge
into shared JSON files (~/.claude.json, settings.json, and a project’s
repo-root .mcp.json for project-scope MCP servers) that preserves foreign
keys. IngestPlugins reads enabledPlugins / extraKnownMarketplaces
to discover plugins on import; Render projects each plugin’s components to
Claude’s native paths (~/.claude/skills/<name>/, mcpServers in
.claude.json, …) and deliberately leaves the enablement keys themselves
untouched. The asymmetry is the cross-adapter rule, not a Claude quirk — see
architecture.md § PluginIngester (read-only).
Hook fidelity: the canonical Hook models only command handlers, so (like
Gemini) Ingest leaves a settings.json hook event uncaptured with a warning if
it carries an unmodeled definition/handler field (e.g. statusMessage), a
timeout outside what Hook.Timeout can carry, or a
non-command handler, and Render reports a dropped Skip for any non-command
hook rather than emitting an empty-command entry. An event that was captured
while clean and later enriched natively would leave a stale canonical
hooks/<event>.toml behind that the next apply — which owns the whole
per-event array — would still rewrite lossily; import closes that hole by
retiring the stale canonical file for every semantically refused event
(RefusedHookEvents, the adapter.HookIngestGuard extension; a
structurally-malformed native shape — a settings.json typo — warns but never
deletes canonical config). Because canonical hooks are shared across agents,
retirement hands the event back to every hook-rendering agent at that
scope: each agent’s hook state key is disowned — under the agent’s native
event spelling for renaming agents (Gemini BeforeTool, Cursor preToolUse,
via adapter.HookEventNamer) — so no orphan
cleanup fires, and every native entry is left frozen as-is. So an
import→apply round-trip never rewrites the user’s native /hooks/<event>
array lossily. It also owns the
shared Extra passthrough helpers reused by every MCP-capable adapter:
ExtraNativeKeys (capture unmodeled native fields into source.*Spec.Extra) and
MergeExtra (project them back on render). Both reserve the __ prefix as an
agentsync-internal namespace — symmetric on capture and render — so one adapter’s
synthetic metadata (continuedev’s __block_version/__block_schema) can never
leak into another agent’s config; see
architecture.md § 8.
- Key:
New(Options) *Adapter; theAdapter+PluginIngester+MCPSpecIngestermethods;ParseFrontmatter/EncodeFrontmatter;MergeKeys;IngestMCPSpec;MergeExtra/ExtraNativeKeys. - Depends on: adapter, secrets, source, paths, iox, jsonkeys.
- Files:
claude.go,homedir.go,render.go,ingest.go,ingest_plugins.go,apply.go,paths.go,frontmatter.go,skill.go,command.go,subagent.go,hook.go,lsp.go,memory.go,settings.go,extra.go.
internal/adapter/opencode
Section titled “internal/adapter/opencode”The OpenCode adapter — MCP, memory, skills, subagents, commands via JSONC
round-trip (tailscale/hujson). Skips Hook and LSP (reported with a warning).
- Key:
New(Options) *Adapter; theAdapter+MCPSpecIngestermethods;IngestMCPSpec. - Depends on: adapter, secrets, source, paths, iox.
- Files:
opencode.go,homedir.go,render.go,ingest.go,apply.go,paths.go,skill.go,subagent.go,command.go,memory.go,settings.go.
internal/adapter/codex
Section titled “internal/adapter/codex”The Codex CLI adapter — MCP, memory, skills, subagents, slash commands, and
hooks. MCP servers ([mcp_servers.*]) and hooks (inline [hooks.*]) both merge
into the TOML ~/.codex/config.toml via the merge-toml-keys strategy
(MergeTOML in settings.go, which preserves the user’s foreign keys) — so
config.toml is the adapter’s single key-merge file; skills land in the shared
~/.agents/skills/; subagents project to Codex’s TOML agent format and commands
to global-only custom prompts.
Implements PluginIngester (parses [plugins."<name>@<source>"] enable-state
on import); Render does not re-emit those tables on apply, matching the
cross-adapter invariant — see
architecture.md § PluginIngester (read-only).
Skips LSP (Codex has no LSP concept). Hook ingest has the shared guard-and-warn
posture and implements adapter.HookIngestGuard (RefusedHookEvents over the
same toml.Unmarshal parse Ingest uses), with one deliberate divergence from
the claude/gemini/cursor twins: a non-command handler type is representable
(Codex parses-and-skips unknown types; Render re-emits Type verbatim with a
reported reduced Skip) and therefore never refused — only unmodeled fields
trigger retirement.
- Key:
New(Options) *Adapter; theAdapter+PluginIngester+MCPSpecIngestermethods;MergeTOML;IngestMCPSpec. - Depends on: adapter, adapter/claude (frontmatter helpers), secrets, source, paths, iox, jsonkeys, go-toml/v2.
- Files:
codex.go,homedir.go,render.go,mcp.go,ingest.go,ingest_plugins.go,apply.go,paths.go,skill.go,command.go,subagent.go,hook.go,memory.go,settings.go.
internal/adapter/grok
Section titled “internal/adapter/grok”The dedicated Grok Build adapter: user/project instructions, complete skills,
legacy Markdown commands, TOML MCP, and JSON command hooks. Uses
codex.MergeTOML, jsonkeys.ConvertNumbers, claude.SkillFileOps, and
a Grok-local hook parser; no canonical schema changes. Implements
PathKeyMerger for mixed-format cleanup, HookIngestGuard for enriched-event
retirement, WarnEmitter, and VersionedDirs. See Grok support.
- Key:
New(Options) *Adapter; theAdapter+MCPSpecIngestermethods;IngestMCPSpec. - Files:
grok.go,paths.go,render.go,mcp.go,hook.go,hook_ingest.go,ingest.go,apply.go.
internal/adapter/cursor
Section titled “internal/adapter/cursor”The Cursor adapter — MCP, memory, skills, subagents, slash commands, and hooks.
MCP lands in .cursor/mcp.json (the same mcpServers shape as Claude) and hooks
in .cursor/hooks.json ({ "version": 1, "hooks": { … } }) — both JSON, so the
adapter’s single key-merge strategy is merge-json-keys. The required hooks
version is injected post-merge in applyWrite (never rendered into op.Content,
so it is never an orphan-strippable owned key). Memory projects to the repo-root
AGENTS.md at project scope only (user-level rules live in Cursor’s app-local
storage); skills to .cursor/skills/; subagents to .cursor/agents/<name>.md
(tools/color dropped); commands to .cursor/commands/<name>.md (plain
markdown — frontmatter dropped). Skips LSP (Cursor has no LSP concept).
Implements no PluginIngester yet — Cursor’s native plugin enable-state location
is undocumented, so plugin discovery on import is deferred; apply still fans
out plugin components like every adapter. Hook ingest has the same
guard-and-warn posture as Claude’s/Gemini’s — an unrepresentable event is
refused whole, never captured lossily — and implements
adapter.HookIngestGuard (RefusedHookEvents, reporting refused events under
their canonical names), so a Cursor-side native enrichment triggers import’s
stale-hook retirement just like a Claude- or Gemini-side one.
- Key:
New(Options) *Adapter; theAdapter+MCPSpecIngestermethods;IngestMCPSpec. - Depends on: adapter, adapter/claude (frontmatter/skill/extra helpers), secrets, source, paths, iox, jsonkeys, afero.
- Files:
cursor.go,homedir.go,render.go,mcp.go,ingest.go,apply.go,paths.go,skill.go,command.go,subagent.go,hook.go,memory.go.
internal/adapter/gemini
Section titled “internal/adapter/gemini”The Gemini CLI adapter — MCP, memory, slash commands, subagents, and hooks. MCP
(mcpServers, with Gemini’s url/httpUrl transport split) and hooks (hooks,
the same nested shape as Claude) both merge into .gemini/settings.json via
merge-jsonc-keys — settings.json is the adapter’s single key-merge file, so the
user’s other keys (theme, model, …) are preserved. Memory projects to
GEMINI.md (~/.gemini/GEMINI.md user / repo-root GEMINI.md project); commands
to .gemini/commands/<name>.toml (description + prompt); subagents to
.gemini/agents/<name>.md. Skips Skill (Gemini uses extensions, not Agent
Skills) and LSP (no LSP concept) — both ✗ skip. No PluginIngester (no
native plugin enable-state agentsync models). Hook ingest has the same
guard-and-warn posture as Claude’s — an unrepresentable event is refused whole,
never captured lossily — and implements adapter.HookIngestGuard
(RefusedHookEvents, reporting refused events under their canonical names),
so a Gemini-side native enrichment triggers import’s stale-hook retirement just
like a Claude-side one.
- Key:
New(Options) *Adapter; theAdapter+MCPSpecIngestermethods;IngestMCPSpec. - Depends on: adapter, adapter/claude (frontmatter helpers), secrets, source, paths, iox, jsonkeys, go-toml/v2.
- Files:
gemini.go,homedir.go,render.go,mcp.go,ingest.go,apply.go,paths.go,command.go,subagent.go,hook.go,memory.go.
internal/adapter/continuedev
Section titled “internal/adapter/continuedev”The Continue adapter (package continuedev — continue is a Go keyword; the
agent name is still continue). MCP, memory, and slash commands, projected as
Continue “blocks” — one file per item, so there is no key-merge
(KeyMergeStrategy() returns ""): MCP → .continue/mcpServers/<id>.yaml
(stdio command/args/env; remote streamable-http/sse + url +
requestOptions.headers); memory → .continue/rules/agentsync.md (a
frontmatter-less always-apply rule); commands → .continue/prompts/<name>.md
prompt blocks. Skills/subagents/hooks/LSP have no faithful Continue target and
are skipped with a report (Skill/Subagent/Hook/LSP). No
PluginIngester. Deliberately implements no adapter.MCPSpecIngester: its
MCP is one whole FILE per server (replace), and its own IngestMCPSpec
operand is an element of a block’s YAML mcpServers LIST, not a root-keyed
value — the interface’s contract would not hold. reconcile’s write-back
refuses that file rather than copying it verbatim (the whole-file arm can
neither translate the YAML nor re-reference secrets); agentsync import continue:mcp:<id> captures the edit. See
architecture § MCPSpecIngester.
- Key:
New(Options) *Adapter; theAdaptermethods;IngestMCPSpec. - Depends on: adapter, adapter/claude (frontmatter/Extra helpers), secrets, source, paths, iox, sigs.k8s.io/yaml.
- Files:
continue.go,homedir.go,render.go,mcp.go,ingest.go,apply.go,paths.go,command.go,memory.go.
internal/adapter/windsurf
Section titled “internal/adapter/windsurf”The Windsurf (Cascade) adapter — MCP, memory, and slash commands, scope-
asymmetric to match Windsurf’s layout: only MCP is user-scope-only
(~/.codeium/windsurf/mcp_config.json, JSON mcpServers via merge-json-keys;
remote uses serverUrl), skipped (reported) at project scope. Memory and
commands render at both scopes — project → .windsurf/rules/agentsync.md
(workspace rule) / .windsurf/workflows/<name>.md, user → the global rules file
~/.codeium/windsurf/memories/global_rules.md / ~/.codeium/windsurf/global_workflows/
— all plain markdown. Skills/subagents/hooks/LSP have no Windsurf concept and are
skipped. It implements WarnEmitter: Ingest emits a warning when a workspace
rule lacks the agentsync-rendered trigger: always_on frontmatter. No
PluginIngester.
- Key:
New(Options) *Adapter; theAdapter+MCPSpecIngestermethods;IngestMCPSpec. - Depends on: adapter, adapter/claude (Extra helpers), secrets, source, paths, iox, jsonkeys.
- Files:
windsurf.go,homedir.go,render.go,mcp.go,ingest.go,apply.go,paths.go,command.go,memory.go.
internal/adapter/roo
Section titled “internal/adapter/roo”The Roo Code adapter — MCP, memory, and slash commands via clean filesystem
.roo/ paths. MCP → .roo/mcp.json (project-level, mcpServers via
merge-json-keys; remote uses explicit type: streamable-http/sse); memory →
.roo/rules/agentsync.md (plain markdown rule) and commands →
.roo/commands/<name>.md (markdown + frontmatter — keeps description +
argument-hint), both at user and project scope. Roo’s global MCP is VS Code
globalStorage (not targeted — user-scope MCP is reported as a skip). Skips
Skill/Subagent/Hook/LSP. No PluginIngester.
- Key:
New(Options) *Adapter; theAdapter+MCPSpecIngestermethods;IngestMCPSpec. - Depends on: adapter, adapter/claude (frontmatter/Extra helpers), secrets, source, paths, iox, jsonkeys.
- Files:
roo.go,homedir.go,render.go,mcp.go,ingest.go,apply.go,paths.go,command.go,memory.go.
internal/adapter/cline
Section titled “internal/adapter/cline”The Cline adapter — MCP, memory, and slash commands, scope-asymmetric: MCP
renders at user scope into the Cline CLI’s clean ~/.cline/mcp.json
(merge-json-keys; transport inferred, no type — remote uses url+headers),
while memory (.clinerules/agentsync.md, plain markdown) and commands
(.clinerules/workflows/<name>.md, plain markdown) render at project scope; the
non-applicable scope reports a skip. Cline has no project MCP file (its VS Code
extension uses OS/editor-specific globalStorage agentsync does not write) and its
global rules live in ~/Documents/Cline/ (also not targeted). Skills/subagents/
hooks/LSP have no Cline concept and are skipped. Emits no Ingest warnings
(rules/workflows are plain markdown), so it does not implement WarnEmitter. No
PluginIngester.
- Key:
New(Options) *Adapter; theAdapter+MCPSpecIngestermethods;IngestMCPSpec. - Depends on: adapter, adapter/claude (Extra helpers), secrets, source, paths, iox, jsonkeys.
- Files:
cline.go,homedir.go,render.go,mcp.go,ingest.go,apply.go,paths.go,command.go,memory.go.
internal/adapter/generic
Section titled “internal/adapter/generic”The breadth-tier adapter: one data-driven Adapter implementation that serves
many agents from a table of verified Specs (specs.go) rather than a package
each. Covers memory (a rules/instructions file, plain markdown), MCP where
the agent reads a JSON server-map agentsync can express, and Agent Skills
(SKILL.md directories) where the agent natively scans a skills directory — every
other component is reported as a skip, except Factory command hooks (hooks.json;
only that registered wrapper implements HookIngestGuard). A Spec declares per-scope memory/MCP/skills
paths plus MCP “dialect” knobs that capture the tail’s variance (top-level key
mcpServers/servers/mcp/context_servers/the flat namespaced amp.mcpServers;
transport field type/transport/inferred; stdio value stdio/local; remote
URL key url/httpUrl/serverUrl). The MCP merge is JSONC-tolerant (hujson), so a
commented settings file (Zed/Copilot/Amp) is preserved, not clobbered (re-emitted
as plain JSON, like OpenCode). Skills need no dialect — the on-disk format is
uniform — so the tier reuses the deep adapters’ shared claude.SkillFileOps
projection; an agent’s Skills target is usually the cross-vendor .agents/skills/
(byte-identical to Codex, so the render pipeline dedupes the ops). Breadth agents
register through the normal registry and flow through apply/import (drift, secrets,
capture). Adding an agent is a verified table row, not a package. It implements
adapter.MCPSpecIngester per-Spec, so reconcile’s key-level write-back inverts a
native entry through THIS agent’s dialect knobs — the collision that made a
pointer-root allowlist unfixable (crush’s /mcp is OpenCode’s; eleven breadth
agents share Claude’s /mcpServers) is answered by asking the rendering adapter.
- Key:
Spec,New(Spec, Options) *Adapter; theAdapter+MCPSpecIngestermethods;Specs(). - Depends on: adapter, adapter/claude (Extra + SkillFileOps helpers), secrets, source, paths, iox, jsonkeys.
- Files:
generic.go,homedir.go,render.go,ingest.go,apply.go,specs.go.
internal/adapter/noop
Section titled “internal/adapter/noop”Placeholder adapter that detects true and renders nothing. Used as a registry
stand-in in tests; no production agent is registered as a noop today (every valid
agent has a real adapter). agent add/import still reject any future
noop-registered agent unless AGENTSYNC_ALLOW_UNIMPLEMENTED=1.
- Depends on: adapter, secrets, source. Files:
noop.go.
Pipeline & state
Section titled “Pipeline & state”internal/render
Section titled “internal/render”Orchestrates apply: canonical + registry → per-agent FileOps/Skips, runs
collision detection and backups, records state, and builds the translation
report. It reclaims two kinds of orphan: emptied key-merge sections (synthesized
cleanup ops for orphaned owned keys — built by adapter.NewCleanupOp and stamped
adapter.OpCleanup, so consumers identify them by kind rather than by shape)
and whole-file components whose
source_id is under skills/, subagents/, commands/, or the retired
agents/ spelling — a destination whose source no longer renders it is deleted,
backing up a hand-edit first, and SKIPPED (with the state entry kept, so the next
apply retries) when it cannot be read. Plan also validates
every component id that becomes a destination filename — subagent/command/skill
Name plus MCP/LSP server ids (a per-server-file adapter like continuedev joins
the id into a path) — against source.ValidateComponentID before any adapter
joins it into a destination filename — the Render-time path-traversal guard,
symmetric with the dest→source write boundary (see architecture §7).
- Key:
Plan;Apply;PreviewApply(dry-run: collision preview + synced/would-change verdict);Writer(NewWriter/NewPreviewWriter);TranslationReport(PrintText/PrintJSON);BuildReport;RecordOpsState;PruneStaleState;RenderPlan.WithSiblingOwners(records what enabled agents left out of an--agentsrun own in state, without rendering them);NewSharedDests(whole-file destinations another agent holds: written this run, or owned by an agent left out of it);BackupFile/PruneBackups;CollisionReport; and the orphan-reclamation trio —OrphanFiles(what state owns but no other agent holds),OrphanDeletes(the per-agent deletes, filtered throughSharedDestsbefore apply performs them),OrphanIsReclaimable(is this component KIND reclaimed at all — drives reconcile’s prompt wording) andOrphanDeleteWillProceed(will THIS destination actually be removed on this run — keeps the apply summary from counting a skipped delete).IsRegularOrAbsentis shared withinternal/cli’s destination reads so a FIFO cannot block those: every one of them goes throughreadDestBytes(internal/cli/destread.go), which applies this predicate before the open.TestEveryDestinationReadGoesThroughTheGatebacks this up by failing on a bareos.ReadFile(op.Path)anywhere underinternal/cli— a two-spelling text matcher, so it catches the copy-paste that happened rather than every possible spelling. It is now also true of this package’s ownWriter.Writeconvergence read (which shape-checks beforeReadFile) and of the adapterIngestpaths (which read throughadapter.ReadFileOptional/ReadDirOptional), soapply,apply --dry-run,reconcile --auto-override(which re-applies throughWriter.Write),import <agent>anddoctor(through its plugin check) all RETURN on a non-regular destination instead of blocking inopen(2)— issues #241 and #242.TestCommandsDoNotHangOnNonRegularDestination(internal/cli) drives every one of those commands against a real FIFO destination under a time bound. - Depends on: adapter, secrets, source, state, paths, iox.
- Files:
pipeline.go,writer.go,state_apply.go,report.go.
internal/capture
Section titled “internal/capture”The single dest→source write-back path: re-references secrets, preserves
source-only fields, writes via source.Write*. Used by import and reconcile.
- Key:
Capture(home, ingested, opts) (Result, error);Opts;Result. - Depends on: source, secrets, paths, iox.
- Files:
capture.go,leak_fixture.go(compile-time leak guard).
internal/drift
Section titled “internal/drift”Pure 3-way classifier — no IO. The walk that feeds it (walkPlanItems) lives in
internal/cli beside the destination readers it needs, so this package stays
IO-free.
- Key:
Class(Clean,Pending,Drift,Converged,Conflict,New,ForeignCollision,Orphan,OrphanDrifted);Classify(hsrc, happlied, hdest);SafeForAutoApply(c). - Files:
classifier.go.
internal/state
Section titled “internal/state”Persists last-applied hashes and plugin/marketplace pins to
.state/targets.json; schema-versioned with migrators. Owns the targets.json
key format: Key is a typed, length-prefixed, injective identifier
(agent · scope · portable project · portable dest path · JSON pointer) used as
the Go map key of Files/Keys, so a hand-rolled fmt.Sprintf key is a
compile error rather than a convention (issue #227). The encoding has exactly
one owner — String/ParseKey live here and nothing else formats or decodes it
— but neither is sealed: ParseKey is exported (below), and a state.Key{…}
composite literal compiles anywhere, which is how render’s and cli’s tests
plant keys in a specific portable spelling instead of going through
NewFileKey. Also owns the per-machine run record .state/last-run.json, which
backs the one-time first-run-after-upgrade notice — a SEPARATE file on purpose:
it must be writable by read-only commands and must never gate on (or bump) the
drift state’s SchemaVersion.
- Key:
SchemaVersion(currently 2);Key(+NewFileKey,NewPointerKey,ParseKey,String,AbsPath,InTree);Targets(Files,Keys,Marketplaces,Plugins);FileEntry;KeyEntry;Load/Save;ErrNonUTF8Key(Saverefuses a key JSON cannot carry losslessly);migrate(v1 keys → typed keys);LastRun/LoadLastRun/SaveLastRun. - Depends on: iox, paths. Files:
schema.go,store.go,key.go,legacy.go,migrate.go,lastrun.go.
internal/marketplace
Section titled “internal/marketplace”Models the Claude marketplace/plugin format, fetches sources, and projects plugin manifests into canonical components.
- Key:
Marketplace,PluginEntry,Source,PluginManifest;ProjectionResult+ itsAsCanonical()/ReplaceComponentsIn()(the projection→canonical component copy lives next to the struct, not in three CLI call sites, so a new component kind cannot be added here and silently dropped from every diagnostic surface — reflectively guarded);Project/ProjectWithReader;ProjectInstalled(one installed plugin in isolation — letsexplain <id>attribute coverage to the named plugin rather than the flattened union);Fetcher(interface) withGitFetcher/NPMFetcher/RelativeFetcher;LoadProjected/LoadProjectedLenient/LoadProjectedExcluding;namespaceProjected(renames each plugin-provided subagent/skill/command to<plugin>-<name>and stamps its provenance — including the providing plugin’sagents/native_agentstargeting, which travels with the component because the flattened canonical drops the association — so two plugins shipping one name cannot collide at a destination path — see architecture.md § Plugin component namespacing);CacheAsideSuffix/IsCacheAside(the<dir>..oldsibling a cache replace parks the old tree at, which every cache-root scan skips as scratch). - Depends on: source, log.
- Files:
manifest.go,treehash.go(thetree:v1:content hash),projection.go,loadprojected.go,cache_aside.go,fetcher.go,fetch_git.go,fetch_npm.go,fetch_relative.go,update.go.
Infrastructure & presentation
Section titled “Infrastructure & presentation”Leaf packages with no internal dependencies, plus the thin ui presentation
layer (which builds only on untrusted).
internal/git
Section titled “internal/git”The only go-git surface in the codebase: a local-only, directory-level
rollback history for destination git backup (issue #118). Each managed
destination dir becomes its own repo carrying an [agentsync] managed = true
marker so agentsync only ever auto-commits into repos it created; a checkpoint is
recorded after each apply and revert rolls a dir back append-only. It exposes
no remote/push API — enforced by the source-scanning TestNoPushSurface
guard — so a backup can never leave the machine (the history may hold the
cleartext secrets the rendered files already contain).
- Key:
Detect/State(StateUntracked/StateAgentsyncOwned/StateForeign;State.String()→agentsync-versioned/foreign source control/untracked);Init/Open/OwnsExactly/HasNestedRepoBelow;Stage/StageTrackedDeletions/CommitStaged/SnapshotDirtyTracked/IsClean;Log/Resolve/Plan/Restore;Identity;NoticeFile. - Depends on: nothing internal (leaf).
- Files:
git.go,init.go,commit.go,log.go,restore.go,perms.go.
internal/iox
Section titled “internal/iox”Atomic file IO and locking.
- Key:
AtomicWrite(dest, data, mode);Lock/AcquireLock/AcquireLockTimeout;ErrSymlinkDest;AllowSymlinkDestEnv;SymlinkDestAllowed(the one reading of it, shared withinternal/cli’s read-side gate). - Files:
atomic.go,lock.go.
internal/jsonkeys
Section titled “internal/jsonkeys”Per-key JSON-pointer merge that preserves foreign keys and uses json.Number
(no float64 rounding). It is also the single home for RFC 6901 pointer
mechanics: EscapeToken/UnescapeToken were hand-rolled seven times across
three packages before #235, and their order of operations is not symmetric
(escape does ~ first, decode does ~1 first), so getting either backwards
silently corrupts any key holding a / or a ~.
- Key:
DecodeObject;DecodeYAML;MergeKeys(existing, ours, ownedPointers);EscapeToken/UnescapeToken/SplitPointer;Get(m, ptr) (any, bool)— the one pointer resolver, whose presence bool separates “absent” from “present and null” (render.RecordOpsStateneeds the difference; the CLI’s drift diagnostics discard it). - Files:
jsonkeys.go,jsonc.go,pointer.go.
internal/paths
Section titled “internal/paths”Resolves AGENTSYNC_HOME, AGENTSYNC_TARGET_ROOT, $HOME, and third-party agent
home overrides (GROK_HOME); converts between absolute and ${HOME}-relative
forms for portable state. AGENTSYNC_TARGET_ROOT is the sandbox switch: when set,
every resolved path lives under it and the user-facing overrides are ignored
(issue #270).
- Key:
Env(interface),OSEnv,MapEnv;HomeDir;AgentsyncHome;AgentHomeOverride;ContainsDir(lexical containment by declared spelling — de-nesting, the owner map, the traversal guard’s assertion, and the spelling leg of the never-at-$HOMEguard) andContainsDirResolved/SameDirResolved(directory identity — symlinks resolved through the deepest existing ancestor, case-folded on macOS/Windows — the identity leg of that guard and theGROK_HOMErefusal);HomeRelative/FromHomeRelative. - Files:
paths.go.
internal/log
Section titled “internal/log”slog setup. Builds a logger over ui.SlogHandler and installs it as the
process-wide default from the root command’s PersistentPreRunE, which is what
routes library-side slog calls (internal/render, internal/marketplace) into
the CLI’s diagnostic vocabulary instead of the stdlib default’s timestamped
lines. Key: New(w, p, verbose) *slog.Logger; Install(w, p, verbose);
Detach() (unbinds the process default to a discarding handler — the seam test
binaries need, since each Execute() otherwise leaves slog.Default() pointed at
a finished test’s buffer). Depends on: ui. Files: log.go.
internal/untrusted
Section titled “internal/untrusted”The display trust boundary for fetched/native metadata. Owns Sanitize (strip
terminal-control + deceptive bidi/zero-width runes) and the Text defined string
type whose String() sanitizes — so a plugin/marketplace id, version, or name
typed untrusted.Text is safe to print through fmt by construction; the raw
value is reachable only via the explicit Unverified(). ui.Sanitize delegates
here. See architecture §7 and SECURITY.md.
- Key:
Text(.String()/.Unverified()/.Empty());Wrap;Sanitize. - Files:
untrusted.go.
internal/ui
Section titled “internal/ui”The presentation layer — every command renders styled output through a *Printer
so color, glyph, and spacing decisions live in one place. Owns the curated glyph
vocabulary (✓/◐/✗/⚠/•/→), the --color mode resolution, the
diagnostic vocabulary (below), and a WarnWriter that rewrites the
warning: emitter sentinel into the shared WARN label. Sanitize delegates to
internal/untrusted, so untrusted metadata printed through ui is stripped of
terminal-control and deceptive-format runes by construction.
Diagnostics vs results. Output splits in two, and every ui API belongs to
one side:
| What it is | Stream | Rendering | |
|---|---|---|---|
| Diagnostic | a notice about the run | stderr | ✗ ERROR / ⚠ WARN / ℹ INFO / • DEBUG, message at column 9 |
| Result | what the command was asked to produce | stdout | no level label; a success outcome leads with a curated emoji |
The level label is <glyph> <WORD padded to 5> plus a two-column gutter, so
messages start at the same column for every level and Detailf continuation
lines hang under them. Success lines carry no level word — an INFO on
added agent: claude is noise that dilutes the labels that matter — and instead
use EmojiSuccess ✅ / EmojiApplied 🎉 / EmojiRemoved 🧹 / EmojiImported 📥
/ EmojiReverted 🔙 / EmojiInit ✨, chosen by what happened rather than by
which command ran.
SlogHandler renders log/slog records through the same vocabulary and is
installed as the process-wide default by the root command, so a slog.Warn from
internal/render or internal/marketplace is byte-identical to a command’s own
p.Warnf. Record timestamps are dropped: these are user-facing CLI diagnostics,
not a log stream anyone greps by time.
Handle locks its writer (a record is a label line plus an optional attribute
line, which must not interleave) and returns write errors rather than swallowing
them. The slog.Warn / WarnWriter / p.Warnf triple being byte-identical is
pinned by TestWarnPathsAreByteIdentical.
Who sanitizes. SlogHandler.Handle and reportErrorTo’s terminal error line
(via sanitizeLines) sanitize their own input, because neither has a call site that
could: a log record and a wrapped error chain are both assembled elsewhere. Everywhere else the
caller applies ui.Sanitize at the display boundary, as the rest of this
codebase does — the Diagf/Detailf/Successf family deliberately does not, so a
caller can pass pre-styled text (the upgrade-notice banner composes
p.Bold/p.Yellow fragments into a Warnf) without having its own escape codes
stripped.
WarnWriter is the third self-sanitizing site, for the same reason as the other
two: its input comes from the adapter and capture packages, which cannot call
ui.Sanitize (they must not import ui — that constraint is the reason the
warning: sentinel exists), so if ui does not sanitize, nothing does. It
sanitizes the message body only; the passthrough branch stays verbatim because
that branch carries agentsync’s own already-styled lines, whose ANSI sanitizing
would strip.
That backstop does not replace %q at the emitters, and its bound is worth stating
exactly, because the asymmetry above is easy to over-read. WarnWriter.Write splits
on \n before emit runs, so a sanitized body never contains an interior
newline. An emitter that interpolates a raw newline into a warning: line puts
lines 2..n through the passthrough branch — unlabeled and unsanitized — so for
the newline case %q (which escapes it) is still the control. Nearly every emitter
already uses %q for the untrusted identifier; the residual is the handful of
%v-of-error sites, where a multi-line wrapped error could carry native-config text
into the passthrough branch. Pre-existing; not introduced by the diagnostic
vocabulary, and not closed by it.
Note that %s on an untrusted.Text value — which is how plugin and marketplace
identity fields are typed — was never a hole: Text.String() sanitizes by
construction. The gap was only ever plain string values read out of native config.
- Key:
Printer(New,Color,Section, colour helpers; color is resolved per stream, and the diagnostic writers style for the stream they target);Level+Label;Errorf/Warnf/Infof/Diagf/Fdiagf;Detailf/Fdetailf;Successf/Fsuccessf+ theEmoji*vocabulary;SlogHandler/NewSlogHandler; the package-levelPadhelper;ColorMode/ParseColorMode; theGlyph*vocabulary;WarnWriter(NewWarnWriter,RouteTo,Flush);Sanitize. - Depends on: untrusted.
- Files:
ui.go,diag.go,slog.go,spinner.go.
internal/testenv
Section titled “internal/testenv”Guards FS-touching tests so they only run in the hermetic container, and — by
being imported — scrubs the ambient environment at package init so no test’s
result depends on what the developer’s shell exports: every AGENTSYNC_*
override (except the harness’s own AGENTSYNC_TEST_* / AGENTSYNC_LIVE_*
signals) plus GROK_HOME, NO_COLOR and EDITOR are unset before any
TestMain or t.Setenv can run (issue #270). Its tests double as guards: the
source scan that keeps agent-home variables behind paths.AgentHomeOverride,
and the parity check between the scrub list and the configured-environment CI
legs.
- Key:
RequireContainer(t);MustRunInContainer();ScrubAmbient();InContainer() bool;EnvVar(AGENTSYNC_TEST_IN_CONTAINER). - Files:
container.go;guards_test.go(the source-scan and parity guards, plusTestConfiguredLegIsLive, which observes from inside a test that the configured container leg’s fake agent binaries are whatPATHresolves).
Dependency direction at a glance
Section titled “Dependency direction at a glance”cli sits on top of everything. render, capture, and the adapters depend on
source + secrets. source/secrets/state depend only on the leaf infra
packages (iox, jsonkeys, paths, and — for the canonical plugin/marketplace
identity fields typed untrusted.Text — untrusted). git (destination
rollback history, reached only from cli) is likewise a leaf. drift, git,
iox, jsonkeys, paths, and untrusted depend on nothing internal —
they’re the foundation; ui (presentation) builds only on untrusted, and
log only on ui (it wires ui.SlogHandler in as the slog default). See the
rendered dependency graph in
architecture §12.