render

package
v0.7.2 Latest Latest
Warning

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

Go to latest
Published: Jun 20, 2026 License: MIT Imports: 20 Imported by: 0

Documentation

Overview

Package render orchestrates the apply pipeline: canonical model + adapter registry -> per-agent FileOps + Skips. apply flag controls whether ops are written to disk or returned for inspection (e.g. --dry-run).

Package render — translation report.

TranslationReport summarises, per plugin (or source component), how many items each adapter rendered vs skipped. Emitted at the end of apply (and rendered per-plugin by explain); verify does not print it.

Index

Constants

View Source
const DefaultBackupKeep = 20

DefaultBackupKeep is how many backup-timestamp dirs apply retains.

Variables

This section is empty.

Functions

func BackupFile

func BackupFile(home, path string) (string, error)

BackupFile copies the file at path into <home>/.state/backups/<ts>/ verbatim (0600) and returns the backup path. It is the standalone analog of the apply Writer's per-collision backup, for callers (reconcile's interactive orphan delete) that must preserve a file before a destructive action so no choice loses content. Returns ("", nil) when path is missing.

func CollectPointers

func CollectPointers(m map[string]any, prefix string) []string

CollectPointers walks m and returns JSON pointers for every leaf-or-object at the second level (e.g. /mcpServers/github → stop). agentsync owns at the second-level granularity; deeper edits fall under that key's value hash. The prefix argument is used for recursive calls; callers should pass "".

func IsKeyMerge

func IsKeyMerge(strategy string) bool

IsKeyMerge reports whether a MergeStrategy accumulates pointers into a shared file (rather than replacing the whole file). Such ops must never be deduped by path — one agent emits several of them to the same destination. The rendered op.Content is always JSON regardless of the on-disk format (TOML for merge-toml-keys); only the destination file is decoded per strategy via decodeDestObject.

func OrphanFiles

func OrphanFiles(s *state.Targets, userHome, agent string, scope adapter.Scope, project string, ops []adapter.FileOp) []string

OrphanFiles returns the absolute dest paths that agent+scope+project still OWNS in state as whole-file (replace-strategy) entries but the current plan's ops no longer render — i.e. the source component was removed since the last apply. The same detection PruneStaleState uses, surfaced so diagnostics (status/diff/reconcile) can report a dest the next apply would prune instead of falsely reporting "clean". Returns absolute paths, sorted.

func PruneBackups

func PruneBackups(home string, keep int) error

PruneBackups removes all but the most recent `keep` timestamp directories under <home>/.state/backups. Each backup is a verbatim copy of a pre-existing native config file (which may contain secrets), so they must not accumulate unbounded — a disk-bloat and credential-lingering concern. The dir names are zero-padded, fixed-width timestamps, so lexical sort is chronological. Best-effort: a removal error for one dir doesn't abort.

func PruneStaleState

func PruneStaleState(s *state.Targets, userHome, agent string, scope adapter.Scope, project string, ops []adapter.FileOp)

PruneStaleState removes Files / Keys entries owned by agent+scope+project whose path or pointer is no longer produced by the current set of ops. This must be called BEFORE RecordOpsState so the freshly-applied entries don't get pruned by their own absence in the previous run's state.

Without this, removing an MCP server / skill / hook from ~/.agentsync/ leaves its state entry behind forever; it shows up as `Orphan` in `status` and `targets.json` grows unbounded over time.

