source

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Jun 6, 2026 License: MIT Imports: 13 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

This section is empty.

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 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"`
}

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

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        string          `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            // resolved AGENTS.md after @import expansion
	Fragments map[string]string // 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 Plugin

type Plugin struct {
	ID     string     `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          string   `toml:"id"`
	Version     string   `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