pack

package
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Jul 6, 2026 License: Apache-2.0 Imports: 19 Imported by: 0

Documentation

Overview

Package pack implements sf's pack-authoring contract: a pack is a git repository or local directory holding a pack.yaml manifest plus the artifacts it bundles. A pack can carry all four kinds of thing a project might want to adopt in one shot:

  • plugins sf plugins (see internal/plugin), bundled in a subdirectory of the pack or referenced by a separate git URL.
  • claude Claude Code skills and slash commands.
  • instructions agent instructions (AGENTS.md and friends) for the target project's root.
  • templates arbitrary project-root files/directories, shaped exactly like instructions.

`sf pack install` reads the manifest and lays each artifact onto the shelf it belongs to:

plugins        → $XDG_DATA_HOME/sofia/plugins/<name>/ (plugin.Install /
                 plugin.InstallFromGit — see internal/plugin)
claude          → $CLAUDE_DIR (env override; default ~/.claude):
                 skills/<basename(src)>/, commands/<basename(src)>
instructions,
templates       → the target project's root (default: cwd)

instructions and templates are plain files — no Claude-specific hook is required to benefit from them, so a pack works unmodified in, say, a Codex-driven repo that never touches $CLAUDE_DIR. The claude block is entirely optional.

Manifest

pack.yaml sits at the pack's root (see manifest.go for the exact shape). Parsing is non-strict — unknown top-level keys are ignored, the same forward-compatibility stance as plugin.ParseManifest — so a pack.yaml written for a newer sf still parses on an older one. Every declared path is validated through safeRel: an absolute path, or one that still climbs above its root after filepath.Clean, is rejected outright rather than silently escaping the pack or its target shelf. Manifest paths are always "/"- separated (Windows groundwork); Validate converts them with filepath.FromSlash before any of them touch the filesystem.

Reading a pack's source files follows symlinks the ordinary way (os.ReadFile/filepath.WalkDir don't distinguish them): a symlink inside a pack's source tree can pull content from outside it into the install. This is documented rather than guarded here; a hard rejection can follow if it ever matters in practice.

State: canon + receipt

Two things persist per installed pack, both under $XDG_DATA_HOME/sofia/packs/:

<name>/                 a canonical copy of the pack's own source tree
                        (.git stripped) — what `sf pack list`/`info` read
                        the description from, and what a later reinstall
                        re-derives its plugin/claude side effects from.
.receipts/<name>.json   what Install actually wrote: source provenance,
                        the plugins/claude files it placed globally, and
                        a per-project map of the files it placed there
                        (see receipt.go for the exact shape).

Install: plan, then check, then write

Install resolves the source (shallow git clone or a local directory), parses and validates the manifest, and expands every FileMap into concrete file writes *before* touching disk. It then checks every one of those writes against the receipt and the filesystem: a destination that doesn't exist yet, or already holds exactly the planned content, or holds exactly what the receipt last recorded there, is safe to (re)write. Anything else — a file the pack doesn't own, or one edited since install — is a conflict, and *every* conflict is collected and reported together (with --force as the escape hatch) before a single byte is written. That ordering is the whole point: a partially-applied install (say, plugins landed but a project file conflicted) would be worse than refusing up front.

Only once the plan is clear does Install apply it: plugins first (then exactly one plugin.Update() — never one per plugin, or `sf plugin list` goes stale mid-batch), then claude files, then project files, then the canon copy, then the receipt.

Uninstall

Uninstall is the mirror image, gated the same way: a file whose on-disk sha still matches the receipt is removed (and its now-empty parent directories pruned up to the project root); anything else is left in place with a warning. Once no project references a pack any more, its global footprint (claude files, plugins, canon copy, receipt) is torn down the same way; while other projects still reference it, the globals and the receipt stay.

Index

Constants

View Source
const ManifestSchema = 1

ManifestSchema is the pack.yaml schema version this host understands. Same convention as plugin.ManifestSchema: it only bumps on a breaking change to the manifest shape.

View Source
const ReceiptVersion = 1

ReceiptVersion is the on-disk schema of a pack receipt. Bumped only on a breaking change to the receipt shape.

Variables

This section is empty.

Functions

func ListInstalled

func ListInstalled() ([]string, error)

ListInstalled returns the names of every installed pack (one receipt per pack), sorted. This is what `sf pack list` enumerates.

func NewCommand

func NewCommand() *cobra.Command

