8b1b11001a
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>
156 lines
5.8 KiB
Go
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
|
|
}
|