Skip to the content.

tuck CLI – Implementation Specification

Status: authoritative for the command-line interface. Scope: command surface, flags, configuration, output (human + JSON), error reporting, plan rendering, and internal resolution algorithms.

This document is the source of truth for the CLI and its internal resolution algorithms. It defines command semantics, source selection, path resolution, planning, output envelopes, and error classification.

tuck is a Go dotfiles manager. It maps package directories onto real target directories, deploys only leaf entries, never folds directories, and never lets the caller’s working directory affect correctness except when resolving an explicitly relative input path.


1. Concepts


2. Command surface

Command depth mirrors operation frequency:

tuck [global-flags] <command> [command-args] [command-flags]

Synopsis:

# File operations (top-level)
tuck adopt  <package-ref> <file>
tuck eject  <file>
tuck status <file>

# Package operations
tuck package use     <package-ref>...
tuck package use     --all
tuck package drop    <package-ref>...
tuck package refresh <package-ref>...
tuck package list
tuck package show    <package-ref>
tuck package status  [package-ref]

# Source operations
tuck source add     <path> [--id <id>] [--default]
tuck source add     <path> --init [--id <id>] [--name <id>] [--description <text>] [--default]
tuck source init    <path> [--name <id>] [--description <text>]
tuck source rm      <id>
tuck source list
tuck source default <id>

Aliases:

Canonical Alias Scope
package pkg command group
package list package ls, pkg ls package group
package show package tree, pkg tree package group
source list source ls source group

Semantics:

All mutating commands plan by default and mutate only with --apply.

Set Commands Moves real files? Default execution
File movement adopt, eject Yes dry-run; needs --apply
Package link management package use, package drop, package refresh No dry-run; needs --apply
Read-only status, package list, package show, package status, source list No n/a
Source management source add, source rm, source default No target-tree writes immediate machine-state write

3. Invocation model

3.1 Context selection

There are exactly two target contexts. home is the unconditional default; the boolean --root flag selects root:

--root is a global selector flag so it may appear before command groups or leaf commands. Commands that operate on target paths or packages use it: adopt, eject, status, and all package subcommands. source commands ignore it with an ignored_flag warning because they manage the source registry rather than selecting a target context.

3.2 Source selection

Every command that operates on packages or target paths uses exactly one active source. The active source is resolved in this order:

  1. --source <id> (-s) on the command line.
  2. The machine-local default source (tuck source default <id> or tuck source add <path> --default).
  3. The sole enabled source, when exactly one source is enabled.
  4. Otherwise, error no_source.

--source accepts an enabled source id only for commands that resolve an active source. It is a global selector flag so it may appear before command groups or leaf commands. adopt, eject, status, and all package subcommands use it. source commands ignore it with an ignored_flag warning because they manage the source registry rather than selecting an active source.

There is no simultaneous multi-source operation. The listing commands operate only on the active source. eject and status <file> infer ownership in the active source only. A managed symlink that points into a non-active source reads as unmanaged unless that source is made active.


4. Flags

Some flags are globally parseable because they are common execution selectors, even when not every command uses them. Commands that do not use a selector must warn before ignoring it; mutation flags remain command-local.

Flag Alias Scope Meaning
--json   universal Emit one JSON document instead of human output.
--no-color   universal Disable colored console output.
--help -h universal Print help for the program or command.
--version -v root only Print version.
--source <id> -s global selector; used by file and package commands Select the active source by enabled id.
--root   global selector; used by file and package commands Select the root context.
--apply   mutating target-tree commands Execute the plan. Without it, print the plan only.
--default   source add Make the added source the machine-local default.
--init   source add Create a missing .tuck.toml before registering the source.
--id <id>   source add Machine-local source id override. Does not rewrite .tuck.toml.
--name <id>   source init, source add --init Manifest source id to write. Defaults to the path basename.
--description <text>   source init, source add --init Optional manifest description to write.
--all   package use Use every package in the active source/context.

Domain commands are adopt, eject, status, and all package subcommands. Mutating target-tree commands are adopt, eject, package use, package drop, and package refresh.

Flag interaction rules:


5. Configuration

tuck has no central config file. Configuration is split between two artifacts:

There is no --config flag and no $TUCK_CONFIG. There is no config setting for the default context; home is always the default unless --root is passed.

5.1 Bootstrap

git clone <repo> ~/.dotfiles
tuck source add ~/.dotfiles --default
tuck pkg use zsh git --apply