The userHome argument (the user's $HOME, paths.HomeDir) is used to normalize op.Path to its HOME-relative form so state-key lookups match keys written by RecordOpsState. It must NOT be the agentsync home — dest files live under $HOME, not under ~/.agentsync.

func RecordOpsState

func RecordOpsState(s *state.Targets, userHome, agent string, scope adapter.Scope, project string, ops []adapter.FileOp) error

RecordOpsState updates s with hashes for files and keys produced by ops. Caller is expected to call this AFTER a successful Apply.

Both op.Path and project are normalized to HOME-relative form via paths.HomeRelative (against userHome, the user's $HOME) before being embedded in the state-map keys, so the resulting targets.json is portable across machines whose $HOME differs (e.g. /Users/alice/ vs /home/alice/ after a chezmoi sync). userHome must NOT be the agentsync home: dest files live under $HOME, not under ~/.agentsync, so using the agentsync home as the base leaves every key machine-absolute and unportable.

Types

type AgentResult

type AgentResult struct {
	Ops   []adapter.FileOp
	Skips []adapter.Skip
}

type CollisionReport

type CollisionReport struct {
	Agent    string
	Path     string
	Pointer  string // empty for whole-file collisions
	BackupTo string // absolute path of the backup that was written
}

CollisionReport describes one foreign-collision the writer detected and backed up. Callers can surface these to the user.

func Apply

func Apply(
	p RenderPlan,
	reg *adapter.Registry,
	st *state.Targets,
	home string,
	userHome string,
	scope adapter.Scope,
	project string,
) ([]CollisionReport, map[string]bool, map[string]bool, error)

Apply commits a RenderPlan by constructing one Writer per agent and invoking adapter.Apply with that writer. The writer enforces the foreign-collision backup invariant on every destination write — there is no separate "guard" pass; the guarantee is intrinsic to the only write path adapters are permitted to use.

Returns the union of CollisionReports across all agents so the caller can surface them, plus the set of destination paths actually written this run (so the apply-error rescue can record state for exactly those files and no others). If any adapter returns an error, applies completed so far are NOT rolled back (each underlying iox.AtomicWrite is atomic per-file, but the plan as a whole is not transactional); the reports and written-set returned reflect the work done before the failure.

Deduplication: when two adapters emit a "write" op for the same path (e.g. a shared skill file written by both claude and opencode), the first one wins and the second is silently skipped. Content is deterministic per path, so skipping a duplicate is always safe.

func PreviewApply

func PreviewApply(
	p RenderPlan,
	reg *adapter.Registry,
	st *state.Targets,
	home string,
	userHome string,
	scope adapter.Scope,
	project string,
) (reports []CollisionReport, unchanged, wouldChange map[string]bool, err error)

PreviewApply runs the apply pipeline through non-writing preview writers and reports what a real apply would do, without touching disk. It returns the foreign-collision reports a real apply would produce, plus the per-destination convergence verdict: `unchanged` paths already hold exactly the bytes apply would write (the dry-run shows them as "synced"), and `wouldChange` paths would be created or modified (shown as "write"). Used by `apply --dry-run`.

It supersedes the older op.Content-only collision preview: because it runs the real adapter Apply, each merge is performed for real (against the on-disk destination), so the preview's synced/write verdict and collision set match the eventual apply exactly rather than approximating it.

func (CollisionReport) String

func (r CollisionReport) String() string

String formats a CollisionReport for human output.

type PluginRow

type PluginRow struct {
	// Plugin is the plugin id from fetched marketplace metadata (untrusted), so
	// it is untrusted.Text: it sanitizes itself when printed to the terminal and
	// marshals RAW to JSON (the --json machine contract owns its own escaping).
	Plugin untrusted.Text `json:"plugin"`
	Agent  string         `json:"agent"`
	// Coverage is "full", "partial", or "none".
	Coverage string `json:"coverage"`
	// MCP, Commands, Skills, Subagents, Hooks, and LSP count the components the
	// plugin (or whole model) hosts that target this agent — the inventory, so
	// the report is descriptive of everything the plugin ships, not just MCP +
	// commands. MCP and LSP honour each server's `enabled`/`agents` targeting
	// (a server scoped to other agents is not counted here); the markdown
	// component kinds (commands/skills/subagents) and hooks have no per-agent
	// allowlist, so their counts are the model's totals. These describe what is
	// hosted, NOT what successfully rendered: a hosted component the adapter
	// cannot translate is still counted here and reported under Skips below.
	MCP       int `json:"mcp"`
	Commands  int `json:"commands"`
	Skills    int `json:"skills"`
	Subagents int `json:"subagents"`
	Hooks     int `json:"hooks"`
	LSP       int `json:"lsp"`
	// Skips is the number of components the adapter explicitly skipped.
	Skips int `json:"skips"`
	// SkipDetails enumerates the components behind Skips — what the adapter
	// could not translate, and why — so a "(N skipped)" tally is never a dead
	// end. Attribution follows the canonical+plan passed to BuildReport (see its
	// doc): when the caller passes the whole flattened model (apply), the
	// skips are the agent's across every component and are repeated under each
	// plugin row; when the caller passes a per-plugin-scoped model (`explain
	// <id>`, which re-projects one plugin in isolation), they are narrowed to
	// that plugin's own components.
	SkipDetails []SkipDetail `json:"skipDetails,omitempty"`
	// Disabled is true when the plugin is disabled for this scope (e.g. by a
	// project marker's [plugins] disabled). Its components are not rendered.
	Disabled bool `json:"disabled,omitempty"`
}

PluginRow is one row in the translation report — one plugin × one agent.

type RenderPlan

type RenderPlan struct {
	PerAgent map[string]AgentResult
}

RenderPlan holds the result of rendering a canonical model through every selected adapter. PerAgent[name] is the per-agent breakdown.

func Plan

func Plan(r secrets.Resolved, reg *adapter.Registry, agents []string, scope adapter.Scope, project string, s *state.Targets, userHome string) (RenderPlan, error)

Plan asks each adapter named in agents to render the canonical model. Returns a RenderPlan, never writes anything. Use Apply() to commit.

s may be nil; when non-nil, OwnedKeys is populated on merge-json-keys ops from state.Keys so the apply pipeline knows which JSON-pointer paths it owns.

userHome (the user's $HOME, paths.HomeDir) is required so OwnedKeys lookups match the HOME-relative form state stores. It is NOT the agentsync home — dest files live under $HOME, not under ~/.agentsync.

func (RenderPlan) Total

func (p RenderPlan) Total() int

Total returns the total number of FileOps across all agents.

type SkipDetail added in v0.5.0

type SkipDetail struct {
	Component string `json:"component"`
	// Name is the skipped component's name, which for a plugin component derives
	// from fetched marketplace metadata (untrusted) — untrusted.Text so a display
	// site sanitizes it by construction. Reason is adapter-authored (trusted).
	Name   untrusted.Text `json:"name,omitempty"`
	Reason string         `json:"reason"`
	// Kind classifies the loss as "reduced" (the component still rendered, minus
	// some fields) or "dropped" (the whole component was not emitted). It is set by
	// the adapter and carried verbatim from adapter.Skip; explain reads it directly
	// instead of re-deriving the split from the component name, and --json surfaces
	// it as an explicit "kind" field (adapter.SkipKind marshals to the string form).
	Kind adapter.SkipKind `json:"kind"`
}

SkipDetail names one component an adapter could not translate, with the human reason it was skipped. It mirrors adapter.Skip but carries JSON tags for the report's machine-readable surface.

type TranslationReport

type TranslationReport struct {
	Rows []PluginRow `json:"rows"`
}

TranslationReport holds all plugin×agent rows.

func BuildReport

func BuildReport(c source.Canonical, plan RenderPlan, agents []string) TranslationReport

BuildReport constructs a TranslationReport from the canonical model and a RenderPlan: one row per plugin (c.Plugins) × agent. Each row carries the per-agent component inventory (countMCPServers / countLSPServers honour enabled+agent targeting; commands/skills/subagents/hooks are model totals) and the skips from plan.PerAgent[agent].Skips.

coverage = full when skips==0; otherwise partial when the adapter still rendered something for the agent (plan ops non-empty), none when every hosted component was skipped. The rendered signal is the plan's ops, not the inventory counts — a hosted-but-unsupported component (e.g. an LSP server on an agent with no LSP concept) is counted in the inventory yet renders nothing, so its row is "none". A disabled plugin yields a single disabled-marker row. With no plugins installed, one "(base)" row per agent summarizes the whole canonical.

BuildReport does NOT itself correlate a component to its origin plugin — the projected canonical is flattened with no origin tag. Attribution is therefore the caller's choice of what model+plan to pass:

  • apply passes the whole flattened model, so every plugin row carries the same global counts/skips (the documented summary behavior).
  • `explain <id>` re-projects ONE plugin in isolation (marketplace.ProjectInstalled) and passes a model+plan holding only that plugin's components, so its row reflects that plugin alone.

func (TranslationReport) PrintJSON

func (r TranslationReport) PrintJSON(w io.Writer) error

PrintJSON writes the structured JSON form.

func (TranslationReport) PrintText

func (r TranslationReport) PrintText(w io.Writer)

PrintText writes the human-readable report to w in its plain form.

plugin: demo@test-mp
  claude    ✓ full   (1 mcp, 0 commands)
  opencode  ✓ full   (1 mcp, 0 commands)

Use PrintTextStyled to render the same report with semantic color via a *ui.Printer. Plain output is byte-stable so existing test fixtures hold.

func (TranslationReport) PrintTextStyled

func (r TranslationReport) PrintTextStyled(w io.Writer, p *ui.Printer)

PrintTextStyled writes the report with the same layout as PrintText, but styled via p: bold "plugin:" labels, semantically colored coverage marks (green=full, yellow=partial, red=none), and faint trailing counts. When p has color disabled (non-TTY, NO_COLOR, --color=never), the output is visually identical to PrintText — only ANSI is suppressed.

type Writer

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

Writer is THE funnel for native-destination writes. Every adapter's Apply method receives one of these and routes its writes through it (rather than calling iox.AtomicWrite directly), so the foreign-collision backup invariant is enforced at the only place it matters: the moment of the write.

Pre-write, Writer checks the (agent, scope, project, path) tuple against state. If state does not yet record this destination as owned AND the file already exists with content that differs from what we are about to write, the existing content is copied to <home>/.state/backups/<ts>/<original-path> before the new write proceeds. For merge ops, the same check runs at JSON-pointer granularity using op.Content (ours pre-merge) and op.OwnedKeys.

The forbidigo lint rule in .golangci.yml fails any direct iox.AtomicWrite call outside the allowed packages, so a future contributor cannot silently bypass this guard.

func NewPreviewWriter

func NewPreviewWriter(st *state.Targets, home, userHome string, scope adapter.Scope, project, agent string) *Writer

NewPreviewWriter constructs a Writer that records foreign-collision reports without performing any disk writes. Used by `apply --dry-run` to surface the same backup-and-overwrite events a real apply would produce.

func NewWriter

func NewWriter(st *state.Targets, home, userHome string, scope adapter.Scope, project, agent string) *Writer

NewWriter constructs a Writer for one (agent, scope, project) tuple. The render layer creates one writer per agent during Apply. home is the agentsync home (backup root); userHome is the user's $HOME (state-key normalization base).

func (*Writer) Delete

func (w *Writer) Delete(op adapter.FileOp) error

Delete satisfies adapter.DestWriter. Idempotent on missing files.

Skill-orphan deletes (op.SourceID under "skills/", synthesized by Apply when a skill or bundled file is removed from source) get two extra guarantees: a dest that drifted from what agentsync last wrote is backed up before removal (the never-destroy-unsynced-content invariant the write path enforces), and empty skill directories left behind are pruned up to — but never including — the agent's skills root. Other delete callers (agent disable --purge, reconcile orphan removal) pass an empty SourceID and keep the plain idempotent remove.

func (*Writer) Reports

func (w *Writer) Reports() []CollisionReport

Reports returns the per-write collision reports accumulated so far. Safe to call after Apply completes.

func (*Writer) Unchanged

func (w *Writer) Unchanged() map[string]bool

Unchanged returns the set of destination paths that already held exactly the bytes apply would write, so the atomic write (and its mtime churn) was skipped. apply uses it to report "up to date" instead of "applied: N ops".

func (*Writer) WouldChange

func (w *Writer) WouldChange() map[string]bool

WouldChange returns the set of destination paths a dry-run write would have created or modified (the complement of Unchanged for the preview). It is only populated when the writer is in dry-run mode; on a real apply it is empty because the write is actually performed. `apply --dry-run` uses it to label each planned destination "write" vs "synced".

func (*Writer) Write

func (w *Writer) Write(op adapter.FileOp, finalBytes []byte) error

Write satisfies adapter.DestWriter. finalBytes is the post-merge content for merge ops, or op.Content for replace ops.

func (*Writer) Wrote

func (w *Writer) Wrote() map[string]bool

Wrote returns the set of destination paths this writer actually wrote. Used by the apply-error rescue to record state ONLY for files agentsync committed this run — a pre-existing foreign file at an op that was never attempted must not be recorded as owned (that would suppress its backup).

Jump to

Keyboard shortcuts

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