Files
marketplace/cli/internal/config/config.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

448 lines
15 KiB
Go

// Package config holds the two files kettle reads: what this project is, and
// who this machine is.
//
// The split is the whole design. `<project>/.kettle/config.yaml` says which
// tracker repository the issues belong to and which login to reach it under —
// facts about the project, written by `kettle init`. The credentials themselves
// live in one file per machine, outside any repository, mode 0600.
//
// A token in a file inside a working tree ends up in a commit. Not always, not
// immediately, and not by anyone careless — but a project config is exactly the
// file somebody eventually decides to share, and a secret that has ever been
// pushed is a secret that has to be rotated. So the project pins a login by
// NAME and the name is worth nothing on its own.
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
)
// Environment overrides, each winning over the file it shadows. They exist for
// CI, for a one-off run against another instance, and for anyone who would
// rather not have a token on disk at all.
const (
EnvLogin = "KETTLE_LOGIN"
EnvURL = "KETTLE_URL"
EnvToken = "KETTLE_TOKEN"
EnvRepo = "KETTLE_REPO"
// EnvHome relocates the machine-wide login file; the test suite sets it so
// a run can never read or write the developer's own.
EnvHome = "KETTLE_CONFIG_HOME"
)
const projectHeader = `# kettle — project configuration
#
# login the name of a login in the machine-wide file, NOT a credential.
# Manage those with ` + "`kettle auth`" + `; they live outside this tree.
# repo the tracker repository these issues belong to, as owner/name.
#
# Overrides, when you need one: ` + EnvLogin + `, ` + EnvRepo + `, ` + EnvURL + `, ` + EnvToken + `.
`
const scaffoldHeader = `# kettle — what was last written into this project's agent-harness tree.
#
# Written by ` + "`kettle init`" + ` and ` + "`kettle gen scaffold`" + `; read by
# ` + "`kettle config`" + `, which says so when the build that wrote the tree is not the
# build that is installed now. Nothing resolves from this file — deleting it
# costs the warning and nothing else.
`
// Project is `<project>/.kettle/config.yaml`.
type Project struct {
// Login names an entry in the machine-wide login file. Never a token.
Login string `yaml:"login"`
// Repo is the tracker repository, as owner/name.
Repo string `yaml:"repo"`
}
// Scaffold is `<project>/.kettle/scaffold.yaml`: what `kettle` last wrote into
// this project's agent-harness tree, and which build wrote it.
//
// A file of its own rather than two more keys in Project, and that is the rule
// in "Unknown keys are an error" being obeyed rather than worked around: a field
// added to config.yaml is a one-way door for a file that may be committed and
// read by whatever version each machine happens to have. This one is written and
// read by a single binary about a single directory, so it can carry that cost.
//
// It exists to answer one question — "were these documents written by the kettle
// that is installed now?" — because the documents are generated whole and an
// operator has no other way to tell a current tree from one four releases old.
type Scaffold struct {
// Version is what `kettle version` reported when the tree was written. A
// hand build says `dev` and means it.
Version string `yaml:"version"`
// Out is where the tree went, relative to the project root when it is
// underneath it.
Out string `yaml:"out"`
}
// Login is one set of credentials for one Gitea instance.
type Login struct {
Name string `yaml:"name"`
URL string `yaml:"url"`
User string `yaml:"user,omitempty"`
// Scopes is what the token was minted with, as Gitea spells it —
// `write:issue`, `read:repository`. DOCUMENTATION ONLY, exactly like User:
// nothing is checked against it and nothing is refused because of it. It is
// written down because the instance will not say. `GET /user/tokens` needs
// basic auth, not token auth, so a token cannot be asked what it may do —
// and the failure that costs an afternoon is a 403 on a release from a token
// somebody minted for issues a year ago.
Scopes []string `yaml:"scopes,omitempty"`
Token string `yaml:"token"`
}
// Logins is the machine-wide file.
type Logins struct {
Logins []Login `yaml:"logins"`
}
// ErrNoConfig means the project has no config.yaml yet.
var ErrNoConfig = errors.New("no project configuration")
// ProjectPath is where this project's config.yaml is, or "" with no project.
func ProjectPath(start string) string { return project.ConfigPath(start) }
// LoadProject reads the project configuration.
//
// A missing file is ErrNoConfig, not an empty config: "this project has not
// been told which tracker it belongs to" and "it belongs to no tracker" are
// different answers and only one of them is fixable by running init.
func LoadProject(start string) (*Project, error) {
path := ProjectPath(start)
if path == "" {
return nil, project.NotFoundError(start)
}
raw, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil, fmt.Errorf("%w at %s — run `kettle init` there", ErrNoConfig, path)
}
if err != nil {
return nil, err
}
var p Project
if err := strictUnmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return &p, nil
}
// ReadProjectFile reads a config.yaml at a path already known, reporting
// whether the file was there.
//
// LoadProject resolves the path by walking for a marker, which is the right
// thing everywhere except inside `kettle init` — the command that is creating
// the marker, and on a dry run may not have created it at all.
func ReadProjectFile(path string) (*Project, bool, error) {
raw, err := os.ReadFile(path)
if os.IsNotExist(err) {
return &Project{}, false, nil
}
if err != nil {
return nil, false, err
}
var p Project
if err := strictUnmarshal(raw, &p); err != nil {
return nil, true, fmt.Errorf("%s: %w", path, err)
}
return &p, true, nil
}
// SaveProject writes the project configuration, header comment and all.
func SaveProject(path string, p *Project) error {
body, err := yaml.Marshal(p)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, append([]byte(projectHeader+"\n"), body...), 0o644)
}
// ScaffoldPath is where this project records what it last had written into it,
// or "" with no project.
func ScaffoldPath(start string) string { return project.ScaffoldPath(start) }
// ReadScaffoldFile reads a scaffold record at a path already known, reporting
// whether the file was there.
//
// A missing file is the zero value and not an error: a project initialized
// before this record existed, or one written with --no-scaffold, has nothing to
// say here and that is an ordinary state rather than a fault.
func ReadScaffoldFile(path string) (*Scaffold, bool, error) {
raw, err := os.ReadFile(path)
if os.IsNotExist(err) {
return &Scaffold{}, false, nil
}
if err != nil {
return nil, false, err
}
var s Scaffold
if err := strictUnmarshal(raw, &s); err != nil {
return nil, true, fmt.Errorf("%s: %w", path, err)
}
return &s, true, nil
}
// SaveScaffoldFile writes the scaffold record.
func SaveScaffoldFile(path string, s *Scaffold) error {
body, err := yaml.Marshal(s)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, append([]byte(scaffoldHeader+"\n"), body...), 0o644)
}
// LoginsPath is the machine-wide login file.
//
// One file per machine, deliberately outside every working tree: which tokens
// this computer holds is a fact about the computer, the way which issues a tree
// holds is a fact about the tree.
func LoginsPath() string {
if h := os.Getenv(EnvHome); h != "" {
return filepath.Join(h, "logins.yaml")
}
if x := os.Getenv("XDG_CONFIG_HOME"); x != "" {
return filepath.Join(x, "kettle", "logins.yaml")
}
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".config", "kettle", "logins.yaml")
}
// LoadLogins reads the machine-wide login file. A missing file is an empty
// list, not an error: a machine with no logins yet is an ordinary machine.
func LoadLogins() (*Logins, error) {
path := LoginsPath()
if path == "" {
return &Logins{}, nil
}
raw, err := os.ReadFile(path)
if os.IsNotExist(err) {
return &Logins{}, nil
}
if err != nil {
return nil, err
}
var l Logins
if err := strictUnmarshal(raw, &l); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return &l, nil
}
// SaveLogins writes the machine-wide login file with 0600, and creates its
// directory with 0700. The file holds bearer tokens; nothing else on the
// machine has any business reading it.
func SaveLogins(l *Logins) error {
path := LoginsPath()
if path == "" {
return errors.New("cannot locate a home directory for the login file — set " + EnvHome)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
body, err := yaml.Marshal(l)
if err != nil {
return err
}
return os.WriteFile(path, body, 0o600)
}
// Find returns the login with this name.
func (l *Logins) Find(name string) *Login {
for i := range l.Logins {
if l.Logins[i].Name == name {
return &l.Logins[i]
}
}
return nil
}
// Names lists every login on this machine, for an error message that can
// actually be acted on.
func (l *Logins) Names() []string {
out := make([]string, 0, len(l.Logins))
for _, e := range l.Logins {
out = append(out, e.Name)
}
return out
}
// Resolved is everything the transport needs, with every override applied.
type Resolved struct {
Login string
URL string
Token string
Owner string
Repo string
// Scopes is what the pinned login records its token was minted with.
// Documentation, carried this far so `kettle config` can show it beside the
// token it belongs to; nothing dials on it. A token out of the environment
// records nothing, and an empty list means "not written down", never "none".
Scopes []string
}
// Slug is owner/name, the way a tracker writes it.
func (r *Resolved) Slug() string { return r.Owner + "/" + r.Repo }
// Redacted is the same thing with the token replaced, for printing.
func (r *Resolved) Redacted() Resolved {
out := *r
if out.Token != "" {
out.Token = "(set)"
}
return out
}
// Resolve merges the project config, the machine's login file, and the
// environment into what the transport needs.
//
// Every failure names the file it read and the command that fixes it. "401
// Unauthorized" is what happens when this function is allowed to return a
// half-filled struct.
func Resolve(start string) (*Resolved, error) {
var p Project
if loaded, err := LoadProject(start); err == nil {
p = *loaded
} else if !errors.Is(err, ErrNoConfig) {
return nil, err
}
return merge(p)
}
// ResolveOutsideAProject is Resolve for a caller that legitimately has no
// project to stand in.
//
// `cmd/release` is the one, and it is not an exception being carved out: the
// marker is gitignored, so a fresh clone has none, and a tool that publishes a
// tag must not create one on its way past. With no marker there is nothing to
// merge and the ENVIRONMENT IS the configuration — KETTLE_URL, KETTLE_TOKEN and
// KETTLE_REPO, which is exactly what somebody exports before cutting a release.
//
// A marker that IS there is read as always, overrides and all, so the same
// command run from a maintainer's own checkout picks up the login pinned in it
// and needs no token in the shell.
//
// Every other caller wants Resolve: for `kettle`, "no project" is the answer,
// not a state to work around. A push that quietly ran against whatever was in
// the environment would be a push into somebody else's repository.
func ResolveOutsideAProject(start string) (*Resolved, error) {
if ProjectPath(start) == "" {
return merge(Project{})
}
return Resolve(start)
}
// merge applies the login file and the environment to a project's settings.
func merge(p Project) (*Resolved, error) {
out := &Resolved{Login: p.Login}
if v := os.Getenv(EnvLogin); v != "" {
out.Login = v
}
repo := p.Repo
if v := os.Getenv(EnvRepo); v != "" {
repo = v
}
if repo != "" {
owner, name, ok := strings.Cut(repo, "/")
if !ok || owner == "" || name == "" {
return nil, fmt.Errorf("repo %q is not owner/name", repo)
}
out.Owner, out.Repo = owner, name
}
if out.Login != "" {
logins, err := LoadLogins()
if err != nil {
return nil, err
}
entry := logins.Find(out.Login)
if entry == nil {
known := "none on this machine"
if names := logins.Names(); len(names) > 0 {
known = strings.Join(names, ", ")
}
return nil, fmt.Errorf("no login %q in %s — known: %s; add one with `kettle auth add`",
out.Login, LoginsPath(), known)
}
out.URL, out.Token, out.Scopes = entry.URL, entry.Token, entry.Scopes
}
if v := os.Getenv(EnvURL); v != "" {
out.URL = v
}
if v := os.Getenv(EnvToken); v != "" {
out.Token = v
}
out.URL = strings.TrimRight(out.URL, "/")
return out, nil
}
// Require is Resolve plus the assertion that the result can actually reach a
// tracker.
func Require(start string) (*Resolved, error) {
r, err := Resolve(start)
if err != nil {
return nil, err
}
if err := r.Complete(); err != nil {
return nil, err
}
return r, nil
}
// Complete reports what a resolved configuration is still missing, naming the
// one command or the one variable that supplies each.
//
// A half-filled struct allowed through is a 401 three calls later, and "401
// Unauthorized" names nothing an operator can act on. It is a method rather
// than part of Resolve because the two questions are different: `kettle config`
// wants to SHOW a half-filled configuration, and everything that dials wants to
// refuse one.
func (r *Resolved) Complete() error {
var missing []string
if r.URL == "" {
missing = append(missing, "a URL (pin a login with `kettle init --login`, or set "+EnvURL+")")
}
if r.Token == "" {
missing = append(missing, "a token (`kettle auth add`, or set "+EnvToken+")")
}
if r.Owner == "" {
missing = append(missing, "a repository (`kettle init --repo owner/name`, or set "+EnvRepo+")")
}
if len(missing) > 0 {
return fmt.Errorf("this project has no %s", strings.Join(missing, ", and no "))
}
return nil
}
// strictUnmarshal refuses keys the struct does not know.
//
// The alternative is silence: an older binary reading a newer config would drop
// the setting it did not recognize, and rewriting the file would delete it.
// Being told "unknown field" beats finding out later.
func strictUnmarshal(raw []byte, out any) error {
dec := yaml.NewDecoder(strings.NewReader(string(raw)))
dec.KnownFields(true)
if err := dec.Decode(out); err != nil && err.Error() != "EOF" {
return err
}
return nil
}