.tuck.toml at the source root is a control file, not a package directory, so it is ignored by package enumeration. No compatibility fallback is provided for the old tuck.toml name; .tuck.toml is the only recognized manifest path.

5.2 Source manifest

Committed in the repo at <repo>/.tuck.toml:

name        = "public"
description = "public dotfiles"

Fields:

Source manifests contain source identity, not package/file deployment policy. Package-owned policy lives in package manifests so a package can be copied between sources while carrying its deployment requirements with it.

The source-manifest format is open to additive keys. Unknown top-level keys are ignored so that newer repos remain readable by older binaries. A missing or unreadable .tuck.toml is manifest_missing; a malformed manifest or invalid/missing name is manifest_invalid.

5.3 Package manifest

Package manifests are optional. When present, they are committed at the package root:

<source>/<package>/.tuck.toml
<source>/.root/<package>/.tuck.toml

Example:

[[file]]
path = ".config/symlink-hostile-app/config"
deploy = "copy"
mode = "0600"

Fields:

Duplicate [[file]] entries for the same path, malformed TOML, invalid paths, unknown deploy values, and invalid modes are manifest_invalid. Unknown keys are ignored for forward compatibility.

Package-local .tuck.toml is control metadata. It is excluded from package entry enumeration, package show output, package use/drop/refresh plans, adopt destinations, and eject pruning decisions.

5.4 Machine-local state

Generated and updated by tuck source commands; not committed. Location:

${XDG_STATE_HOME:-~/.local/state}/tuck/sources.toml

For tests, the state directory may be overridden by TUCK_TEST_STATE_DIR, which is compiled in only under the tuck_testhooks build tag.

When a root-context apply is re-run through sudo, sudo may reset HOME and XDG_STATE_HOME to root’s environment. If tuck is running as root and SUDO_UID identifies the invoking user, and no explicit XDG_STATE_HOME is set, tuck reads and writes the invoking user’s state directory instead of root’s. This keeps source selection in the user’s machine-local scope while still using elevated privileges for root-context target writes.

default = "public"
checksum = "sha256:..."

[[source]]
path    = "/home/me/.dotfiles"
id      = "public"
enabled = true

[[source]]
path    = "/home/me/.dotfiles-private"
id      = "private"
enabled = true

[[copy]]
source  = "public"
context = "home"
package = "zsh"
path    = ".config/symlink-hostile-app/config"
target  = "/home/me/.config/symlink-hostile-app/config"
sourceChecksum = "sha256:..."
targetChecksum = "sha256:..."
targetMode = "0600"

Fields per [[source]] entry:

Top-level fields:

First Release copied-file state:

If the state file is absent or has no entries, no source is enabled. A command that needs an active source fails with no_source. Reading the state for source list is not itself an error.

The state file remains human-readable text. tuck may also maintain a generated checksum sidecar (for example sources.toml.sha256) or equivalent checksum field to detect accidental manual edits/truncation quickly. A mismatch is reported as state_checksum_mismatch with a repair hint; it is not a tamper-proof security mechanism.

5.5 Validation

Performed when state is loaded:

State and manifest failures exit 1; --json exposes the stable error.code.


6. Package references

A package reference is a plain package name:

package-ref := package-name

Examples: zsh, ssh, git.

Rejected before package resolution:

Resolution rules:


7. Command reference

Every mutating target-tree command first builds a complete plan. If any conflict is found, it prints conflicts and mutates nothing.

7.1 adopt

tuck [--json] adopt [--source <id>] [--root] [--apply] [--copy] [--mode <mode>] [--replace] <package-ref> <file>

7.2 eject

tuck [--json] eject [--source <id>] [--root] [--apply] <file>

7.3 status

tuck [--json] status [--source <id>] [--root] <file>

7.4 package use

tuck [--json] package use [--source <id>] [--root] [--apply] <package-ref>...
tuck [--json] package use [--source <id>] [--root] [--apply] --all

7.5 package drop

tuck [--json] package drop [--source <id>] [--root] [--apply] <package-ref>...

7.6 package refresh

tuck [--json] package refresh [--source <id>] [--root] [--apply] <package-ref>...

7.7 package list

tuck [--json] package list [--source <id>] [--root]

7.8 package show

tuck [--json] package show [--source <id>] [--root] <package-ref>

7.9 package status

tuck [--json] package status [--source <id>] [--root] [package-ref]

