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
- Source – a dotfiles repository. Each repository carries a committed
manifest (
<repo>/.tuck.toml) declaring its shortname, used as the default source id. A source is made usable on a machine withtuck source add <path>, which records its effective id, path, and enabled state in machine-local state. - Target context – selects where package entries appear:
home: package base<source.path>, target root$HOME(default).root: package base<source.path>/.root, target root/.
- Package – one directory inside a package base. It is identified
internally as
source:context:name(for examplepublic:home:zsh). CLI input never includes source or context; source is selected with--sourceor machine state, and context is selected with--root. - Package reference – CLI input naming a package by its plain
<name>(for examplezsh). Package refs do not contain source, context, path separators, or:. - Managed entry – a target deployment owned by the active source. Symlink ownership is inferred from the link payload; copied-file ownership is recorded in machine-local state because it cannot be inferred from filesystem shape.
- Leaf vs. directory entries – only leaf entries become target deployments; directory entries become real directories in the target tree.
2. Command surface
Command depth mirrors operation frequency:
- file operations are top-level commands;
- package operations live under
package(aliaspkg); - source operations live under
source.
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:
adopt– make one existing real target file part of a package, then deploy it according to package policy.eject– stop managing one deployed target and leave a real file in the target tree.status <file>– classify a single target path in the active source/context.package use– create managed target deployments for package leaf entries.package drop– remove managed target deployments for selected packages. It does not move package files back into the target tree.package refresh– rebuild selected package deployments as a single planned drop-then-use operation, also normalizing symlink payloads to the preferred relative form.package list– list packages in the active source and context.package show– display one package’s contents.package status– report deployment state for package entries; without a package, summarize all packages in the active source.source add– register and enable a dotfiles repository on this machine.source init– create a repository manifest without registering the source.source rm– remove a source from machine-local state.source list– list sources recorded on this machine.source default– set the machine-local default active source.
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
- Unknown commands, missing help topics, unknown flags, and related CLI shell
errors follow urfave/cli’s framework behavior. They exit
1. - With no command,
tuckprints top-level help and exits0. A future version may use baretuckas a TUI entry point. - Invoking a command group with no subcommand (
tuck package,tuck source) prints that group’s help and exits0. --help,--version, the no-command case, and command-group help bypass machine-state discovery and source resolution.--helpand--versionare framework-rendered text even when--jsonis also present. There is nohelpcommand; help is exposed only through-h/--help.tuck source add <path>does not require an existing active source; it establishes one.tuck source listrequires only readable machine state.
3.1 Context selection
There are exactly two target contexts. home is the unconditional default; the
boolean --root flag selects root:
- no
--root->home(package base<source.path>, target root$HOME); --root->root(package base<source.path>/.root, target 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:
--source <id>(-s) on the command line.- The machine-local default source (
tuck source default <id>ortuck source add <path> --default). - The sole enabled source, when exactly one source is enabled.
- 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:
- Mutating target-tree commands build and print a plan unless
--applyis given. --sourceand--rootmay be placed before command groups or leaf commands.sourcecommands ignore them with warnings.--applystays local to mutating target-tree commands. Keeping mutation intent close to the mutating verb is part of the dry-run safety model.--jsonimplies--no-color.- Console color is enabled only when the destination stream is a terminal and
NO_COLORis unset.--no-colordisables color on all console streams. package userequires either one or more package refs or--all, but not both.
5. Configuration
tuck has no central config file. Configuration is split between two artifacts:
- a source manifest committed in each dotfiles repo (
<repo>/.tuck.toml), which carries the repo’s portable identity; - optional package manifests committed inside package roots
(
<package-root>/.tuck.toml), which carry package-owned deployment policy; - machine-local state (
sources.toml), generated bytuck sourcecommands, which records which repos are enabled on this machine and where they live.
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:
name(required) – the repo’s short id. Used as the source id unlesssource add --idoverrides it in machine-local state. Must not be empty, must not be.tuck.toml, and must not contain a path separator or:.description(optional) – a human-readable label shown insource list.
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:
[[file]]declares policy for one package-relative leaf path.path(required) is the package-relative file path, using the same path that maps to the target tree. It must be relative, non-empty, stay inside the package, and refer to a leaf entry rather than a directory or.tuck.tomlitself.deploy(optional, default"symlink") selects the deployment strategy for that leaf. First Release values are"symlink"and"copy". Hardlinks are a possible future strategy but are not committed behavior in this spec.mode(optional) is an explicit octal file mode such as"0600". It applies after creating a copied target. Owner/group management is out of scope.
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:
path(required) – the canonical repository path on this machine.id(required) – the effective source id, authoritative for selection and display identities. Defaults to the manifestname, or tosource add --idwhen provided.enabled(optional, defaulttrue) – whether the source participates.
Top-level fields:
default(optional) – the effective id of the machine-local default active source. Default status belongs only to the registry, never to individual source entries.checksum(optional, generated) – a checksum over the normalized state file or over a generated sidecar payload. It is a fast validation signal, not a security boundary.
First Release copied-file state:
[[copy]]entries record copied targets because copy ownership cannot be inferred from symlink payloads.source,context,package, andpathidentify the package leaf.targetrecords the target path for status/drop operations.sourceChecksumandtargetChecksumrecord the last applied source and target bytes so status can distinguish unchanged copies, source drift, target drift, and both-sides drift.targetMode(generated) records the last applied target mode. Target drift includes mode drift as well as byte drift. A changed package-manifestmodeor changed source-file mode is source-side drift because it changes the next intended deployment.
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:
- Effective
idvalues are unique across enabled entries and must not be empty,.tuck.toml, or contain a path separator or:. - If top-level
defaultis set, it must name an enabled entry. - Each enabled
pathis expanded and canonicalized. A missing path is a state error. - Enabled source roots must not overlap: no enabled
pathmay equal, contain, or be contained by another enabledpath(path-segment aware). - Each enabled
pathmust contain a readable, valid.tuck.toml. - For the
rootcontext, the package base is<source.path>/.root. A source that has no.rootdirectory contributes no packages in the root context but is not itself an error.
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:
- a ref containing
:; - an empty package name;
- a package name starting with
.; - an absolute package name;
- a name containing
..path segments; - a name containing a path separator.
Resolution rules:
package use,package drop,package refresh,package show, andpackage status <ref>require the package to exist in the active source and context, elsepackage_not_found.adopt <ref> <file>may create the package if absent. The source is still fixed by active-source resolution.
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>
- Arguments: one package name and one existing real target file. The package may already exist or be created in the active source.
- Behavior: expand
<file>without following the final symlink; reject if outside the target root or inside any enabled source repository; reject unless it is a real file. Convert the target path to the destination package path and load package metadata for that path. For symlink deployments, reject if the package path already exists unless--replaceis set; without replacement, plan: make package parents, move target-to-package, symlink target-to-package. With--replace, require the existing package path to be a regular file, copy target-to-package, remove the target, and symlink target-to-package. For copy deployments,adoptis the target-wins reconciliation path: if the package path already exists, accept it only for an already tracked copy or when--replaceis set; copy target bytes into the package source and update copied-file state. If the package path does not exist, plan to move target-to-package and copy it back to the target.--copyforces this copy deployment path and writes per-file[[file]] deploy = "copy"package config during apply.--modeis valid only with--copy; it sets the copied target mode and writes the normalized octal mode to package config. Without--mode, no explicit mode is written; the moved package source keeps the target’s current mode and copied-file state records the applied mode. Other existing non-regular package paths still conflict. - Execution: dry-run by default; mutates only with
--apply.
7.2 eject
tuck [--json] eject [--source <id>] [--root] [--apply] <file>
- Arguments: one target path that is a managed deployment.
- Behavior: expand
<file>without following the final symlink; classify in the active source/context. For symlink deployments, reject unless it is a managed symlink whose link path matches the package-relative target and whose package file exists and is not a directory; plan: remove symlink, move package-to-target, and remove now-empty intermediate source/package directories below the package root. For copy deployments, reject unless it is a tracked copied target and the package source has not drifted from copied-file state; plan to stop tracking the copy, remove the package source, and leave the real target file in place. If the package source changed since deployment, conflict rather than discarding source-side changes. - Execution: dry-run by default; mutates only with
--apply.
7.3 status
tuck [--json] status [--source <id>] [--root] <file>
- Arguments: one target path.
- Behavior: classify the target path as absent, managed, unmanaged, mismatched, owned by another package, real file, real directory, or special file, or outside the selected target root. Symlink ownership is inferred only in the active source; copied-file ownership is read from active-source machine state.
- Aliases:
stat. - Execution: read-only.
7.4 package use
tuck [--json] package use [--source <id>] [--root] [--apply] <package-ref>...
tuck [--json] package use [--source <id>] [--root] [--apply] --all
- Arguments: one or more existing package names, or
--all. - Behavior: enumerate package entries; plan
mkdirfor absent directory entries and deploy leaf entries according to package metadata. Default leaf deployment issymlink;deploy = "copy"plans a real copied target.package useis conservative: it does not overwrite or reconcile existing real-file targets or drifted copied targets. Real-file conflicts include a hint to runtuck adopt --replacewith the selected source/context when the target should win. Symlink payloads are relative. - Execution: dry-run by default; mutates only with
--apply.
7.5 package drop
tuck [--json] package drop [--source <id>] [--root] [--apply] <package-ref>...
- Arguments: one or more existing package names.
- Behavior: for each leaf entry, plan removal only when the target is managed
by the selected package for the same entry. Symlink deployments plan
remove_symlink. Copy deployments planremove_copywhen the target is absent or has not drifted from copied-file state; target drift is a conflict. Source drift alone does not blockdropbecause dropping a deployment does not discard source changes. Directories are never pruned. - Execution: dry-run by default; mutates only with
--apply.
7.6 package refresh
tuck [--json] package refresh [--source <id>] [--root] [--apply] <package-ref>...
- Arguments: one or more existing package names.
- Behavior: build a drop plan, then a use plan against the post-drop state.
package refreshis equivalent to explicitpackage dropfollowed bypackage use, planned and conflict-checked as one operation. Already-owned symlinks may be removed and recreated to normalize relative payloads. For copy deployments, source-only drift refreshes because the drop phase removes the clean copied target and the use phase copies the current source; target drift conflicts becausedropconflicts. Apply removals before creations. - Execution: dry-run by default; mutates only with
--apply. - Aliases:
package fresh,pkg refresh,pkg fresh.
7.7 package list
tuck [--json] package list [--source <id>] [--root]
- Behavior: list direct child package directories of the active source’s
package base. Skip dot-prefixed directories (including
.root) and non-directories such as.tuck.toml. - Aliases:
package ls,pkg list,pkg ls.
7.8 package show
tuck [--json] package show [--source <id>] [--root] <package-ref>
- Behavior: resolve the package in the active source/context and show its
file tree. Package-local
.tuck.tomlis control metadata and is not shown as a deployable entry. - Aliases:
package tree,pkg show,pkg tree.
7.9 package status
tuck [--json] package status [--source <id>] [--root] [package-ref]
- With
<package-ref>: resolve the package and report each leaf entry asdeployed,absent,conflict,mismatch,owned_by_other,copy_missing,copy_source_modified,copy_target_modified, orcopy_drift. - Without a ref: summarize every package in the active source/context.
- Execution: read-only. Reported conflicts in the body do not make the
command fail; it exits
0when the query succeeds. - Aliases:
package stat,pkg status,pkg stat.
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]
- Behavior: inspect and edit per-file
[[file]]entries in<package-root>/.tuck.toml. There are no package-level deployment defaults; absent file config meansdeploy = "symlink"and no explicit mode. show: withoutpath, list configured file entries. Withpath, show the effective config for that package-relative leaf.set: require a package-relative leaf path and at least one of--deployor--mode; write the package manifest atomically.unset: require a package-relative leaf path. With no field flags, remove the file entry. With flags, clear only those fields and prune the entry or package manifest when it becomes empty.
<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]
- Arguments: one repository path.
- Behavior: read
<path>/.tuck.toml; record or update the source entry in machine-local state with canonical path, effective id (--idor manifest name),enabled = true, and top-level default per--default. Validate the complete write; on validation failure, write nothing. - With
--init: if<path>/.tuck.tomlis missing, create the manifest first using--nameor the path basename and optional--description, then register the source as above. Existing valid manifests are registered normally. Existing invalid manifests still fail.--idaffects only machine-local state;--nameremains the manifest name to write.--nameand--descriptionare valid only with--init.
7.12 source init
tuck [--json] source init <path> [--name <id>] [--description <text>]
- Arguments: one repository path.
- Behavior: create
<path>if needed and write a new<path>/.tuck.tomlmanifest. The manifest name defaults to the path basename. Existing manifests are not overwritten. The command does not register the source in machine-local state.
7.13 source rm
tuck [--json] source rm <id>
- Arguments: one source id.
- Behavior: remove the entry from machine-local state. If it was the default,
clear the default. Removing a missing source is an error (
unknown_source). - Aliases:
source remove.
7.14 source list
tuck [--json] source list
- Behavior: list source entries from machine-local state: id, path, enabled,
and default marker. Absent or empty state reports “no sources enabled” and exits
0. - Aliases:
source ls.
7.15 source default
tuck [--json] source default <id>
- Arguments: one enabled source id.
- Behavior: set the top-level machine-local default source id. Unknown or disabled source ids are errors.
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:
- Resolve all sources, packages, target paths, and ownership before mutating.
- Accumulate all conflicts; do not stop at the first.
- If any conflict exists, print conflicts, exit
1, and mutate nothing. - If conflict-free, print the planned actions.
- Mutate only when
--applyis given. - 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
1and mutate nothing. - 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:
- Duplicate package names within one invocation are de-duplicated.
- Within one
package useorpackage refreshinvocation, two different selected packages producing the same leaf target are amultiple_providersplanning conflict.tuckdoes not maintain a cross-source provider index, so this code is not apackage statusquery result. - Two planned actions targeting the same path with incompatible types are a conflict.
- A copied target is mutable outside
tuck.package usenever reconciles drifted copies.package dropand thereforepackage refreshconflict when the copied target has drifted from the last recordedtargetChecksumor explicit target mode. Source drift alone does not blockdrop, so source-only drift can be resolved bypackage refreshwhile preserving refresh as drop-then-use.
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:
- The plan is marked as requiring privilege (
privilege.required). - A separate predicate determines whether privilege is satisfied.
- When
--applyis requested, privilege is required, and privilege is not satisfied,tuckprints the plan, performs no mutation, and exits1. - Plan-only runs mark the requirement and exit
0. - Read-only commands never require privilege.
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
}
schemaVersion– integer; incremented on breaking changes.command– canonical command name, including group when present.context–"home"or"root"for domain commands; omitted or"home"for source/meta commands.kind– one ofplan,sources,packages,tree,status,help,version,error.data– payload determined bykind.exitCode– process exit code (0or1).
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:
- configuration/state:
manifest_missing,manifest_invalid,manifest_exists,state_invalid,source_root_missing,no_source,unknown_source; - references/resolution:
invalid_ref,package_not_found; - command validation:
invalid_args; - target classification/conflicts:
real_file,real_directory,special_file,unmanaged_symlink,owned_by_other,path_mismatch,outside_target_root,inside_source_repo,package_path_exists,not_a_managed_symlink,copy_drift,copy_source_modified,copy_target_modified; - plan collision conflicts:
multiple_providerswhen onepackage useorpackage refreshinvocation selects packages that would write the same leaf target; - state validation:
state_checksum_mismatch; - execution:
io_error,runtime_error.
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:
copy_source_modified: the package source changed while the copied target is still clean. Hint: runtuck package refresh <pkg> --applyto intentionally deploy the source version.copy_target_modified: the copied target changed outsidetuck. Hint: if the package source should win, remove the target and runtuck package use <pkg> --apply; if the target should win, runtuck adopt <pkg> <target> --apply.copy_drift: both package source and copied target changed. Hint: compare and reconcile them, then choose the source-wins path (remove target, thenpackage use) or the target-wins path (adopt).
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 |
/ |
- A package base is the directory that holds packages for one source and context.
- A package root is one concrete package directory inside a base:
packageRoot(source, context, name) = join(packageBase(source, context), name). - A package identity is
source-id + context + package-name, displayed assource:context:name. - A managed symlink is a target symlink whose payload resolves inside the active source’s package root and whose target location matches the package-relative path. A managed copy is a target regular file recorded in copied-file state.
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)
Infer symlink 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
A.6 Copy deployment for symlink-hostile files
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:
- Command depth mirrors operation frequency. File operations are top-level,
package operations are grouped under
package/pkg, and rare source operations are grouped undersource. - Package verbs changed.
deploy/undeploy/redeploybecamepackage use/package drop/package refresh. A package is a collection of files; it does not itself get “linked”. - Adopt is package-first.
adopt <package> <file>reads as “put this file into this package”;ejectandstatusstay file-first because they start from an existing target. - Status split by level. File status is
tuck status <file>. Package status istuck package status [pkg]. - Source commands aligned with package commands.
source enablebecamesource add;source rmandsource defaultare part of the designed surface. - Forgiving selectors, local mutation.
--sourceand--rootare globally parseable selectors that domain commands use and source commands ignore with warnings.--applystays local to mutating plan commands. - Exit codes are binary. Detailed classification moved from process exit
codes into stderr and the JSON
error.code. - Framework-owned meta output.
--helpand--versionstay text output even when--jsonis present;helpis 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.