Files
marketplace/cli/cmd/release/main.go
T
naudachu 8b1b11001a feat: drop the kettle plugin; the binary writes its own skills
The plugin and the binary shipped on two release cadences and nothing on an
operator's machine ever checked that the one they installed described the other.
The generated flag block existed precisely so a renamed flag could not ship with
documentation recommending the old one — and then shipped one version behind the
registry it came from, which is the same bug one hop downstream.

So the prose moved into the binary. `internal/scaffold` embeds every document;
`kettle init` and `kettle gen scaffold` write them into a project's own
`.claude/`. The two cannot disagree because there is one artefact.

The namespace survived the move. A project's skills are flat, so the prefix is
spelled into the directory name (`kettle-issue`); a project's *commands* take
their namespace from a subdirectory, so `commands/kettle/init.md` is still
`/kettle:init`. Four of the six command files are thin pointers at a skill, and
that is what kept ~1,600 lines of `/kettle:…` cross-references true without a
rewrite. `init` and `auth` lost `disable-model-invocation: true` — being a
command is that property — and `auth` now restricts `allowed-tools` so a model
cannot reach `kettle auth add` at all.

`gen scaffold` writes files whole rather than splicing a region. The old
refusal protected somebody's hand-written prose around the block; that prose is
embedded now, so there is none to protect, and preserving local edits would
freeze a project's documentation at whatever version first initialized it.
`--check` warns before an upgrade discards one.

The plugin's `agents-sync.sh` — 141 lines of Python behind a filename that said
`.sh` — became `internal/mirror` and `kettle mirror`. Same seven branches, same
refusal to merge two real files that differ, now with a table test per branch
and a check that a repair converges in one pass. `--hook` is the PreToolUse
form and exits 0 on every path including a panic. It is opt-in per project,
which is strictly narrower than the plugin hook that was on for everybody who
installed it.

`kettle init --interactive` walks a person through the login, the token (read
with the echo off, so it lands in no history and no file), the repository, the
`.claude/` tree and the mirror hook. It refuses a stdin that is not a terminal
and names the flags instead: every question it asks has one, and it performs
nothing itself, so an interactive run and a flag run are one code path.

Two rules that used to be prose are now the binary's: init refuses a linked
worktree and names the main checkout, and writing into an existing
`.claude/settings.json` is refused with the snippet printed rather than
reformatting a file the operator commits.

The scaffold version stamp went to its own `.kettle/scaffold.yaml` rather than
into `config.yaml`, because unknown keys there are a hard error and that file
may be committed and read by whatever build each machine has.

golang.org/x/term becomes a direct dependency; it was already in the tree
indirectly, so no module was added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 16:17:24 +05:00

156 lines
5.8 KiB
Go