7.10 package config

tuck [--json] package config show [--source <id>] [--root] <package-ref> [path]
tuck [--json] package config set [--source <id>] [--root] <package-ref> <path> [--deploy symlink|copy] [--mode <mode>]
tuck [--json] package config unset [--source <id>] [--root] <package-ref> <path> [--deploy] [--mode]

<mode> flags accept octal modes such as 0600 or chmod-style rwx expressions such as u=rw,go= and g+x. CLI expressions are resolved against the current source/target mode available to the command and are stored in package manifests as normalized octal. Package manifests themselves accept only octal mode values.

7.11 source add

tuck [--json] source add <path> [--id <id>] [--default]
tuck [--json] source add <path> --init [--id <id>] [--name <id>] [--description <text>] [--default]

7.12 source init

tuck [--json] source init <path> [--name <id>] [--description <text>]

7.13 source rm

tuck [--json] source rm <id>

7.14 source list

tuck [--json] source list

7.15 source default

tuck [--json] source default <id>

8. Plan and action model

Mutating commands that plan filesystem changes emit an ordered list of actions:

Action Fields Meaning
mkdir path Create a real directory in the target tree.
rmdir path Remove an empty directory left behind in a source package tree.
symlink linkPath, payload, target Create a symlink. payload is relative link text; target is the resolved destination.
copy src, dst, mode Copy a regular file and optionally set the destination mode.
package_config path Write package-local .tuck.toml metadata.
remove_file path Remove a package source file.
remove_symlink path Remove a managed target symlink.
remove_copy path Remove a tracked copied target if present and delete its copy-state record.
forget_copy path Delete a copy-state record while leaving the copied target in place.
move src, dst Move a real file.

Planning rules:

  1. Resolve all sources, packages, target paths, and ownership before mutating.
  2. Accumulate all conflicts; do not stop at the first.
  3. If any conflict exists, print conflicts, exit 1, and mutate nothing.
  4. If conflict-free, print the planned actions.
  5. Mutate only when --apply is given.
  6. Before the first mutation, preflight the ordered action list against the current filesystem plus the planned effects of earlier actions. If preflight finds a predictable failure, exit 1 and mutate nothing.
  7. Apply actions in listed order. For package refresh, removals precede creations.

Apply preflight is a best-effort safety gate, not a transaction or rollback system. It validates action requirements that can be known before mutation, such as destination existence, parent-directory usability, symlink-vs-directory expectations, and whether later rmdir actions will see directories as empty after earlier planned removals or moves. Execution still checks each action when it runs because the filesystem may change after preflight.

Collision and deduplication rules:

8.1 Privilege (root context)

tuck never silently self-escalates. Privilege is decided by a preflight check before any mutation and is separate from where test writes land.

For root-context commands whose conflict-free plan contains write actions:


9. Output formats

Every command supports human output (default) and --json.

Streams. Primary results – plans, listings, status, and JSON envelopes – go to stdout. Diagnostics – error messages and hints – go to stderr. With --json, exactly one JSON document is written to stdout and stderr stays empty.

9.1 Console output

A plan renders as a header, a plan: block, optional conflicts: block, and summary. Console output may use ANSI color for semantic emphasis when writing to a terminal: successes and additive plan actions, warnings and dry-run hints, errors and conflicts, muted secondary information, and identifiers/paths. Exact glyphs and color codes are not stable API. --no-color, NO_COLOR, redirected streams, and --json all produce colorless output.

Example:

tuck pkg use zsh   (context: home, dry-run)

plan:
  + mkdir  ~/.config/zsh
  + link   ~/.config/zsh/.zshrc -> ~/.dotfiles/zsh/.config/zsh/.zshrc

2 actions, 0 conflicts
re-run with --apply to execute

Error format:

error: <message>
code: <stable error code>
hint: <actionable suggestion>

9.2 JSON envelope

With --json, a command prints exactly one JSON document:

{
  "schemaVersion": 1,
  "command": "package use",
  "context": "home",
  "kind": "plan",
  "data": {},
  "exitCode": 0
}

Successful or failed results may include top-level warnings. Warnings are diagnostics that do not change exitCode; in JSON mode they are included in the single stdout envelope and stderr remains empty:

