Files
harness-sync/internal/cli/init.go
T

251 lines
7.3 KiB
Go

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 <name,name>")
}
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("<!-- from %s -->\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)
}