feat: reach the rest of Gitea with kettle api, and drop tea

The plugin required `tea`, Gitea's own CLI, for everything that is not an
issue: releases, pull requests, milestones, branches, actions, webhooks. That
put a second binary, a second set of logins nothing here could see, and 400
lines documenting somebody else's flags outside anything this repository can
test. One command over the transport that already existed removes all three.

Transport: `post` — the hand-rolled request the SDK cannot express, written for
the dependency endpoint — is generalized to an exported `Do`, and `post` is
three lines on top of it. Same http.Client, so the same RoundTripper files the
body under .kettle/payload/, the same `token …` header authenticates it, and a
non-2xx is the same *APIError. It does not paginate, does not reformat the
answer, and names no domain concept, so the layering test is untouched.

The endpoint rule is `tea api`'s, so an endpoint table written for that tool
still works — with one restriction it did not have: a full URL must be on this
instance. Every request carries the project's token in a header, and a URL on
another host would hand the token to whatever was typed.

Command: `kettle api <endpoint>` in a new `api` group, so the generator writes
plugins/kettle/skills/api/SKILL.md — group, directory and /kettle:api are one
word. No --repo and no --login, for the reason no sync command has them: a
cross-repository address is an address, and another instance is KETTLE_URL.
`-X DELETE` needs `--yes`; a flag typed on purpose is an operator's decision.

Scopes: a token minted for issues carries write:issue and answers 403 on the
first request outside issues, naming no scope. Gitea cannot be asked what a
token may do — its own token listing needs a password — so `auth add --scopes`
records it, `auth list` and `config` show it, and a 403 says which category it
is likely to be. Documentation only; nothing is checked against it.

