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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,11 @@ Thumbs.db
# SDD skill registry (contiene rutas absolutas de la máquina local)
.atl/

# Directorios locales de herramientas de agentes IA (config y caché por máquina)
.pi/
.pi-lens/
.antigravitycli/

# Residuo de tests (backups escritos por el writer durante la suite)
*.yaml.bak

85 changes: 85 additions & 0 deletions internal/cli/import.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package cli

import (
"errors"
"fmt"
"os"
"path/filepath"

"github.com/ezzek/skill-sync/internal/agent"
"github.com/ezzek/skill-sync/internal/agent/config_resolver"
"github.com/ezzek/skill-sync/internal/importer"
"github.com/spf13/cobra"
)

// NewImportCmd returns the Cobra command for importing a single skill.
func NewImportCmd() *cobra.Command {
var configPath string

cmd := &cobra.Command{
Use: "import <skill-name>",
Short: "Import a skill from a configured agent source into ./.agents/skills/",
Args: cobra.ExactArgs(1),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
cwd, err := getWd()
if err != nil {
return fmt.Errorf("getwd: %w", err)
}
return runImport(cmd, configPath, cwd, args[0])
},
}
cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to skill-sync.yaml")
return cmd
}

func runImport(cmd *cobra.Command, configPath, cwd, skillID string) error {
// 1. Resolve config.
resolvedPath, err := config_resolver.New().Resolve(configPath)
if err != nil {
return fmt.Errorf("no skill-sync.yaml found; run 'skill-sync init' to create one")
}
f, err := os.Open(resolvedPath)
if err != nil {
return fmt.Errorf("open config: %w", err)
}
defer f.Close()

cfg, err := agent.ParseConfig(f)
if err != nil {
return fmt.Errorf("parse config: %w", err)
}
if home, err := os.UserHomeDir(); err == nil {
cfg.ExpandTargets(home)
}

// 2. Build importer.
destRoot := filepath.Join(cwd, ".agents", "skills")
imp := importer.New(cfg.Targets, destRoot)

// 3. Find candidates.
candidates, err := imp.FindSkill(skillID)
if err != nil {
return err
}
if len(candidates) == 0 {
return fmt.Errorf("skill %q not found in any configured source", skillID)
}

// 4. Resolve winner.
winner, _, err := importer.Resolve(candidates)
if err != nil {
if errors.Is(err, importer.ErrConflict) {
return fmt.Errorf("conflict: %q has identical version and mtime in multiple sources; resolve manually", skillID)
}
return err
}

// 5. Copy.
if err := imp.CopySkill(skillID, winner.SourceDir); err != nil {
return err
}

fmt.Fprintf(cmd.OutOrStdout(), "Imported %s from %s\n", skillID, winner.SourceDir)
return nil
}
233 changes: 233 additions & 0 deletions internal/cli/import_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
package cli

import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)

// writeImportSkillFile creates a skill directory with SKILL.md at
// <base>/<target>/skills/<skillID>/SKILL.md
func writeImportSkillFile(t *testing.T, base, target, skillID, content string) {
t.Helper()
skillDir := filepath.Join(base, target, "skills", skillID)
if err := os.MkdirAll(skillDir, 0755); err != nil {
t.Fatalf("failed to create skill dir: %v", err)
}
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0644); err != nil {
t.Fatalf("failed to write skill file: %v", err)
}
}

