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 force 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 } // Issue 1: refuse overwrite without --force if _, statErr := os.Stat(filepath.Join(r, "config.yaml")); statErr == nil && !force { return fmt.Errorf("canonical root %s already initialised (use --force to re-import; existing skills/agents/mcp will be merged)", r) } candidates := reg.DetectedNames() if len(candidates) == 0 { return fmt.Errorf("no harnesses detected under %s", home) } var picked []string switch { case len(from) > 0: for _, f := range from { if f != "" { picked = append(picked, f) } } case noPrompt: picked = candidates default: picked, err = ui.MultiSelect("Import from which harnesses?", candidates) if err != nil { return err } } if len(picked) == 0 { return fmt.Errorf("no harnesses selected; rerun with --no-prompt or --from ") } for _, name := range picked { if _, ok := reg.Get(name); !ok { return fmt.Errorf("unknown adapter %q (try `harness-sync adapter list`)", name) } } 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") cmd.Flags().BoolVar(&force, "force", false, "re-import even if canonical root already initialised") 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 skills: %d\n agents: %d\n mcp servers: %d\n instructions: %d source(s)\n", picked, root, len(bundle.Skills), len(bundle.Agents), len(bundle.MCP.Servers), len(instructions), ) return nil } // importedProfileBody is the placeholder written to profiles/imported.yaml on first init. // Written using escape sequences to avoid literal newlines in string literals. const importedProfileBody = "# harness-sync profile \xe2\x80\x94 generated by init\n" + "# Edit the gateway block below to point at your LLM gateway before running\n" + "# `harness-sync apply`. The dummy token is fine for most local gateways.\n" + "name: imported\n" + "gateway:\n" + " url: \"\" # \xe2\x86\x90 set this\n" + " token: \"dummy\"\n" + " default_model: \"\" # \xe2\x86\x90 set this\n" + "models: []\n" 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 } // Issue 2: write placeholder profile only if it doesn't exist yet (preserve user edits on re-init). profPath := filepath.Join(root, "profiles", "imported.yaml") if _, statErr := os.Stat(profPath); os.IsNotExist(statErr) { if err = os.WriteFile(profPath, []byte(importedProfileBody), 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 { rel := s.Path if rel == "" { rel = filepath.Join(safeFilename(s.Name), "SKILL.md") } path := filepath.Join(root, "skills", rel) 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 { rel := a.Path if rel == "" { rel = safeFilename(a.Name) + ".md" } path := filepath.Join(root, "agents", rel) if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { return err } 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 } // safeFilename strips path separators and other characters that would break // when used as a basename. func safeFilename(s string) string { r := strings.NewReplacer("/", "-", "\\", "-", ":", "-", "\x00", "") return r.Replace(s) }