skills/use — the tea reference, 239 lines of it — becomes skills/api: what to
ask for, which endpoints paginate, and how to write a body. Every mention of
`tea` as a requirement is gone from the manifests, the READMEs, the runner and
the four other skills; what survives is the back-compat with the old plugin,
which is a decision and not a debt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
naudachu
2026-08-12 14:25:20 +05:00
parent e177f46510
commit f18a633185
32 changed files with 1335 additions and 519 deletions
+29 -6
View File
@@ -39,8 +39,8 @@ anything — which is how the doc generator reads them.
**The tree is flat.** `kettle new`, not `kettle issue new`: an agent pays for every
token of every invocation, and the grouping that matters for reading is carried in
`Group` and only shows up in the docs. Three groups, in presentation order:
`project`, `issue`, `sync`.
`Group` and only shows up in the docs. Four groups, in presentation order:
`project`, `issue`, `sync`, `api`.
| file | what is in it |
|---|---|
@@ -51,10 +51,16 @@ token of every invocation, and the grouping that matters for reading is carried
| `init.go` `auth.go` `config.go` `version.go` | group `project`. `version.go` also holds `Version`, the string a release build stamps in with `-ldflags -X` |
| `new.go` `check.go` `ac.go` `tree.go` `index.go` `evict.go` | group `issue` — no network in any of them |
| `pull.go` `push.go` `remote.go` `comment.go` `close.go` `labels.go` `evict_sync.go` | group `sync` |
| `api.go` | group `api`, alone in it: one request to an endpoint nothing here wraps |
| `cli_test.go` | builds the binary in `TestMain`, runs it as a subprocess |
| `sync_pull_test.go` `sync_write_test.go` | the tracker halves, against fake servers |
| `sync_pull_test.go` `sync_write_test.go` `api_test.go` | the tracker halves, against fake servers |
| `gen_test.go` | the generator: determinism, the region splice, the missing-marker refusal |
The fourth group is one command and was still worth naming, because a group is a
skill directory over in the plugin: `api` is a subject somebody loads on its own —
which endpoint, and does it paginate — and folding it into `sync` would have put
"how do I cut a release" behind a skill about the issue round trip.
## Three conventions every command follows
**Flags may come after positionals.** The standard `flag` package stops parsing at
@@ -84,6 +90,20 @@ login a project runs under is a fact about the project, stated once by
`kettle init`. That the two could disagree is what the Python version needed a
`PreToolUse` hook to police.
`api` keeps that rule and needs no flag to: a cross-repository address is an
address, so `repos/other-owner/other-repo/releases` is simply a path with nothing
to substitute — `{owner}` and `{repo}` are filled in only where they are spelled.
Another **instance** is `KETTLE_URL`/`KETTLE_TOKEN`, and a full URL pointing at a
host that is not this project's is refused by the transport rather than sent with
the token attached. It also resolves the store it never reads, exactly as `labels`
does, so "there is no project here" fails the same way for every command that
talks to a tracker.
**`-X DELETE` needs `--yes`.** The only gate of its kind in the tree, and it is
here because this is the only command that can delete something that is not an
issue — a release, a tag, a branch — from an argument. A flag typed on purpose is
an operator's decision; everything else about the request goes out as spelled.
`--out` is the one flag almost every command has, and an explicit one is used
**exactly as typed**: a relative `--out` stays relative to the working directory,
because that is what the operator asked for.
@@ -169,7 +189,10 @@ useful: it writes nothing and exits 1 when anything on disk differs, which is wh
pre-commit hook or a CI step calls, and it wins over `--dry-run`.
One file per **group**, so adding a group here adds a skill directory over there;
name one only when it is a subject somebody would load on its own. A command with no
name one only when it is a subject somebody would load on its own. `api` was
added exactly that way, and the first `gen skills` run after it wrote a stub whose
`description:` said TODO — a stub is not shippable, and filling that line in by
hand is the last step of adding a group, not an optional one. A command with no
`Group` is in no skill and the run says so. A `Long` or `Example` that spells a
region marker out in full is a hard error — the generated block would end inside
itself.
@@ -180,8 +203,8 @@ itself.
three conventions, the round trip, the generator. The file table names every
source file in this directory.
- **Update it when** a command file is added or removed, a group is added, a shared
helper in `flags.go`/`sync.go` changes, an exit code gains a meaning, or the
push/pull ordering guarantees change.
helper in `flags.go`/`sync.go` changes, an exit code gains a meaning, a command
gains a confirmation gate, or the push/pull ordering guarantees change.
- **Do not** copy a flag list or a command's `Long` text here. `kettle help <name>`
and the generated SKILL.md blocks are the two places that exist for it, and a
third would be the one that drifts.
+213
View File
@@ -0,0 +1,213 @@
package cmd
import (
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
// apiMethods is what this command will send. Not a defence against a typo so
// much as against a shell: an unquoted endpoint that swallowed a word must not
// be sent as a verb the server then answers 405 to.
var apiMethods = map[string]bool{
http.MethodGet: true,
http.MethodPost: true,
http.MethodPut: true,
http.MethodPatch: true,
http.MethodDelete: true,
}
func init() {
register(&Command{
Name: "api",
Group: GroupAPI,
Args: "<endpoint>",
Short: "one request to this project's Gitea, for everything that is not an issue",
Long: `Releases, pull requests, milestones, branches, tags, actions, webhooks,
notifications: everything Gitea has that this binary has no command for. One
invocation is ONE request — the credentials, the repository and the payload
scratchpad are the ones this project already resolved, so there is nothing to
configure and no second tool to log in.
THE ENDPOINT IS SPELLED THE WAY GITEA'S OWN DOCUMENTATION SPELLS IT. A bare path
is taken as relative to ` + "`/api/v1/`" + `; a path that already begins ` + "`/api/`" + ` is sent as it
stands, which is how anything outside v1 is reached; a full URL is allowed only
on the instance this project points at, because every request here carries the
project's token in a header and a URL somewhere else would hand that token over.
` + "`{owner}`" + ` and ` + "`{repo}`" + ` are filled in from the project's configuration. Quote an
endpoint that contains ? or & or the shell will take it apart.
ANOTHER REPOSITORY NEEDS NO FLAG — write its address into the path
(` + "`repos/other-owner/other-repo/releases`" + `) and nothing is substituted. There is no
--repo and no --login here for the same reason there is none on push or pull:
which login a project runs under is a fact about the project. Another INSTANCE
is KETTLE_URL and KETTLE_TOKEN, which is also what a CI run uses.
THE ANSWER IS THE SERVER'S BYTES ON STDOUT, unparsed and unreformatted — pipe it
to jq, redirect it to a file. There is no flag that names an output file: in
this tree --out is the issue store, and one word meaning two things is exactly
the trap the tool this replaces set with an -o that wrote a file called "json".
IT DOES NOT PAGINATE. One call is one request, so a listing answers with one
page: ask for the next with ?page=2, and for a bigger one with ?limit=50 (the
server's own default is 30, its maximum is usually 50). A passthrough that
stitched pages together silently would report as one answer something that was
several.
ISSUES ARE NOT THIS COMMAND'S JOB even though it can reach them. An issue read
this way arrives as a full JSON payload — every comment, every label object,
every URL — which is what /kettle:issue and /kettle:sync exist to keep out of a
context window. Use pull, push, comment and close.
A 403 here is usually the token rather than the request: a token minted for
issues carries write:issue, and releases, pull requests, branches and tags are
all under repository. ` + "`kettle auth list`" + ` shows what each login records.
-X DELETE NEEDS --yes. Everything else goes through as typed; a deletion does
not, because a flag typed on purpose is an operator's decision and the URL of a
release is one character away from the URL of the wrong release.
What it cannot do: an upload. Release attachments are multipart/form-data and
this sends JSON — the release tooling in cmd/release does those.`,
Examples: []Example{
{"kettle api repos/{owner}/{repo}/releases", "the latest page of releases, as JSON"},
{"kettle api user", "who this project's token belongs to"},
{`kettle api 'repos/{owner}/{repo}/pulls?state=open&limit=50'`, "quote anything with ? or & in it"},
{"kettle api --data @tmp/release/v0-2-0.json repos/{owner}/{repo}/releases", "a body from a file; POST is implied"},
{"kettle api --field body=lgtm repos/{owner}/{repo}/issues/7/comments", "a small body without a file"},
{"kettle api -X DELETE --yes repos/{owner}/{repo}/releases/12", "a deletion, said out loud"},
{"kettle api repos/{owner}/{repo}/milestones | jq '.[].title'", "the bytes are the server's; jq is yours"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
var method string
fs.StringVar(&method, "method", "", "GET, POST, PUT, PATCH or DELETE (default GET, or POST when there is a body)")
fs.StringVar(&method, "X", "", "the same flag as --method, spelled the way curl and the tool this replaces spell it")
data := fs.String("data", "", "the request body: @file, @- for standard input, or the JSON itself")
var fields stringList
fs.Var(&fields, "field", "key=value, added to a JSON body as a string; repeatable")
status := fs.Bool("status", false, "print the status line on standard error")
yes := fs.Bool("yes", false, "confirm a DELETE")
return func(args []string) error {
if len(args) != 1 {
return Fail("give exactly one endpoint, e.g. `kettle api repos/{owner}/{repo}/releases`")
}
if *data != "" && len(fields) > 0 {
return Fail("--data and --field are two ways of writing one body — use one of them")
}
body, err := apiBody(*data, fields)
if err != nil {
return err
}
verb := strings.ToUpper(method)
switch {
case verb == "" && body != nil:
verb = http.MethodPost
case verb == "":
verb = http.MethodGet
case !apiMethods[verb]:
return Fail("%s is not a method this sends — GET, POST, PUT, PATCH or DELETE", verb)
}
if verb == http.MethodDelete && !*yes {
return Fail("-X DELETE deletes something on the tracker — re-run with --yes if that is what you mean")
}
// The store is resolved and then dropped, exactly as `labels`
// does: this command touches no issue, but it must fail the same
// way as every other tracker command when there is no project,
// naming `kettle init` rather than a connection.
_, client, err := syncStart("")
if err != nil {
return err
}
code, answer, err := client.Do(verb, apiEndpoint(args[0], client.Repo()), body, "")
if *status && code != 0 {
fmt.Fprintf(os.Stderr, "%d %s\n", code, http.StatusText(code))
}
if err != nil {
return err
}
if _, err := os.Stdout.Write(answer); err != nil {
return err
}
// A newline only when the server did not send one: what came
// back is what goes out, and a terminal prompt half way along a
// line of JSON is nobody's idea of raw fidelity.
if n := len(answer); n > 0 && answer[n-1] != '\n' {
fmt.Println()
}
return nil
}
},
})
}
// apiEndpoint fills the two placeholders in.
//
// Two and no more: the owner and the name are what a project pins, and every
// other id in a Gitea path — an issue number, a release id, a comment id — is
// the caller's to know. A path that spells another repository out in full is
// left alone, which is how one project reaches another's releases without a
// flag.
func apiEndpoint(spelled string, repo wire.Repo) string {
return strings.NewReplacer("{owner}", repo.Owner, "{repo}", repo.Name).Replace(spelled)
}
// apiBody is the request body, from whichever of the two flags supplied it.
//
// A nil body is a request with no body at all, which is what a GET and a DELETE
// want — as distinct from `--data '{}'`, which is an empty object and a
// different thing to send.
func apiBody(data string, fields stringList) ([]byte, error) {
if len(fields) > 0 {
out := make(map[string]string, len(fields))
for _, f := range fields {
key, value, ok := strings.Cut(f, "=")
if !ok || key == "" {
return nil, Fail("--field %q is not key=value", f)
}
out[key] = value
}
// Every value is a STRING. Guessing at types is how a tag_name of 1.0
// goes up as the number 1 — and a body that needs a boolean, a number or
// nesting is a body worth writing down, which is what --data is for.
return json.Marshal(out)
}
if data == "" {
return nil, nil
}
raw := []byte(data)
switch {
case data == "@-":
read, err := io.ReadAll(os.Stdin)
if err != nil {
return nil, err
}
raw = read
case strings.HasPrefix(data, "@"):
read, err := os.ReadFile(data[1:])
if err != nil {
return nil, err
}
raw = read
}
// Checked here rather than left to the server, because the answer from
// there is a 400 with a parser's opinion in it, and the file that produced
// it is not named anywhere in that.
if !json.Valid(raw) {
if strings.HasPrefix(data, "@") {
return nil, Fail("%s does not hold JSON — every body this sends is JSON", data[1:])
}
return nil, Fail("--data is not JSON — pass @file, @- for standard input, or valid JSON")
}
return raw, nil
}
+281
View File
@@ -0,0 +1,281 @@
package cmd_test
// `kettle api` end to end: the real binary, in a throwaway project, against a
// fake that records what it was asked for and answers with bytes.
//
// What is worth proving here is not that HTTP works — internal/gitea has that
// against httptest — but the four things this command decides on its own: which
// verb goes out, what the endpoint resolves to, that the answer reaches stdout
// unchanged, and that a deletion does not happen because a model typed it.
//
// Every helper is named `ap…` so it cannot collide with the two fakes already in
// this package. The version handshake is pullVersionRoute's, because a fake that
// does not answer it is a fake no command can build a client against.
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
)
// apCall is one request as the fake saw it.
type apCall struct {
Method string
URI string
Body string
Auth string
}
// apTracker answers everything with the same little JSON object and remembers
// what it was asked. A status can be armed for the one test that wants a
// refusal.
type apTracker struct {
mu sync.Mutex
calls []apCall
status int
answer string
}
func (tr *apTracker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if pullVersionRoute(w, r) {
return
}
raw, _ := io.ReadAll(r.Body)
tr.mu.Lock()
tr.calls = append(tr.calls, apCall{
Method: r.Method,
URI: r.URL.RequestURI(),
Body: string(raw),
Auth: r.Header.Get("Authorization"),
})
status, answer := tr.status, tr.answer
tr.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
if status == 0 {
status = http.StatusOK
}
if answer == "" {
answer = `{"tag_name":"v0.2.0"}`
}
w.WriteHeader(status)
io.WriteString(w, answer)
}
func (tr *apTracker) apCalls() []apCall {
tr.mu.Lock()
defer tr.mu.Unlock()
return append([]apCall{}, tr.calls...)
}
// apEnv starts the fake and returns the environment that points the binary at
// it — the same shape a CI run uses, and a credential home that is a temp
// directory so no fixture can read the developer's own tokens.
func apEnv(t *testing.T, tr *apTracker) []string {
t.Helper()
srv := httptest.NewServer(tr)
t.Cleanup(srv.Close)
return []string{
config.EnvURL + "=" + srv.URL,
config.EnvToken + "=t0ken",
config.EnvRepo + "=owner/repo",
config.EnvHome + "=" + t.TempDir(),
}
}
// A read: GET by default, the placeholders filled from the project, and the
// server's bytes on stdout with nothing done to them.
func TestAPIGetsAndPrintsWhatCameBack(t *testing.T) {
dir := newProject(t)
tr := &apTracker{answer: `{"tag_name":"v0.2.0","draft":false}`}
r := runWith(t, dir, apEnv(t, tr), "", "api", "repos/{owner}/{repo}/releases?limit=50")
if r.code != 0 {
t.Fatalf("exit %d:\n%s", r.code, r.out())
}
if strings.TrimSpace(r.stdout) != `{"tag_name":"v0.2.0","draft":false}` {
t.Errorf("stdout is not the server's bytes:\n%q", r.stdout)
}
calls := tr.apCalls()
if len(calls) != 1 {
t.Fatalf("%d request(s) went out, want 1 — one invocation is one request: %v", len(calls), calls)
}
if calls[0].Method != http.MethodGet {
t.Errorf("method was %s, want GET", calls[0].Method)
}
if calls[0].URI != "/api/v1/repos/owner/repo/releases?limit=50" {
t.Errorf("endpoint resolved to %s", calls[0].URI)
}
if calls[0].Auth != "token t0ken" {
t.Errorf("Authorization was %q — Gitea's scheme is the word token", calls[0].Auth)
}
}
// A body from a file: POST is implied by having one, the bytes arrive as they
// were written, and the transport files a copy in the project's scratchpad.
func TestAPIPostsTheFileItWasGivenAndFilesIt(t *testing.T) {
dir := newProject(t)
tr := &apTracker{}
body := `{"tag_name":"v0.2.0","body":"## Changes\n\nwith ` + "`code`" + ` in it"}`
path := filepath.Join(dir, "release.json")
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
r := runWith(t, dir, apEnv(t, tr), "", "api", "--data", "@"+path, "repos/{owner}/{repo}/releases")
if r.code != 0 {
t.Fatalf("exit %d:\n%s", r.code, r.out())
}
calls := tr.apCalls()
if len(calls) != 1 || calls[0].Method != http.MethodPost {
t.Fatalf("want one POST, got %v", calls)
}
if calls[0].Body != body {
t.Errorf("the server got\n%s\nwant\n%s", calls[0].Body, body)
}
// The scratchpad is the transport's, and it holds what went out whether or
// not the caller named the file.
entries, err := os.ReadDir(filepath.Join(dir, ".kettle", "payload"))
if err != nil || len(entries) != 1 {
t.Fatalf("the request body was not filed under .kettle/payload/ (%v, %v)", entries, err)
}
filed, err := os.ReadFile(filepath.Join(dir, ".kettle", "payload", entries[0].Name()))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(filed), "v0.2.0") {
t.Errorf("the filed body is not the one that was sent:\n%s", filed)
}
}
// --field is the small-body form. Every value is a string, and the object it
// builds is what goes on the wire.
func TestAPIFieldsBuildAJSONObject(t *testing.T) {
dir := newProject(t)
tr := &apTracker{}
r := runWith(t, dir, apEnv(t, tr), "", "api",
"--field", "title=Wire sqlc", "--field", "head=feat/x", "repos/{owner}/{repo}/pulls")
if r.code != 0 {
t.Fatalf("exit %d:\n%s", r.code, r.out())
}
var got map[string]any
if err := json.Unmarshal([]byte(tr.apCalls()[0].Body), &got); err != nil {
t.Fatalf("the body is not JSON: %v (%s)", err, tr.apCalls()[0].Body)
}
if got["title"] != "Wire sqlc" || got["head"] != "feat/x" {
t.Errorf("the fields did not arrive: %v", got)
}
}
// A path that spells another repository out in full is left alone: that is how
// one project reaches another's releases, and why there is no --repo flag.
func TestAPILeavesAFullyNamedRepositoryAlone(t *testing.T) {
dir := newProject(t)
tr := &apTracker{}
mustRunWith(t, dir, apEnv(t, tr), "api", "repos/other-owner/other-repo/releases")
if got := tr.apCalls()[0].URI; got != "/api/v1/repos/other-owner/other-repo/releases" {
t.Errorf("the project's own repository was substituted into a path that named one: %s", got)
}
}
// Outside a project there is nothing to run against, and the failure says which
// command makes one — never a connection error, and never a guess at a tracker.
func TestAPIOutsideAProjectNamesInit(t *testing.T) {
dir := t.TempDir()
tr := &apTracker{}
r := runWith(t, dir, apEnv(t, tr), "", "api", "user")
if r.code != 1 {
t.Fatalf("exit %d, want 1:\n%s", r.code, r.out())
}
if !strings.Contains(r.stderr, "no .kettle/ found") {
t.Errorf("the failure does not name what was searched:\n%s", r.stderr)
}
if len(tr.apCalls()) != 0 {
t.Error("a request went out from a directory that is not a project")
}
}
// A refusal is an exit 1 that quotes the status and what the server said —
// which is the only thing that tells four different 422s apart.
func TestAPIReportsTheStatusAndTheBodyOnAFailure(t *testing.T) {
dir := newProject(t)
tr := &apTracker{status: http.StatusNotFound, answer: `{"message":"release does not exist"}`}
r := runWith(t, dir, apEnv(t, tr), "", "api", "--status", "repos/{owner}/{repo}/releases/9")
if r.code != 1 {
t.Fatalf("exit %d, want 1:\n%s", r.code, r.out())
}
for _, want := range []string{"404", "release does not exist"} {
if !strings.Contains(r.stderr, want) {
t.Errorf("stderr does not mention %q:\n%s", want, r.stderr)
}
}
if strings.Contains(r.stdout, "release does not exist") {
t.Errorf("a failed body was printed as though it were an answer:\n%s", r.stdout)
}
}
// A deletion is an operator's decision. Without --yes nothing is sent at all —
// the refusal comes before the request, not after it.
func TestAPIDeleteNeedsYes(t *testing.T) {
dir := newProject(t)
tr := &apTracker{}
env := apEnv(t, tr)
r := runWith(t, dir, env, "", "api", "-X", "DELETE", "repos/{owner}/{repo}/releases/12")
if r.code != 1 || !strings.Contains(r.stderr, "--yes") {
t.Fatalf("a DELETE without --yes must be refused by name:\n%s", r.out())
}
if len(tr.apCalls()) != 0 {
t.Fatal("the request went out anyway — the gate is before the socket, or it is not a gate")
}
mustRunWith(t, dir, env, "api", "-X", "DELETE", "--yes", "repos/{owner}/{repo}/releases/12")
calls := tr.apCalls()
if len(calls) != 1 || calls[0].Method != http.MethodDelete {
t.Errorf("--yes did not let the deletion through: %v", calls)
}
if calls[0].Body != "" {
t.Errorf("a DELETE carried a body: %q", calls[0].Body)
}
}
// A method this does not send is refused before anything is resolved: an
// unquoted endpoint that lost a word to the shell must not go out as a verb.
func TestAPIRefusesAMethodItDoesNotSend(t *testing.T) {
dir := newProject(t)
tr := &apTracker{}
r := runWith(t, dir, apEnv(t, tr), "", "api", "-X", "HEAD", "user")
if r.code != 1 || !strings.Contains(r.stderr, "GET, POST, PUT, PATCH or DELETE") {
t.Errorf("an unsupported method was not named:\n%s", r.out())
}
if len(tr.apCalls()) != 0 {
t.Error("a request went out for a method this does not send")
}
}
// mustRunWith is mustRun with an environment.
func mustRunWith(t *testing.T, dir string, env []string, args ...string) result {
t.Helper()
r := runWith(t, dir, env, "", args...)
if r.code != 0 {
t.Fatalf("kettle %v exited %d:\n%s", args, r.code, r.out())
}
return r
}
+40 -6
View File
@@ -27,16 +27,27 @@ 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.`,
` + "`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 <read|write>:<category>; 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 {
@@ -61,7 +72,14 @@ argument is in the shell history the moment it is typed:
if who == "" {
who = "—"
}
fmt.Printf(" %-16s %-40s %s\n", l.Name, l.URL, 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
@@ -79,10 +97,11 @@ argument is in the shell history the moment it is typed:
return Fail("no token — pipe one in, or pass --token")
}
entry := config.Login{
Name: *name,
URL: strings.TrimRight(*url, "/"),
User: *user,
Token: secret,
Name: *name,
URL: strings.TrimRight(*url, "/"),
User: *user,
Scopes: splitScopes(*scopes),
Token: secret,
}
if existing := logins.Find(*name); existing != nil {
*existing = entry
@@ -125,6 +144,21 @@ argument is in the shell history the moment it is typed:
})
}
// 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
+34
View File
@@ -474,6 +474,40 @@ func TestAuthListNeverPrintsATokenAndRemoveForgetsIt(t *testing.T) {
}
}
// What a token was minted with is written down because the instance will not
// say: Gitea's own token listing needs a password, not a token. It is
// documentation — nothing is checked against it — and the one thing it must not
// do is read as "none" when nobody wrote it down.
func TestScopesAreRecordedAndShownButNeverInvented(t *testing.T) {
dir := newProject(t)
home := t.TempDir()
env := []string{"KETTLE_CONFIG_HOME=" + home}
runWith(t, dir, env, "s3cr3t-token\n", "auth", "add", "--name", "noodles",
"--url", "https://git.example.com", "--scopes", "write:issue, write:repository")
mustRun(t, dir, "init", "--login", "noodles", "--repo", "owner/name")
listed := runWith(t, dir, env, "", "auth", "list")
if !strings.Contains(listed.stdout, "write:issue, write:repository") {
t.Errorf("`auth list` does not show what was recorded:\n%s", listed.out())
}
if strings.Contains(listed.out(), "s3cr3t-token") {
t.Errorf("`auth list` printed a token:\n%s", listed.out())
}
shown := runWith(t, dir, env, "", "config")
if !strings.Contains(shown.stdout, "scopes write:issue, write:repository") {
t.Errorf("`config` does not show the scopes beside the token they belong to:\n%s", shown.stdout)
}
// A login nobody recorded scopes for says so in those words. "—" would read
// as "no scopes", which is the sentence that gets a working token re-minted.
runWith(t, dir, env, "other-token\n", "auth", "add", "--name", "bare", "--url", "https://git.example.com")
bare := runWith(t, dir, env, "", "auth", "list")
if !strings.Contains(bare.stdout, "(not recorded)") {
t.Errorf("a login with no scopes written down must say so:\n%s", bare.stdout)
}
}
// A pinned login that is not on this machine is a fixable mistake, and the
// message has to say which file was read and what it holds.
func TestAMissingLoginIsExplained(t *testing.T) {
+3 -1
View File
@@ -26,14 +26,16 @@ const (
GroupProject = "project"
GroupIssue = "issue"
GroupSync = "sync"
GroupAPI = "api"
)
var groupOrder = []string{GroupProject, GroupIssue, GroupSync}
var groupOrder = []string{GroupProject, GroupIssue, GroupSync, GroupAPI}
var groupBlurb = map[string]string{
GroupProject: "the project itself",
GroupIssue: "issues as units of work — offline, no tracker involved",
GroupSync: "moving issues between the store and the tracker",
GroupAPI: "everything else Gitea has, reached directly — not issues",
}
// Example is one worked invocation. Both halves are shown in help and in the
+9
View File
@@ -3,6 +3,7 @@ package cmd
import (
"flag"
"fmt"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
@@ -47,6 +48,14 @@ This is the command to reach for when the store looks empty, when a push says
fmt.Printf("login %s\n", orNone(red.Login))
fmt.Printf("url %s\n", orNone(red.URL))
fmt.Printf("token %s\n", orNone(red.Token))
// What the login says its token can do, which is a note somebody
// wrote and not an answer from the instance — a 403 out of
// `kettle api` is read against this line.
scopes := "(not recorded)"
if len(red.Scopes) > 0 {
scopes = strings.Join(red.Scopes, ", ")
}
fmt.Printf("scopes %s\n", scopes)
if r.Owner != "" {
fmt.Printf("repo %s\n", r.Slug())
} else {