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
- func ListInstalled() ([]string, error)
- func NewCommand() *cobra.Command
- func PacksDir() string
- func RenderInfo(w io.Writer, format string, info Info) error
- func RenderList(w io.Writer, format string, infos []Info) error
- func RenderStatus(w io.Writer, format string, st PackStatus) error
- func RenderStatusAll(w io.Writer, format string, sts []PackStatus) error
- type Claude
- type ClaudeFile
- type FileMap
- type Info
- type InstallOptions
- type Manifest
- type PackStatus
- type PluginRef
- type ProjectFile
- type ProjectInstall
- type Receipt
- type Result
- type Source
- type UninstallResult
Constants ¶
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.
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 ¶
ListInstalled returns the names of every installed pack (one receipt per pack), sorted. This is what `sf pack list` enumerates.
func NewCommand ¶
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 ¶
RenderInfo writes one pack's full receipt: source, shelves, and every project it's installed in.
func RenderList ¶
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 ¶
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 ¶
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.
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 ¶
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, …).
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 ¶
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 ¶
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.