// Command release publishes a Gitea release for this repository, from this
// repository's own code.
//
// DELIBERATELY NOT A `kettle` SUBCOMMAND, and not for tidiness. `kettle` is a
// tool for issues, and its command tree is not just a menu: it is what
// `kettle gen scaffold` writes a project's skills and commands from. A verb added
// there arrives in the documentation an agent loads and in the reference an
// operator reads, and "publish a release" is not something either of them does.
// Publishing is build infrastructure — it runs once, on a tag, by the person
// cutting it — and the thing users install should not carry it.
//
// It is still this module's code, built on the same Gitea SDK the binary uses,
// and that is the point: the release is published by the repository it is a
// release of, with nothing to trust that is not in this tree and no third-party
// tool between a tag and what people download.
//
// WHY IT DOES NOT USE internal/gitea, which is otherwise the one door for every
// request. Two reasons, both facts about where this runs rather than
// preferences:
//
// - that transport files every request body under `.kettle/payload/`, a path
// resolved from the project marker — and the marker is gitignored, so a
// fresh clone has none and a build tool has no business creating one;
// - an asset upload's request body IS the binary. Filing a 20 MB multipart
// body as JSON in a scratchpad helps nobody and would double the memory
// this uses for no reader's benefit.
//
// What it does not reinvent is credentials. internal/config resolves the
// instance, the token and the repository exactly as kettle does, environment
// first, and refuses a half-filled configuration by naming what is missing.
// The error vocabulary is internal/gitea's too — a failure here carries the
// status and quotes what the server said, in the same words a `kettle push`
// would use.
//
// IDEMPOTENT END TO END. A tag that already has a release reuses it, an asset
// whose name is already there replaces it, and a run that is repeated because
// the first one died half way through converges on the same release with the
// same assets instead of a second release and doubled attachments.
package main
import (
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
)
const usage = `usage: release --tag <tag> [flags] [<file>…]
Publish a Gitea release for the repository this configuration points at, and
upload each named file as an asset. Re-running it is safe: an existing release
for the tag is reused and an asset of the same name is replaced, never doubled.
Credentials resolve the way kettle's do — ` + config.EnvURL + `, ` + config.EnvToken + ` and
` + config.EnvRepo + `, or, when this is run from inside an initialized project, that
project's .kettle/config.yaml and the machine's login file.
flags:
`
func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) }
// run is main with its edges handed in, so a test can drive the whole tool.
func run(argv []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("release", flag.ContinueOnError)
fs.SetOutput(stderr)
fs.Usage = func() {
fmt.Fprint(stderr, usage)
fs.PrintDefaults()
}
tag := fs.String("tag", "", "the tag to publish, e.g. v1.2.3 (required)")
title := fs.String("title", "", "release title (default: the tag)")
notesFile := fs.String("notes-file", "", "file holding the release notes; empty leaves an existing release's notes alone")
target := fs.String("target", "", "commitish a tag is created from when the tag does not exist yet (default: the default branch)")
draft := fs.Bool("draft", false, "publish as a draft")
prerelease := fs.Bool("prerelease", false, "mark as a prerelease")
if err := fs.Parse(argv); err != nil {
return 2 // flag has already said what it did not like
}
s := spec{
Tag: strings.TrimSpace(*tag),
Title: strings.TrimSpace(*title),
Target: strings.TrimSpace(*target),
Draft: *draft,
Prerelease: *prerelease,
Files: fs.Args(),
}
if s.Tag == "" {
fmt.Fprintln(stderr, "release: --tag is required — the tag this release is for")
fs.Usage()
return 2
}
if err := checkFiles(s.Files); err != nil {
fmt.Fprintf(stderr, "release: %v\n", err)
return 2
}
if *notesFile != "" {
raw, err := os.ReadFile(*notesFile)
if err != nil {
fmt.Fprintf(stderr, "release: reading the notes: %v\n", err)
return 2
}
s.Notes = string(raw)
}
// Nothing above this line dials, and that is the order it is written in:
// every mistake a person can make in the arguments is reported before a
// release exists to be half-published.
cfg, err := config.ResolveOutsideAProject("")
if err != nil {
fmt.Fprintf(stderr, "release: %v\n", err)
return 1
}
got, err := publish(cfg, s)
if err != nil {
fmt.Fprintf(stderr, "release: %v\n", err)
return 1
}
got.print(stdout)
return 0
}
// checkFiles refuses what would fail later, before anything is created.
//
// Every asset is stat'ed up front because the alternative is a release that
// exists with half its assets on it, published by a run that then failed on a
// typo. Two files with one basename are refused for the same reason from the
// other direction: an attachment is addressed by name, so the second would
// replace the first and the receipt would claim both went up.
func checkFiles(files []string) error {
seen := map[string]string{}
for _, path := range files {
fi, err := os.Stat(path)
if err != nil {
return fmt.Errorf("cannot upload %s: %w", path, err)
}
if fi.IsDir() {
return fmt.Errorf("cannot upload %s: it is a directory", path)
}
name := filepath.Base(path)
if first, ok := seen[name]; ok {
return fmt.Errorf("%s and %s are both %q — an asset is addressed by name, so the second would replace the first",
first, path, name)
}
seen[name] = path
}
return nil
}