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. --scopes RECORDS WHAT THE TOKEN WAS MINTED WITH, and records is all it does: nothing is checked against it and nothing is refused because of it. It is worth writing down because the instance will not answer the question — Gitea's own token listing needs a password, not a token, so a token cannot be asked what it may do. Gitea spells them :; issues need ` + "`write:issue`" + `, and everything ` + "`kettle api`" + ` reaches outside issues — releases, pull requests, branches, tags, actions — is ` + "`repository`" + `. A token minted for issues alone answers 403 there, and the 403 names no scope.`, 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 add --name noodles --url https://git.example.com --scopes write:issue,write:repository < t.txt", "and write down what it can do"}, {"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)") scopes := fs.String("scopes", "", "what the token was minted with, comma separated, e.g. write:issue,write:repository; 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 = "—" } // Not recorded is not the same as none, and a listing // that printed "—" for both would be the reason somebody // re-mints a token that was fine. scopes := "(not recorded)" if len(l.Scopes) > 0 { scopes = strings.Join(l.Scopes, ", ") } fmt.Printf(" %-16s %-40s %-16s %s\n", l.Name, l.URL, who, scopes) } 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, Scopes: splitScopes(*scopes), 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) } }, }) } // splitScopes reads the comma-separated list --scopes takes. // // Nothing here validates a scope name against Gitea's set: the set is the // server's and it grows, and a spelling this binary has not heard of is more // likely a newer Gitea than a typo. The field is a note to a human either way. func splitScopes(v string) []string { var out []string for _, s := range strings.Split(v, ",") { if s = strings.TrimSpace(s); s != "" { out = append(out, s) } } return out } // 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() }