source

package
v0.9.0 Latest Latest
Warning

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

Go to latest
Published: Jul 1, 2026 License: MIT Imports: 14 Imported by: 0

Documentation

Overview

Package source loads and represents the canonical agentsync repo layout (~/.agentsync/). Structs in this file are TOML-tagged and serve as the canonical model — there is no separate IR layer; adapters consume these types directly.

Package-level write-back helpers for the canonical source (~/.agentsync/). These are called by the reconcile command when the user selects [w]rite-back, and by the import command to capture native edits.

SECRET-SAFETY INVARIANT (read before adding a caller): these Write* helpers take only the TEMPLATED source types (source.Canonical / its sub-structs) and perform NO secret re-referencing. apply substitutes ${secret:…} to cleartext into destinations, so any canonical reconstructed from a destination holds live credentials. The ONLY sanctioned dest->source write path is capture.Capture, which calls secrets.ReReferenceCanonical first. Do NOT pass these helpers a value obtained by unwrapping secrets.Resolved.Canonical() — it is the resolved (cleartext) apply model and would leak the secret into source. This API is intentionally not lint-fenced (it is the legitimate templated writer Capture is built on), so this discipline is the guard; see CLAUDE.md "Secret-handling invariants" and internal/secrets/resolved.go.

v1 trade-off: TOML comments in the original file are not preserved on write-back. Comment-preserving mutation is deferred to v1.x.

Index

Constants

View Source
const (
	GitBackupModePrompt = "prompt" // default: ask on first write to an untracked dir
	GitBackupModeOn     = "on"     // init + checkpoint silently
	GitBackupModeOff    = "off"    // never init/commit/prompt
)

Destination-git-backup modes.

Variables

This section is empty.

Functions

func ExpandMemoryImports

func ExpandMemoryImports(body string, fragments map[string]string) string

ExpandMemoryImports replaces `@import ./fragments/<name>` directives in body with the named fragment's content, RECURSIVELY — a fragment may itself `@import` another. A single non-recursive pass (the previous behavior) left nested directives as literal `@import` lines in the rendered CLAUDE.md / AGENTS.md. Cycles are broken (a fragment already being expanded is left as a literal directive), recursion is depth-bounded, and unknown fragments are left as literal directives so the user notices.

Each inlined fragment is wrapped in boundary markers (see fragmentMarkerToken) so CollapseMemoryMarkers can reverse the expansion. The one exception is a MARKER COLLISION: if the body or any fragment already contains the marker token, markers are omitted entirely (plain expansion) so they can't corrupt the reverse parse — the write-back guard then keeps that memory apply-only.

func MemoryHasFragments

func MemoryHasFragments(home string) bool

