Help us shape the future of Go! Take the 2026 Go Developer Survey

adapter

package
v0.17.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 13, 2026 License: MIT Imports: 13 Imported by: 0

Documentation

Overview

Package adapter declares the interface every per-agent adapter implements. The registry holds zero or more concrete implementations; the apply pipeline asks each registered adapter to Render a CanonicalModel into FileOps.

Index

Constants

This section is empty.

Variables

View Source
var ErrNotRegularFile = errors.New("not a regular file")

ErrNotRegularFile is returned when path exists but is not a regular file (FIFO, directory, device, socket). Callers must treat it as a loud error, never as "absent": os.ReadFile on a FIFO blocks forever (#242).

View Source
var ErrProjectRootRequired = errors.New("adapter: project scope requires a non-empty project root")

ErrProjectRootRequired is returned by RequireProjectRoot (and thus by every scope-resolving adapter method — Render, Ingest, and IngestPlugins) when a project-scope call supplies no project root.

Functions

func DeclaredPlugins added in v0.15.0

func DeclaredPlugins(c source.Canonical) map[string]source.PluginSpec

DeclaredPlugins returns the plugins a canonical EFFECTIVELY declares — every non-disabled entry, keyed by the plugins/<id>.toml stem.

Extracted as a helper because two call sites must agree on it exactly: DuplicatedNativePlugins returns nil (having checked nothing) when this set is empty, and status's `--agents` scoping note exists to qualify that very silence. A note computing "did we have anything to check?" its own way — off the merged canonical rather than the scoped one, or counting disabled pins — disagrees with the check in both directions: it claims a narrowed check that never ran, and it stays quiet when narrowing really did hide a duplicate. Callers must pass the SAME canonical they pass to DuplicatedNativePlugins.

func DispatchOps added in v0.11.0

func DispatchOps(ops []FileOp, w DestWriter, writeOp func(FileOp) error) error

DispatchOps runs the standard write/delete dispatch over ops: it calls w.Delete for each ActionDelete op and writeOp for each ActionWrite op (the zero value), and returns fmt.Errorf("unknown action %q", op.Action) for any out-of-range value. A delete error is wrapped as fmt.Errorf("delete %s: %w", op.Path, err); a writeOp error is returned as-is (writeOp owns its own wrapping).

Every adapter's Apply (except noop's trivial no-op) delegates here so this dispatch — and its exact error strings — lives in exactly one place. The only per-adapter variation is the writeOp closure: most adapters pass a.applyWrite(op, w) (a per-op key-merge then w.Write); continuedev, which has no merge, passes w.Write(op, op.Content) directly. writeOp MUST route through the DestWriter — DispatchOps never touches the filesystem itself, preserving the DestWriter foreign-collision backup invariant.

func DuplicatedNativePlugins added in v0.15.0

func DuplicatedNativePlugins(c source.Canonical, reg *Registry, agents []string) map[string][]untrusted.Text