// writeImportConfig writes a skill-sync.yaml with the given targets.
func writeImportConfig(t *testing.T, base string, targets []string) string {
t.Helper()
var sb strings.Builder
sb.WriteString("targets:\n")
for _, tgt := range targets {
sb.WriteString(" - " + tgt + "\n")
}
cfgPath := filepath.Join(base, "skill-sync.yaml")
if err := os.WriteFile(cfgPath, []byte(sb.String()), 0644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
return cfgPath
}

func TestImportCmd(t *testing.T) {
origGetWd := getWd
defer func() {
getWd = origGetWd
}()

tests := []struct {
name string
setup func(t *testing.T, base string) (args []string, cfgPath string, cwd string)
verify func(t *testing.T, base string, stdout, stderr string, err error)
wantErr bool
wantErrMsg string
}{
{
name: "happy path — imports skill and creates destination files",
setup: func(t *testing.T, base string) ([]string, string, string) {
toolA := filepath.Join(base, "toolA")
writeImportSkillFile(t, base, "toolA", "git-expert", "---\nversion: 2\nname: Git Expert\ndescription: Git expertise\n---\n# Git Expert v2")
cfgPath := writeImportConfig(t, base, []string{toolA})
return []string{"git-expert"}, cfgPath, base
},
verify: func(t *testing.T, base string, stdout, stderr string, err error) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout, "Imported git-expert") {
t.Errorf("expected stdout to contain 'Imported git-expert', got: %s", stdout)
}
destPath := filepath.Join(base, ".agents", "skills", "git-expert", "SKILL.md")
data, readErr := os.ReadFile(destPath)
if readErr != nil {
t.Fatalf("expected skill file at %s, got error: %v", destPath, readErr)
}
if !strings.Contains(string(data), "# Git Expert v2") {
t.Errorf("expected imported content to contain '# Git Expert v2', got: %s", string(data))
}
},
wantErr: false,
},
{
name: "missing config file — exit 1 with init hint",
setup: func(t *testing.T, base string) ([]string, string, string) {
noCfg := filepath.Join(base, "nonexistent.yaml")
return []string{"git-expert"}, noCfg, base
},
verify: func(t *testing.T, base string, stdout, stderr string, err error) {},
wantErr: true,
wantErrMsg: "skill-sync init",
},
{
name: "skill not found in any source — exit 1",
setup: func(t *testing.T, base string) ([]string, string, string) {
toolA := filepath.Join(base, "toolA")
writeImportSkillFile(t, base, "toolA", "other-skill", "---\nversion: 1\n---\n# Other")
cfgPath := writeImportConfig(t, base, []string{toolA})
return []string{"nonexistent-skill"}, cfgPath, base
},
verify: func(t *testing.T, base string, stdout, stderr string, err error) {},
wantErr: true,
wantErrMsg: "not found",
},
{
name: "wrong arg count — zero args — exit 1 with usage",
setup: func(t *testing.T, base string) ([]string, string, string) {
cfgPath := filepath.Join(base, "skill-sync.yaml")
_ = os.WriteFile(cfgPath, []byte("targets:\n - /tmp\n"), 0644)
return []string{}, cfgPath, base
},
verify: func(t *testing.T, base string, stdout, stderr string, err error) {},
wantErr: true,
wantErrMsg: "accepts 1 arg",
},
{
name: "wrong arg count — two args — exit 1 with usage",
setup: func(t *testing.T, base string) ([]string, string, string) {
cfgPath := filepath.Join(base, "skill-sync.yaml")
_ = os.WriteFile(cfgPath, []byte("targets:\n - /tmp\n"), 0644)
return []string{"arg1", "arg2"}, cfgPath, base
},
verify: func(t *testing.T, base string, stdout, stderr string, err error) {},
wantErr: true,
wantErrMsg: "accepts 1 arg",
},
{
name: "conflict — identical version and mtime, different hash — exit 1",
setup: func(t *testing.T, base string) ([]string, string, string) {
toolA := filepath.Join(base, "toolA")
toolB := filepath.Join(base, "toolB")

writeImportSkillFile(t, base, "toolA", "ambiguous-skill", "---\nversion: 1\n---\n# Content A")
writeImportSkillFile(t, base, "toolB", "ambiguous-skill", "---\nversion: 1\n---\n# Content B")

fixedTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
pathA := filepath.Join(base, "toolA", "skills", "ambiguous-skill", "SKILL.md")
pathB := filepath.Join(base, "toolB", "skills", "ambiguous-skill", "SKILL.md")
_ = os.Chtimes(pathA, fixedTime, fixedTime)
_ = os.Chtimes(pathB, fixedTime, fixedTime)

cfgPath := writeImportConfig(t, base, []string{toolA, toolB})
return []string{"ambiguous-skill"}, cfgPath, base
},
verify: func(t *testing.T, base string, stdout, stderr string, err error) {},
wantErr: true,
wantErrMsg: "conflict",
},
{
name: "idempotent — imports same skill twice without error",
setup: func(t *testing.T, base string) ([]string, string, string) {
toolA := filepath.Join(base, "toolA")
writeImportSkillFile(t, base, "toolA", "stable-skill", "---\nversion: 1\n---\n# Stable Skill")
cfgPath := writeImportConfig(t, base, []string{toolA})
return []string{"stable-skill"}, cfgPath, base
},
verify: func(t *testing.T, base string, stdout, stderr string, err error) {
if err != nil {
t.Fatalf("first import failed: %v", err)
}
if !strings.Contains(stdout, "Imported stable-skill") {
t.Errorf("expected first import confirmation, got: %s", stdout)
}
},
wantErr: false,
},
{
name: "creates .bak backup when destination already exists",
setup: func(t *testing.T, base string) ([]string, string, string) {
toolA := filepath.Join(base, "toolA")
toolB := filepath.Join(base, "toolB")

writeImportSkillFile(t, base, "toolA", "backup-skill", "---\nversion: 2\n---\n# Backup Skill v2")
writeImportSkillFile(t, base, "toolB", "backup-skill", "---\nversion: 1\n---\n# Backup Skill v1")

// Populate the destination before import so it exists and has the old content
destDir := filepath.Join(base, ".agents", "skills", "backup-skill")
if err := os.MkdirAll(destDir, 0755); err != nil {
t.Fatalf("failed to create destination dir: %v", err)
}
if err := os.WriteFile(filepath.Join(destDir, "SKILL.md"), []byte("---\nversion: 1\n---\n# Backup Skill v1"), 0644); err != nil {
t.Fatalf("failed to write destination skill: %v", err)
}

cfgPath := writeImportConfig(t, base, []string{toolA, toolB})
return []string{"backup-skill"}, cfgPath, base
},
verify: func(t *testing.T, base string, stdout, stderr string, err error) {
if err != nil {
t.Fatalf("import failed: %v", err)
}
bakPath := filepath.Join(base, ".agents", "skills", "backup-skill.bak", "SKILL.md")
data, readErr := os.ReadFile(bakPath)
if readErr != nil {
t.Fatalf("expected .bak file at %s, got error: %v", bakPath, readErr)
}
if !strings.Contains(string(data), "# Backup Skill v1") {
t.Errorf("expected .bak to contain old content '# Backup Skill v1', got: %s", string(data))
}
},
wantErr: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
base := t.TempDir()
args, cfgPath, cwd := tt.setup(t, base)

if cwd != "" {
getWd = func() (string, error) { return cwd, nil }
} else {
getWd = os.Getwd
}

cmd := NewImportCmd()
cmd.SetArgs(append([]string{"--config", cfgPath}, args...))

var stdout, stderr strings.Builder
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)

err := cmd.Execute()

if tt.wantErr {
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErrMsg)
}
errStr := err.Error() + stderr.String()
if !strings.Contains(errStr, tt.wantErrMsg) {
t.Errorf("error %q does not contain %q", errStr, tt.wantErrMsg)
}
} else {
tt.verify(t, base, stdout.String(), stderr.String(), err)
}
})
}
}
28 changes: 28 additions & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ package cli