{
  "schemaVersion": 1,
  "command": "source list",
  "kind": "sources",
  "data": { "sources": [] },
  "warnings": [
    {
      "code": "ignored_flag",
      "message": "source list ignores --source",
      "hint": "source commands manage the registry and do not select an active source"
    }
  ],
  "exitCode": 0
}

9.2.1 kind: "plan"

Used by adopt, eject, package use, package drop, and package refresh.

{
  "schemaVersion": 1,
  "command": "package use",
  "context": "home",
  "kind": "plan",
  "data": {
    "dryRun": true,
    "applied": false,
    "packages": ["public:home:zsh"],
    "privilege": { "required": false },
    "actions": [
      { "type": "mkdir", "path": "/home/me/.config/zsh" },
      {
        "type": "symlink",
        "linkPath": "/home/me/.config/zsh/.zshrc",
        "payload": "../../.dotfiles/zsh/.config/zsh/.zshrc",
        "target": "/home/me/.dotfiles/zsh/.config/zsh/.zshrc"
      }
    ],
    "conflicts": []
  },
  "exitCode": 0
}

Conflicts and unsatisfied privilege requirements are included in the plan data and make exitCode 1. A conflict may include an optional hint string when there is one safe next command, for example a real_file package use conflict that can be resolved with tuck adopt --replace.

9.2.2 kind: "packages"

Emitted by package list:

{
  "schemaVersion": 1,
  "command": "package list",
  "context": "home",
  "kind": "packages",
  "data": {
    "source": "public",
    "packages": ["git", "zsh"]
  },
  "exitCode": 0
}

9.2.3 kind: "tree"

Emitted by package show:

{
  "schemaVersion": 1,
  "command": "package show",
  "context": "home",
  "kind": "tree",
  "data": {
    "package": {
      "identity": "public:home:zsh",
      "root": "/home/me/.dotfiles/zsh",
      "entries": [
        { "rel": ".config/zsh", "type": "dir" },
        { "rel": ".config/zsh/.zshrc", "type": "leaf" }
      ]
    }
  },
  "exitCode": 0
}

9.2.4 kind: "status"

Emitted by status and package status:

{
  "schemaVersion": 1,
  "command": "package status",
  "context": "home",
  "kind": "status",
  "data": {
    "entries": [
      {
        "targetPath": "/home/me/.config/zsh/.zshrc",
        "state": "deployed",
        "package": "public:home:zsh",
        "entry": "/home/me/.dotfiles/zsh/.config/zsh/.zshrc"
      }
    ]
  },
  "exitCode": 0
}

state is one of deployed, absent, conflict, mismatch, owned_by_other, or unmanaged.

9.2.5 kind: "sources"

Emitted by source list, source add, source init, source rm, and source default:

{
  "schemaVersion": 1,
  "command": "source list",
  "kind": "sources",
  "data": {
    "sources": [
      { "id": "public", "path": "/home/me/.dotfiles", "enabled": true, "default": true }
    ]
  },
  "exitCode": 0
}

9.2.6 kind: "error"

{
  "schemaVersion": 1,
  "command": "package use",
  "context": "home",
  "kind": "error",
  "data": {
    "error": {
      "code": "package_not_found",
      "message": "package \"ssh\" not found in source \"public\"",
      "hint": "run tuck pkg list to see packages in the active source"
    }
  },
  "exitCode": 1
}

10. Exit codes and error codes

Exit codes are binary:

Code Meaning
0 Success: command completed, dry-run printed, read completed, or help/version printed.
1 Failure: CLI parse/dispatch issue, config/state problem, resolution error, conflict, privilege failure, or runtime error.

Detailed failure classification is carried by stderr and, with --json, by data.error.code.

Stable error codes include:

package status exits 0 when the query succeeds, even if it reports conflicts inside the response body.

Copy drift hints should preserve the command model:


11. Help and usage text

tuck --help and per-command help are rendered by urfave/cli’s default help templates from command metadata. Help output includes the command name, usage line, version where relevant, visible commands, and options. Exact section order, capitalization, spacing, and flag rendering are framework-owned.

Acceptance tests assert help loosely with exit code and stable key substrings, not byte-for-byte output. --json does not make help or version output machine-readable.


12. Resolution algorithms

This section is normative. It specifies the internal resolution, ownership, conflict, and operation algorithms that the command reference and plan model build on.

12.1 Contexts, bases, and identities

Context packageBase(source, context) targetRoot(context)
home <source.path> $HOME
root <source.path>/.root /

12.2 Path primitives

Expand input path