DuplicatedNativePlugins reports, per agent, the declared plugins that agent installs ITSELF and that agentsync also projects to it — the duplicate: every skill, subagent, and command lands twice (once from the agent's own install dir, once from agentsync's projection into the agent's standalone paths) and every hook handler fires twice.

It is the counterpart to UndeclaredNativePlugins, and the safety net for a deliberate design choice: apply's output is a pure function of canonical state and never probes the destination, so a plugin installed natively AFTER it was declared in agentsync cannot be noticed at apply time. `import` seeds `native_agents` for what exists at import time; this catches everything after.

A plugin is duplicated for an agent when all of:

  • the agent's native config has it enabled (matched by NAME, the plugins/<id>.toml stem, as in UndeclaredNativePlugins);
  • the canonical source declares it and it is not disabled;
  • agentsync projects it to this agent — i.e. the `agents` allowlist targets the agent and `native_agents` does NOT already defer to it.

Read-only and best-effort: a discovery error is skipped, never surfaced as a failure, exactly like the sibling nudge.

func MergeStrategyForPath added in v0.15.0

func MergeStrategyForPath(a Adapter, path string) string

MergeStrategyForPath resolves the adapter's format for a shared destination. Single-format adapters keep their existing KeyMergeStrategy contract.

func NativePluginOwners added in v0.15.0

func NativePluginOwners(reg *Registry) map[string][]string

NativePluginOwners maps a plugin NAME to the agents whose own plugin manager already has it enabled. It is the input to `native_agents`: an agent that installs a plugin itself must not also receive agentsync's projection of that plugin's components, or every skill/subagent/command lands twice and every hook fires twice (the agent serves its own copy from its install dir, which agentsync neither writes nor reads).

Every registered PluginIngester is probed, not just the agent being imported from: a plugin can be installed natively in several harnesses, and each of them needs its own deferral. Agents that are not currently enabled in agentsync are probed too — excluding one costs nothing today and is already correct if the user enables it later.

Matching is by NAME (the plugins/<id>.toml stem), mirroring UndeclaredNativePlugins: the marketplace id an agent records natively can differ from the declared marketplace name agentsync keys its file on. A discovery error is skipped silently — a missing or malformed native config means "no plugins discovered", never a failed import.

func NonEmptyDirs added in v0.7.5

func NonEmptyDirs(dirs ...string) []string

NonEmptyDirs returns the non-empty arguments as a slice — a small shared helper for adapters assembling their VersionRoots from a Paths struct whose fields may be "" at a given scope.

func ParseHookTimeout added in v0.16.0

func ParseHookTimeout(h map[string]any) (int, source.HookTimeoutParse)

ParseHookTimeout reads a native handler's "timeout" expressed in SECONDS and returns it as a canonical Hook.Timeout. An absent field is HookTimeoutOK with value 0 (no timeout), which is the overwhelmingly common case.

func ParseHookTimeoutMillis added in v0.16.0

func ParseHookTimeoutMillis(h map[string]any) (int, source.HookTimeoutParse)

ParseHookTimeoutMillis reads a native handler's "timeout" expressed in MILLISECONDS (Gemini CLI) and converts it to canonical whole SECONDS. A native value that is not a whole number of seconds — Gemini's own 1500, say — is refused as unrepresentable rather than rounded, so agentsync never silently changes how long a user's hook is allowed to run.

func QuotedKeys added in v0.11.0

func QuotedKeys(keys []string) string

QuotedKeys renders untrusted native key names for a warning line: each key %q-quoted (control bytes escaped — a key containing a newline cannot forge a second warning line), comma-separated. This is a log-forging defense; it lives here, in one place, so no adapter's warning path can drift back to raw interpolation.

func ReadDirOptional added in v0.11.0

func ReadDirOptional(path string) (entries []os.DirEntry, present bool, err error)

ReadDirOptional lists the directory at path with the same absent-vs-error discrimination as ReadFileOptional: (entries, true, nil) when the directory exists and is readable, (nil, false, nil) when it is absent (os.IsNotExist), and (nil, false, err) for any other read error (which callers MUST surface). A component directory the user never created stays a silent skip; a present directory that cannot be listed (permission, transient I/O) is loud, so it is never mistaken for "no skills/commands/subagents".

func ReadExisting added in v0.16.0

func ReadExisting(path string) ([]byte, error)

ReadExisting returns dest bytes, or (nil, nil) if the path is absent. A present non-regular dest (FIFO, directory) returns ErrNotRegularFile without opening the path.

func ReadFileOptional added in v0.11.0

func ReadFileOptional(path string) (data []byte, present bool, err error)

ReadFileOptional reads the file at path while distinguishing a genuinely-absent file from one that is present but unreadable, so an ingest read never conflates the two. It returns:

  • (data, true, nil) when the file exists and is readable;
  • (nil, false, nil) when the file is absent (os.IsNotExist) — the only legitimate "component not present" case, which callers skip silently;
  • (nil, false, err) for any OTHER read error (permission, EISDIR from a directory sitting where a file is expected, a FIFO, transient I/O), which callers MUST surface — return it (or, for a per-entry read inside a loop, warn) — rather than treat as "absent".

The hazard this guards against: an ingest read that acts only on the err==nil branch of os.ReadFile reads an unreadable-but-present file identically to an absent one, yielding an empty component (empty Memory.Body, no commands, no hooks). Drift/capture/reconcile then misread that empty component as "the user cleared it" and can destructively write nothing back over the canonical source (often a committed dotfiles repo). Only os.IsNotExist is a benign skip; every other error is loud.

A FIFO is present-but-unreadable: os.ReadFile does not fail, it blocks in open(2). The shape check uses Stat (follows symlinks) so a link to a FIFO is refused the same way as a bare one.

func RequireProjectRoot

func RequireProjectRoot(scope Scope, project string) error

RequireProjectRoot guards the adapter boundary against a project-scope call with an empty project root. Every adapter resolves its destinations via a ResolvePaths that falls through to USER-scope paths when project == "" — so a caller reaching an adapter with (ScopeProject, "") would SILENTLY write the project overlay into the user's global config (or read it from there). The CLI's resolveScope already guarantees a non-empty root for project scope; this is the belt-and-suspenders that turns a future caller's mistake into a loud error at the narrowest waist every read/write funnels through, instead of a silent wrong-scope I/O. Adapters MUST call it first thing in every method that resolves scope-dependent paths: Render, Ingest, and IngestPlugins.

func SetHookTimeout added in v0.16.0

func SetHookTimeout(handler map[string]any, seconds int)

SetHookTimeout writes a canonical timeout onto a native handler object in SECONDS, omitting the key entirely at 0 so a canonical source that carries no timeout renders byte-identically to one written before the field existed.

func SetHookTimeoutMillis added in v0.16.0

func SetHookTimeoutMillis(handler map[string]any, seconds int)

SetHookTimeoutMillis writes a canonical timeout onto a native handler object in MILLISECONDS (Gemini CLI). int64 arithmetic keeps the multiply honest on a 32-bit build; the loader caps canonical timeouts at source.MaxHookTimeout, so the product always fits.

func UndeclaredNativePlugins added in v0.15.0

func UndeclaredNativePlugins(c source.Canonical, reg *Registry, agents []string) map[string][]untrusted.Text

UndeclaredNativePlugins reports, per agent, the plugins enabled in that agent's native config that are NOT declared in the canonical source — a read-only nudge surfaced by `status` and `doctor`.

agentsync treats natively-installed plugins as foreign-managed (the design's "jointly-owned cache" note), so this never blocks or auto-imports; it just points the user at `import <agent>:plugin`. Only agents whose adapter implements PluginIngester are probed (others yield nothing), and a discovery error is skipped silently since the nudge is best-effort.

Matching is by plugin NAME — the plugins/<name>.toml stem — not by "name@marketplace", because the marketplace id an agent records natively can differ from the declared marketplace name agentsync keys its file on. Name matching errs toward NOT nagging (a same-named plugin from another marketplace counts as declared), which suits a nudge. The native plugin name is carried as untrusted.Text end to end (a plugin author influences it, and status/doctor print it), so matching/dedup keys off its raw Unverified() value while the value handed to the print sites keeps the Text wrapper and sanitizes on display.

func UnmodeledKeys added in v0.11.0

func UnmodeledKeys(m map[string]any, modeled map[string]bool) []string

UnmodeledKeys returns the sorted keys of m that are not in modeled — the fields of a decoded native hook def/handler the canonical model cannot represent. Shared by the hook-ingesting adapters' refusal guards (claude, gemini, cursor, codex), which are otherwise deliberately per-package (their nesting/ordering semantics differ); this pair carries zero per-adapter divergence. Pair with QuotedKeys when the result reaches a warning line.

Types

type Action added in v0.15.0

type Action int

Action says what a FileOp does to its destination. The zero value IS ActionWrite — a FileOp built without naming an action writes — which is why there is nothing to normalize at any intake: no empty spelling exists that an executor would write while a guard matching "write" let it through. Unlike SkipKind below, whose zero value is invalid by design so an unstamped Skip fails TestEverySkipLiteralSetsKind, the zero values of Action and OpKind are deliberately valid (write, render): the common case must need no stamp.

const (
	// ActionWrite writes Content to Path — whole-file, or key-merged per
	// MergeStrategy. It is the zero value.
	ActionWrite Action = iota
	// ActionDelete removes Path. No adapter renders one and none enters a
	// plan's Ops: deletes are synthesized at apply time (orphan reclamation),
	// by `agent disable --purge`, and as the drift walk's orphan item.
	ActionDelete
)

func (Action) String added in v0.15.0

func (a Action) String() string

String renders the action for the dry-run op label and DispatchOps' error text. %q on an Action quotes this, so an out-of-range value reads "action(<n>)" rather than a bare integer.

type Adapter

type Adapter interface {
	Name() string
	// Detect reports whether the agent appears installed — its config dir exists
	// under the target root, or its binary is on PATH. Informational only: the
	// `doctor` command consumes it to print a per-agent detected/not-detected
	// line; it never gates apply or fails a readiness check.
	Detect() (bool, error)
	// Render projects the resolved canonical (secrets already substituted to
	// cleartext, or wrapped templated for a preview) into destination FileOps.
	// It accepts only secrets.Resolved — never a raw source.Canonical — so the
	// render egress is type-distinct from the dest->source write path.
	//
	// CONTRACT — at ScopeProject the project root MUST be non-empty. Any adapter
	// method that resolves scope-dependent destinations MUST call
	// RequireProjectRoot first thing and return ErrProjectRootRequired for an
	// empty root, so a project-scope call can never silently fall through to
	// user-scope destinations. That covers Render and Ingest here, and
	// IngestPlugins on the PluginIngester extension below; the three real
	// adapters do all of these. (A pure no-op adapter that resolves no paths has
	// nothing to fall through to.) See RequireProjectRoot.
	Render(r secrets.Resolved, scope Scope, project string) ([]FileOp, []Skip, error)
	Ingest(scope Scope, project string) (source.Canonical, error)
	// KeyMergeStrategy returns this adapter's single JSON-key-merge strategy
	// ("merge-json-keys" for claude, "merge-jsonc-keys" for opencode), or ""
	// if the adapter does not merge keys. The render layer needs it to
	// synthesize cleanup ops that remove now-orphaned owned keys from a
	// destination when the source section became empty (no render op exists
	// to carry the removal). It MUST be exact — applying the wrong strategy to
	// a JSONC file would parse it as strict JSON and clobber the file.
	//
	// Adapters with multiple native formats implement PathKeyMerger. Cleanup
	// callers use MergeStrategyForPath so each destination keeps its format.
	// TestKeyMergeStrategy_MatchesEmittedOps pins this against rendered ops.
	KeyMergeStrategy() string
	// Apply executes ops against destinations. Adapters MUST route every
	// destination write through w.Write / w.Delete rather than calling
	// iox.AtomicWrite or os.Remove directly — w owns the foreign-collision
	// backup invariant. A forbidigo lint rule in .golangci.yml backs this
	// up at build time by failing direct iox.AtomicWrite calls outside the
	// allowed packages.
	Apply(ops []FileOp, w DestWriter) error
}

Adapter is the per-agent contract.

type DestWriter

type DestWriter interface {
	// Write writes finalBytes to op.Path, after backing up any pre-existing
	// foreign content. For replace ops, finalBytes is op.Content. For merge
	// ops, the adapter performs its merge first (claude → jsonkeys.MergeKeys;
	// opencode → hujson.MergeJSONC) and passes the post-merge bytes here;
	// the writer uses op.Content (ours pre-merge) plus op.OwnedKeys to
	// detect per-key collisions for the backup decision.
	Write(op FileOp, finalBytes []byte) error

	// Delete removes op.Path. No backup — agentsync only deletes paths it
	// already owns per state. Idempotent on missing files.
	Delete(op FileOp) error
}

DestWriter is the single funnel for any write that targets a native agent destination file (~/.claude.json, ~/.claude/agents/*.md, ~/.config/opencode/*, etc). It enforces the foreign-collision backup invariant: a pre-existing destination that agentsync does not yet own is copied to <home>/.state/backups/<ts>/<original-path> before being overwritten.

Adapters MUST use DestWriter instead of calling iox.AtomicWrite or os.Remove directly. The forbidigo lint rule in .golangci.yml will fail any direct iox.AtomicWrite call outside the allowed packages, so a new adapter or a new write path inside an existing adapter cannot silently regress the backup guarantee.

type FileOp

type FileOp struct {
	Action        Action // write (zero value) | delete
	Kind          OpKind // render (zero value) | cleanup
	Path          string
	Content       []byte
	Mode          uint32
	SourceID      string // canonical source path that produced this op
	MergeStrategy string // "replace" (default) | "merge-json-keys" | "merge-jsonc-keys" | "merge-toml-keys"
	// OwnedKeys lists the JSON pointers agentsync owns at this key-merge
	// destination, so the merge can remove any the op no longer carries.
	// render.Plan populates it from state — scoped to the top-level sections
	// this op writes — whenever Plan is given a state (plugin explain/poll pass
	// nil). A value an adapter's Render sets is only a fallback for Apply driven
	// without the pipeline; Plan overwrites it on every key-merge op whenever it
	// has state.
	OwnedKeys []string
}

FileOp describes one destination-side change. Action says what happens to the destination (write — the zero value — or delete); Kind says why the op exists (an ordinary render, or a synthesized orphan cleanup). Path is absolute (after AGENTSYNC_TARGET_ROOT redirection).

CONTRACT — Content is ALWAYS JSON for a key-merge op, regardless of the destination file's on-disk format. MergeStrategy names the on-disk format the adapter's Apply and the render pipeline decode/encode against:

"replace" (default)   whole-file write; Content is the literal file bytes.
"merge-json-keys"     shared JSON   (~/.claude.json, settings.json).
"merge-jsonc-keys"    shared JSONC  (opencode.json; comments tolerated).
"merge-toml-keys"     shared TOML   (~/.codex/config.toml).

For every merge-* strategy, Content is the JSON projection of the owned subtree ({"mcpServers": …} / {"hooks": …} / {"mcp_servers": …}) — the pointer-merge "currency" the pipeline reasons over (OwnedKeys synthesis, orphan cleanup, per-pointer state hashing, foreign-collision backup are all format-agnostic). Only the DESTINATION file is parsed/emitted per strategy (render.IsKeyMerge gates the classification; the adapter's Apply does the format-specific merge). A new TOML/YAML-backed agent must keep Content JSON, not emit the on-disk format here.

func NewCleanupOp added in v0.15.0

func NewCleanupOp(path, strategy string, owned []string) FileOp

NewCleanupOp builds the op that prunes owned keys from a key-merge destination: an ActionWrite of "{}" whose only work is the OwnedKeys removal the merge path performs. It is the only producer of OpCleanup: every site that synthesizes a cleanup op must call it so the kind is never missed, and TestEveryCleanupLiteralUsesNewCleanupOp fails any production FileOp literal that hand-rolls the cleanup shape or stamps OpCleanup by hand instead.

type HookEventNamer added in v0.11.0

type HookEventNamer interface {
	NativeHookEvent(canonical string) (string, bool)
}

HookEventNamer is an OPTIONAL extension to Adapter for agents that RENAME canonical hook events in their native config (Gemini: PreToolUse→BeforeTool; Cursor: PreToolUse→preToolUse). NativeHookEvent returns the native spelling the adapter uses in its owned "/hooks/<name>" state pointers, or ("", false) when the canonical event has no native equivalent on this agent. Import's stale-hook retirement needs it: the all-agents state disown (cli.retireRefusedHookEvents) matches keys by event name, and matching only the CANONICAL spelling would leave a renaming agent's key owned with no canonical render behind it — the next apply's orphan cleanup would then delete the event from that agent's native config, the exact clobber retirement exists to prevent. An adapter that renders hooks under the canonical name (claude, codex) does not implement it.

type HookIngestGuard added in v0.11.0

type HookIngestGuard interface {
	RefusedHookEvents(scope Scope, project string) ([]string, error)
}

HookIngestGuard is an OPTIONAL extension to Adapter for agents whose hook ingest REFUSES unrepresentable native events (unmodeled fields; on most adapters also non-command handlers) rather than capturing a lossy subset. RefusedHookEvents re-reads the destination (Ingest's ten-adapter (source.Canonical, error) signature has no channel for refusals) and returns the SEMANTICALLY refused event names — structurally-malformed native shapes are excluded, since the caller deletes canonical config for every returned event and a native typo must not be destructive. `import` uses it to retire a stale canonical hooks/<event>.toml: an event captured while clean and later enriched natively is skipped by Ingest, but a stale canonical file left in place keeps the next apply — which owns the whole per-event array — lossily rewriting the user's native entry (the second-order corruption class of issue #124). Canonical hooks are SHARED across agents, so retirement freezes the event for every agent at that scope (see cli.retireRefusedHookEvents). Like Render/Ingest it MUST honor RequireProjectRoot.

Implemented by claude, gemini, cursor, and codex — every hook-rendering adapter. Event names are always CANONICAL: a renaming adapter (gemini spells PreToolUse as BeforeTool natively, cursor as preToolUse) must map its refused native events back to the canonical spelling, because the caller retires hooks/<canonical>.toml — and a native event with no canonical equivalent must never be returned (there is no canonical file to retire). WHAT counts as a semantic refusal is per-adapter (codex, whose render round-trips non-command handler types verbatim, refuses only unmodeled fields); the registry-wide guard TestHookIngestGuard_ReportsCanonicalNames (internal/cli) pins the canonical-spelling contract for every implementor.

type MCPSpecIngester added in v0.15.0

type MCPSpecIngester interface {
	IngestMCPSpec(raw map[string]any) source.MCPServerSpec
}

MCPSpecIngester is an OPTIONAL extension to Adapter: an agent whose native MCP config is a KEY-MERGE object — one root key holding one entry per server — implements it so a dest→source write-back can translate a SINGLE native server entry back to the canonical model in that agent's own dialect. Callers type-assert for it (`reconcile`'s key-level [w]rite-back); an adapter that does not implement it has its MCP key items refused rather than mistranslated.

**Read-only by design, like PluginIngester.** IngestMCPSpec is an INVERSE of Render, never a second render path: the spec it returns is destination-derived and goes to the canonical source only through capture.Capture, which re-references secrets and runs the fail-closed leak backstop. It must not write anything.

The operand is the value at `<root key>/<server id>` in the DECODED destination object (`map[string]any` — JSON, JSONC, or TOML; the pipeline's merge currency), exactly what the adapter's own Ingest passes to its translator. The implementation MUST therefore be the same function Ingest uses, not a second copy: the dialect is the thing that drifts (OpenCode's array-shaped `command` and `environment`; Codex's `http_headers`; Gemini's `httpUrl`; Windsurf's `serverUrl`; Roo's `streamable-http`; the generic tier's per-Spec `MCPTarget` knobs), and a divergent copy silently moves modeled fields into `Extra` on capture.

**Why an optional interface rather than a method on Adapter or a registry-side table.** A method on Adapter would force all 31 adapters — including `noop` and Continue, neither of which has a root-keyed MCP object — to implement a method whose contract they cannot honor. A registry-side name→func table is exactly the hand-maintained allowlist this interface exists to delete: it rots the moment an agent is added. The optional interface follows the PluginIngester precedent (name what the implementor DOES) and puts the dialect in the one place that already owns it — the adapter that rendered the bytes being read back.

The signature returns no error: a native value the dialect cannot represent (a number inside a string-typed field such as `args`, say) is DROPPED, on write-back exactly as on Ingest, never stringified and never passed through Extra. Refusing instead would need every implementor and every Ingest to return an error; the residual is documented at the reconcile call site and in the capability matrix, and pinned by characterization tests.

Implemented by every adapter that renders MCP as a key-merge op: claude, opencode, codex, cursor, gemini, windsurf, roo, cline, grok, and the generic breadth tier (per its Spec's MCPTarget). Continue does NOT implement it — it renders one whole FILE per server (`MergeStrategy: "replace"`), and its own IngestMCPSpec operand is an element of a YAML `mcpServers` LIST inside a block, not a root-keyed value. Its MCP write-back reaches the whole-file arm but is REFUSED there: that arm copies the destination verbatim, which for a secret-bearing kind would put YAML into a canonical TOML file and persist resolved secrets in cleartext, so reconcile refuses the kind and points at `agentsync import continue:mcp:<id>`, which captures the edit through Ingest and capture.Capture. The registry-wide guard TestMCPSpecIngester_CoversEveryKeyMergeMCPRenderer (internal/cli) renders a real MCP fixture through every registered adapter and fails if one emits an MCP key-merge FileOp without implementing this interface, so a new MCP-capable adapter cannot ship without its inverse.

type NativeMarketplace

type NativeMarketplace struct {
	ID     string
	Source NativeSource
}

NativeMarketplace is a marketplace registered in an agent's native config, as discovered by `import`. ID is the agent's own marketplace identifier (the "@<marketplace>" half of a plugin reference), which need not match the name declared inside the fetched marketplace.json.

type NativePlugin

type NativePlugin struct {
	Name          untrusted.Text // the "<plugin>" half of a plugin reference
	MarketplaceID string         // the marketplace it was installed from
	Enabled       bool
}

NativePlugin is a plugin recorded in an agent's native config, as discovered by `import`. Enabled is false for an explicitly-disabled entry.

Name is untrusted.Text: a plugin author influences the name the agent persists in its own config, and `status`/`doctor` read it back via IngestPlugins and print it in their "undeclared native plugins" notes, so it must sanitize on display by construction (its String() runs untrusted.Sanitize). Reach the raw bytes via Unverified() only for non-display use — the plugins/<name>.toml stem, a map key, ValidateComponentID. The reflection-based TestUntrustedFieldGuard (internal/adapter) fails the build if a new string field here ships unclassified. See docs/architecture.md §7 and SECURITY.md.

MarketplaceID stays a plain string: unlike Name it is not rendered into the status/doctor layout this hardening targets; its only display sites are `import`'s warn diagnostics, the same plain-string surface as the sibling NativeMarketplace / NativeSource fields (marketplace ids, source types). That whole import-diagnostics surface is a deliberate plain-string subset — promote these together if it is ever hardened.

type NativeSource

type NativeSource struct {
	Type    string // "github" | "git" | "url" | "npm" | "file" | "directory" | …
	Repo    string // "owner/repo" for github
	URL     string // for git/url
	Path    string // for file/directory
	Ref     string // branch/tag/commit
	Package string // for npm
}

NativeSource describes where a natively-registered marketplace is fetched from, in the agent's own vocabulary. `import` maps it onto an agentsync marketplace source. Fields mirror Claude's extraKnownMarketplaces `source` object; only the fields relevant to a given Type are populated.

type OpKind added in v0.15.0

type OpKind int

OpKind says why a FileOp exists — an ordinary render, or a synthesized orphan cleanup — orthogonally to what it does (Action). The zero value is OpRender, an ordinary op an adapter's Render emitted.

const (
	// OpRender is an ordinary rendered op. It is the zero value.
	OpRender OpKind = iota
	// OpCleanup marks an orphan-cleanup op: an ActionWrite of "{}" to a
	// key-merge destination whose only work is pruning OwnedKeys — the merge
	// path performs the removal, so the op writes nothing new. Consumers
	// identify it by this kind, never by that shape. NewCleanupOp is its
	// producer (called from render.orphanCleanupOps when a key-merge section
	// empties in the source, and from `agent disable --purge`).
	OpCleanup
)

func (OpKind) String added in v0.15.0

func (k OpKind) String() string

String is the Stringer form — "render" | "cleanup" | "opkind(<n>)" — kept for symmetry with Action and for %v in test failures; nothing in production prints a kind.

type PassThroughWriter

type PassThroughWriter struct{}

PassThroughWriter is a DestWriter that performs writes via iox.AtomicWrite with no foreign-collision backup. It exists so adapter unit tests can exercise their Apply/applyWrite branches without standing up a full render.Writer + state.Targets — the production render.Writer wraps the same iox primitive but adds the backup decision.

It is exported (not in *_test.go) so tests across the adapter/* tree can share it; production code must NOT use it. The forbidigo lint rule in .golangci.yml blocks any direct iox.AtomicWrite call outside the writer, but this helper is the explicit non-production exception.

func (PassThroughWriter) Delete

func (PassThroughWriter) Delete(op FileOp) error

Delete removes op.Path. Idempotent on missing files.

func (PassThroughWriter) Write

func (PassThroughWriter) Write(op FileOp, finalBytes []byte) error

Write calls iox.AtomicWrite with the supplied final bytes.

type PathKeyMerger added in v0.15.0

type PathKeyMerger interface {
	KeyMergeStrategyForPath(path string) string
}

PathKeyMerger describes adapters whose shared files use different formats. The path is absolute, including for orphan cleanup and agent disable --purge. Implementations must return the same strategy Render stamps on that path.

type PluginIngester

type PluginIngester interface {
	// IngestPlugins resolves scope-dependent paths (it reads the agent's native
	// config, which differs per scope), so — like Render/Ingest — it MUST call
	// RequireProjectRoot first and return ErrProjectRootRequired for a
	// project-scope call with no root, even though import only ever calls it at
	// user scope today (plugins are a user-scope concept).
	IngestPlugins(scope Scope, project string) ([]NativeMarketplace, []NativePlugin, error)
}

PluginIngester is an OPTIONAL extension to Adapter: an agent that tracks installed plugins + marketplaces in its native config implements it so `import` can capture them into the canonical source. `import` type-asserts for it; an adapter that does not implement it simply imports no plugins.

**Read-only by design — asymmetric on purpose.** PluginIngester has no `Render`-side counterpart, and `Adapter.Render` MUST NOT emit plugin- enablement or marketplace-registry metadata back into the native config. The invariant for every adapter (current and future) is:

import   — read enable-state + marketplace sources from native config
apply    — fan out the plugin's COMPONENTS (skills, MCP, commands,
           subagents, hooks, LSP) to the agent's native component paths
           via the normal Render path. Plugin identity dissolves at the
           projection boundary.

Once a plugin's skills land at `~/.claude/skills/<name>/`, its MCP server in `mcpServers`, its commands in `~/.claude/commands/<name>.md`, etc., the consumer agent reads them as regular components — it doesn't need plugin- manager metadata to use them. Writing enable-state back would (1) ping-pong against the user's own `/plugin disable` in the agent's UI on every apply, (2) blur ownership between agentsync (canonical source of truth) and the agent's plugin manager, and (3) double-install with the agent's own plugin install dir.

The PluginIngester interface is kept off the core Adapter because the canonical schema does not otherwise depend on a native plugin concept (OpenCode has no plugins; the Cursor adapter has them but its enable-state location is undocumented, so it implements no PluginIngester yet). See `docs/architecture.md` § "PluginIngester (read-only)" for the full rationale.

type Registry

type Registry struct {
	// contains filtered or unexported fields
}

Registry is an in-memory map of adapter name -> Adapter.

func NewRegistry

func NewRegistry() *Registry

func (*Registry) Lookup

func (r *Registry) Lookup(name string) Adapter

Lookup returns the adapter for name, or nil.

func (*Registry) Names

func (r *Registry) Names() []string

Names returns adapter names in deterministic order (sorted).

func (*Registry) Register

func (r *Registry) Register(a Adapter) error

Register adds a; returns error if name collides.

type Scope

type Scope int

Scope distinguishes user-level vs project-level apply targets.

const (
	ScopeUser Scope = iota
	ScopeProject
)

func (Scope) String

func (s Scope) String() string

type Skip

type Skip struct {
	Component string // "skill" | "subagent" | "command" | etc.
	Name      string
	// Reason explains the skip to a user. It MUST name the offending FIELD,
	// never interpolate that field's VALUE.
	//
	// Render receives the secret-RESOLVED canonical, so a value taken off an
	// MCP/LSP `Command`/`URL`/`Args`/`Env`/`Headers` or a Hook `Command` (the
	// secret-bearing set — secrets.walkSecretFields) is live cleartext at this
	// point. Skip reasons are emitted as METADATA by `agentsync explain` (text
	// and --json) and by the apply translation report, both of which document
	// themselves as never printing destination content — so interpolating one
	// here prints a vault secret in a command the user believes is safe against
	// a locked vault. Component+Name already identify the item; the value adds
	// nothing a user cannot get from `agentsync diff`.
	//
	// Guarded by TestSkipReasonsNeverCarrySecretValues.
	Reason string
	Kind   SkipKind
}

Skip describes a component the adapter could not fully render. Surfaces in the translation report and in `apply --strict`'s exit logic. Kind distinguishes a whole-component drop from a field-level reduction (the component still rendered) and MUST be set explicitly at every construction site.

type SkipKind added in v0.5.0

type SkipKind int

SkipKind classifies how much of a component was lost, so consumers never have to re-derive the distinction from a component-name convention. The adapter that constructs the Skip sets it — it is the only layer that knows whether the whole component was dropped or merely reduced. (It replaces the former "-frontmatter" component-suffix convention, which encoded the same fact in a string only the CLI knew how to parse.)

const (
	// SkipKindUnset is the zero value and is INVALID: every constructed Skip MUST
	// set Kind to one of the classifications below. Leaving it unset is a bug,
	// caught by two complementary guards. TestEverySkipLiteralSetsKind
	// (internal/adapter) statically parses every production adapter.Skip literal
	// under internal/ and fails if one omits Kind — reachability-independent, so a
	// skip site gated on a path that is never empty at runtime cannot hide from it.
	// TestEveryAdapterClassifiesSkips (internal/cli) is the behavioral complement:
	// it renders every registered adapter at both scopes and fails if any emitted
	// skip carries SkipKindUnset, while also pinning that both kind values are
	// exercised. Together they close the "no compile-time guard" gap a silent
	// string suffix left open.
	SkipKindUnset SkipKind = iota
	// SkipDropped — the whole component had no native target on this agent and was
	// not emitted at all (e.g. an LSP server on an agent with no LSP concept, a
	// SessionEnd hook on Codex).
	SkipDropped
	// SkipReduced — the component still rendered, minus fields the target agent has
	// no home for (e.g. a subagent's Claude-only tools/color frontmatter).
	SkipReduced
)

func (SkipKind) MarshalJSON added in v0.5.0

func (k SkipKind) MarshalJSON() ([]byte, error)

MarshalJSON emits the lowercase string form so `explain --json` carries an explicit "kind":"reduced"|"dropped" rather than an opaque integer.

func (SkipKind) String added in v0.5.0

func (k SkipKind) String() string

String renders the kind for human notes and the report's machine surface.

type VersionedDirs added in v0.7.5

type VersionedDirs interface {
	VersionRoots(scope Scope, project string) []string
}

VersionedDirs is an OPTIONAL extension to Adapter: an adapter that writes into one or more on-disk directories declares them so the apply tail can git-init and checkpoint those directories as a local-only rollback history (issue #118). An adapter with no versionable directory (e.g. noop) does not implement it.

VersionRoots is READ-ONLY and user-scope-focused — it reports the directories to back up; it does not widen the Render/Apply contract. The unit of versioning is the DIRECTORY, not the agent: an adapter returns its own config dir PLUS any SHARED cross-agent dir it writes into (e.g. Codex and several breadth agents all write skills to ~/.agents/skills; OpenCode writes skills to ~/.claude/skills). The apply tail unions these across all enabled adapters, drops any root nested under another (no repo inside a repo), and de-duplicates — so a shared dir is versioned exactly ONCE no matter how many adapters target it.

At ScopeProject it MUST return nil: project destinations live inside the user's own project repo, left to that repo's source control. Each returned path is absolute (after AGENTSYNC_TARGET_ROOT redirection), matching FileOp.Path. A deep agent may also write a stray top-level file outside any returned dir (Claude's ~/.claude.json); those are intentionally NOT versioned — agentsync never inits a repo at $HOME. See docs/architecture.md.

type WarnEmitter

type WarnEmitter interface {
	SetStderr(w io.Writer)
}

WarnEmitter is an OPTIONAL extension to Adapter: an adapter that emits Ingest warnings implements it to let callers redirect the stream away from the default (os.Stderr). Implementors are SOURCES of warnings that accept a sink — the name follows the PluginIngester precedent of describing what the implementor does, not the parameter the method takes. Kept off the core Adapter so adapters that emit no warnings aren't forced to implement a setter they'll never use; ui.WarnWriter type-asserts for it, so callers pass any adapter and let the structural match decide.

Implementations MUST:

  • Treat a nil writer as "reset to default" — subsequent warnings go to os.Stderr (or whatever the implementor's pre-SetStderr default was), not to io.Discard or any silent sink. Pinned per-adapter by TestSetStderr_NilResetsToDefault tests that capture os.Stderr and assert the warning lands there.
  • Not panic on a nil writer.

Configuring stderr is meant to happen BEFORE Ingest runs. Today's adapters snapshot the writer at Ingest entry (warn := a.stderr()), so a SetStderr call mid-Ingest is ignored for the remainder of that call. Use RouteTo-before-Ingest (the import pattern); don't depend on dynamic switching during a single Ingest invocation.

Directories

Path Synopsis
Package adaptertest holds test helpers shared by the adapter packages.
Package adaptertest holds test helpers shared by the adapter packages.
Package claude implements the Claude Code adapter for agentsync.
Package claude implements the Claude Code adapter for agentsync.
Package cline implements the Cline adapter for agentsync.
Package cline implements the Cline adapter for agentsync.
Package codex implements the Codex CLI adapter for agentsync.
Package codex implements the Codex CLI adapter for agentsync.
Package continuedev implements the Continue adapter for agentsync.
Package continuedev implements the Continue adapter for agentsync.
Package cursor implements the Cursor adapter for agentsync.
Package cursor implements the Cursor adapter for agentsync.
Package gemini implements the Gemini CLI adapter for agentsync.
Package gemini implements the Gemini CLI adapter for agentsync.
Package generic implements a data-driven "breadth-tier" adapter: one Go implementation that supports many agents from a table of Specs, rather than a hand-written package per agent.
Package generic implements a data-driven "breadth-tier" adapter: one Go implementation that supports many agents from a table of Specs, rather than a hand-written package per agent.
Package grok implements the Grok Build adapter using its native TOML MCP configuration and Markdown/JSON component files.
Package grok implements the Grok Build adapter using its native TOML MCP configuration and Markdown/JSON component files.
Package noop provides a do-nothing Adapter used as a registry/test placeholder: it Renders no FileOps, Ingests an empty canonical, and its Apply is a true no-op (it never writes, even when handed FileOps).
Package noop provides a do-nothing Adapter used as a registry/test placeholder: it Renders no FileOps, Ingests an empty canonical, and its Apply is a true no-op (it never writes, even when handed FileOps).
Package opencode implements the OpenCode adapter for agentsync.
Package opencode implements the OpenCode adapter for agentsync.
Package roo implements the Roo Code adapter for agentsync.
Package roo implements the Roo Code adapter for agentsync.
Package windsurf implements the Windsurf (Cascade) adapter for agentsync.
Package windsurf implements the Windsurf (Cascade) adapter for agentsync.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL