Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## [Unreleased]

### Changed

- `skill --install` now explains each action instead of printing a bare verb:
every line names the agent, the action, the target path with `~` for the home
directory, and the reason (`skip` says the file is already up to date,
`conflict` says to pass `--force`). `--json` output is unchanged. [#59]

## [0.2.1] - 2026-06-23

### Fixed
Expand Down Expand Up @@ -94,6 +101,7 @@
[#54]: https://github.com/dreikanter/dotfiles-cli/pull/54
[#55]: https://github.com/dreikanter/dotfiles-cli/pull/55
[#57]: https://github.com/dreikanter/dotfiles-cli/pull/57
[#59]: https://github.com/dreikanter/dotfiles-cli/pull/59

## [0.1.2] - 2026-05-04

Expand Down
56 changes: 50 additions & 6 deletions internal/cli/skill.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"strings"
"text/tabwriter"

"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -65,6 +66,8 @@ type agentTarget struct {
// - "overwrite" — file existed with different content; --force was set.
// - "skip" — file existed with byte-identical content; no-op.
// - "conflict" — file existed with different content; --force was NOT set.
//
// installReasons carries the plain-text explanation for each of them.
type installAction struct {
Agent string `json:"agent"`
Path string `json:"path"`
Expand All @@ -76,6 +79,16 @@ type installResponse struct {
Actions []installAction `json:"actions"`
}

// installReasons explains why an action was chosen, so the plain-text output
// says more than the bare verb. "create" needs no explanation and is absent.
// The wording is tense-neutral, because dry-run output has to stay
// byte-identical to a real run.
var installReasons = map[string]string{
"skip": "already up to date",
"overwrite": "replacing local changes",
"conflict": "edited locally, pass --force to replace",
}

// agents is the registry of supported install targets. Add a new entry to
// support a new agent; iteration order is preserved.
var agents = []agentTarget{
Expand Down Expand Up @@ -131,6 +144,10 @@ values: claude. Existing files are left alone unless --force is set;
byte-identical existing files are reported as "skip" and exit zero.
Use -n/--dry-run to preview install actions without writing.

Plain-text install output is one line per agent — agent, action, path, and
the reason the action was chosen — with home directory paths shortened to ~.
Use --json for machine-readable output.

` + skillJSONShape,
Example: ` dotfiles skill
dotfiles skill --json | jq .
Expand Down Expand Up @@ -185,13 +202,13 @@ func runSkillInstall(cmd *cobra.Command, skill Skill) error {
return err
}
} else {
// An unresolvable home dir just means no abbreviation happens.
home, _ := os.UserHomeDir()
tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
for _, a := range actions {
line := fmt.Sprintf("%s\t%s\t%s", a.Action, a.Agent, a.Path)
if a.Error != "" {
line += "\t" + a.Error
}
fmt.Fprintln(out, line)
fmt.Fprintln(tw, abbrevHome(installLine(a), home))
}
_ = tw.Flush()
}

if installHasFailures(actions) {
Expand All @@ -200,6 +217,33 @@ func runSkillInstall(cmd *cobra.Command, skill Skill) error {
return nil
}

// installLine renders one action as tab-separated columns: agent, action,
// path, and the parenthesized reason the action was chosen. A failed target
// reports the action word "error" with the message as its reason.
func installLine(a installAction) string {
action, reason := a.Action, installReasons[a.Action]
if a.Error != "" {
action, reason = "error", a.Error
}
// Pad the action to the width of the longest word so the columns land in
// the same place across runs, not just within one run's output.
line := fmt.Sprintf("%s\t%-9s\t%s", a.Agent, action, a.Path)
if reason != "" {
line += "\t(" + reason + ")"
}
return line
}

// abbrevHome shortens home directory paths to ~ so the reason stays on screen
// instead of being pushed off the right edge. It rewrites paths embedded in
// messages too, hence the substring replacement.
func abbrevHome(s, home string) string {
if home == "" || home == string(filepath.Separator) {
return s
}
return strings.ReplaceAll(s, home, "~")
}

// planInstall returns the action that running install for target would take,
// without performing any writes. The four action kinds and the OS-error
// fallback are defined on installAction.
Expand All @@ -217,7 +261,7 @@ func planInstall(skill Skill, target agentTarget, force bool) installAction {
// The command never creates an unfamiliar agent directory.
skillsDir := filepath.Dir(filepath.Dir(path))
if st, statErr := os.Stat(skillsDir); statErr != nil || !st.IsDir() {
a.Error = fmt.Sprintf("agent %q skills directory missing: %s", target.Name, skillsDir)
a.Error = fmt.Sprintf("no skills directory at %s", skillsDir)
return a
}

Expand Down
60 changes: 59 additions & 1 deletion internal/cli/skill_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,64 @@ func TestSkill_InstallAutoDetectMultiAgent(t *testing.T) {
require.FileExists(t, fakePath)
}

// squeeze collapses the column padding of an install line so assertions can
// describe the line without hard-coding tabwriter's spacing.
func squeeze(s string) string {
return strings.Join(strings.Fields(strings.TrimSpace(s)), " ")
}

func TestSkill_InstallPlainTextReasons(t *testing.T) {
_, skillPath := installSandbox(t)

const path = "~/.claude/skills/dotfiles/SKILL.md"

create, err := runCLI(t, "skill", "--install", "--agent=claude")
require.NoError(t, err)
assert.Equal(t, "claude create "+path, squeeze(create))

skip, err := runCLI(t, "skill", "--install", "--agent=claude")
require.NoError(t, err)
assert.Equal(t, "claude skip "+path+" (already up to date)", squeeze(skip))

// --force on an up-to-date file still skips; the reason must say why.
forced, err := runCLI(t, "skill", "--install", "--agent=claude", "--force")
require.NoError(t, err)
assert.Equal(t, skip, forced, "--force must not change an up-to-date report")

require.NoError(t, os.WriteFile(skillPath, []byte("MUTATED\n"), 0o644))

conflict, err := runCLI(t, "skill", "--install", "--agent=claude")
require.Error(t, err)
assert.Equal(t, "claude conflict "+path+" (edited locally, pass --force to replace)", squeeze(conflict))

overwrite, err := runCLI(t, "skill", "--install", "--agent=claude", "--force")
require.NoError(t, err)
assert.Equal(t, "claude overwrite "+path+" (replacing local changes)", squeeze(overwrite))
}

func TestSkill_InstallPlainTextDryRunMatchesRealRun(t *testing.T) {
installSandbox(t)

preview, err := runCLI(t, "skill", "--install", "--agent=claude", "--dry-run")
require.NoError(t, err)
real, err := runCLI(t, "skill", "--install", "--agent=claude")
require.NoError(t, err)
assert.Equal(t, preview, real, "dry-run output must be byte-identical to a real run")
}

func TestSkill_InstallPlainTextError(t *testing.T) {
// Sandbox HOME without creating .claude/skills/.
home := t.TempDir()
t.Setenv("HOME", home)

out, err := runCLI(t, "skill", "--install", "--agent=claude")
require.Error(t, err)
assert.Equal(t,
"claude error ~/.claude/skills/dotfiles/SKILL.md (no skills directory at ~/.claude/skills)",
squeeze(out))
assert.NotContains(t, out, home, "home directory must be abbreviated to ~")
}

func TestSkill_ForceRequiresInstall(t *testing.T) {
out, err := runCLI(t, "skill", "--force", "--json")
require.Error(t, err)
Expand All @@ -303,5 +361,5 @@ func TestSkill_InstallSkillsDirMissing(t *testing.T) {
var resp installJSONResp
require.NoError(t, json.Unmarshal([]byte(out), &resp))
require.Len(t, resp.Actions, 1)
assert.Contains(t, resp.Actions[0].Error, "skills directory missing")
assert.Contains(t, resp.Actions[0].Error, "no skills directory at")
}