diff --git a/internal/cli/adapter.go b/internal/cli/adapter.go new file mode 100644 index 0000000..c55d8ad --- /dev/null +++ b/internal/cli/adapter.go @@ -0,0 +1,28 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/lukaszraczylo/harness-sync/internal/adapter" +) + +// NewAdapter returns the `adapter` parent command with a `list` subcommand. +func NewAdapter(reg *adapter.Registry) *cobra.Command { + list := &cobra.Command{ + Use: "list", + Short: "List registered adapters", + RunE: func(cmd *cobra.Command, _ []string) error { + for _, a := range reg.All() { + if _, err := fmt.Fprintln(cmd.OutOrStdout(), a.Name()); err != nil { + return err + } + } + return nil + }, + } + cmd := &cobra.Command{Use: "adapter", Short: "Adapter introspection"} + cmd.AddCommand(list) + return cmd +} diff --git a/internal/cli/adapter_test.go b/internal/cli/adapter_test.go new file mode 100644 index 0000000..a735f31 --- /dev/null +++ b/internal/cli/adapter_test.go @@ -0,0 +1,27 @@ +package cli + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/lukaszraczylo/harness-sync/internal/adapter" +) + +func TestAdapterList(t *testing.T) { + reg := adapter.NewRegistry() + reg.Register(&detectableAdapter{name: "alpha", detect: true}) + reg.Register(&detectableAdapter{name: "beta", detect: false}) + + var buf bytes.Buffer + cmd := NewAdapter(reg) + cmd.SetArgs([]string{"list"}) + cmd.SetOut(&buf) + require.NoError(t, cmd.Execute()) + + out := buf.String() + assert.Contains(t, out, "alpha") + assert.Contains(t, out, "beta") +} diff --git a/internal/cli/apply.go b/internal/cli/apply.go new file mode 100644 index 0000000..d5485b2 --- /dev/null +++ b/internal/cli/apply.go @@ -0,0 +1,91 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/lukaszraczylo/harness-sync/internal/adapter" + "github.com/lukaszraczylo/harness-sync/internal/apply" + "github.com/lukaszraczylo/harness-sync/internal/canonical" + "github.com/lukaszraczylo/harness-sync/internal/gitx" +) + +// NewApply returns the `apply` subcommand. It renders the canonical bundle +// to each detected (or selected) harness using the apply pipeline. +func NewApply(reg *adapter.Registry) *cobra.Command { + var ( + dryRun bool + force bool + root string + ) + cmd := &cobra.Command{ + Use: "apply [harness...]", + Short: "Render canonical and propagate to harnesses", + RunE: func(cmd *cobra.Command, args []string) error { + if root == "" { + h, err := os.UserHomeDir() + if err != nil { + return err + } + root = filepath.Join(h, ".config", "harness-sync") + } + b, err := canonical.Load(root) + if err != nil { + return err + } + + var selected []adapter.Adapter + if len(args) == 0 { + for _, a := range reg.All() { + if a.Detect() { + selected = append(selected, a) + } + } + } else { + for _, name := range args { + a, ok := reg.Get(name) + if !ok { + return fmt.Errorf("unknown adapter %q", name) + } + selected = append(selected, a) + } + } + + opt := apply.Options{ + Bundle: b, + Adapters: selected, + Repo: gitx.New(root), + DryRun: dryRun, + Force: force, + } + rep, err := apply.Run(opt) + if err != nil { + return err + } + mode := "applied" + if dryRun { + mode = "dry-run" + } + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %d written, %d skipped, %d conflicts\n", + mode, rep.Written, rep.Skipped, rep.Conflicts); err != nil { + return err + } + for _, a := range rep.Actions { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), " %-10s %-12s %s %s\n", a.Adapter, a.Kind, a.Dest, a.Note); err != nil { + return err + } + } + if rep.Conflicts > 0 { + return fmt.Errorf("%d conflicts; resolve .rej files", rep.Conflicts) + } + return nil + }, + } + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print actions without writing") + cmd.Flags().BoolVar(&force, "force", false, "overwrite without 3-way merge") + cmd.Flags().StringVar(&root, "root", "", "canonical root (default ~/.config/harness-sync)") + return cmd +} diff --git a/internal/cli/apply_test.go b/internal/cli/apply_test.go new file mode 100644 index 0000000..055c3af --- /dev/null +++ b/internal/cli/apply_test.go @@ -0,0 +1,32 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/lukaszraczylo/harness-sync/internal/adapter" +) + +func TestApplyDryRun(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "config.yaml"), []byte("active_profile: home\n"), 0o600)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "profiles"), 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(root, "profiles", "home.yaml"), + []byte("name: home\ngateway:\n url: u\n token: t\n default_model: m\nmodels:\n - {id: m}\n"), 0o600)) + + reg := adapter.NewRegistry() + reg.Register(&detectableAdapter{name: "stub", detect: true}) + + var buf bytes.Buffer + cmd := NewApply(reg) + cmd.SetOut(&buf) + cmd.SetArgs([]string{"--dry-run", "--root", root}) + require.NoError(t, cmd.Execute()) + + assert.Contains(t, buf.String(), "dry-run") +} diff --git a/internal/cli/diff.go b/internal/cli/diff.go new file mode 100644 index 0000000..42c289a --- /dev/null +++ b/internal/cli/diff.go @@ -0,0 +1,23 @@ +package cli + +import ( + "github.com/spf13/cobra" + + "github.com/lukaszraczylo/harness-sync/internal/adapter" +) + +// NewDiff returns the `diff` subcommand. It is a shorthand for +// `apply --dry-run` and shows pending changes without writing. +func NewDiff(reg *adapter.Registry) *cobra.Command { + return &cobra.Command{ + Use: "diff [harness...]", + Short: "Show pending changes (alias for `apply --dry-run`)", + RunE: func(cmd *cobra.Command, args []string) error { + apply := NewApply(reg) + apply.SetOut(cmd.OutOrStdout()) + apply.SetErr(cmd.ErrOrStderr()) + apply.SetArgs(append([]string{"--dry-run"}, args...)) + return apply.Execute() + }, + } +} diff --git a/internal/cli/diff_test.go b/internal/cli/diff_test.go new file mode 100644 index 0000000..ba4034c --- /dev/null +++ b/internal/cli/diff_test.go @@ -0,0 +1,31 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/lukaszraczylo/harness-sync/internal/adapter" +) + +func TestDiffInvokesDryRun(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "config.yaml"), []byte("active_profile: home\n"), 0o600)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "profiles"), 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(root, "profiles", "home.yaml"), + []byte("name: home\ngateway:\n url: u\n token: t\n default_model: m\nmodels:\n - {id: m}\n"), 0o600)) + + reg := adapter.NewRegistry() + reg.Register(&detectableAdapter{name: "stub", detect: true}) + + var buf bytes.Buffer + cmd := NewDiff(reg) + cmd.SetOut(&buf) + cmd.SetArgs([]string{"--root", root}) + require.NoError(t, cmd.Execute()) + assert.Contains(t, buf.String(), "dry-run") +} diff --git a/internal/cli/init.go b/internal/cli/init.go new file mode 100644 index 0000000..c0eb0ea --- /dev/null +++ b/internal/cli/init.go @@ -0,0 +1,207 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "github.com/lukaszraczylo/harness-sync/internal/adapter" + "github.com/lukaszraczylo/harness-sync/internal/canonical" + "github.com/lukaszraczylo/harness-sync/internal/gitx" + "github.com/lukaszraczylo/harness-sync/internal/render" + "github.com/lukaszraczylo/harness-sync/internal/ui" +) + +// NewInit returns the `init` subcommand: scan for detected harnesses, import +// their content into a canonical tree, and produce an initial git commit. +func NewInit(reg *adapter.Registry) *cobra.Command { + var ( + root string + home string + from []string + noPrompt bool + ) + cmd := &cobra.Command{ + Use: "init", + Short: "Initialise canonical config from existing harnesses", + RunE: func(cmd *cobra.Command, _ []string) error { + var err error + r, err := resolveRoot(root) + if err != nil { + return err + } + if home == "" { + home, err = os.UserHomeDir() + if err != nil { + return err + } + } + if err = os.MkdirAll(r, 0o750); err != nil { + return err + } + candidates := reg.DetectedNames() + if len(candidates) == 0 { + return fmt.Errorf("no harnesses detected under %s", home) + } + var picked []string + switch { + case len(from) > 0: + picked = from + case noPrompt: + picked = candidates + default: + picked, err = ui.MultiSelect("Import from which harnesses?", candidates) + if err != nil { + return err + } + } + return runImport(cmd, reg, r, home, picked) + }, + } + cmd.Flags().StringVar(&root, "root", "", "canonical root (default ~/.config/harness-sync)") + cmd.Flags().StringVar(&home, "home", "", "home dir (default $HOME)") + cmd.Flags().StringSliceVar(&from, "from", nil, "import from specific adapter(s), skip prompt") + cmd.Flags().BoolVar(&noPrompt, "no-prompt", false, "import from all detected adapters without prompting") + return cmd +} + +func runImport(cmd *cobra.Command, reg *adapter.Registry, root, home string, picked []string) error { + bundle := &canonical.Bundle{ + Root: root, + Config: canonical.Config{ActiveProfile: "imported"}, + } + seenSkills := map[string]bool{} + seenAgents := map[string]bool{} + seenMCP := map[string]bool{} + var instructions []string + + for _, name := range picked { + ad, ok := reg.Get(name) + if !ok { + return fmt.Errorf("unknown adapter %q", name) + } + res, err := ad.Import(home) + if err != nil { + return fmt.Errorf("import %s: %w", name, err) + } + for _, s := range res.Skills { + key := s.Name + "|" + s.Body + if seenSkills[key] { + continue + } + seenSkills[key] = true + bundle.Skills = append(bundle.Skills, s) + } + for _, a := range res.Agents { + key := a.Name + "|" + a.Body + if seenAgents[key] { + continue + } + seenAgents[key] = true + bundle.Agents = append(bundle.Agents, a) + } + for _, m := range res.MCP { + if seenMCP[m.Name] { + continue + } + seenMCP[m.Name] = true + bundle.MCP.Servers = append(bundle.MCP.Servers, m) + } + if res.Instructions != "" { + instructions = append(instructions, fmt.Sprintf("\n%s", name, res.Instructions)) + } + } + bundle.Instructions.Global = strings.Join(instructions, "\n\n") + + if err := writeCanonical(root, bundle, picked); err != nil { + return err + } + repo := gitx.New(root) + if !repo.IsRepo() { + if err := repo.Init(); err != nil { + return err + } + if err := repo.Configure("harness-sync", "harness-sync@localhost"); err != nil { + return err + } + } + if err := repo.AddAll(); err != nil { + return err + } + if err := repo.Commit(fmt.Sprintf("import from %s", strings.Join(picked, ", "))); err != nil { + return err + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "imported from %v into %s\n", picked, root) + return nil +} + +func writeCanonical(root string, b *canonical.Bundle, picked []string) error { + for _, sub := range []string{"profiles", "skills", "agents", "instructions"} { + if err := os.MkdirAll(filepath.Join(root, sub), 0o750); err != nil { + return err + } + } + + cfgBody, err := render.YAML(map[string]any{ + "enabled_harnesses": picked, + "active_profile": "imported", + }) + if err != nil { + return err + } + if err = os.WriteFile(filepath.Join(root, "config.yaml"), cfgBody, 0o600); err != nil { + return err + } + + profBody, err := render.YAML(canonical.Profile{ + Name: "imported", + Gateway: canonical.Gateway{ + URL: "https://gateway.local", + Token: "dummy", + DefaultModel: "claude-sonnet-4-6", + }, + Models: []canonical.Model{{ID: "claude-sonnet-4-6", Alias: "sonnet"}}, + }) + if err != nil { + return err + } + if err = os.WriteFile(filepath.Join(root, "profiles", "imported.yaml"), profBody, 0o600); err != nil { + return err + } + + if len(b.MCP.Servers) > 0 { + mcpBody, err := render.YAML(b.MCP) + if err != nil { + return err + } + if err = os.WriteFile(filepath.Join(root, "mcp.yaml"), mcpBody, 0o600); err != nil { + return err + } + } + + for _, s := range b.Skills { + path := filepath.Join(root, "skills", s.Name, "SKILL.md") + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return err + } + if err := os.WriteFile(path, []byte(s.Body), 0o600); err != nil { + return err + } + } + for _, a := range b.Agents { + path := filepath.Join(root, "agents", a.Name+".md") + if err := os.WriteFile(path, []byte(a.Body), 0o600); err != nil { + return err + } + } + if b.Instructions.Global != "" { + if err := os.WriteFile(filepath.Join(root, "instructions", "global.md"), + []byte(b.Instructions.Global), 0o600); err != nil { + return err + } + } + return nil +} diff --git a/internal/cli/init_test.go b/internal/cli/init_test.go new file mode 100644 index 0000000..7006b6a --- /dev/null +++ b/internal/cli/init_test.go @@ -0,0 +1,51 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/lukaszraczylo/harness-sync/internal/adapter" + "github.com/lukaszraczylo/harness-sync/internal/canonical" +) + +type importableAdapter struct { + res *adapter.ImportResult + name string +} + +func (i *importableAdapter) Name() string { return i.name } +func (i *importableAdapter) Detect() bool { return true } +func (i *importableAdapter) Render(_ *canonical.Bundle) (*adapter.FileSet, error) { + return adapter.NewFileSet(), nil +} +func (i *importableAdapter) Import(_ string) (*adapter.ImportResult, error) { return i.res, nil } + +func TestInitImportWritesCanonical(t *testing.T) { + root := t.TempDir() + home := t.TempDir() + + reg := adapter.NewRegistry() + reg.Register(&importableAdapter{ + name: "stub", + res: &adapter.ImportResult{ + Skills: []canonical.Skill{{Name: "hi", Body: "---\nname: hi\n---\nhi body", Path: "hi/SKILL.md"}}, + Instructions: "# global", + MCP: []canonical.MCPServer{{Name: "filepuff", Command: "/bin/x"}}, + }, + }) + + cmd := NewInit(reg) + cmd.SetArgs([]string{"--root", root, "--home", home, "--from", "stub", "--no-prompt"}) + require.NoError(t, cmd.Execute()) + + assert.FileExists(t, filepath.Join(root, "config.yaml")) + assert.FileExists(t, filepath.Join(root, "instructions", "global.md")) + assert.FileExists(t, filepath.Join(root, "mcp.yaml")) + assert.FileExists(t, filepath.Join(root, "skills", "hi", "SKILL.md")) + _, err := os.Stat(filepath.Join(root, ".git")) + require.NoError(t, err) +} diff --git a/internal/cli/profile.go b/internal/cli/profile.go new file mode 100644 index 0000000..5fcadb8 --- /dev/null +++ b/internal/cli/profile.go @@ -0,0 +1,83 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/spf13/cobra" +) + +// NewProfile returns the `profile` subcommand with `list` and `use`. +func NewProfile() *cobra.Command { + var root string + rootFlag := func(c *cobra.Command) { + c.Flags().StringVar(&root, "root", "", "canonical root (default ~/.config/harness-sync)") + } + + list := &cobra.Command{ + Use: "list", + Short: "List available profiles", + RunE: func(cmd *cobra.Command, _ []string) error { + r, err := resolveRoot(root) + if err != nil { + return err + } + entries, err := os.ReadDir(filepath.Join(r, "profiles")) + if err != nil { + return err + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".yaml") { + names = append(names, strings.TrimSuffix(e.Name(), ".yaml")) + } + } + sort.Strings(names) + for _, n := range names { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), n) + } + return nil + }, + } + rootFlag(list) + + use := &cobra.Command{ + Use: "use ", + Short: "Set active profile (rewrites config.yaml)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + r, err := resolveRoot(root) + if err != nil { + return err + } + target := args[0] + profPath := filepath.Join(r, "profiles", target+".yaml") + if _, err := os.Stat(profPath); err != nil { + return fmt.Errorf("profile %q not found at %s", target, profPath) + } + configPath := filepath.Join(r, "config.yaml") + existing, _ := os.ReadFile(configPath) + updated := setActiveProfile(string(existing), target) + return os.WriteFile(configPath, []byte(updated), 0o600) + }, + } + rootFlag(use) + + cmd := &cobra.Command{Use: "profile", Short: "Manage profiles"} + cmd.AddCommand(list, use) + return cmd +} + +func setActiveProfile(existing, name string) string { + lines := strings.Split(existing, "\n") + for i, l := range lines { + if strings.HasPrefix(l, "active_profile:") { + lines[i] = "active_profile: " + name + return strings.Join(lines, "\n") + } + } + return strings.TrimRight(existing, "\n") + "\nactive_profile: " + name + "\n" +} diff --git a/internal/cli/profile_test.go b/internal/cli/profile_test.go new file mode 100644 index 0000000..4602a23 --- /dev/null +++ b/internal/cli/profile_test.go @@ -0,0 +1,43 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProfileList(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "profiles"), 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(root, "profiles", "home.yaml"), []byte("name: home\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(root, "profiles", "work.yaml"), []byte("name: work\n"), 0o600)) + + var buf bytes.Buffer + cmd := NewProfile() + cmd.SetArgs([]string{"list", "--root", root}) + cmd.SetOut(&buf) + require.NoError(t, cmd.Execute()) + + out := buf.String() + assert.Contains(t, out, "home") + assert.Contains(t, out, "work") +} + +func TestProfileUse(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "profiles"), 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(root, "profiles", "work.yaml"), []byte("name: work\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(root, "config.yaml"), []byte("active_profile: home\n"), 0o600)) + + cmd := NewProfile() + cmd.SetArgs([]string{"use", "work", "--root", root}) + require.NoError(t, cmd.Execute()) + + body, err := os.ReadFile(filepath.Join(root, "config.yaml")) + require.NoError(t, err) + assert.Contains(t, string(body), "active_profile: work") +} diff --git a/internal/cli/rollback.go b/internal/cli/rollback.go new file mode 100644 index 0000000..d925b79 --- /dev/null +++ b/internal/cli/rollback.go @@ -0,0 +1,37 @@ +package cli + +import ( + "strconv" + + "github.com/spf13/cobra" + + "github.com/lukaszraczylo/harness-sync/internal/gitx" +) + +// NewRollback returns the `rollback` subcommand. It reverts the last N apply +// commits in the canonical repo by calling `git revert` over a range. +func NewRollback() *cobra.Command { + var root string + cmd := &cobra.Command{ + Use: "rollback [n]", + Short: "Revert the last N apply commits in the canonical repo", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + r, err := resolveRoot(root) + if err != nil { + return err + } + n := 1 + if len(args) == 1 { + v, err := strconv.Atoi(args[0]) + if err != nil || v < 1 { + return cmd.Help() + } + n = v + } + return gitx.New(r).Revert(n) + }, + } + cmd.Flags().StringVar(&root, "root", "", "canonical root") + return cmd +} diff --git a/internal/cli/rollback_test.go b/internal/cli/rollback_test.go new file mode 100644 index 0000000..0121062 --- /dev/null +++ b/internal/cli/rollback_test.go @@ -0,0 +1,12 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRollbackHelp(t *testing.T) { + cmd := NewRollback() + assert.Equal(t, "rollback [n]", cmd.Use) +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 76cb3ef..6d4aef2 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -1,7 +1,13 @@ // Package cli provides the cobra command tree for harness-sync. package cli -import "github.com/spf13/cobra" +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" +) func NewRoot(version string) *cobra.Command { return &cobra.Command{ @@ -11,3 +17,16 @@ func NewRoot(version string) *cobra.Command { SilenceUsage: true, } } + +// resolveRoot returns the canonical root: the flag value when non-empty, +// else $HOME/.config/harness-sync. +func resolveRoot(flag string) (string, error) { + if flag != "" { + return flag, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home dir: %w", err) + } + return filepath.Join(home, ".config", "harness-sync"), nil +} diff --git a/internal/cli/show.go b/internal/cli/show.go new file mode 100644 index 0000000..b8d2a5f --- /dev/null +++ b/internal/cli/show.go @@ -0,0 +1,120 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/lukaszraczylo/harness-sync/internal/adapter" + "github.com/lukaszraczylo/harness-sync/internal/canonical" +) + +// NewShow returns the `show` subcommand which prints the files each adapter +// would manage if `apply` were run now. Useful for auditing what harness-sync +// will write to your system before committing to it. +func NewShow(reg *adapter.Registry) *cobra.Command { + var ( + root string + all bool + ) + cmd := &cobra.Command{ + Use: "show [harness...]", + Short: "List files harness-sync manages for each harness", + Long: `Renders the canonical bundle through each adapter and prints +the resulting targets without writing anything. By default, shows only detected +harnesses; pass --all to include adapters whose harness is not installed, or +positional args to select specific harnesses.`, + RunE: func(cmd *cobra.Command, args []string) error { + r, err := resolveRoot(root) + if err != nil { + return err + } + b, err := canonical.Load(r) + if err != nil { + return err + } + + var selected []adapter.Adapter + switch { + case len(args) > 0: + for _, name := range args { + a, ok := reg.Get(name) + if !ok { + return fmt.Errorf("unknown adapter %q", name) + } + selected = append(selected, a) + } + case all: + selected = reg.All() + default: + for _, a := range reg.All() { + if a.Detect() { + selected = append(selected, a) + } + } + } + + w := cmd.OutOrStdout() + for _, a := range selected { + fs, err := a.Render(b) + if err != nil { + if _, werr := fmt.Fprintf(w, "%s: render error: %v\n", a.Name(), err); werr != nil { + return werr + } + continue + } + if _, werr := fmt.Fprintf(w, "%s (%s)\n", a.Name(), detectLabel(a)); werr != nil { + return werr + } + var iterErr error + fs.ForEach(func(f adapter.File) { + if iterErr != nil { + return + } + switch f.Kind { + case adapter.SymlinkDir, adapter.SymlinkFile: + _, iterErr = fmt.Fprintf(w, " %-12s %s -> %s\n", + f.Kind.String(), short(f.Dest), short(f.SymlinkTarget)) + case adapter.RenderedFile: + _, iterErr = fmt.Fprintf(w, " %-12s %s (%d bytes)\n", + f.Kind.String(), short(f.Dest), len(f.Content)) + } + }) + if iterErr != nil { + return iterErr + } + if _, werr := fmt.Fprintln(w); werr != nil { + return werr + } + } + return nil + }, + } + cmd.Flags().StringVar(&root, "root", "", "canonical root (default ~/.config/harness-sync)") + cmd.Flags().BoolVar(&all, "all", false, "include adapters whose harness is not detected") + return cmd +} + +func detectLabel(a adapter.Adapter) string { + if a.Detect() { + return "detected" + } + return "not detected" +} + +// short collapses the user's home dir to "~" for cleaner output. +func short(p string) string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return p + } + if p == home { + return "~" + } + if len(p) > len(home) && p[:len(home)+1] == home+string(filepath.Separator) { + return "~" + p[len(home):] + } + return p +} diff --git a/internal/cli/show_test.go b/internal/cli/show_test.go new file mode 100644 index 0000000..adf2ed5 --- /dev/null +++ b/internal/cli/show_test.go @@ -0,0 +1,84 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/lukaszraczylo/harness-sync/internal/adapter" + "github.com/lukaszraczylo/harness-sync/internal/canonical" +) + +type renderingAdapter struct { + name string + files []adapter.File + detect bool +} + +func (r *renderingAdapter) Name() string { return r.name } +func (r *renderingAdapter) Detect() bool { return r.detect } +func (r *renderingAdapter) Import(_ string) (*adapter.ImportResult, error) { + return &adapter.ImportResult{}, nil +} +func (r *renderingAdapter) Render(_ *canonical.Bundle) (*adapter.FileSet, error) { + fs := adapter.NewFileSet() + for _, f := range r.files { + fs.Add(f) + } + return fs, nil +} + +func TestShowPrintsTargets(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "config.yaml"), []byte("active_profile: home\n"), 0o600)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "profiles"), 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(root, "profiles", "home.yaml"), + []byte("name: home\ngateway:\n url: u\n token: t\n default_model: m\nmodels:\n - {id: m}\n"), 0o600)) + + reg := adapter.NewRegistry() + reg.Register(&renderingAdapter{ + name: "stub", + detect: true, + files: []adapter.File{ + {Dest: "/tmp/out.json", Kind: adapter.RenderedFile, Content: []byte("{}\n")}, + {Dest: "/tmp/link", Kind: adapter.SymlinkDir, SymlinkTarget: "/canon/skills"}, + }, + }) + + var buf bytes.Buffer + cmd := NewShow(reg) + cmd.SetOut(&buf) + cmd.SetArgs([]string{"--root", root}) + require.NoError(t, cmd.Execute()) + + out := buf.String() + assert.Contains(t, out, "stub") + assert.Contains(t, out, "rendered") + assert.Contains(t, out, "symlink-dir") + assert.Contains(t, out, "/tmp/out.json") + assert.Contains(t, out, "/canon/skills") +} + +func TestShowAllIncludesUndetected(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "config.yaml"), []byte("active_profile: home\n"), 0o600)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "profiles"), 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(root, "profiles", "home.yaml"), + []byte("name: home\ngateway:\n url: u\n token: t\n default_model: m\nmodels:\n - {id: m}\n"), 0o600)) + + reg := adapter.NewRegistry() + reg.Register(&renderingAdapter{name: "absent", detect: false}) + + var buf bytes.Buffer + cmd := NewShow(reg) + cmd.SetOut(&buf) + cmd.SetArgs([]string{"--root", root, "--all"}) + require.NoError(t, cmd.Execute()) + + assert.Contains(t, buf.String(), "absent") + assert.Contains(t, buf.String(), "not detected") +}