package cmd import ( "bufio" "flag" "fmt" "io" "os" "strings" "git.noodles.cam/claude-skills/marketplace/cli/internal/config" ) func init() { register(&Command{ Name: "auth", Group: GroupProject, Args: "list | add | remove ", Short: "manage the tokens this machine holds", Long: `Credentials live in one file per machine, outside every working tree, mode 0600. A project pins a login by NAME; the name is worth nothing on its own, which is what makes it safe to keep in a file inside the repository. The token is read from standard input unless --token is given, because an argument is in the shell history the moment it is typed: kettle auth add --name noodles --url https://git.example.com < token.txt pass show gitea/token | kettle auth add --name noodles --url https://git.example.com ` + "`list`" + ` never prints a token. There is no flag to make it.`, Examples: []Example{ {"kettle auth list", "what this machine holds"}, {"pass show gitea | kettle auth add --name noodles --url https://git.example.com", "add one, token on stdin"}, {"kettle auth remove noodles", "forget it"}, }, Setup: func(fs *flag.FlagSet) func([]string) error { name := fs.String("name", "", "login name (add)") url := fs.String("url", "", "instance URL, e.g. https://git.example.com (add)") user := fs.String("user", "", "account this token belongs to; documentation only (add)") token := fs.String("token", "", "token, if you would rather not use stdin (add)") return func(args []string) error { verb := "list" if len(args) > 0 { verb = args[0] } logins, err := config.LoadLogins() if err != nil { return err } switch verb { case "list": if len(logins.Logins) == 0 { fmt.Printf("no logins in %s\n", config.LoginsPath()) return nil } fmt.Printf("%s\n\n", config.LoginsPath()) for _, l := range logins.Logins { who := l.User if who == "" { who = "—" } fmt.Printf(" %-16s %-40s %s\n", l.Name, l.URL, who) } return nil case "add": if *name == "" || *url == "" { return Fail("--name and --url are both required") } secret := *token if secret == "" { if secret, err = readToken(os.Stdin); err != nil { return err } } if secret == "" { return Fail("no token — pipe one in, or pass --token") } entry := config.Login{ Name: *name, URL: strings.TrimRight(*url, "/"), User: *user, Token: secret, } if existing := logins.Find(*name); existing != nil { *existing = entry } else { logins.Logins = append(logins.Logins, entry) } if err := config.SaveLogins(logins); err != nil { return err } fmt.Printf("%s -> %s %s\n", entry.Name, entry.URL, config.LoginsPath()) return nil case "remove": if len(args) != 2 { return Fail("give exactly one login name to remove") } target := args[1] kept := logins.Logins[:0] found := false for _, l := range logins.Logins { if l.Name == target { found = true continue } kept = append(kept, l) } if !found { return Fail("no login %q in %s", target, config.LoginsPath()) } logins.Logins = kept if err := config.SaveLogins(logins); err != nil { return err } fmt.Printf("removed %s\n", target) return nil } return Fail("unknown subcommand %q — list, add, or remove", verb) } }, }) } // readToken takes the first non-empty line of r, trimmed. // // The first line, not the whole stream: a token piped from a password manager // often arrives with a trailing newline and sometimes with notes underneath it. func readToken(r io.Reader) (string, error) { sc := bufio.NewScanner(r) for sc.Scan() { if line := strings.TrimSpace(sc.Text()); line != "" { return line, nil } } return "", sc.Err() }