NewCommand returns the `sf pack` command group: install/list/inspect/ remove packs — git repos or local directories bundling sf plugins, claude skills/commands, and project instructions/templates. The heavy lifting lives in this package (see pack.go's package doc); the commands are thin wrappers, matching the repo's NewCommand()/RunE→package-function pattern plugin/cmd.go already uses.

func PacksDir

func PacksDir() string

PacksDir is where installed packs' canon copies and receipts live: $XDG_DATA_HOME/sofia/packs, a sibling of plugin.PluginsDir under the same sofia data dir — reusing plugin.DataDir keeps one XDG root for the whole tool instead of inventing a second one.

func RenderInfo

func RenderInfo(w io.Writer, format string, info Info) error

RenderInfo writes one pack's full receipt: source, shelves, and every project it's installed in.

func RenderList

func RenderList(w io.Writer, format string, infos []Info) error

RenderList writes the installed-pack list in the requested format (toon|md|json), following the same --format convention as every other sf tool.

func RenderStatus

func RenderStatus(w io.Writer, format string, st PackStatus) error

RenderStatus writes one pack's drift status: a bare summary line for toon ("ok (14 files)" or "2 modified, 1 missing"), the full counts for md/json.

func RenderStatusAll

func RenderStatusAll(w io.Writer, format string, sts []PackStatus) error

RenderStatusAll writes drift status for every installed pack.

Types

type Claude

type Claude struct {
	Skills   []FileMap `yaml:"skills" json:"skills,omitempty"`
	Commands []FileMap `yaml:"commands" json:"commands,omitempty"`
}

Claude groups the two Claude-specific shelves. Unlike Instructions/ Templates, dest is never settable here: a skill always lands at skills/<basename(src)>/ and a command at commands/<basename(src)> — the shelf's own naming convention decides it, not the manifest author.

type ClaudeFile

type ClaudeFile struct {
	Dest   string `json:"dest"`
	SHA256 string `json:"sha256"`
}

ClaudeFile is one file the pack placed on the Claude shelf. Dest is absolute — the shelf lives outside any project — so Status/Uninstall can stat it directly.

type FileMap

type FileMap struct {
	Src  string `yaml:"src" json:"src"`
	Dest string `yaml:"dest" json:"dest,omitempty"`
}

FileMap is one source→destination mapping: Src is relative to the pack root, Dest relative to the target shelf and defaulting to filepath.Base(Src) when empty. Src may name a single file or a directory, copied recursively.

type Info

type Info struct {
	Receipt     Receipt
	Description string
}

Info gathers everything `sf pack info`/`list` show beyond drift: the receipt plus the pack's own description. Description isn't part of the receipt (see receipt.go) — it's read fresh from the canonical copy's pack.yaml, which is the source of truth for the pack's own metadata.

func LoadInfo

func LoadInfo(name string) (Info, error)

LoadInfo loads a pack's receipt plus its description, for `sf pack info` and `sf pack list`.

type InstallOptions

type InstallOptions struct {
	Src     string // git URL or local directory
	Ref     string // branch or tag; git sources only
	Project string // target project root; "" defaults to cwd
	Force   bool   // skip the conflict check and overwrite
}

InstallOptions configures Install. Project defaults to the current working directory, resolved to an absolute path once at the top of Install so a relative --project is never re-resolved against a later os.Chdir.

type Manifest

type Manifest struct {
	// Schema is the manifest schema version (see ManifestSchema).
	Schema int `yaml:"schema" json:"schema"`
	// Name identifies the pack; see nameRe. It becomes the canon directory
	// name and the receipt file name.
	Name string `yaml:"name" json:"name"`
	// Description is a one-line summary shown by `sf pack list`/`info`.
	Description string `yaml:"description" json:"description,omitempty"`
	// Plugins are sf plugins the pack installs globally; see PluginRef.
	Plugins []PluginRef `yaml:"plugins" json:"plugins,omitempty"`
	// Instructions land at the target project's root (default dest:
	// filepath.Base(Src); directories copy recursively).
	Instructions []FileMap `yaml:"instructions" json:"instructions,omitempty"`
	// Claude groups the two Claude-specific shelves (skills, commands).
	// Entirely optional — a pack with no claude: block never touches
	// $CLAUDE_DIR.
	Claude Claude `yaml:"claude" json:"claude,omitempty"`
	// Templates land at the target project's root, shaped exactly like
	// Instructions.
	Templates []FileMap `yaml:"templates" json:"templates,omitempty"`
}

Manifest is the parsed pack.yaml. Unknown top-level keys are ignored for forward compatibility — the same non-strict decode plugin.ParseManifest uses — so a pack.yaml written for a newer sf still parses on an older one.

func ParseManifest

func ParseManifest(data []byte) (Manifest, error)

ParseManifest decodes a pack.yaml. Unknown keys are ignored for forward compatibility; a syntactically invalid document is an error. Call Validate on the result before trusting it — a syntactically valid manifest can still be schema-incomplete (no name, wrong schema, an ambiguous plugin ref, …).

func (*Manifest) Validate

func (m *Manifest) Validate() error

Validate checks the manifest's invariants and, on success, normalizes every declared path with filepath.FromSlash (manifest paths are always "/"- separated) so every later consumer can filepath.Join them directly without re-converting.

type PackStatus

type PackStatus struct {
	Name     string   `json:"name"`
	Ok       int      `json:"ok"`
	Modified int      `json:"modified"`
	Missing  int      `json:"missing"`
	Projects []string `json:"projects,omitempty"` // project roots this pack is installed in, sorted
}

PackStatus summarizes one installed pack's drift: how many of its recorded files still match what was installed, how many changed, how many vanished. Ok+Modified+Missing always add up to the receipt's total file count (claude files plus every project's files).

func Status

func Status(name string) (PackStatus, error)

Status computes drift for one installed pack. A pack with no receipt is reported as an error — there is no drift to report for something that was never installed.

func StatusAll

func StatusAll() ([]PackStatus, error)

StatusAll computes drift for every installed pack, sorted by name.

type PluginRef

type PluginRef struct {
	Path string `yaml:"path" json:"path,omitempty"`
	Git  string `yaml:"git" json:"git,omitempty"`
	Ref  string `yaml:"ref" json:"ref,omitempty"`
}

PluginRef names one plugin the pack installs globally: Path is a directory inside the pack (installed via plugin.Install), Git is an external repository (via plugin.InstallFromGit) — exactly one of the two is set. Ref is a branch or tag and only makes sense alongside Git (see gitclone.CloneShallow — commit shas aren't supported).

type ProjectFile

type ProjectFile struct {
	Dest   string `json:"dest"`
	SHA256 string `json:"sha256"`
}

ProjectFile is one file the pack placed in a project. Dest is relative to the project root (the owning ProjectInstall's key in Receipt.Projects) and always "/"-separated on disk (Windows groundwork).

type ProjectInstall

type ProjectInstall struct {
	InstalledAt time.Time     `json:"installed_at"`
	Files       []ProjectFile `json:"files"`
}

ProjectInstall is one project's slice of a pack install: when it happened and which files landed there.

type Receipt

type Receipt struct {
	Version     int                       `json:"version"`
	Name        string                    `json:"name"`
	Source      Source                    `json:"source"`
	InstalledAt time.Time                 `json:"installed_at"`
	Plugins     []string                  `json:"plugins,omitempty"`
	Claude      []ClaudeFile              `json:"claude,omitempty"`
	Projects    map[string]ProjectInstall `json:"projects"`
}

Receipt is what Install writes to receiptPath(name) and what Uninstall/Status read back — the one source of truth for "what did this pack actually put on disk", so drift detection never has to re-derive it from the manifest (which may have moved on since).

type Result

type Result struct {
	Name    string
	Source  Source
	Plugins []string
	Files   int // claude + project files written
}

Result summarizes what Install actually wrote, for the CLI to report.

func Install

func Install(opts InstallOptions) (Result, error)

Install resolves opts.Src (a git URL or a local directory), validates its pack.yaml, and lays out every artifact it declares — but only after checking the whole plan for conflicts, so a bad install never applies half of itself. See pack.go's package doc for the full two-phase contract.

type Source

type Source struct {
	URL    string `json:"url,omitempty"`
	Ref    string `json:"ref,omitempty"`
	Commit string `json:"commit,omitempty"`
	Path   string `json:"path,omitempty"`
}

Source records where a pack came from: a git remote (URL/Ref/Commit) or a local directory (Path). Exactly one of URL or Path is set.

type UninstallResult

type UninstallResult struct {
	Warnings []string
	Global   bool
}

UninstallResult reports what Uninstall did beyond the happy path: files it left in place because they'd been edited, and whether the pack's global footprint (claude files, plugins, canon copy, receipt) was torn down too.

func Uninstall

func Uninstall(name, project string) (UninstallResult, error)

Uninstall removes name's footprint from project (project defaults to cwd when empty). Per file the receipt recorded there: a matching sha256 means it's untouched, so it's removed (and its now-empty parent directories pruned up to the project root); a mismatch means it was edited since install, so it's left in place with a warning; already-missing is a silent skip. Once no project references the pack any more, its global footprint is torn down the same way; while other projects still reference it, the globals and the receipt are left as they are.

Jump to

Keyboard shortcuts

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