import (
"bytes"
"fmt"
"path/filepath"

"github.com/ezzek/skill-sync/internal/importer"
"github.com/ezzek/skill-sync/internal/models"
"github.com/ezzek/skill-sync/internal/tui"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -38,6 +41,30 @@ func NewRootCmd(version string) *cobra.Command {
err := verifyCmd.RunE(verifyCmd, nil)
return buf.String(), err
},
FindSkillsForImport: func(sources []string) ([]importer.SkillCandidate, error) {
cwd, err := getWd()
if err != nil {
return nil, err
}
destRoot := filepath.Join(cwd, ".agents", "skills")
imp := importer.New(sources, destRoot)
return imp.FindAllSkills()
},
RunImport: func(candidate importer.SkillCandidate) (string, error) {
if candidate.IsConflict {
return "", importer.ErrConflict
}
cwd, err := getWd()
if err != nil {
return "", err
}
destRoot := filepath.Join(cwd, ".agents", "skills")
imp := importer.New(nil, destRoot)
if err := imp.CopySkill(candidate.SkillID, candidate.SourceDir); err != nil {
return "", err
}
return fmt.Sprintf("Imported %s from %s", candidate.SkillID, candidate.SourceDir), nil
},
}
return tui.NewProgram(callbacks).Run()
},
Expand All @@ -47,6 +74,7 @@ func NewRootCmd(version string) *cobra.Command {
cmd.AddCommand(NewConfigCmd())
cmd.AddCommand(NewSyncCmd())
cmd.AddCommand(NewVerifyCmd())
cmd.AddCommand(NewImportCmd())

return cmd
}
12 changes: 12 additions & 0 deletions internal/importer/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package importer

import "errors"

// ErrSkillNotFound is returned when no skill matching the requested ID
// is found in any of the configured source directories.
var ErrSkillNotFound = errors.New("skill not found in any configured source")

// ErrConflict is returned when a skill exists in multiple sources with
// identical version and mtime but different content (true tie — no winner
// can be automatically determined).
var ErrConflict = errors.New("conflict: identical version and mtime in multiple sources")
Loading
Loading