MemoryHasFragments reports whether the canonical memory at home is composed of fragment files (memory/fragments/*). Write-back of memory (import / reconcile) is UNSAFE when it is: the destination CLAUDE.md/AGENTS.md is the fully EXPANDED memory, so persisting it back into memory/AGENTS.md would inline every `@import` and strand the fragment files. Ingest cannot de-resolve the expansion (which inlined text came from which fragment is unrecoverable), so the only safe action is to refuse/skip the write-back — callers consult this.

func ParseFrontmatter

func ParseFrontmatter(data []byte) (map[string]any, string, error)

ParseFrontmatter extracts YAML frontmatter and body from a markdown file. If the input doesn't begin with "---\n", returns an empty map and the entire input as body. Returns an error on any YAML decode failure; callers that want to accept the kind of relaxed "key: value-with-colons" frontmatter that Claude Code itself reads should use ParseFrontmatterWithReport instead.

func ParseFrontmatterWithReport

func ParseFrontmatterWithReport(data []byte) (fm map[string]any, body string, lenient bool, err error)

ParseFrontmatterWithReport is the lenient-fallback variant: it first tries strict YAML; on a YAML decode failure it falls back to a line-oriented "key: rest-of-line" parser that accepts bare colons inside values. lenient is true iff the strict parse failed but the lenient one succeeded — callers (typically Ingest paths) can then surface a warning so the user knows their SKILL.md is not strict YAML.

The lenient parser exists because Claude Code itself reads frontmatter that way: a SKILL.md whose description contains a bare "Triggers on: X, Y" parses fine in claude.ai but breaks sigs.k8s.io/yaml. Without this fallback, `import` silently dropped any skill with such a description (the call site swallowed the parse error with `continue`).

func RenderManagedMemory added in v0.4.0

func RenderManagedMemory(body string, fragments map[string]string, destFile string, banner bool) string

RenderManagedMemory expands fragment imports (see ExpandMemoryImports) and, when banner is true, PREPENDS the agentsync managed-file notice naming destFile. The banner is wrapped in reversible markers (see managedMarkerNamespace) so StripManagedBanner removes it on capture — it is therefore never part of the canonical memory source and never compounds across applies. Every adapter renders memory through this one helper so the notice is byte-identical across agents.

Returns plain expansion (no banner) when banner is false, destFile is empty, or the memory already contains the reserved managed marker token (a belt-and- suspenders guard; checkReservedMarkers already rejects such a canonical at load time). Callers guard `body == ""` before calling, so the banner is only ever attached to a non-empty memory file.

func StripManagedBanner added in v0.4.0

func StripManagedBanner(s string) string

StripManagedBanner removes the managed-file banner block(s) RenderManagedMemory injects, returning the memory content without it. It is the inverse half that keeps the banner out of the canonical source: capture (import/reconcile) calls it on a rendered memory file BEFORE CollapseMemoryMarkers, so neither the banner nor the markers reach memory/AGENTS.md. It matches only agentsync's full rendered banner (managedBannerRe), so a user-authored `<!-- agentsync:managed -->` block — even one that opens like the banner — is left untouched and never silently deleted (checkReservedMarkers rejects it loudly instead). It is idempotent and a no-op on content that carries no banner.

func ValidateComponentID

func ValidateComponentID(kind, id string) error

ValidateComponentID rejects a component id/name/event that would not produce a clean file path under its source subdirectory. This is the single dest->source write boundary, reached by `import` and `reconcile` write-back with ids/keys taken from a NATIVE config — which may be foreign, synced, or project-supplied. Without this, an id like "../../../tmp/x" joined into the path is an arbitrary-file-write (on write) / read (on read) primitive. Mirrors the CLI's validateMCPID; here it guards every Write*/Read* so no caller can bypass it.

func WriteCommand

func WriteCommand(home string, cm Command) error

WriteCommand writes commands/<name>.md from cm into home. Overwrites atomically.

func WriteHooks

func WriteHooks(home, event string, hooks []Hook) error

WriteHooks writes hooks/<event>.toml for the given event. Overwrites atomically.

func WriteLSP

func WriteLSP(home string, ls LSPServer) error

WriteLSP writes lsp/<id>.toml from ls into home. Overwrites atomically.

func WriteMCP

func WriteMCP(home, id string, m MCPServer) error

WriteMCP writes mcp/<id>.toml from m into home. Overwrites atomically.

func WriteMarketplace

func WriteMarketplace(home, name string, m Marketplace) error

WriteMarketplace writes marketplaces/<name>.toml from m into home. Overwrites atomically.

func WriteMemory

func WriteMemory(home string, m Memory) error

WriteMemory writes memory/AGENTS.md (m.Body) plus every fragment in m.Fragments (memory/fragments/<name>, traversal-checked) into home. It is the faithful inverse of loadMemory — a reverse-collapse (CollapseMemoryMarkers) populates Fragments, so import/reconcile can restore a fragmented memory.

It still REFUSES the one dangerous shape: a flattened body (no Fragments) over a source that IS fragment-composed. That only happens when a native memory edit could not be reversed (markers absent/disabled), where writing the expanded body would inline every @import and orphan the fragment files; the caller surfaces it rather than flatten silently.

func WritePlugin

func WritePlugin(home, id string, p Plugin) error

WritePlugin writes plugins/<id>.toml from p into home. Overwrites atomically.

func WriteSkill

func WriteSkill(home string, sk Skill) error

WriteSkill writes skills/<name>/SKILL.md plus every bundled file (scripts/, references/, assets/, …) from sk into home. Each file is written atomically with its preserved permission bits; bundled content is written verbatim (never frontmatter-encoded), so binary assets round-trip byte-for-byte.

func WriteSubagent

func WriteSubagent(home string, sa Subagent) error

WriteSubagent writes agents/<name>.md from sa into home. Overwrites atomically.

Types

type Agent

type Agent struct {
	Enabled bool   `toml:"enabled"`
	Scope   string `toml:"scope,omitempty"` // "user" | "project"
}

type Canonical

type Canonical struct {
	Config       Config
	MCPServers   []MCPServer
	Skills       []Skill
	Subagents    []Subagent
	Commands     []Command
	Hooks        []Hook
	LSPServers   []LSPServer
	Plugins      []Plugin
	Marketplaces []Marketplace
	Memory       Memory
	// Project holds the project-only canonical set by project.Merge. It is nil
	// for a user-scope canonical (one loaded directly from ~/.agentsync/ without
	// a project overlay). Scope-aware render paths (apply/status/diff/reconcile
	// --scope project) render from Project instead of the merged canonical so
	// user-scope items are not duplicated into the project directory.
	Project *Canonical
}

Canonical is the in-memory image of a fully-loaded ~/.agentsync/ tree.

func Load

func Load(fs afero.Fs, home string) (Canonical, error)

Load reads a canonical model from <home>. Missing home or missing subdirectories return an empty Canonical (not an error). Malformed files return an error with a path prefix for actionability.

Load is plugin-UNAWARE: it does not project plugin manifests. Callers that need plugin components expanded into the model use marketplace.LoadProjected (which owns the single plugin projector). This keeps source free of any dependency on plugin/marketplace concepts.

type Command

type Command struct {
	Name        string         // filename without .md extension
	Frontmatter map[string]any // YAML frontmatter
	Body        string         // markdown body
}

Command mirrors commands/<name>.md (frontmatter + body). Slash commands in Claude live at ~/.claude/commands/<name>.md.

type Config

type Config struct {
	Agents               map[string]Agent           `toml:"agents"`
	Updates              UpdateDefaults             `toml:"updates"`
	Secrets              SecretsConfig              `toml:"secrets"`
	Memory               MemoryConfig               `toml:"memory"`
	DestinationGitBackup DestinationGitBackupConfig `toml:"destination_directory_git_backup"`
}

Config mirrors agentsync.toml at the root of ~/.agentsync/.

func (Config) MemoryBannerEnabled added in v0.4.0

func (cfg Config) MemoryBannerEnabled() bool

MemoryBannerEnabled reports whether the managed-file banner should be rendered into memory files for this config. It is ON by default; only an explicit `[memory] banner = false` in agentsync.toml disables it.

type DestinationGitBackupConfig added in v0.7.5

type DestinationGitBackupConfig struct {
	Mode        string `toml:"mode,omitempty"`         // "prompt" | "on" | "off"
	AuthorName  string `toml:"author_name,omitempty"`  // commit author override
	AuthorEmail string `toml:"author_email,omitempty"` // commit email override
}

DestinationGitBackupConfig mirrors the [destination_directory_git_backup] table in agentsync.toml. It controls whether `apply` git-versions the rendered destination dirs (~/.claude, ~/.codex, …) so a bad apply is revertible — a LOCAL-ONLY rollback history that is never pushed (issue #118; see the secret- handling note in CLAUDE.md). The table name is deliberately explicit: these repos back up the destination directories, not general git config. An empty Mode means "prompt".

func (DestinationGitBackupConfig) EffectiveMode added in v0.7.5

func (g DestinationGitBackupConfig) EffectiveMode() string

EffectiveMode returns Mode, defaulting to GitBackupModePrompt when unset.

type Hook

type Hook struct {
	Event   string // e.g. "PreToolUse"
	Matcher string // glob/regex string
	Type    string // "command"
	Command string // shell command
}

Hook represents a single hook entry for an event.

type LSPServer

type LSPServer struct {
	ID   string
	Spec LSPServerSpec
}

LSPServer mirrors lsp/<id>.toml.

func ReadLSP

func ReadLSP(home, id string) (ls LSPServer, ok bool, err error)

ReadLSP reads lsp/<id>.toml from home. ok is false when the file does not exist. Mirrors ReadMCP: used by capture to preserve source-only LSP fields (agents/enabled) that the rendered destination spec doesn't carry.

type LSPServerSpec

type LSPServerSpec struct {
	Command string            `toml:"command,omitempty"`
	Args    []string          `toml:"args,omitempty"`
	Env     map[string]string `toml:"env,omitempty"`
	URL     string            `toml:"url,omitempty"`
	Headers map[string]string `toml:"headers,omitempty"`
	// Agents / Enabled mirror MCPServerSpec: they are source-only targeting
	// fields that the rendered destination never carries, so capture must
	// preserve them (via source.ReadLSP) rather than reset them from the dest.
	Agents  []string `toml:"agents,omitempty"`  // ["*"] or ["claude",...]; empty = all
	Enabled *bool    `toml:"enabled,omitempty"` // nil means default-on
	// Extra carries native LSP-server fields agentsync does not model, captured
	// on ingest and rendered back verbatim. Passthrough only — see the note on
	// MCPServerSpec.Extra (never secret-resolved or walked; the capture leak
	// backstop scans it).
	Extra map[string]any `toml:"extra,omitempty"`
}

LSPServerSpec holds the server configuration for an LSP server.

type MCPServer

type MCPServer struct {
	ID     string        `toml:"-"` // filename minus .toml
	Server MCPServerSpec `toml:"server"`
}

MCPServer mirrors mcp/<id>.toml.

func ReadMCP

func ReadMCP(home, id string) (m MCPServer, ok bool, err error)

ReadMCP reads mcp/<id>.toml from home. ok is false when the file does not exist. Used by reconcile write-back to preserve source-only fields (agents/enabled) that the rendered destination spec doesn't carry.

type MCPServerSpec

type MCPServerSpec struct {
	Type    string            `toml:"type"` // stdio | http | sse
	Command string            `toml:"command,omitempty"`
	Args    []string          `toml:"args,omitempty"`
	URL     string            `toml:"url,omitempty"`
	Headers map[string]string `toml:"headers,omitempty"`
	Env     map[string]string `toml:"env,omitempty"`
	Agents  []string          `toml:"agents,omitempty"`  // ["*"] or ["claude","opencode"]
	Enabled *bool             `toml:"enabled,omitempty"` // nil means default-on
	// Extra carries native MCP-server fields agentsync does not model (e.g.
	// timeout, disabled, cwd), captured on ingest and rendered back verbatim so
	// import/reconcile/apply are not lossy for them. It is PASSTHROUGH ONLY:
	// values are never secret-resolved (a ${secret:…} here is written literally,
	// not substituted) and never visited by walkSecretFields — so it stays out of
	// the secret machinery. The capture leak backstop (secrets.ResidualSecretCleartext)
	// scans it instead, refusing a write that would persist a live secret value.
	Extra map[string]any `toml:"extra,omitempty"`
}

type Marketplace

type Marketplace struct {
	// Name is the declared marketplace name (from marketplace.json, falling back
	// to a URL-derived slug) — untrusted.Text so a print site sanitizes it by
	// construction; use Unverified() for path/lookup use.
	Name        untrusted.Text  `toml:"-"`
	Marketplace MarketplaceSpec `toml:"marketplace"`
}

Marketplace mirrors marketplaces/<name>.toml.

type MarketplaceSpec

type MarketplaceSpec struct {
	URL               string `toml:"url"`
	Ref               string `toml:"ref,omitempty"`
	DefaultUpdateMode string `toml:"default_update_mode,omitempty"`
}

type Memory

type Memory struct {
	Body      string            // raw AGENTS.md (with @import directives); expansion happens at render via ExpandMemoryImports
	Fragments map[string]string // fragment body, keyed by slash path under memory/fragments/ (loaded recursively)
}

Memory mirrors memory/AGENTS.md and memory/fragments/.

func CollapseMemoryMarkers

func CollapseMemoryMarkers(dest string) (Memory, bool, error)

CollapseMemoryMarkers reverses ExpandMemoryImports' marker emission: it parses a rendered memory file back into memory/AGENTS.md (with `@import` directives restored where each fragment block was) plus the fragment files. It is how import/reconcile capture a native memory edit back into the fragment structure instead of flattening it.

Returns (mem, true, nil) when balanced markers were found and collapsed; (zero, false, nil) when the input carries no markers (caller takes the plain path); (zero, true, err) when markers are present but malformed, unbalanced, reference a traversing path, or the same fragment appears twice with differing content — the caller must then refuse rather than guess.

type MemoryConfig added in v0.4.0

type MemoryConfig struct {
	// Banner controls the agentsync "managed file" notice prepended to every
	// rendered memory file (CLAUDE.md, AGENTS.md, …) — see RenderManagedMemory.
	// It is ON by default; set `banner = false` to opt out. The field is a *bool
	// so "unset" (nil → default-on) is distinguishable from "explicitly false",
	// which the project-overlay merge needs to tell an explicit project override
	// from an inherited default.
	Banner *bool `toml:"banner,omitempty"`
}

MemoryConfig mirrors the [memory] table in agentsync.toml.

type Plugin

type Plugin struct {
	// ID is the plugin's filesystem id (the plugins/<id>.toml stem), originating
	// from a marketplace install — untrusted.Text so a print site sanitizes it by
	// construction; use Unverified() for path/lookup use.
	ID     untrusted.Text `toml:"-"`
	Plugin PluginSpec     `toml:"plugin"`
}

Plugin mirrors plugins/<id>.toml.

NOTE: a per-agent `[plugin.overrides.<agent>]` table is NOT wired in v1 — the projector does not consult it. It was previously a struct field that never parsed and was read nowhere, so it has been removed rather than left as a silent no-op. (Per-agent fan-out is still controllable via a component's `agents` allowlist.)

type PluginSpec

type PluginSpec struct {
	// ID is "<name>@<marketplace>" and Version comes from the fetched manifest;
	// both are untrusted.Text (see Plugin.ID). ManifestSHA/Update are
	// agentsync-controlled and stay plain strings.
	ID          untrusted.Text `toml:"id"`
	Version     untrusted.Text `toml:"version,omitempty"`
	ManifestSHA string         `toml:"manifest_sha,omitempty"`
	Update      string         `toml:"update,omitempty"` // pinned | track | manual
	Agents      []string       `toml:"agents,omitempty"`
	// Disabled, when true, suppresses the plugin's projection during
	// marketplace.LoadProjected. `agentsync plugin disable <id>` sets this.
	// Without honouring it there, the CLI's TOML write would be a no-op:
	// projection would still surface the plugin's MCP servers / skills / etc.
	// into the canonical model and apply would ship them.
	Disabled bool `toml:"disabled,omitempty"`
}

type SecretsConfig

type SecretsConfig struct {
	Backend      string `toml:"backend"` // "env" | "age"
	File         string `toml:"file"`
	Recipient    string `toml:"recipient"`
	IdentityFile string `toml:"identity_file"`
}

type Skill

type Skill struct {
	Name        string         `toml:"-"` // dirname
	Frontmatter map[string]any `toml:"-"` // YAML frontmatter parsed
	Body        string         `toml:"-"` // markdown body
	Files       []SkillFile    `toml:"-"` // bundled files other than SKILL.md
}

Skill mirrors a skill directory skills/<name>/. Per the Agent Skills spec a skill is a DIRECTORY whose only required member is SKILL.md (frontmatter + body); it may also bundle scripts/, references/, assets/, and arbitrary nested files. Files captures everything in the directory other than SKILL.md so apply/import/reconcile are not lossy for those resources.

type SkillFile

type SkillFile struct {
	Path    string `toml:"-"`
	Content []byte `toml:"-"`
	Mode    uint32 `toml:"-"`
}

SkillFile is one bundled resource inside a skill directory (e.g. scripts/extract.py, references/REFERENCE.md, assets/logo.png). Content is captured verbatim — never secret-substituted, never frontmatter-parsed — so binary assets round-trip byte-for-byte. Path is relative to the skill directory and slash-separated; Mode preserves the file's permission bits so executable scripts keep their +x bit.

func ReadSkillFiles

func ReadSkillFiles(fs afero.Fs, skillDir string) ([]SkillFile, error)

ReadSkillFiles walks skillDir and returns every regular file other than SKILL.md as a SkillFile, with a slash-separated path relative to skillDir and the file's permission bits preserved. It is the single bundled-file capture implementation shared by the canonical loader and every adapter's Ingest, so the "a skill is a directory, not just SKILL.md" rule cannot drift between them. Non-regular files (symlinks, devices) are skipped — only real bundled resources are captured. Results are sorted by path for deterministic ordering.

type Subagent

type Subagent struct {
	Name        string         // filename without .md extension
	Frontmatter map[string]any // YAML frontmatter (description, tools, model, color, etc.)
	Body        string         // markdown body
}

Subagent mirrors agents/<name>.md (frontmatter + body). Subagents in Claude live at ~/.claude/agents/<name>.md.

type UpdateDefaults

type UpdateDefaults struct {
	DefaultMode     string `toml:"default_mode"`     // pinned | track | manual
	DefaultInterval string `toml:"default_interval"` // e.g. "24h"
}

Jump to

Keyboard shortcuts

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