For any user-supplied path: expand a leading ~; if relative, resolve against the process current working directory; clean lexical components; do not follow the final component when the command must inspect the symlink itself (adopt, eject, status).

Canonicalize source roots

For each enabled source, expand ~, make absolute, clean, and resolve symlinks in the root itself. A source root that does not exist is a state error.

Check containment

inside(child, root) is true only when child equals root or is a descendant after both are absolute and clean. The test is path-segment aware: /home/me/.dotfiles-private is not inside /home/me/.dotfiles.

Convert package path to target path

rel = relativePath(packageRoot, packageEntryPath)
reject if rel is "." or starts with ".."
targetPath = clean(join(targetRoot, rel))
reject if not inside(targetPath, targetRoot)
return targetPath

Convert target path to package path

absTarget = expand input path
reject if not inside(absTarget, targetRoot)
rel = relativePath(targetRoot, absTarget)
reject if rel is "." or starts with ".."
packagePath = clean(join(packageRoot, rel))
reject if not inside(packagePath, packageRoot)
return packagePath

For root, targetRoot is /; root commands must still reject paths inside any enabled source repository to avoid adopting a source into itself.

12.3 Source and package resolution

Select active source

if --source given:
    look up enabled source by id            (else unknown_source)
else if a machine-local default source is set:
    use it
else if exactly one source is enabled:
    use it
else:
    error no_source

eject and status select the active source the same way and infer ownership within it.

Parse package reference

Reject before resolution: any :, an empty name, a name starting with ., an absolute name, a name with .. segments, or a name containing a path separator.

Resolve existing package

For package use, package drop, package refresh, package show, and package status <ref>:

parse ref
source = select active source
packageRoot = packageRoot(source, context, name)
if packageRoot does not exist: error package_not_found
return source, context, name, packageRoot

Resolve package for adopt

adopt may create a new package path, but the source is fixed:

parse ref
source = select active source
packageRoot = packageRoot(source, context, name)   # may or may not exist
return source, context, name, packageRoot

12.4 Package entry enumeration

metadata = parse packageRoot/.tuck.toml if present
walk packageRoot depth-first
for each entry (skip packageRoot itself):
    rel = relativePath(packageRoot, entry)
    reject if rel escapes packageRoot
    if rel == ".tuck.toml": continue
    if entry is a directory: record a directory entry
    else:                    record a leaf entry with metadata for rel

Directory entries cause real directories to be created in the target tree and are never represented as symlinks. Leaf entries are deployed according to their metadata. For symlink deployment, if a package leaf is itself a symlink, the target link points at the package symlink itself.

Package-local metadata may override a leaf’s deployment strategy. Metadata paths must name leaf entries in the package. The default strategy is symlink; copy materializes the package file as a separate target file and records state for future ownership/drift checks. Directories and .tuck.toml never use copy.

12.5 Ownership resolution

Classify target path

Input: targetPath, optional selected package identity, context, and active source.

stat = lstat(targetPath)
if not exists:                         return Absent
if directory and not symlink:          return RealDirectory
if regular file and not symlink:
    if copied target record exists for active source/context/targetPath:
        return ManagedCopy(record and drift status)
    return RealFile
if other non-symlink type:             return SpecialFile
# symlink:
owner = inferSymlinkOwner(targetPath, context, active source)
if owner is ManagedPathMismatch:       return ManagedPathMismatch(owner)
if owner is none:                      return UnmanagedSymlink
if no selected package:                return ManagedSymlink(owner)
if owner == selected package:          return ManagedBySelectedPackage(owner)
return ManagedByOtherPackage(owner)

Ownership inference scans only the active source.

payload = readlink(targetLinkPath)
targetAbs = payload is relative
              ? clean(join(dirname(targetLinkPath), payload))
              : clean(payload)

base = packageBase(activeSource, context)
if not inside(targetAbs, base): return none
relToBase   = relativePath(base, targetAbs)
packageName = first path segment of relToBase
packageRoot = join(base, packageName)
packageRel  = relativePath(packageRoot, targetAbs)
expectedTarget = clean(join(targetRoot(context), packageRel))
if clean(targetLinkPath) != expectedTarget:
    return ManagedPathMismatch(activeSource, context, packageName, packageRel, expectedTarget)
return ManagedOwner(activeSource, context, packageName, packageRoot, packageRel)

Broken symlinks are still classifiable if their lexical target is inside a package root. A managed symlink whose link path does not match its package-relative path is reported as a mismatch and is never mutated automatically.

Classify copied target

Copied-file ownership is state-backed, not inferred from filesystem structure:

record = lookup copy state by active source, context, package, packageRel
stat = lstat(targetPath)
if not exists and record exists: return TrackedCopyAbsent(record)
if not exists: return Absent
if not regular file: return conflict

if no record for targetPath: return UntrackedCopyTarget

sourceChecksum = checksum(packageEntryPath)
targetChecksum = checksum(targetPath)
expectedMode = entry.mode if explicit else mode(packageEntryPath)
sourceChanged = sourceChecksum != record.sourceChecksum
             or expectedMode != record.targetMode
targetChanged = targetChecksum != record.targetChecksum
             or mode(targetPath) != record.targetMode

if sourceChanged and targetChanged: return CopyBothModified
if sourceChanged:                   return CopySourceModified
if targetChanged:                   return CopyTargetModified
return TrackedCopyUnchanged

Copy state describes existing copied deployments. New copy state is created only for entries currently configured with deploy = "copy", but existing copy state is still consulted by status, drop, and refresh so a package can change a leaf from copy to symlink without leaving an orphaned copied target. Symlink ownership remains payload-inferred.

12.6 Conflict rules

Package use (leaf). A leaf target is linkable when it is absent, or already a symlink owned by the selected package pointing at the same entry. It conflicts when it is a real file, real directory, special file, unmanaged symlink, managed by another package, or managed by the selected package but mapping to a different package-relative path.

Package use (copy leaf). A leaf configured with deploy = "copy" is copyable when the target is absent, or when it is already tracked as an unchanged copied entry. If the target is absent and a stale copy-state record exists, the new copy overwrites that stale record. package use conflicts when the target exists but is untracked, when a tracked target has changed, when the package source has changed while the tracked target still exists, or when any non-regular-file condition would make copying unsafe. Applying the copy updates the copied-entry state checksums and applied mode.

Package drop. Drop removes the currently managed deployment for the package leaf, regardless of whether the package’s current desired deployment strategy is symlink or copy. A managed symlink for the selected package/path is removed. A tracked copied target is removable when the target is absent, unchanged, or only the package source or package metadata has changed since the last copy. Removal deletes the target if present and clears the copy-state record. Target drift and both-sides drift are conflicts because the target changed outside tuck; the user must choose whether to remove that target outside tuck and deploy the source, or adopt the target as the new package source.

Directory. A package directory entry’s target is valid when absent or already a real directory. It conflicts when the target is a file, symlink, or non-directory special file.

Adopt. Requires: target exists, is a real file, is inside the selected target root, and is not inside any enabled source repository. For symlink deployment, the destination package path must not already exist unless --replace is set; --replace accepts only an existing regular package file, copies the target over it, removes the target, and links the target back to the package file. For copy deployment, a missing destination package path is created from the target; an existing destination package path is accepted only when the target is already a tracked copy for the same source/context/package/path or when --replace is set, making adopt --replace the explicit target-wins reconciliation command.

Eject. For symlink deployments, requires a managed symlink in the selected context, a matching package-relative target path, and an existing non-directory package file. After moving the package file back to the target, eject removes empty intermediate directories along the source/package path, stopping before the package root. For copy deployments, requires a tracked copied target and an unchanged package source; eject forgets the copy, removes the package source, and leaves the real target in place. If the package source changed since the last copy, eject conflicts rather than discarding source-side edits.

12.7 Operation algorithms

Every mutating command first builds a complete plan. If any conflict is found, it prints conflicts and mutates nothing. All are dry-run by default and mutate only with --apply.

package use

resolvedPackages = resolve existing package for each ref (or all packages)
plannedTargets = {}

for each package:
    for each directory entry:
        targetDir = convert package path -> target path
        switch classify(targetDir):
            Absent:        plan mkdir targetDir
            RealDirectory: no-op
            else:          conflict
    for each leaf entry:
        targetPath = convert package path -> target path
        if targetPath in plannedTargets with a different entry:
            conflict multiple_providers; continue  # same invocation only
        if entry.deploy == "copy":
            switch classifyCopiedTarget(targetPath, entry):
                Absent, TrackedCopyAbsent:   plan copy entry -> targetPath
                TrackedCopyUnchanged:         no-op
                else:                        conflict
            continue
        switch classify(targetPath, selected package):
            Absent:                                plan symlink targetPath -> entry
            ManagedBySelectedPackage(same entry):  no-op
            else:                                  conflict

Symlink payloads are relative: payload = relativePath(dirname(targetPath), packageEntryPath).

package drop

resolvedPackages = resolve existing package for each ref
for each package, for each leaf entry:
    targetPath = convert package path -> target path
    if copy state record exists for package entry:
        switch classifyCopiedTarget(targetPath, entry):
            TrackedCopyAbsent,
            TrackedCopyUnchanged,
            CopySourceModified:               plan remove_copy targetPath; continue
            CopyTargetModified,
            CopyBothModified:                 conflict; continue
    switch classify(targetPath, selected package):
        Absent:                                no-op
        ManagedBySelectedPackage(same entry):  plan remove_symlink targetPath
        else:                                  conflict

Directories are never pruned.

package refresh

build drop plan for selected packages; if conflicts: stop
build use plan against the post-drop state; if conflicts: stop
apply all remove_symlink/remove_copy actions, then all symlink/copy/mkdir actions

For copy deployments, package refresh stays equivalent to the composed drop-then-use operation. Source-only drift succeeds because the drop phase can remove an unchanged copied target and clear stale copy state, then the use phase copies the current package source. Target drift fails in the drop phase.

adopt

targetPath = expand input path (do not follow final symlink)
reject if not inside(targetPath, targetRoot(context))
reject if inside(targetPath, any enabled source repository)
reject unless classify(targetPath) is RealFile

(source, context, name, packageRoot) = resolve package for adopt
packagePath = convert target path -> package path
reject if not inside(packagePath, packageRoot)
entry = package metadata for packagePath, defaulting to deploy = "symlink"
if --copy: entry.deploy = "copy"

if entry.deploy == "copy":
    if --mode: entry.mode = normalize --mode against target mode
    copied = classifyCopiedTarget(targetPath, entry)
    if packagePath exists:
        reject unless copied is tracked for this source/context/package/path
        plan copy targetPath -> packagePath
        plan copy packagePath -> targetPath
    else:
        plan mkdir dirname(packagePath)
        plan move targetPath -> packagePath
        plan copy packagePath -> targetPath
    if --copy: plan package_config packageRoot/.tuck.toml with deploy = "copy" and optional mode for path
    stop

reject if packagePath already exists

plan mkdir dirname(packagePath)
plan move targetPath -> packagePath
plan symlink targetPath -> packagePath

eject

targetPath = expand input path (do not follow final symlink)
if targetPath matches copied-file state in active source/context:
    record = copy state record
    owner = package identity from record
    entry = package metadata for record.path
    copied = classifyCopiedTarget(targetPath, entry)
    reject if copied is CopySourceModified or CopyBothModified
    packagePath = join(owner.packageRoot, record.path)
    reject if packagePath does not exist
    plan forget_copy targetPath
    plan remove_file packagePath
    for dir in parents(dirname(packagePath), stop before owner.packageRoot), deepest first:
        if dir will be empty after the remove and earlier planned rmdir actions:
            plan rmdir dir
    stop

reject unless classify(targetPath, context) is ManagedSymlink
owner = classification.owner

packagePath    = join(owner.packageRoot, owner.packageRel)
expectedTarget = join(targetRoot(context), owner.packageRel)
reject if targetPath != expectedTarget
reject if packagePath does not exist
reject if packagePath is a directory

plan remove_symlink targetPath
plan move packagePath -> targetPath
for dir in parents(dirname(packagePath), stop before owner.packageRoot), deepest first:
    if dir will be empty after the move and earlier planned rmdir actions:
        plan rmdir dir

The package root itself is left in place. Removing a package is a separate operation.

12.8 Listing algorithms

package list

base = packageBase(activeSource, context)
list direct child directories of base as packages
    skip names starting with `.`
    skip non-directories

package show

Resolve the package in the active source and show that package root’s tree.

package status

With a ref, enumerate package leaf entries and classify each target path. Without a ref, summarize every package in the active source/context.

12.9 Execution planning

Mutations are explicit actions: mkdir, rmdir, symlink, remove_symlink, and move. Planning resolves the active source, packages, target paths, and ownership before mutating; accumulates all conflicts; exits 1 without mutation on any conflict; prints actions on a clean plan; mutates only with --apply; and preflights the full action list before the first mutation. Root-context mutations make their privilege requirement visible in the plan and never self-escalate.


