package cli import ( "errors" "fmt" "io" "os" "path/filepath" "strings" "git.hrafn.xyz/aether/gosick/internal/homesick/core" "git.hrafn.xyz/aether/gosick/internal/homesick/version" "github.com/alecthomas/kong" ) func Run(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int { model := &cliModel{} app, err := core.NewApp(stdin, stdout, stderr) if err != nil { _, _ = fmt.Fprintf(stderr, "error: %v\n", err) return 1 } parser, err := kong.New( model, kong.Name(programName()), kong.Description("Your home is your castle. Don't leave your precious dotfiles behind."), kong.Writers(stdout, stderr), kong.Exit(func(int) {}), kong.ConfigureHelp(kong.HelpOptions{Compact: true}), ) if err != nil { _, _ = fmt.Fprintf(stderr, "error: %v\n", err) return 1 } normalizedArgs := normalizeArgs(args) ctx, err := parser.Parse(normalizedArgs) if err != nil { var parseErr *kong.ParseError if errors.As(err, &parseErr) { if parseErr.ExitCode() == 0 || isHelpRequest(normalizedArgs) { return 0 } _, _ = fmt.Fprintf(stderr, "error: %v\n", err) if parseErr.Context != nil { _ = parseErr.Context.PrintUsage(false) } return parseErr.ExitCode() } _, _ = fmt.Fprintf(stderr, "error: %v\n", err) return 1 } app.Quiet = model.Quiet app.Pretend = model.Pretend || model.DryRun if err := ctx.Run(app); err != nil { var exitErr *cliExitError if errors.As(err, &exitErr) { _, _ = fmt.Fprintf(stderr, "error: %v\n", exitErr.err) return exitErr.code } _, _ = fmt.Fprintf(stderr, "error: %v\n", err) return 1 } return 0 } type cliModel struct { Pretend bool `help:"Preview actions without executing commands."` DryRun bool `name:"dry-run" help:"Alias for --pretend."` Quiet bool `help:"Suppress status output."` Clone cloneCmd `cmd:"" help:"Clone a castle."` List listCmd `cmd:"" help:"List castles."` ShowPath showPathCmd `cmd:"" name:"show_path" help:"Show the path of a castle."` Status statusCmd `cmd:"" help:"Show git status for a castle."` Diff diffCmd `cmd:"" help:"Show git diff for a castle."` Link linkCmd `cmd:"" help:"Symlink all dotfiles from a castle."` Unlink unlinkCmd `cmd:"" help:"Unsymlink all dotfiles from a castle."` Track trackCmd `cmd:"" help:"Track a file in a castle."` Version versionCmd `cmd:"" help:"Display the current version."` Pull pullCmd `cmd:"" help:"Pull the specified castle."` Push pushCmd `cmd:"" help:"Push the specified castle."` Commit commitCmd `cmd:"" help:"Commit the specified castle's changes."` Destroy destroyCmd `cmd:"" help:"Destroy a castle."` Cd cdCmd `cmd:"" help:"Print the path to a castle."` Open openCmd `cmd:"" help:"Open a castle."` Exec execCmd `cmd:"" help:"Execute a command in a castle."` ExecAll execAllCmd `cmd:"" name:"exec_all" help:"Execute a command in every castle."` Rc rcCmd `cmd:"" help:"Run rc hooks for a castle."` Generate generateCmd `cmd:"" help:"Generate a castle."` } type cloneCmd struct { URI string `arg:"" name:"URI" help:"Castle URI to clone."` Destination string `arg:"" optional:"" name:"CASTLE_NAME" help:"Optional local castle name."` } func (c *cloneCmd) Run(app *core.App) error { return app.Clone(c.URI, c.Destination) } type listCmd struct{} func (c *listCmd) Run(app *core.App) error { return app.List() } type showPathCmd struct { Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` } func (c *showPathCmd) Run(app *core.App) error { return app.ShowPath(defaultCastle(c.Castle)) } type statusCmd struct { Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` } func (c *statusCmd) Run(app *core.App) error { return app.Status(defaultCastle(c.Castle)) } type diffCmd struct { Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` } func (c *diffCmd) Run(app *core.App) error { return app.Diff(defaultCastle(c.Castle)) } type linkCmd struct { Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` } func (c *linkCmd) Run(app *core.App) error { return app.LinkCastle(defaultCastle(c.Castle)) } type unlinkCmd struct { Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` } func (c *unlinkCmd) Run(app *core.App) error { return app.Unlink(defaultCastle(c.Castle)) } type trackCmd struct { File string `arg:"" name:"FILE" help:"File to track."` Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` } func (c *trackCmd) Run(app *core.App) error { return app.Track(c.File, defaultCastle(c.Castle)) } type versionCmd struct{} func (c *versionCmd) Run(app *core.App) error { return app.Version(version.String) } type pullCmd struct { All bool `help:"Pull all castles."` Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` } type pushCmd struct { Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` } type commitCmd struct { Message string `short:"m" required:"" name:"MESSAGE" help:"Commit message."` Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` } type destroyCmd struct { Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` } type cdCmd struct { Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` } type openCmd struct { Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` } type execCmd struct { Castle string `arg:"" name:"CASTLE" help:"Castle name."` Command []string `arg:"" name:"COMMAND" help:"Shell command to execute in the castle root."` } type execAllCmd struct { Command []string `arg:"" name:"COMMAND" help:"Shell command to execute in each castle root."` } type rcCmd struct { Force bool `help:"Bypass legacy .homesickrc safety confirmation."` Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` } type generateCmd struct { Path string `arg:"" name:"PATH" help:"Path to generate a homesick-ready castle repository."` } func (c *pullCmd) Run(app *core.App) error { if c.All { if strings.TrimSpace(c.Castle) != "" { return errors.New("pull accepts either --all or CASTLE, not both") } return app.PullAll() } return app.Pull(defaultCastle(c.Castle)) } func (c *pushCmd) Run(app *core.App) error { return app.Push(defaultCastle(c.Castle)) } func (c *commitCmd) Run(app *core.App) error { return app.Commit(defaultCastle(c.Castle), c.Message) } func (c *destroyCmd) Run(app *core.App) error { return app.Destroy(defaultCastle(c.Castle)) } func (c *cdCmd) Run(app *core.App) error { return app.ShowPath(defaultCastle(c.Castle)) } func (c *openCmd) Run(app *core.App) error { return app.Open(defaultCastle(c.Castle)) } func (c *execCmd) Run(app *core.App) error { return app.Exec(c.Castle, c.Command) } func (c *execAllCmd) Run(app *core.App) error { return app.ExecAll(c.Command) } func (c *rcCmd) Run(app *core.App) error { return app.Rc(defaultCastle(c.Castle), c.Force) } func (c *generateCmd) Run(app *core.App) error { return app.Generate(c.Path) } func defaultCastle(castle string) string { if strings.TrimSpace(castle) == "" { return "dotfiles" } return castle } func programName() string { if len(os.Args) > 0 { if name := strings.TrimSpace(filepath.Base(os.Args[0])); name != "" { return name } } return "gosick" } func normalizeArgs(args []string) []string { if len(args) == 0 { return []string{"--help"} } prefix, rest := splitLeadingGlobalFlags(args) if len(rest) == 0 { return args } switch rest[0] { case "-h", "--help": return []string{"--help"} case "help": if len(rest) == 1 { return []string{"--help"} } normalized := append([]string{}, prefix...) normalized = append(normalized, rest[1:]...) return append(normalized, "--help") case "-v", "--version": return []string{"version"} case "symlink": normalized := append([]string{}, prefix...) normalized = append(normalized, "link") return append(normalized, rest[1:]...) case "commit": if len(rest) == 3 && !hasCommitMessageFlag(rest[1:]) { normalized := append([]string{}, prefix...) return append(normalized, "commit", "-m", rest[2], rest[1]) } return args default: return args } } func splitLeadingGlobalFlags(args []string) ([]string, []string) { i := 0 for i < len(args) { switch args[i] { case "--pretend", "--dry-run", "--quiet": i++ default: return args[:i], args[i:] } } return args, nil } func hasCommitMessageFlag(args []string) bool { for _, arg := range args { if arg == "-m" || strings.HasPrefix(arg, "--MESSAGE") || strings.HasPrefix(arg, "--message") { return true } } return false } func isHelpRequest(args []string) bool { for _, arg := range args { if arg == "-h" || arg == "--help" { return true } } return false } type cliExitError struct { code int err error } func (e *cliExitError) Error() string { return e.err.Error() }