Appendix A – Worked examples

A.0 Bootstrap a new machine

$ git clone git@github.com:me/dotfiles.git ~/.dotfiles
$ tuck source add ~/.dotfiles --default
added source "public" -> /home/me/.dotfiles (default)

$ tuck source list
* public   /home/me/.dotfiles          (default)

$ tuck pkg use zsh git --apply
2 actions, 0 conflicts -- applied

A.1 Package use

$ tuck pkg use zsh
tuck pkg use zsh   (context: home, dry-run)

plan:
  + mkdir  ~/.config/zsh
  + link   ~/.config/zsh/.zshrc -> ~/.dotfiles/zsh/.config/zsh/.zshrc

2 actions, 0 conflicts
re-run with --apply to execute

A.2 Adopt a file

$ tuck adopt git ~/.gitconfig
plan:
  ~ move   ~/.gitconfig -> ~/.dotfiles/git/.gitconfig
  + link   ~/.gitconfig -> ~/.dotfiles/git/.gitconfig

$ tuck adopt git ~/.gitconfig --apply
applied

A.3 Eject a file

$ tuck eject ~/.gitconfig --apply
plan:
  - unlink ~/.gitconfig
  ~ move   ~/.dotfiles/git/.gitconfig -> ~/.gitconfig
applied

A.4 Status

$ tuck status ~/.gitconfig
deployed by public:home:git

$ tuck pkg status git
git: 1 deployed, 0 absent, 0 conflicts

A.5 Root context

$ tuck pkg use sshd --root
# dry-run by default

$ tuck pkg use sshd --root --apply
plan:
  + mkdir  /etc/ssh
  + link   /etc/ssh/sshd_config -> /dotfiles/.root/sshd/etc/ssh/sshd_config

privilege required: root-context write

2 actions, 0 conflicts
# exit 1; with --json, kind is "plan" and privilege.satisfied is false

Some applications reject symlinked config files. Configure those package leaves in the package manifest at ~/.dotfiles/zsh/.tuck.toml:

[[file]]
path = ".config/symlink-hostile-app/config"
deploy = "copy"
mode = "0600"

Then package use plans a copy rather than a symlink:

$ tuck pkg use zsh
plan:
  + copy   ~/.dotfiles/zsh/.config/symlink-hostile-app/config -> ~/.config/symlink-hostile-app/config (mode 0600)

1 action, 0 conflicts

Copied targets are tracked in machine-local state. If the target is edited after deployment, later package use, package drop, or package refresh reports copy drift instead of overwriting or removing it silently. If only the package source changed, package refresh is the explicit drop-then-use path that deploys the source version. If the target changed, the user must decide whether to remove the target and deploy the source with package use, or make the target the source of truth with adopt.


Appendix B – Relationship to the previous CLI surface

This spec redesigns the CLI surface from first principles while keeping the domain model fixed.

Deliberate changes:

  1. Command depth mirrors operation frequency. File operations are top-level, package operations are grouped under package/pkg, and rare source operations are grouped under source.
  2. Package verbs changed. deploy/undeploy/redeploy became package use/package drop/package refresh. A package is a collection of files; it does not itself get “linked”.
  3. Adopt is package-first. adopt <package> <file> reads as “put this file into this package”; eject and status stay file-first because they start from an existing target.
  4. Status split by level. File status is tuck status <file>. Package status is tuck package status [pkg].
  5. Source commands aligned with package commands. source enable became source add; source rm and source default are part of the designed surface.
  6. Forgiving selectors, local mutation. --source and --root are globally parseable selectors that domain commands use and source commands ignore with warnings. --apply stays local to mutating plan commands.
  7. Exit codes are binary. Detailed classification moved from process exit codes into stderr and the JSON error.code.
  8. Framework-owned meta output. --help and --version stay text output even when --json is present; help is not a command.

The internal algorithms for path primitives, package enumeration, ownership inference, conflict rules, and execution planning are preserved, translated into the current vocabulary.

Implementation framework

The CLI is built on urfave/cli v3. The framework is authoritative for surface mechanics: command/flag parsing, exact flag placement behavior, help/version rendering, and usage-error formatting. The spec aims to be close to, not byte-identical with, framework defaults. Where they differ, prefer idiomatic urfave/cli behavior when that is the intended product decision, then update this spec and acceptance tests to match.