Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b1b11001a | |||
| f18a633185 | |||
| e177f46510 |
@@ -4,11 +4,6 @@
|
||||
"name": "naudachu"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "kettle",
|
||||
"source": "./plugins/kettle",
|
||||
"description": "Issues as local markdown, driven by the kettle binary: /kettle:init makes a directory a project, /kettle:issue works on issues offline (format, validation, checkboxes, dependency graph), /kettle:sync moves them to and from Gitea, /kettle:auth manages the credential a project runs under, /kettle:use is the tea CLI reference for the Gitea entities kettle does not cover, and the kettle-runner subagent executes batches on a cheap model. Needs the kettle binary on PATH — build it from cli/ in this repository (Go 1.26)."
|
||||
},
|
||||
{
|
||||
"name": "tdl",
|
||||
"source": "./plugins/tdl",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "command -v kettle >/dev/null && kettle mirror --hook || true" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -1,9 +1,19 @@
|
||||
.DS_Store
|
||||
.docs/
|
||||
.claude/
|
||||
.tea/
|
||||
tmp/
|
||||
|
||||
# `.claude/*` and not `.claude/`, because git does not descend into an excluded
|
||||
# DIRECTORY and the negation below would never be reached.
|
||||
#
|
||||
# The exception is the hook registration, which is a fact about this repository
|
||||
# rather than about whoever cloned it: `kettle mirror --hook` keeps every
|
||||
# AGENTS.md paired with its CLAUDE.md symlink, and a fresh clone should get that
|
||||
# without anyone remembering to wire it up. Everything else under .claude/ here
|
||||
# is a local scratch tree and stays ignored.
|
||||
.claude/*
|
||||
!.claude/settings.json
|
||||
|
||||
# `make dist` and `make build` — release artifacts are rebuilt from a tag, never
|
||||
# committed
|
||||
cli/dist/
|
||||
|
||||
@@ -1,27 +1,52 @@
|
||||
# AGENTS.md — the repository root
|
||||
|
||||
One repository holding a Claude Code **plugin marketplace** and the **binary one
|
||||
of its plugins drives**. Three things live here and nothing else does:
|
||||
One repository holding the **`kettle` binary** and a Claude Code **plugin
|
||||
marketplace** with one plugin left in it. Three things live here and nothing else
|
||||
does:
|
||||
|
||||
```
|
||||
.claude-plugin/marketplace.json the catalog: one entry per plugin
|
||||
cli/ the kettle binary — Go, no cobra, 8 packages
|
||||
cli/ the kettle binary — Go, no cobra, 9 packages
|
||||
plugins/ one directory per plugin
|
||||
.claude-plugin/marketplace.json the catalog: one entry per plugin
|
||||
```
|
||||
|
||||
Where to go from here, and each of these directories documents itself:
|
||||
|
||||
| directory | what it is |
|
||||
|---|---|
|
||||
| [`cli/`](cli/AGENTS.md) | one Go module, two binaries: `kettle`, which owns every mechanical thing about issues — the format, the store, the credentials, the tracker — and [`cmd/release`](cli/cmd/release/AGENTS.md), which publishes this repository's own releases. `make check` is the gate; there is no CI here |
|
||||
| [`cli/`](cli/AGENTS.md) | one Go module, two binaries: `kettle`, which owns this project's connection to its tracker — the format, the store, the credentials, the transport, through `kettle api` every Gitea entity that has no command of its own, and the skills and commands it writes into a project's `.claude/` — and [`cmd/release`](cli/cmd/release/AGENTS.md), which publishes this repository's own releases. `make check` is the gate; there is no CI here |
|
||||
| [`plugins/`](plugins/AGENTS.md) | what a plugin is here, and what the catalog entry has to match |
|
||||
| [`plugins/kettle/`](plugins/kettle/AGENTS.md) | the plugin that wraps the binary: skills, the runner subagent, the hooks |
|
||||
| [`plugins/tdl/`](plugins/tdl/AGENTS.md) | the Three Dots Labs Go rule set — no binary, no state, just rules and templates |
|
||||
|
||||
The split between `cli/` and `plugins/kettle/` is the one architectural fact
|
||||
worth carrying: **a binary holds what can be enforced, a plugin holds what can
|
||||
only be stated.** Anything mechanical belongs in Go where a test can hold it
|
||||
down; anything that is a judgement an operator makes belongs in a SKILL.md.
|
||||
## The one architectural fact worth carrying
|
||||
|
||||
**A binary holds what can be enforced; prose holds what can only be stated.**
|
||||
Anything mechanical belongs in Go where a test can hold it down; anything that is
|
||||
a judgement an operator makes belongs in a SKILL.md.
|
||||
|
||||
There used to be a second half to that sentence — *and a plugin holds the prose* —
|
||||
and it is gone, because it was wrong in a way that took a year to show. `kettle`
|
||||
shipped as a plugin wrapping a binary, on two release cadences, and **nothing on
|
||||
an operator's machine ever checked that the plugin they installed described the
|
||||
binary they installed.** The generated flag block existed precisely so a renamed
|
||||
flag could not ship with documentation recommending the old one, and it shipped
|
||||
one version behind the registry it came from — the same bug, one hop downstream.
|
||||
|
||||
So the prose moved *into* the binary. `cli/internal/scaffold` embeds it,
|
||||
`kettle init` and `kettle gen scaffold` write it into a project's own `.claude/`,
|
||||
and the two cannot disagree because there is only one artefact. What a project
|
||||
gets:
|
||||
|
||||
```
|
||||
.claude/commands/kettle/*.md /kettle:init, /kettle:auth, /kettle:issue, …
|
||||
.claude/skills/kettle-*/ loaded by description, never by name
|
||||
.claude/agents/kettle-runner.md the batch runner, on a cheap model
|
||||
```
|
||||
|
||||
The same rule made `kettle api` (one door for every request, rather than
|
||||
requiring `tea` and its second set of invisible logins) and `kettle mirror`
|
||||
(below). Each time, something mechanical was sitting where no test could reach
|
||||
it, and each time it came into Go.
|
||||
|
||||
## The AGENTS.md convention
|
||||
|
||||
@@ -34,11 +59,10 @@ design to change one request.
|
||||
Three rules make that work:
|
||||
|
||||
1. **`AGENTS.md` is the real file; `CLAUDE.md` beside it is a symlink to it.**
|
||||
`plugins/kettle/hooks/agents-sync.sh` enforces that before every Bash call and
|
||||
repairs any directory that drifted — it renames, re-points and swaps, and it
|
||||
never deletes content. Two real files with different content is the one case
|
||||
it refuses to resolve and reports instead. `CLAUDE.md` is gitignored, because
|
||||
it is generated.
|
||||
`kettle mirror` enforces that and repairs any directory that drifted — it
|
||||
renames, re-points and swaps, and it never deletes content. Two real files
|
||||
with different content is the one case it refuses to resolve and reports
|
||||
instead. `CLAUDE.md` is gitignored, because it is generated.
|
||||
2. **A directory's file describes that directory only.** What a parent or a child
|
||||
owns gets a link, never a second copy — the copy is what goes stale. If a
|
||||
sentence is true of the whole binary it belongs in `cli/AGENTS.md`; if it is
|
||||
@@ -56,13 +80,16 @@ section at the bottom of each one is for.
|
||||
A hook that nagged after every write was written and then removed: it would have
|
||||
fired for every user of the `kettle` plugin, on every edit in every repository
|
||||
they touched, to enforce a documentation convention that is this repository's and
|
||||
nobody else's. A plugin about issue tracking does not get to reach that far.
|
||||
nobody else's. **That argument survived the plugin.** `kettle mirror --hook` is
|
||||
what stayed, and it is narrower on both axes: it repairs the filesystem layout
|
||||
rather than asking anybody for anything, and it is **opt-in per project** —
|
||||
`kettle init --mirror-hook`, or one question in `kettle init --interactive`.
|
||||
It cannot fail a tool call; it exits 0 on every path, including its own bugs,
|
||||
because documentation maintenance is not permitted to break a build.
|
||||
|
||||
`plugins/kettle/hooks/agents-sync.sh` stays, because it repairs the filesystem
|
||||
layout rather than asking anybody for anything: `AGENTS.md` a real file,
|
||||
`CLAUDE.md` a symlink to it. It cannot fail a tool call — it exits 0 on every
|
||||
path, including its own bugs, because documentation maintenance is not permitted
|
||||
to break a build.
|
||||
This repository registers it in `.claude/settings.json`, which is the one thing
|
||||
under `.claude/` that is not gitignored here — a fresh clone should get the
|
||||
convention without anyone remembering to wire it up.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -72,19 +99,22 @@ cd cli && make help # install, dist, release
|
||||
```
|
||||
|
||||
**There is no CI on the instance this lives on**, so `make check` is the only thing
|
||||
between a mistake and the tracker, and it is on whoever is committing to run it. Its
|
||||
last step is the repository's one mechanical documentation invariant: the
|
||||
command reference an agent reads inside the plugin is generated from the command
|
||||
registry the binary is built from. Everything else in this tree — including every
|
||||
`AGENTS.md` — is prose, and prose is held true by the hook above and by whoever
|
||||
is editing.
|
||||
between a mistake and the tracker, and it is on whoever is committing to run it.
|
||||
Its last step is the repository's one mechanical documentation invariant, and it
|
||||
is circular on purpose: the documents an operator will be given are embedded in
|
||||
the binary with the registry's flag tables already spliced in, so a flag change
|
||||
makes them stale and `gen scaffold --check` exits 1. Regenerate, then **rebuild** —
|
||||
the binary has to pick the new bytes up.
|
||||
|
||||
Everything else in this tree — including every `AGENTS.md` — is prose, held true
|
||||
by `kettle mirror` and by whoever is editing.
|
||||
|
||||
## Keeping this file true
|
||||
|
||||
- **Scope:** the repository layout, the plugin/binary split, and the AGENTS.md
|
||||
- **Scope:** the repository layout, the binary/prose split, and the AGENTS.md
|
||||
convention itself. Every deeper subject belongs to a deeper file.
|
||||
- **Update it when** a top-level directory appears or goes, a plugin is added or
|
||||
removed, the marketplace catalog changes shape, or either hook's behaviour
|
||||
removed, the marketplace catalog changes shape, or the mirror hook's behaviour
|
||||
changes.
|
||||
- **Do not** put a command reference, a package's rules, or a skill's procedure
|
||||
here. Link to the file that owns it.
|
||||
|
||||
@@ -1,51 +1,63 @@
|
||||
# claude-skills — a Claude Code plugin marketplace
|
||||
# claude-skills — the `kettle` binary, and a plugin marketplace
|
||||
|
||||
One repository, one marketplace, two plugins, and the `kettle` binary the issue
|
||||
plugin is built on. Register the marketplace once and install whichever pieces you
|
||||
want; each plugin is independent and carries its own manifest and docs.
|
||||
One repository holding two things: **`kettle`**, a Go binary that keeps a
|
||||
project's issues as local markdown and syncs them with Gitea, and a small Claude
|
||||
Code **plugin marketplace** with one plugin in it.
|
||||
|
||||
## Installation
|
||||
## Installing kettle
|
||||
|
||||
`kettle` is one binary and it installs itself into your project. There is no
|
||||
plugin to add — `cli/go.mod` requires **Go 1.26**, and the first build downloads
|
||||
eight modules, verified against `go.sum`:
|
||||
|
||||
```bash
|
||||
go install git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest
|
||||
|
||||
# or from a clone of this repository, version stamped:
|
||||
cd cli && make install # ~/.local/bin/kettle
|
||||
cd cli && make install BINDIR=$(go env GOPATH)/bin
|
||||
```
|
||||
|
||||
Put the target directory on your `PATH` and check with `kettle version`. Then, in
|
||||
a project you want to track issues in:
|
||||
|
||||
```bash
|
||||
kettle init --interactive
|
||||
```
|
||||
|
||||
That walks you through the login, the token (typed with the echo off, so it lands
|
||||
in no history and no file), the tracker repository, and the `.claude/` tree —
|
||||
which is where the slash commands and skills come from:
|
||||
|
||||
```
|
||||
.claude/commands/kettle/*.md /kettle:init /kettle:auth /kettle:issue
|
||||
/kettle:sync /kettle:api /kettle:project
|
||||
.claude/skills/kettle-*/ loaded by description, never by name
|
||||
.claude/agents/kettle-runner.md the batch runner, on a cheap model
|
||||
```
|
||||
|
||||
Every flag table in those documents is rendered from the binary's own command
|
||||
registry, and the prose around it is embedded in the same binary. **They cannot
|
||||
describe a version of `kettle` other than the one that wrote them** — which is
|
||||
the whole reason this stopped being a plugin. `kettle config` tells you when the
|
||||
tree on disk was written by an older build; `kettle gen scaffold` refreshes it.
|
||||
|
||||
Prefer flags to prompts? Everything `--interactive` asks has one:
|
||||
|
||||
```bash
|
||||
kettle init --login noodles --repo owner/name --mirror-hook
|
||||
kettle init --no-scaffold # marker and config only
|
||||
```
|
||||
|
||||
## The marketplace
|
||||
|
||||
```
|
||||
/plugin marketplace add https://git.noodles.cam/claude-skills/marketplace.git
|
||||
```
|
||||
|
||||
Working from a local clone? Point at the directory instead:
|
||||
|
||||
```
|
||||
/plugin marketplace add /path/to/marketplace
|
||||
```
|
||||
|
||||
Then install what you need:
|
||||
|
||||
```
|
||||
/plugin install kettle@claude-skills
|
||||
/plugin install tdl@claude-skills
|
||||
```
|
||||
|
||||
Use `/plugin` to enable, disable, or update them later.
|
||||
|
||||
**`kettle` also needs its binary**, which no plugin can install for you. Build it
|
||||
from this repository — `cli/go.mod` requires **Go 1.26**, and the first build
|
||||
downloads eight modules, verified against `go.sum`:
|
||||
|
||||
```bash
|
||||
cd cli && make install # ~/.local/bin/kettle, version stamped
|
||||
cd cli && make install BINDIR=$(go env GOPATH)/bin
|
||||
|
||||
# or plain go, without the version stamp:
|
||||
cd cli && go build -o ~/.local/bin/kettle ./cmd/kettle
|
||||
go install git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest
|
||||
```
|
||||
|
||||
Put the target directory on your `PATH` and check with `kettle version`. A skill
|
||||
that answers `command not found: kettle` is telling you exactly this.
|
||||
|
||||
## What ships here
|
||||
|
||||
| Plugin | Commands | What it does |
|
||||
|---|---|---|
|
||||
| [`kettle`](plugins/kettle) | `/kettle:init` `/kettle:auth` `/kettle:issue` `/kettle:sync` `/kettle:use` `/kettle:project` | Issues as local markdown, cleanly layered. Issues are units of work offline first and tracker rows second; the `kettle` binary does the work, and each skill's command reference is generated from the binary's own command registry so it cannot drift |
|
||||
| [`tdl`](plugins/tdl) | `/tdl:audit` | Three Dots Labs Go conventions as an enforceable rule set — audits a Go project against nine categories of CQRS/DDD/Clean-Architecture rules by severity, or scaffolds services, handlers, entities, repositories and Watermill adapters from templates that already follow them |
|
||||
|
||||
---
|
||||
@@ -53,8 +65,9 @@ that answers `command not found: kettle` is telling you exactly this.
|
||||
# The `kettle` CLI
|
||||
|
||||
A single Go binary that keeps a project's issues as flat markdown files and moves
|
||||
them to and from Gitea when you say so. **Everything outside the `sync` group works
|
||||
with the network off.** Two direct dependencies, vendored, no cobra, seven
|
||||
them to and from Gitea when you say so — and, with `kettle api`, reaches every
|
||||
other Gitea entity under the same login, so nothing here needs a second CLI.
|
||||
**Everything outside the `sync` and `api` groups works with the network off.** Two direct dependencies, vendored, no cobra, seven
|
||||
internal packages with one direction of imports — see
|
||||
[`cli/AGENTS.md`](cli/AGENTS.md).
|
||||
|
||||
@@ -67,7 +80,8 @@ internal packages with one direction of imports — see
|
||||
| `kettle init` | makes this directory a project: creates the `.kettle/` marker every other command resolves the store from, migrates an older store in, gitignores it |
|
||||
| `kettle auth` | manages the tokens this machine holds — one file, mode 0600, outside every working tree |
|
||||
| `kettle config` | prints every path and setting a run resolved to, with overrides applied. Never prints a token |
|
||||
| `kettle gen skills` | rewrites the plugin's SKILL.md command reference from the binary's own command registry |
|
||||
| `kettle gen scaffold` | writes the `.claude/` commands, skills and subagent, prose and flag tables both, from inside this binary |
|
||||
| `kettle mirror` | keeps `CLAUDE.md` a symlink to `AGENTS.md` in every directory below here. `--hook` is the `PreToolUse` form and never fails a call |
|
||||
| `kettle version` | what this binary was built as — `dev` from a working tree, the tag when a release build stamped it in |
|
||||
|
||||
**issue** — units of work, offline, no tracker involved
|
||||
@@ -93,6 +107,12 @@ internal packages with one direction of imports — see
|
||||
| `kettle labels` | creates the canonical `type/*` and `severity/*` labels in a repository, `exclusive` flag and all |
|
||||
| `kettle sync-evict` | refreshes state from the tracker first, then evicts what is closed |
|
||||
|
||||
**api** — everything else Gitea has, reached directly
|
||||
|
||||
| command | what it does |
|
||||
|---|---|
|
||||
| `kettle api` | one request to an endpoint this binary has no command for — pull requests, releases, tags, milestones, branches, actions, webhooks — under the login the project already pins. One invocation is one request; it does not paginate, does not reformat the answer, and `-X DELETE` needs `--yes` |
|
||||
|
||||
`kettle help` prints the tree; `kettle help <command>` prints one command in full —
|
||||
flags, defaults and worked examples. Flags may be typed after positional arguments.
|
||||
|
||||
@@ -232,22 +252,25 @@ Five steps in one command, non-zero the moment any of them fails:
|
||||
| `go vet ./...` | the compiler's own second opinion |
|
||||
| `go test -count=1 ./...` | the whole suite, with the test cache defeated: a gate that can pass because it passed an hour ago on different code is not a gate |
|
||||
| `go mod verify` + `go build -mod=vendor ./...` | every module matches its hash in `go.sum`, and the committed `vendor/` is complete and is what compiles. A `vendor/` that has drifted from `go.mod` breaks nobody until somebody builds with a cold module cache |
|
||||
| `kettle gen skills --check` | the plugin's SKILL.md command reference still agrees with the binary it documents. Exit 1 the moment it does not, and `gen skills` without `--check` is the fix |
|
||||
| `kettle gen scaffold --check` | the documents embedded in the binary still agree with the registry it is built from. Exit 1 the moment they do not, and `dist/kettle gen scaffold --out internal/scaffold/assets` is the fix |
|
||||
|
||||
That last one is the invariant that keeps the plugin honest: everything a
|
||||
SKILL.md says about a `kettle` command — its usage line, its flags, its
|
||||
examples — is generated from the registry the binary is built from, between
|
||||
`<!-- kettle:gen -->` markers. Prose outside the markers is never touched.
|
||||
That last one is circular on purpose. What a project gets is embedded in the
|
||||
binary with the registry's flag tables **already spliced in**, so the embedded
|
||||
copy has to hold what the current registry would produce. Change a flag and it
|
||||
goes stale; regenerate, then rebuild, because the binary has to pick the new
|
||||
bytes up.
|
||||
|
||||
### Where the design notes are
|
||||
|
||||
**Every directory with a story carries its own `AGENTS.md`**, starting at [the
|
||||
repository root](AGENTS.md) and recursing into [`cli/`](cli/AGENTS.md), each of its
|
||||
packages, and each plugin. A `CLAUDE.md` beside one is a generated symlink to it, and
|
||||
two hooks in the `kettle` plugin keep the arrangement honest: one repairs the layout
|
||||
before every Bash call, the other asks for the nearest `AGENTS.md` to be corrected
|
||||
after a file is written. Read the one next to the code you are changing rather than
|
||||
the whole tree.
|
||||
packages, and each plugin. A `CLAUDE.md` beside one is a generated symlink to it,
|
||||
and `kettle mirror` keeps that arrangement honest — six of the seven states it can
|
||||
find are repaired losslessly, and the seventh (two real files whose contents
|
||||
differ) is reported and left alone, because one of them is somebody's writing.
|
||||
Register it with `kettle init --mirror-hook` if you want it run before every Bash
|
||||
call. Read the file next to the code you are changing rather than the whole
|
||||
tree.
|
||||
|
||||
`make help` lists the rest — `build`, `install [BINDIR=…]`, `dist`, `clean`.
|
||||
`make install` puts a version-stamped binary in `~/.local/bin` (override with
|
||||
@@ -268,7 +291,8 @@ repository's own code. `cli/cmd/release` is a second binary in the same module
|
||||
that talks to Gitea's API through the same SDK `kettle` uses: no third-party
|
||||
action, and nothing between a tag and what people download that is not in this
|
||||
tree. It is deliberately not a `kettle` subcommand — `kettle` is a tool for
|
||||
issues, and its command tree is what generates the plugin's documentation.
|
||||
issues, and its command tree is what generates the documents it writes into a
|
||||
project.
|
||||
|
||||
```bash
|
||||
cd cli
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.kettle/
|
||||
+115
-46
@@ -1,17 +1,33 @@
|
||||
# AGENTS.md — the kettle CLI
|
||||
|
||||
`kettle` is a globally installed binary. It owns everything mechanical about
|
||||
issues: what an issue is, where the store lives, who this machine is, and how
|
||||
issues move to and from Gitea. It replaced a set of Python scripts that used to
|
||||
ship inside the plugin.
|
||||
`kettle` is a globally installed binary, and **it is the whole product**. There is
|
||||
no plugin any more: it owns this project's connection to its tracker — the
|
||||
credentials, the transport, the payload scratchpad — and it owns the documents an
|
||||
operator and a model read, which it writes into `.claude/` and which are embedded
|
||||
in it.
|
||||
|
||||
The plugin keeps what only a plugin can carry — the rules an operator states and
|
||||
a binary cannot enforce. Everything else is here.
|
||||
Issues are its main subject and no longer its only one: what an issue is, where
|
||||
the store lives, who this machine is, how issues move to and from Gitea, and,
|
||||
through `kettle api`, every other Gitea entity that has no command of its own.
|
||||
|
||||
That is a widening, three times over, and it is worth being straight about each.
|
||||
"Issues and nothing else" was the line until `cmd/release` crossed it (publishing
|
||||
this repository's own releases), then `kettle api` (because the alternative was
|
||||
requiring `tea` — a second CLI with a second set of logins nothing here could
|
||||
see, documented in 400 lines of somebody else's flag reference nothing here could
|
||||
check), and then `kettle mirror` (141 lines of Python that used to be a plugin
|
||||
hook and had nowhere to live once the plugin went).
|
||||
|
||||
One rule made all three: **a binary holds what can be enforced.** Every one of
|
||||
them was mechanical, and every one of them was somewhere a test could not reach.
|
||||
What has *not* widened is the domain — `internal/issue` still knows nothing about
|
||||
trackers, `api` is transport plus a command, and `mirror` and `scaffold` import
|
||||
nothing at all.
|
||||
|
||||
**This file is the binary's map.** Each package documents its own rules in its own
|
||||
directory; nothing below is repeated there and nothing there is repeated here.
|
||||
|
||||
## Why a binary
|
||||
## Why a binary, and then why no plugin
|
||||
|
||||
Three failures in the Python version were failures of *runtime*, not of logic:
|
||||
|
||||
@@ -27,6 +43,22 @@ A compiled binary answers all three by construction. There is one walk
|
||||
([`internal/project`](internal/project/AGENTS.md)), it is imported rather than
|
||||
re-derived, and the layering rule is a build graph a test walks.
|
||||
|
||||
A fourth failure survived that migration and outlived it by a year. The plugin
|
||||
shipped the skills; **nothing on an operator's machine ever checked that the
|
||||
plugin they had installed described the binary they had installed.** The
|
||||
generated 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 was generated from, which is the same bug one hop downstream. So the
|
||||
documents moved inside the binary ([`internal/scaffold`](internal/scaffold/AGENTS.md))
|
||||
and `kettle gen scaffold` writes them into a project. Prose that travels inside
|
||||
the binary cannot be a version behind it.
|
||||
|
||||
What that cost is worth naming rather than glossing: the marketplace was
|
||||
discovery, and a README URL is not a shop window. The `/kettle:…` prefix came
|
||||
free from the plugin and had to be rebuilt out of a `commands/kettle/`
|
||||
subdirectory. Both were paid knowingly — a document describing the wrong binary
|
||||
is worse than one nobody found.
|
||||
|
||||
## Layers
|
||||
|
||||
Knowledge flows one way. The arrow means "imports"; follow a name to that
|
||||
@@ -37,18 +69,22 @@ cmd/kettle thin main; exit status only
|
||||
cmd/release build infrastructure, not a kettle verb — see its own file
|
||||
|
||||
internal/cmd the command tree: flags, receipts, exit codes
|
||||
│ │ │
|
||||
│ │ └────► internal/config who this machine is, what this
|
||||
│ │ project points at; yaml lives here
|
||||
│ │ and only here
|
||||
│ └───────────► internal/gitea TRANSPORT: one door for every
|
||||
│ │ request, pagination, payload dumps,
|
||||
│ │ the number -> slug ledger
|
||||
│ ▼
|
||||
├────────────────────► internal/wire ADDRESSES: Repo and Key, and the
|
||||
│ ▲ parsing that reads them. Imports
|
||||
│ │ nothing.
|
||||
└──► internal/mapping ─────┘ BRIDGE: md <-> the SDK's payloads,
|
||||
│ │ │ │ │
|
||||
│ │ │ │ ├─────► internal/scaffold the documents written into .claude/,
|
||||
│ │ │ │ │ embedded. Imports nothing.
|
||||
│ │ │ │ └─────► internal/mirror AGENTS.md ⇄ CLAUDE.md, seven
|
||||
│ │ │ │ branches. Imports nothing.
|
||||
│ │ │ └────────► internal/config who this machine is, what this
|
||||
│ │ │ project points at; yaml lives here
|
||||
│ │ │ and only here
|
||||
│ │ └────────────► internal/gitea TRANSPORT: one door for every
|
||||
│ │ │ request, pagination, payload dumps,
|
||||
│ │ │ the number -> slug ledger
|
||||
│ │ ▼
|
||||
├──┼──────────────────► internal/wire ADDRESSES: Repo and Key, and the
|
||||
│ │ ▲ parsing that reads them. Imports
|
||||
│ │ │ nothing.
|
||||
└──► internal/mapping ──────┘ BRIDGE: md <-> the SDK's payloads,
|
||||
│ no I/O; label colours live here
|
||||
▼
|
||||
internal/issue DOMAIN what an issue is: format, taxonomy, validation,
|
||||
@@ -56,31 +92,45 @@ internal/issue DOMAIN what an issue is: format, taxonomy, validation,
|
||||
│ offline — no tracker, no network, no JSON
|
||||
▼
|
||||
internal/project ROOT which directory is the project, and every path
|
||||
resolved from it: store, payload, config
|
||||
resolved from it: store, payload, config, scaffold
|
||||
depends on nothing
|
||||
```
|
||||
|
||||
Three packages at the bottom depend on nothing — `project`, `wire`, and now
|
||||
`mirror` and `scaffold` beside them. The last two are there for a reason pointing
|
||||
outward rather than inward: `mirror` walks any directory on any machine, and
|
||||
`scaffold`'s documents must exist wherever the binary does. One import of
|
||||
`internal/config` in the first, or one `os.ReadFile` in the second, and each
|
||||
stops being the thing it is for.
|
||||
|
||||
| package | layer | what its own file opens with |
|
||||
|---|---|---|
|
||||
| [`cmd/kettle`](cmd/kettle/AGENTS.md) | entry point | `os.Exit(cmd.Main(os.Args[1:]))`, and why there is nothing else in it |
|
||||
| [`cmd/release`](cmd/release/AGENTS.md) | build tool | why publishing a release is not a `kettle` verb, and why it goes around the transport |
|
||||
| [`internal/cmd`](internal/cmd/AGENTS.md) | commands | the registry every command is a value in, and the generator that writes the plugin's docs from it |
|
||||
| [`internal/cmd`](internal/cmd/AGENTS.md) | commands | the registry every command is a value in, and the generator that writes a project's documents from it |
|
||||
| [`internal/config`](internal/config/AGENTS.md) | configuration | two files, and why the tokens are not in the one inside the repository |
|
||||
| [`internal/gitea`](internal/gitea/AGENTS.md) | transport | the SDK, the payload scratchpad, the ledger, the dependency endpoint |
|
||||
| [`internal/mapping`](internal/mapping/AGENTS.md) | bridge | md ↔ payload, the id marker, label colours, the checkbox merge |
|
||||
| [`internal/wire`](internal/wire/AGENTS.md) | addresses | `42`, `#42`, `owner/repo#42`, a URL — four spellings of one thing |
|
||||
| [`internal/issue`](internal/issue/AGENTS.md) | domain | the format, the taxonomy, the store, eviction — all of it offline |
|
||||
| [`internal/scaffold`](internal/scaffold/AGENTS.md) | documents | the commands, skills and subagent written into `.claude/`, and why they are not a plugin |
|
||||
| [`internal/mirror`](internal/mirror/AGENTS.md) | filesystem | one invariant per directory, seven branches, one refusal |
|
||||
| [`internal/project`](internal/project/AGENTS.md) | root | the walk, and every path resolved from its answer |
|
||||
|
||||
The rules that hold the layers apart, and the seven tests that fail when one
|
||||
The rules that hold the layers apart, and the eleven tests that fail when one
|
||||
breaks, are in [`internal/AGENTS.md`](internal/AGENTS.md). Read the diagram
|
||||
bottom-up: each layer knows strictly less about trackers than the one above it.
|
||||
|
||||
## Dependencies, and building
|
||||
|
||||
`gopkg.in/yaml.v3` and `code.gitea.io/sdk/gitea` — eight modules once the SDK's
|
||||
own are counted. No cobra: commands are values in a registry, which is what lets
|
||||
the plugin's SKILL.md files be generated from the same struct that holds the code.
|
||||
`gopkg.in/yaml.v3`, `code.gitea.io/sdk/gitea` and `golang.org/x/term` — eight
|
||||
modules once the SDK's own are counted, and the third of those was already among
|
||||
them before anything here imported it. It became direct for one prompt:
|
||||
`term.ReadPassword`, so an operator typing a token at `kettle init --interactive`
|
||||
leaves it in no history, no file and no scrollback.
|
||||
|
||||
No cobra: commands are values in a registry, which is what lets a project's
|
||||
skills be generated from the same struct that holds the code.
|
||||
|
||||
```bash
|
||||
make install # build straight onto your PATH, version stamped
|
||||
@@ -92,7 +142,7 @@ go install git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest
|
||||
|
||||
**`make` is the build, and it is also the CI.** There is no act_runner on the
|
||||
instance this lives on and none is planned, so nothing runs on a push: `make check`
|
||||
— fmt, vet, test, `go mod verify`, build, `gen skills --check` — is the only thing
|
||||
— fmt, vet, test, `go mod verify`, build, `gen scaffold --check` — is the only thing
|
||||
standing between a mistake and the tracker, and it is on whoever is committing to run
|
||||
it. `make help` lists the rest; `make dist` cross-compiles four platforms with a
|
||||
`SHA256SUMS`, and `make release TAG=v1.2.3` publishes them through
|
||||
@@ -147,20 +197,30 @@ The mechanics of each half live with the commands that implement them, in
|
||||
[`internal/gitea`](internal/gitea/AGENTS.md)'s, and the marker that survives a lost
|
||||
ledger is [`internal/mapping`](internal/mapping/AGENTS.md)'s.
|
||||
|
||||
## No guard hook
|
||||
## No guard hook (and the one hook that stayed)
|
||||
|
||||
The Python version needed a `PreToolUse` hook to block any `tea` command that
|
||||
would run under a login the model picked instead of the operator. That whole
|
||||
apparatus is gone. The binary holds its own credentials and reads the login out
|
||||
of the project's own configuration, so there is no argument to police and no way
|
||||
for the transport and the guard to disagree — the failure the hook existed to
|
||||
catch is not expressible any more.
|
||||
catch is not expressible any more. `kettle api` did not put it back: it takes an
|
||||
endpoint and a body, never a login, and a full URL on another host is refused
|
||||
rather than sent with this project's token attached.
|
||||
|
||||
There is also no `--login` and no `--repo` on any sync command bar `labels`.
|
||||
A cross-repository address is still an address: `kettle pull owner/repo#42`
|
||||
re-points the client for that one call, which is bookkeeping and not a second
|
||||
connection.
|
||||
|
||||
The one `PreToolUse` hook that exists now is `kettle mirror --hook`, and it
|
||||
polices nothing: it repairs an `AGENTS.md`/`CLAUDE.md` pair and reports. It is
|
||||
**opt-in per project** — `kettle init --mirror-hook`, or a question in
|
||||
`--interactive` — which is strictly narrower than what it replaced, a hook that
|
||||
was on for everybody who installed the plugin, in every repository they touched,
|
||||
enforcing a convention that is this repository's. It cannot fail a tool call: it
|
||||
exits 0 on every path including its own bugs.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
@@ -188,31 +248,39 @@ Three disciplines every test follows:
|
||||
## Documentation that is generated
|
||||
|
||||
```bash
|
||||
kettle gen skills --out ../plugins/kettle/skills # rewrite the blocks
|
||||
kettle gen skills --out ../plugins/kettle/skills --check # exit 1 if stale
|
||||
kettle gen scaffold # into <project>/.claude
|
||||
dist/kettle gen scaffold --out internal/scaffold/assets # into the embedded copy
|
||||
make check # exit 1 if that copy is stale
|
||||
```
|
||||
|
||||
Everything between `<!-- kettle:gen -->` and `<!-- /kettle:gen -->` in the
|
||||
plugin's SKILL.md files comes from the command registry, so a renamed flag cannot
|
||||
ship with documentation that recommends the old one. `--check` is what a
|
||||
pre-commit hook or a CI step calls. The generator is
|
||||
[`internal/cmd`](internal/cmd/AGENTS.md)'s, and the seam between command groups
|
||||
and plugin skills is [the plugin's](../plugins/kettle/AGENTS.md).
|
||||
Everything between `<!-- kettle:gen -->` and `<!-- /kettle:gen -->` comes from the
|
||||
command registry, so a renamed flag cannot ship with documentation that recommends
|
||||
the old one — and since the prose around it is embedded too, the whole document
|
||||
ships with the binary rather than beside it. The generator is
|
||||
[`internal/cmd`](internal/cmd/AGENTS.md)'s and the documents are
|
||||
[`internal/scaffold`](internal/scaffold/AGENTS.md)'s.
|
||||
|
||||
Note the asymmetry, because it is easy to get backwards: **a SKILL.md command
|
||||
block is generated and must never be hand-edited; every AGENTS.md in this tree is
|
||||
hand-written and must never be generated.** One is a flag table, the other is a
|
||||
reason.
|
||||
**The loop is circular on purpose.** `assets/` holds the block already spliced in,
|
||||
because that is what a project gets byte for byte; so a registry change makes the
|
||||
render differ from the embedded copy, `--check` exits 1, regenerating fixes it,
|
||||
and the binary has to be **rebuilt** to pick the new bytes up.
|
||||
|
||||
Note the asymmetry, because it is easy to get backwards: **everything under
|
||||
`internal/scaffold/assets` is written out and must never be edited in a project;
|
||||
every AGENTS.md in this tree is hand-written and must never be generated.** One is
|
||||
a document with a flag table in it, the other is a reason.
|
||||
|
||||
## Status
|
||||
|
||||
Done and tested: every package in the table above, and the commands `init`, `auth`,
|
||||
`config`, `gen`, `version`, `new`, `check`, `ac`, `tree`, `index`, `evict`, `pull`,
|
||||
`push`, `remote`, `comment`, `close`, `labels`, `sync-evict` — plus `cmd/release`,
|
||||
against a fake Gitea.
|
||||
`config`, `gen`, `mirror`, `version`, `new`, `check`, `ac`, `tree`, `index`,
|
||||
`evict`, `pull`, `push`, `remote`, `comment`, `close`, `labels`, `sync-evict`,
|
||||
`api` — plus `cmd/release`, against a fake Gitea.
|
||||
|
||||
The plugin is rewired: it lives at `plugins/kettle`, ships no Python domain code
|
||||
and no guard hook, and its command reference is generated from this registry.
|
||||
**The plugin is gone.** Its skills are embedded here, its hook is `kettle mirror`,
|
||||
and `kettle init` writes the whole `.claude/` tree. The marketplace catalogue at
|
||||
the repository root now holds one entry, [`tdl`](../plugins/tdl/AGENTS.md), which
|
||||
has no binary and never needed one.
|
||||
|
||||
## Keeping this file true
|
||||
|
||||
@@ -221,6 +289,7 @@ and no guard hook, and its command reference is generated from this registry.
|
||||
`go.sum`, `Makefile`, and the shape of the tree.
|
||||
- **Update it when** a package is added or removed (the diagram and the table both
|
||||
name every one), a dependency changes, a `make` target is added or its meaning
|
||||
changes, or a cross-package contract like the round trip changes.
|
||||
changes, a cross-package contract like the round trip changes, or the argument
|
||||
for embedding the documents rather than shipping them stops holding.
|
||||
- **Do not** describe a package's internals here. That is what the files it links to
|
||||
are for, and a summary that drifts is worse than a link.
|
||||
|
||||
+15
-11
@@ -15,17 +15,19 @@
|
||||
SHELL := /bin/sh
|
||||
|
||||
# Sequential on purpose: `check` builds the binary and then asks it whether the
|
||||
# plugin's documentation still matches, and -j would let the second start first.
|
||||
# documents embedded in it still match its own command registry, and -j would
|
||||
# let the second start first.
|
||||
.NOTPARALLEL:
|
||||
|
||||
MODULE := git.noodles.cam/claude-skills/marketplace/cli
|
||||
SKILLS := ../plugins/kettle/skills
|
||||
ASSETS := internal/scaffold/assets
|
||||
DIST := dist
|
||||
BIN := $(DIST)/kettle
|
||||
REMOTE ?= origin
|
||||
|
||||
# Where `make install` puts the binary. The plugin's skills expect `kettle` on
|
||||
# PATH and say so when it is not; override for a Go-style layout:
|
||||
# Where `make install` puts the binary. Everything kettle writes into a project
|
||||
# expects `kettle` on PATH and says so when it is not; override for a Go-style
|
||||
# layout:
|
||||
# make install BINDIR=$(go env GOPATH)/bin
|
||||
BINDIR ?= $(HOME)/.local/bin
|
||||
|
||||
@@ -99,13 +101,15 @@ build:
|
||||
@go build -trimpath -ldflags '$(LDFLAGS)' -o $(BIN) ./cmd/kettle
|
||||
@echo 'build $(BIN) — $(VERSION)'
|
||||
|
||||
# The documentation invariant: everything the plugin's SKILL.md files say about
|
||||
# a kettle command is generated from the registry the binary is built from, and
|
||||
# this exits 1 the moment the two disagree. Run `kettle gen skills --out …`
|
||||
# without --check to fix it.
|
||||
# The documentation invariant, and it is circular on purpose. The documents an
|
||||
# operator gets are embedded in the binary, and the flag tables inside them are
|
||||
# rendered from the registry that same binary is built from — so the embedded
|
||||
# copy has to already hold what the current registry would produce. This exits 1
|
||||
# the moment it does not; `$(BIN) gen scaffold --out $(ASSETS)` fixes it, and
|
||||
# then the binary has to be rebuilt to pick the new bytes up.
|
||||
gen-check: build
|
||||
@echo 'docs gen skills --check'
|
||||
@$(BIN) gen skills --out $(SKILLS) --check
|
||||
@echo 'docs gen scaffold --check'
|
||||
@$(BIN) gen scaffold --out $(ASSETS) --check
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# building for other people
|
||||
@@ -117,7 +121,7 @@ install:
|
||||
@echo 'installed $(BINDIR)/kettle — $(VERSION)'
|
||||
@case ":$$PATH:" in \
|
||||
*":$(BINDIR):"*) ;; \
|
||||
*) echo "note: $(BINDIR) is not on your PATH, so the plugin's skills will not find it" ;; \
|
||||
*) echo "note: $(BINDIR) is not on your PATH, so nothing kettle writes will find it" ;; \
|
||||
esac
|
||||
|
||||
# CGO_ENABLED=0 because these binaries are downloaded by people whose machines
|
||||
|
||||
@@ -11,8 +11,8 @@ repository's own code.** Driven by `make release TAG=v1.2.3`, never by a user.
|
||||
|
||||
## Why it is not a `kettle` subcommand
|
||||
|
||||
`kettle`'s command tree is not just a menu: it is what `kettle gen skills`
|
||||
generates the plugin's SKILL.md files from. A verb added there arrives in the
|
||||
`kettle`'s 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
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//
|
||||
// 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 skills` generates the plugin's SKILL.md files from. A verb added
|
||||
// `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
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@ go 1.26
|
||||
|
||||
require (
|
||||
code.gitea.io/sdk/gitea v0.25.1
|
||||
golang.org/x/term v0.45.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -13,5 +14,5 @@ require (
|
||||
github.com/go-fed/httpsig v1.1.0 // indirect
|
||||
github.com/hashicorp/go-version v1.9.0 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
)
|
||||
|
||||
+4
-4
@@ -24,11 +24,11 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
||||
+18
-4
@@ -1,6 +1,6 @@
|
||||
# AGENTS.md — internal/, and the boundaries between the packages in it
|
||||
|
||||
Seven packages, one direction of knowledge. The diagram is in
|
||||
Nine packages, one direction of knowledge. The diagram is in
|
||||
[`cli/AGENTS.md`](../AGENTS.md); **this file owns the rules that hold it and the
|
||||
tests that fail when one is broken.** Each package's own document owns what is
|
||||
inside it.
|
||||
@@ -12,7 +12,7 @@ number, a login, an HTTP call, a label colour — that shows up in
|
||||
section, an acceptance criterion, a type taxonomy — that shows up in
|
||||
[`gitea`](gitea/AGENTS.md) is in the wrong place too.
|
||||
|
||||
## Four rules, seven tests
|
||||
## Six rules, eleven tests
|
||||
|
||||
Each test fails on a real mistake rather than on a naming convention.
|
||||
|
||||
@@ -22,10 +22,24 @@ Each test fails on a real mistake rather than on a naming convention.
|
||||
| [`wire`](wire/AGENTS.md) imports **only** the standard library | `TestWireDependsOnNothing` and `TestWireReachesNeitherTheNetworkNorTheDisk`, the same two checks |
|
||||
| [`gitea`](gitea/AGENTS.md) must not import [`issue`](issue/AGENTS.md) **or** [`mapping`](mapping/AGENTS.md) | `TestTransportDoesNotImportTheDomain` — the transport knows numbers, logins, HTTP and JSON, and none of what they mean |
|
||||
| [`mapping`](mapping/AGENTS.md) reaches for nothing but the domain, `wire` and the SDK, and does no I/O | `TestTheBridgeTranslatesAndNothingElse` on its **direct** imports, with `os`, `net/http`, `internal/gitea`, `internal/config` and `internal/project` named; `TestTheBridgeHasNoClock` greps its sources for `time.Now` |
|
||||
| [`mirror`](mirror/AGENTS.md) imports **only** the standard library, and shells out to nothing | `TestMirrorDependsOnNothing` and `TestMirrorNeitherDialsNorShellsOut` |
|
||||
| [`scaffold`](scaffold/AGENTS.md) imports **only** the standard library, and reads nothing off the disk | `TestScaffoldDependsOnNothing` and `TestScaffoldReadsNothingOffTheDisk` |
|
||||
|
||||
The domain's two tests were **untouched by the migration to the Gitea SDK, and
|
||||
that is the point: the domain did not notice it happened.**
|
||||
|
||||
The last two rows are the newest and are there for the same reason as `wire`'s,
|
||||
turned outward rather than inward. `mirror` walks any directory on the machine
|
||||
and `scaffold` hands out documents that must exist wherever the binary does — so
|
||||
one import of `internal/config` would make `mirror` unusable outside a project,
|
||||
and one `os.ReadFile` would make `scaffold`'s documents files that can be
|
||||
missing. Neither failure would show up in this repository; both would show up on
|
||||
somebody else's machine.
|
||||
|
||||
`mirror` bans `os/exec` by name, and that one is a small monument: this package
|
||||
was 141 lines of Python behind a filename that said `.sh`, so the shell-out it
|
||||
must not grow is the exact thing it used to be.
|
||||
|
||||
## The one rule that got weaker, and why the trade was taken
|
||||
|
||||
The payload shapes used to live in `wire`, a package that imported the standard
|
||||
@@ -76,8 +90,8 @@ and add its rule to the table above.
|
||||
|
||||
## Keeping this file true
|
||||
|
||||
- **Scope:** the boundaries *between* the packages under `internal/` — the four
|
||||
rules, the seven tests that hold them, and the history of the one that changed.
|
||||
- **Scope:** the boundaries *between* the packages under `internal/` — the six
|
||||
rules, the eleven tests that hold them, and the history of the one that changed.
|
||||
Files: every `layering_test.go`, plus `TestTransportDoesNotImportTheDomain` in
|
||||
`gitea/client_test.go`.
|
||||
- **Update it when** a layering test is added, renamed, removed or weakened; when
|
||||
|
||||
+128
-28
@@ -11,9 +11,9 @@ and therefore cannot be tested.
|
||||
|
||||
Each command is one `register(&Command{…})` in an `init()`, carrying the metadata a
|
||||
human needs — `Short`, `Long`, `Examples`, `Args`, `Group` — **in the same struct
|
||||
that carries the code**. That is what lets the plugin's SKILL.md files be generated
|
||||
from this list: a command whose flags changed cannot ship with documentation that
|
||||
says otherwise.
|
||||
that carries the code**. That is what lets a project's skills be generated from
|
||||
this list: a command whose flags changed cannot ship with documentation that says
|
||||
otherwise.
|
||||
|
||||
```go
|
||||
func init() {
|
||||
@@ -39,21 +39,41 @@ 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 |
|
||||
|---|---|
|
||||
| `command.go` | `Command`, the registry, `Main`, help rendering, `SilentError`, `Fail`, and `permute` |
|
||||
| `flags.go` | `storeFlag`/`storeRoot`, `wasSet`, the repeatable `stringList` |
|
||||
| `sync.go` | `syncStart`/`syncStartExisting`, `commentsSidecarPath` — the shared opening of every tracker command |
|
||||
| `gen.go` | `kettle gen skills`: the generated region in the plugin's SKILL.md files |
|
||||
| `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` |
|
||||
| `gen.go` | `kettle gen scaffold`: rendering [`scaffold`](../scaffold/AGENTS.md)'s documents with the registry's flag tables spliced in |
|
||||
| `init.go` `auth.go` `config.go` `version.go` `mirror.go` | group `project`. `version.go` also holds `Version`, the string a release build stamps in with `-ldflags -X` |
|
||||
| `interactive.go` | `kettle init --interactive`: the only thing here that reads a terminal |
|
||||
| `settings.go` | registering `kettle mirror --hook` in `.claude/settings.json`, and refusing to reformat one it did not write |
|
||||
| `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 |
|
||||
| `gen_test.go` | the generator: determinism, the region splice, the missing-marker refusal |
|
||||
| `sync_pull_test.go` `sync_write_test.go` `api_test.go` | the tracker halves, against fake servers |
|
||||
| `gen_test.go` | the generator: the whole tree, determinism, and that a local edit does not survive |
|
||||
| `mirror_test.go` | the repair command, and the hook form's promise never to fail a Bash call |
|
||||
| `init_scaffold_test.go` | the `.claude/` tree, the settings refusal, the worktree refusal, the not-a-terminal refusal |
|
||||
| `scaffold_coverage_test.go` | the registry ⇄ documents seam, in the one package that can see both |
|
||||
|
||||
The fourth group is one command and was still worth naming, because a group is a
|
||||
skill written into a project: `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.
|
||||
|
||||
**`mirror` is the one command in the tree that has nothing to do with issues**,
|
||||
and it is here rather than in a second binary for the reason this repository
|
||||
keeps repeating to itself: a binary holds what can be enforced. Repairing an
|
||||
`AGENTS.md`/`CLAUDE.md` pair is a seven-branch filesystem decision with one
|
||||
refusal in it, which is a thing a table test can hold down and a shell script
|
||||
cannot. It was 141 lines of Python behind a filename that said `.sh`, shipped in
|
||||
the plugin's hooks; when the plugin went, the shell had nowhere to live and Go
|
||||
did. The domain is untouched: [`mirror`](../mirror/AGENTS.md) imports nothing.
|
||||
|
||||
## Three conventions every command follows
|
||||
|
||||
@@ -84,6 +104,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.
|
||||
@@ -94,7 +128,9 @@ because that is what the operator asked for.
|
||||
kettle help # the tree, grouped
|
||||
kettle help push # one command in full: flags, defaults, examples
|
||||
|
||||
kettle init --interactive # a person at a terminal, walked through it
|
||||
kettle init --login noodles --repo owner/name
|
||||
kettle mirror --check # AGENTS.md ⇄ CLAUDE.md, everywhere below here
|
||||
kettle new --type task --title "Wire sqlc into the appclick repo layer"
|
||||
kettle ac wire-sqlc-appclick --check 3
|
||||
kettle check --strict # exit 1 on any error; --strict counts warnings too
|
||||
@@ -105,9 +141,59 @@ kettle sync-evict --dry-run
|
||||
```
|
||||
|
||||
Every command's own `Long` text is the reference — it is what
|
||||
`kettle help <name>` prints and what the generator writes into the plugin. **Do not
|
||||
restate a flag table here**; it would be a third copy of something already in two
|
||||
places, one of them mechanically checked.
|
||||
`kettle help <name>` prints and what the generator splices into
|
||||
[`scaffold`](../scaffold/AGENTS.md)'s documents. **Do not restate a flag table
|
||||
here**; it would be a third copy of something already in two places, one of them
|
||||
mechanically checked.
|
||||
|
||||
## `--interactive`, and the two things it may never become
|
||||
|
||||
`interactive.go` is the only code in this binary that reads a terminal. Two rules
|
||||
hold it in place, and both are about what it must not turn into.
|
||||
|
||||
**It answers no question the flags cannot answer.** Every prompt has a field in
|
||||
`initOptions` and therefore a flag — `--login`, `--repo`, `--scaffold-out`,
|
||||
`--no-scaffold`, `--mirror-hook`. A capability reachable only by answering a
|
||||
prompt would be a capability no script, no CI run and no agent could ever use.
|
||||
|
||||
**It performs nothing.** It fills the struct in and hands it back, so `runInit`
|
||||
is one code path: the run that follows an interactive session is byte for byte
|
||||
the run somebody else gets from flags.
|
||||
|
||||
It **refuses a standard input that is not a terminal**, and that refusal is load
|
||||
bearing rather than fussy. A model that reached for `--interactive` would
|
||||
otherwise block forever on a prompt nobody is going to answer; the error names
|
||||
the flags instead. It is also what makes the one genuinely better thing here
|
||||
possible — `term.ReadPassword` puts a token in no history, no file and no
|
||||
scrollback, which every other route into this binary can only approximate.
|
||||
|
||||
`--repo` is offered with a guess read out of `.git/config`, parsed rather than
|
||||
shelled out to. `git` is one more thing that has to be installed and one more
|
||||
process to misread; a guess is worth what it costs, and anything unparseable is
|
||||
no guess at all.
|
||||
|
||||
## Writing into `.claude/settings.json`
|
||||
|
||||
`settings.go` registers `kettle mirror --hook` on `PreToolUse(Bash)`, and is the
|
||||
only place this binary touches a file the operator owns and commits. It has three
|
||||
outcomes and the third is the whole reason it is not ten lines long:
|
||||
|
||||
- **no file** — written, hook and all;
|
||||
- **a file already holding a `kettle mirror` hook** — nothing happens;
|
||||
- **a file holding something else** — **refused**, with the snippet printed to
|
||||
paste, unless `--force-settings`.
|
||||
|
||||
Go's `encoding/json` cannot preserve key order, so any merge reformats the whole
|
||||
document. An operator who asked for a documentation hook and got an unrelated
|
||||
diff across a file they share with their team has been badly served; a snippet
|
||||
they paste costs them ten seconds. The refusal is reported on stderr and the run
|
||||
still **succeeds** — everything before it already happened, and reporting a
|
||||
failure would send somebody looking for damage there is none of.
|
||||
|
||||
The registered command carries a `command -v kettle` guard. That line outlives
|
||||
this binary: an operator who uninstalls `kettle` would otherwise get
|
||||
`command not found` on every Bash call, from a hook they set up months ago and
|
||||
have long stopped thinking about.
|
||||
|
||||
## push and pull, the two halves of one rule
|
||||
|
||||
@@ -153,35 +239,49 @@ the body** — it is a fetch, not a merge — with checkbox state the one except
|
||||
|
||||
## The generator
|
||||
|
||||
`gen.go` writes the plugin's SKILL.md command reference from this registry.
|
||||
`gen.go` writes a project's `.claude/` tree: [`scaffold`](../scaffold/AGENTS.md)'s
|
||||
embedded documents, with this registry's flag tables spliced into the four that
|
||||
declare a region.
|
||||
|
||||
**It owns a region, not a file.** Everything between `<!-- kettle:gen -->` and
|
||||
`<!-- /kettle:gen -->` is replaced on every run; every byte outside comes back
|
||||
exactly as it was, which matters most for `description:`, the prose that decides
|
||||
whether an agent loads the skill at all and the one thing here no generator can
|
||||
write. A file with **no** markers is reported and left alone, never overwritten —
|
||||
clobbering somebody's prose because they forgot a marker is the failure this design
|
||||
exists to prevent.
|
||||
**It writes every file whole, and that is a deliberate reversal.** The old
|
||||
generator owned a region and left every byte outside it alone, because the prose
|
||||
around the block was somebody's hand-written file in this repository. It is not
|
||||
any more — it is embedded — so there is no hand-written half left to protect, and
|
||||
preserving local edits would freeze a project's documentation at whatever version
|
||||
first initialized it. The markers stay in the output so a reader can still see
|
||||
which half came from the registry.
|
||||
|
||||
The principle the old refusal protected did not go away; it moved. **Nobody's
|
||||
prose is clobbered because nobody's prose is there** — it lives in `assets/`, next
|
||||
to the code it describes, and `--check` warns before an upgrade replaces a local
|
||||
edit.
|
||||
|
||||
The output is deterministic to the byte — no timestamps, no map iteration — so
|
||||
regenerating something unchanged produces no diff. `--check` is that property made
|
||||
useful: it writes nothing and exits 1 when anything on disk differs, which is what a
|
||||
pre-commit hook or a CI step calls, and it wins over `--dry-run`.
|
||||
useful: it writes nothing and exits 1 when anything on disk differs, which is what
|
||||
a 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
|
||||
`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
|
||||
One document per **group**, so adding a group here means adding one under
|
||||
`internal/scaffold/assets`; name a group only when it is a subject somebody would
|
||||
load on its own. `TestEveryGroupHasSomewhereToBeWritten` is what makes that a
|
||||
two-step change rather than a silent one-step mistake — the generator walks the
|
||||
documents, not the registry, so a group with no document would have its commands
|
||||
written nowhere and nothing would say so. A `Long` or `Example` that spells a
|
||||
region marker out in full is a hard error: the generated block would end inside
|
||||
itself.
|
||||
|
||||
`--out` defaults to `<project>/.claude`, resolved by the same walk everything else
|
||||
uses, and no marker is an answer rather than a fallback.
|
||||
|
||||
## Keeping this file true
|
||||
|
||||
- **Scope:** the shape of the command tree — the registry, the shared helpers, the
|
||||
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, the push/pull ordering guarantees change, or the rules
|
||||
around writing into `.claude/` 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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -82,6 +100,7 @@ argument is in the shell history the moment it is typed:
|
||||
Name: *name,
|
||||
URL: strings.TrimRight(*url, "/"),
|
||||
User: *user,
|
||||
Scopes: splitScopes(*scopes),
|
||||
Token: secret,
|
||||
}
|
||||
if existing := logins.Find(*name); existing != nil {
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
//
|
||||
// Commands are values, not init() side effects on a framework: each one carries
|
||||
// the metadata a human needs (what it does, what it takes, worked examples) in
|
||||
// the same struct that carries the code. That is deliberate — the plugin's
|
||||
// SKILL.md files are generated from this list, so a command whose flags changed
|
||||
// cannot ship with documentation that says otherwise.
|
||||
// the same struct that carries the code. That is deliberate — the skills kettle
|
||||
// writes into a project are generated from this list, so a command whose flags
|
||||
// changed cannot ship with documentation that says otherwise.
|
||||
//
|
||||
// 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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -36,6 +37,7 @@ This is the command to reach for when the store looks empty, when a push says
|
||||
fmt.Printf("payload %s\n", project.PayloadRoot(""))
|
||||
fmt.Printf("config %s\n", config.ProjectPath(""))
|
||||
fmt.Printf("logins %s\n", config.LoginsPath())
|
||||
printScaffold()
|
||||
|
||||
r, err := config.Resolve("")
|
||||
if err != nil {
|
||||
@@ -47,6 +49,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 {
|
||||
@@ -57,3 +67,30 @@ This is the command to reach for when the store looks empty, when a push says
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// printScaffold says where the agent-harness tree went and whether the build
|
||||
// that wrote it is the build that is running now.
|
||||
//
|
||||
// The mismatch is worth a line because these documents are generated whole:
|
||||
// there is nothing inside one that tells an operator how old it is, and a skill
|
||||
// four releases behind describes flags that have since been renamed. It is a
|
||||
// note, never a failure — `kettle gen scaffold` is the fix and the operator
|
||||
// decides when to run it.
|
||||
func printScaffold() {
|
||||
rec, existed, err := config.ReadScaffoldFile(config.ScaffoldPath(""))
|
||||
if err != nil || !existed {
|
||||
fmt.Printf("scaffold (none written — `kettle gen scaffold`)\n")
|
||||
return
|
||||
}
|
||||
out := rec.Out
|
||||
if out == "" {
|
||||
out = "(unrecorded)"
|
||||
}
|
||||
switch {
|
||||
case rec.Version == Version:
|
||||
fmt.Printf("scaffold %s (kettle %s)\n", out, rec.Version)
|
||||
default:
|
||||
fmt.Printf("scaffold %s (written by kettle %s; this is %s — run `kettle gen scaffold`)\n",
|
||||
out, rec.Version, Version)
|
||||
}
|
||||
}
|
||||
|
||||
+138
-119
@@ -9,21 +9,24 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/scaffold"
|
||||
)
|
||||
|
||||
// The region markers. What sits between them belongs to the generator; the
|
||||
// rest of the file belongs to whoever wrote it.
|
||||
// The region markers. What sits between them comes from the registry; the rest
|
||||
// of the document is the embedded prose around it.
|
||||
const (
|
||||
genOpen = "<!-- kettle:gen -->"
|
||||
genClose = "<!-- /kettle:gen -->"
|
||||
)
|
||||
|
||||
// genBanner opens every generated region. The first thing anybody who finds
|
||||
// the block wants to do is edit it in place, so the block says who wrote it and
|
||||
// genBanner opens every generated region. The first thing anybody who finds the
|
||||
// block wants to do is edit it in place, so the block says who wrote it and
|
||||
// which command writes it again.
|
||||
const genBanner = "**Generated from the kettle command registry by `kettle gen skills`.** " +
|
||||
const genBanner = "**Generated from the kettle command registry by `kettle gen scaffold`.** " +
|
||||
"Everything between the two markers is replaced on the next run — " +
|
||||
"hand-written prose belongs outside them."
|
||||
"the prose around it is embedded in the binary and replaced with it."
|
||||
|
||||
// exampleAlign is the widest example command that still gets its `# what`
|
||||
// padded into a column. One long pipeline would otherwise push every other
|
||||
@@ -34,176 +37,199 @@ func init() {
|
||||
register(&Command{
|
||||
Name: "gen",
|
||||
Group: GroupProject,
|
||||
Args: "skills",
|
||||
Short: "write the plugin's SKILL.md files from the command registry",
|
||||
Long: `A SKILL.md tells an agent how to invoke this binary. Hand-written, it drifts: a
|
||||
flag is renamed here and the documentation goes on recommending the old one,
|
||||
and the agent that reads it fails in a way nobody traces back to a stale
|
||||
sentence. Everything those files say about a command — its usage line, its
|
||||
flags with their defaults, its worked examples — is already in the registry
|
||||
this binary is built from, so it is written from there and cannot disagree.
|
||||
Args: "scaffold",
|
||||
Short: "write this project's .claude/ commands, skills and subagent",
|
||||
Long: `A skill tells an agent how to invoke this binary, and a command is how an
|
||||
operator invokes one by hand. Both are written from here, whole, because both
|
||||
travel INSIDE the binary: the prose is embedded next to the code it describes
|
||||
and the flag tables are rendered from the command registry the binary is built
|
||||
from, so neither can be a version behind the other.
|
||||
|
||||
THE GENERATOR OWNS A REGION, NOT A FILE. Each SKILL.md carries a pair of HTML
|
||||
comment markers — ` + "`kettle:gen`" + ` to open and ` + "`/kettle:gen`" + ` to close, both written in
|
||||
the ` + "`<!-- … -->`" + ` form and visible at the top and bottom of the block below.
|
||||
Everything between them is replaced on every run; every byte outside them comes
|
||||
back exactly as it was, which matters most for ` + "`description:`" + `, the prose that
|
||||
decides whether an agent loads the skill at all, and the one thing here that no
|
||||
generator can write.
|
||||
That is the whole reason these documents are not a plugin any more. A plugin
|
||||
ships on its own cadence, and nothing on an operator's machine ever checked that
|
||||
the one they installed described the binary they installed — so a renamed flag
|
||||
could still arrive with documentation recommending the old one, which is exactly
|
||||
the failure the generated block was invented to prevent, one hop further
|
||||
downstream.
|
||||
|
||||
A file with no markers is REPORTED AND LEFT ALONE, never overwritten: clobbering
|
||||
somebody's prose because they forgot a marker is the failure this design exists
|
||||
to prevent. A file that does not exist yet is created with a frontmatter stub
|
||||
around a generated block, for a human to fill in.
|
||||
EVERY FILE IS WRITTEN WHOLE, and that is a deliberate reversal. The old
|
||||
generator owned a region and left every byte outside it alone, because the prose
|
||||
around the block was somebody's hand-written file. It is not any more: it is
|
||||
embedded, so there is no hand-written half left to protect, and preserving local
|
||||
edits would mean freezing a project's documentation at whatever version first
|
||||
initialized it. The markers stay in the output so a reader can see which half
|
||||
came from the registry.
|
||||
|
||||
WHAT THIS MEANS FOR A LOCAL EDIT: it does not survive. Run --check before an
|
||||
upgrade if you have made one; the fix for a sentence that is wrong is a newer
|
||||
kettle, not a patch that the next run silently discards.
|
||||
|
||||
The output is deterministic to the byte — no timestamps, no map iteration — so
|
||||
regenerating something that has not changed produces no diff. --check is that
|
||||
property made useful: it writes nothing and exits 1 when any file on disk
|
||||
differs from what would be generated, which is what a pre-commit hook or a CI
|
||||
step calls. It wins over --dry-run when both are given.`,
|
||||
differs from what would be written, which is what a pre-commit hook or a CI step
|
||||
calls. It wins over --dry-run when both are given.`,
|
||||
Examples: []Example{
|
||||
{"kettle gen skills --out ../plugins/kettle/skills", "write the region in every group's SKILL.md"},
|
||||
{"kettle gen skills --out ../plugins/kettle/skills --dry-run", "print what would change; write nothing"},
|
||||
{"kettle gen skills --out ../plugins/kettle/skills --check", "exit 1 if the docs are out of date"},
|
||||
{"kettle gen scaffold", "write .claude/ under this project"},
|
||||
{"kettle gen scaffold --out ~/code/x/.claude", "write it somewhere else"},
|
||||
{"kettle gen scaffold --dry-run", "print what would change; write nothing"},
|
||||
{"kettle gen scaffold --check", "exit 1 if the documents are out of date"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
out := fs.String("out", "", "directory the skills live in; one <group>/SKILL.md under it")
|
||||
out := fs.String("out", "", "directory to write into (default: <project>/"+scaffold.Marker+")")
|
||||
dryRun := fs.Bool("dry-run", false, "print what would change; write nothing")
|
||||
check := fs.Bool("check", false, "write nothing, exit 1 if anything is out of date")
|
||||
|
||||
return func(args []string) error {
|
||||
target := "skills"
|
||||
target := "scaffold"
|
||||
if len(args) > 0 {
|
||||
target = args[0]
|
||||
}
|
||||
if len(args) > 1 || target != "skills" {
|
||||
return Fail("the only target is `skills` — try `kettle gen skills --out <dir>`")
|
||||
if len(args) > 1 || target != "scaffold" {
|
||||
return Fail("the only target is `scaffold` — try `kettle gen scaffold`")
|
||||
}
|
||||
if *out == "" {
|
||||
return Fail("--out is required — the directory the SKILL.md files live under")
|
||||
dir, err := scaffoldDir(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return genSkills(*out, *dryRun, *check)
|
||||
return genScaffold(dir, *dryRun, *check)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// errNoRegion is what a file that the generator may not touch reports.
|
||||
var errNoRegion = errors.New("no " + genOpen + " … " + genClose + " region")
|
||||
// scaffoldDir resolves where the tree goes.
|
||||
//
|
||||
// An explicit --out is used exactly as typed, relative and all, because that is
|
||||
// what the operator asked for. Without one the answer comes from the marker, the
|
||||
// same walk every other command uses — and no marker is an answer rather than a
|
||||
// fallback, because a `.claude/` written into a plausible-looking directory is
|
||||
// the failure the marker exists to replace.
|
||||
func scaffoldDir(out string) (string, error) {
|
||||
if out != "" {
|
||||
return out, nil
|
||||
}
|
||||
root := project.Root("")
|
||||
if root == "" {
|
||||
return "", project.NotFoundError("")
|
||||
}
|
||||
return filepath.Join(root, scaffold.Marker), nil
|
||||
}
|
||||
|
||||
func genSkills(dir string, dryRun, check bool) error {
|
||||
func genScaffold(dir string, dryRun, check bool) error {
|
||||
// --check is a read-only question about the working tree, so it overrules
|
||||
// --dry-run rather than combining with it.
|
||||
if check {
|
||||
dryRun = true
|
||||
}
|
||||
|
||||
groups := docGroups()
|
||||
var written, unchanged, outdated, kept int
|
||||
for _, group := range groups {
|
||||
path := filepath.Join(dir, group, "SKILL.md")
|
||||
block, err := renderGroup(commandsIn(group))
|
||||
files, err := renderAll()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var written, unchanged, outdated int
|
||||
for _, f := range files {
|
||||
path := filepath.Join(dir, filepath.FromSlash(f.Path))
|
||||
existing, err := os.ReadFile(path)
|
||||
switch {
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
outdated++
|
||||
if check {
|
||||
fmt.Printf("%-13s %s\n", "missing", path)
|
||||
continue
|
||||
}
|
||||
if dryRun {
|
||||
fmt.Printf("%-13s %s\n", "would create", path)
|
||||
continue
|
||||
}
|
||||
if err := writeFile(path, stubFile(group, block)); err != nil {
|
||||
if err := report(path, "missing", "would create", "created", dryRun, check, func() error {
|
||||
return writeFile(path, f.Body)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if !dryRun {
|
||||
written++
|
||||
fmt.Printf("%-13s %s\n", "created", path)
|
||||
}
|
||||
|
||||
case err != nil:
|
||||
return err
|
||||
|
||||
default:
|
||||
want, err := spliceRegion(string(existing), block)
|
||||
if err != nil {
|
||||
// Reported, never repaired: a missing marker is somebody's
|
||||
// prose sitting where the block used to be.
|
||||
kept++
|
||||
fmt.Fprintf(os.Stderr, "kettle gen: %s left alone — %v\n", path, err)
|
||||
continue
|
||||
}
|
||||
if want == string(existing) {
|
||||
case string(existing) == f.Body:
|
||||
unchanged++
|
||||
fmt.Printf("%-13s %s\n", "unchanged", path)
|
||||
continue
|
||||
}
|
||||
|
||||
default:
|
||||
outdated++
|
||||
if check {
|
||||
fmt.Printf("%-13s %s\n", "stale", path)
|
||||
continue
|
||||
}
|
||||
if dryRun {
|
||||
fmt.Printf("%-13s %s\n", "would update", path)
|
||||
continue
|
||||
}
|
||||
if err := writeFile(path, want); err != nil {
|
||||
if err := report(path, "stale", "would update", "updated", dryRun, check, func() error {
|
||||
return writeFile(path, f.Body)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if !dryRun {
|
||||
written++
|
||||
fmt.Printf("%-13s %s\n", "updated", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case check:
|
||||
fmt.Printf("%d file(s) checked, %d out of date, %d without a region\n",
|
||||
len(groups), outdated, kept)
|
||||
fmt.Printf("%d file(s) checked, %d out of date\n", len(files), outdated)
|
||||
if outdated > 0 {
|
||||
fmt.Printf("run `kettle gen skills --out %s`\n", dir)
|
||||
fmt.Printf("run `kettle gen scaffold --out %s`\n", dir)
|
||||
return SilentError{Code: 1}
|
||||
}
|
||||
case dryRun:
|
||||
fmt.Printf("%d file(s) would change, %d unchanged, %d without a region — nothing was written\n",
|
||||
outdated, unchanged, kept)
|
||||
fmt.Printf("%d file(s) would change, %d unchanged — nothing was written\n", outdated, unchanged)
|
||||
default:
|
||||
fmt.Printf("%d file(s) written, %d unchanged, %d without a region\n", written, unchanged, kept)
|
||||
fmt.Printf("%d file(s) written, %d unchanged\n", written, unchanged)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// docGroups lists the groups that have commands, in the order Commands()
|
||||
// returns them — the same order twice, so two runs cannot differ.
|
||||
func docGroups() []string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, c := range Commands() {
|
||||
if c.Group == "" {
|
||||
fmt.Fprintf(os.Stderr, "kettle gen: command %q has no group and is in no skill\n", c.Name)
|
||||
continue
|
||||
// report prints one line for one file and performs the write unless this run is
|
||||
// only answering a question.
|
||||
func report(path, checkWord, dryWord, doneWord string, dryRun, check bool, write func() error) error {
|
||||
switch {
|
||||
case check:
|
||||
fmt.Printf("%-13s %s\n", checkWord, path)
|
||||
case dryRun:
|
||||
fmt.Printf("%-13s %s\n", dryWord, path)
|
||||
default:
|
||||
if err := write(); err != nil {
|
||||
return err
|
||||
}
|
||||
if !seen[c.Group] {
|
||||
seen[c.Group] = true
|
||||
out = append(out, c.Group)
|
||||
fmt.Printf("%-13s %s\n", doneWord, path)
|
||||
}
|
||||
}
|
||||
return out
|
||||
return nil
|
||||
}
|
||||
|
||||
func commandsIn(group string) []*Command {
|
||||
var out []*Command
|
||||
for _, c := range Commands() {
|
||||
if c.Group == group {
|
||||
out = append(out, c)
|
||||
// renderAll is every embedded document with its generated region filled in.
|
||||
//
|
||||
// Nothing here touches the disk: the result is what the tree SHOULD be, and
|
||||
// comparing it against what is there is a separate question asked by the caller.
|
||||
// That split is what lets --check be exact rather than a heuristic about
|
||||
// timestamps.
|
||||
func renderAll() ([]scaffold.File, error) {
|
||||
files := scaffold.Files()
|
||||
out := make([]scaffold.File, 0, len(files))
|
||||
for _, f := range files {
|
||||
if f.Group == "" {
|
||||
out = append(out, f)
|
||||
continue
|
||||
}
|
||||
block, err := renderGroup(commandsIn(f.Group))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out
|
||||
body, err := spliceRegion(f.Body, block)
|
||||
if err != nil {
|
||||
// The embedded document is shipped inside this binary, so a missing
|
||||
// marker is a build-time mistake in this repository and not
|
||||
// something an operator can have caused.
|
||||
return nil, Fail("%s: %v — this is a bug in the embedded document, not in your project", f.Path, err)
|
||||
}
|
||||
f.Body = body
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// errNoRegion is what a document that declares a region and has none reports.
|
||||
var errNoRegion = errors.New("no " + genOpen + " … " + genClose + " region")
|
||||
|
||||
// renderGroup is the generated block for one group, without the markers and
|
||||
// without a trailing newline.
|
||||
func renderGroup(cmds []*Command) (string, error) {
|
||||
@@ -295,23 +321,16 @@ func spliceRegion(existing, block string) (string, error) {
|
||||
return existing[:start] + region(block) + existing[rest+end+len(genClose):], nil
|
||||
}
|
||||
|
||||
// stubFile is a new SKILL.md: the least frontmatter that is still a skill,
|
||||
// and the region.
|
||||
//
|
||||
// The description is left as a TODO on purpose. It is the sentence that decides
|
||||
// whether an agent loads this skill at all — prose a human tunes against real
|
||||
// failures to trigger, and the one thing here a generator has no way to write.
|
||||
func stubFile(group, block string) string {
|
||||
title := "# kettle " + group + "\n"
|
||||
if blurb := groupBlurb[group]; blurb != "" {
|
||||
title += "\n" + blurb + "\n"
|
||||
// commandsIn lists a group's commands in the order Commands() returns them —
|
||||
// the same order twice, so two runs cannot differ.
|
||||
func commandsIn(group string) []*Command {
|
||||
var out []*Command
|
||||
for _, c := range Commands() {
|
||||
if c.Group == group {
|
||||
out = append(out, c)
|
||||
}
|
||||
return "---\n" +
|
||||
"name: " + group + "\n" +
|
||||
"description: TODO — write this by hand. It is the only thing that decides whether an agent loads this skill at all, so it is prose a human tunes; kettle gen never reads or writes it.\n" +
|
||||
"---\n\n" +
|
||||
title + "\n" +
|
||||
region(block) + "\n"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func writeFile(path, content string) error {
|
||||
|
||||
+118
-114
@@ -1,9 +1,9 @@
|
||||
package cmd_test
|
||||
|
||||
// `kettle gen` writes documentation an agent reads to invoke this binary, into
|
||||
// files a human also writes prose in. Both halves of that are tested here: what
|
||||
// it produces has to be the same twice over, and what it does NOT own has to
|
||||
// come back byte for byte.
|
||||
// `kettle gen scaffold` writes the documents an operator invokes and a model
|
||||
// loads. They are embedded in the binary, so this file tests the two properties
|
||||
// that follow from that: what it produces is the same twice over, and it is the
|
||||
// binary's answer rather than whatever happens to be on disk.
|
||||
|
||||
import (
|
||||
"os"
|
||||
@@ -17,39 +17,66 @@ const (
|
||||
genClose = "<!-- /kettle:gen -->"
|
||||
)
|
||||
|
||||
func TestGenWritesOneFilePerGroupAndIsIdempotent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "skills")
|
||||
// everything the tree is made of. Named here rather than derived, because a file
|
||||
// that silently stopped being written is exactly the failure this catches.
|
||||
var scaffoldFiles = []string{
|
||||
"agents/kettle-runner.md",
|
||||
"commands/kettle/api.md",
|
||||
"commands/kettle/auth.md",
|
||||
"commands/kettle/init.md",
|
||||
"commands/kettle/issue.md",
|
||||
"commands/kettle/project.md",
|
||||
"commands/kettle/sync.md",
|
||||
"skills/kettle-api/SKILL.md",
|
||||
"skills/kettle-issue/SKILL.md",
|
||||
"skills/kettle-issue/references/format.md",
|
||||
"skills/kettle-project/SKILL.md",
|
||||
"skills/kettle-sync/SKILL.md",
|
||||
}
|
||||
|
||||
first := mustRun(t, dir, "gen", "skills", "--out", out)
|
||||
for _, group := range []string{"project", "issue", "sync"} {
|
||||
path := filepath.Join(out, group, "SKILL.md")
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("%s was not created: %v\n%s", path, err, first.out())
|
||||
func TestGenWritesTheWholeTreeAndIsIdempotent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "out")
|
||||
|
||||
first := mustRun(t, dir, "gen", "scaffold", "--out", out)
|
||||
for _, rel := range scaffoldFiles {
|
||||
path := filepath.Join(out, filepath.FromSlash(rel))
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("%s was not created: %v\n%s", rel, err, first.out())
|
||||
}
|
||||
body := string(raw)
|
||||
// The frontmatter is what makes it a skill at all, and the description
|
||||
// is prose a human tunes — the stub says so and generates nothing.
|
||||
if !strings.HasPrefix(body, "---\nname: "+group+"\n") {
|
||||
t.Errorf("%s has no frontmatter naming the group:\n%s", path, firstLines(body, 5))
|
||||
}
|
||||
|
||||
// The four skills that carry a flag table carry the markers around it, so a
|
||||
// reader can see which half came from the registry.
|
||||
for _, group := range []string{"project", "issue", "sync", "api"} {
|
||||
path := filepath.Join(out, "skills", "kettle-"+group, "SKILL.md")
|
||||
body := readFile(t, path)
|
||||
if !strings.HasPrefix(body, "---\nname: kettle-"+group+"\n") {
|
||||
t.Errorf("%s does not name itself after its directory:\n%s", path, firstLines(body, 4))
|
||||
}
|
||||
if !strings.Contains(body, genOpen) || !strings.Contains(body, genClose) {
|
||||
t.Errorf("%s was created without the region markers:\n%s", path, body)
|
||||
t.Errorf("%s has no region markers", path)
|
||||
}
|
||||
// The block has to say what wrote it: the first thing anybody who finds
|
||||
// it will want to do is edit it in place.
|
||||
if !strings.Contains(body, "kettle gen skills") {
|
||||
t.Errorf("%s does not name the command that regenerates it:\n%s", path, body)
|
||||
if !strings.Contains(body, "kettle gen scaffold") {
|
||||
t.Errorf("%s does not name the command that regenerates it", path)
|
||||
}
|
||||
}
|
||||
|
||||
// A command is invoked by a person who typed it, and takes its name from its
|
||||
// filename — a `name:` here would be a second spelling free to drift.
|
||||
initBody := readFile(t, filepath.Join(out, "commands", "kettle", "init.md"))
|
||||
if strings.Contains(firstLines(initBody, 6), "\nname:") {
|
||||
t.Errorf("the init command carries a name: of its own:\n%s", firstLines(initBody, 6))
|
||||
}
|
||||
if !strings.Contains(initBody, "description:") {
|
||||
t.Errorf("the init command has no description for the command list:\n%s", firstLines(initBody, 6))
|
||||
}
|
||||
|
||||
// One command's documentation, end to end: usage line, short, a flag out of
|
||||
// the flag set, and a worked example with its explanation beside it.
|
||||
issues, err := os.ReadFile(filepath.Join(out, "issue", "SKILL.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
issues := readFile(t, filepath.Join(out, "skills", "kettle-issue", "SKILL.md"))
|
||||
for _, want := range []string{
|
||||
"## `kettle evict [<id>…]`",
|
||||
"remove closed issues from the local store",
|
||||
@@ -57,8 +84,8 @@ func TestGenWritesOneFilePerGroupAndIsIdempotent(t *testing.T) {
|
||||
"kettle evict --dry-run",
|
||||
"# print what would go; touch nothing",
|
||||
} {
|
||||
if !strings.Contains(string(issues), want) {
|
||||
t.Errorf("the issue group is missing %q:\n%s", want, issues)
|
||||
if !strings.Contains(issues, want) {
|
||||
t.Errorf("the issue skill is missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +93,7 @@ func TestGenWritesOneFilePerGroupAndIsIdempotent(t *testing.T) {
|
||||
// changed must produce no diff at all, or every run of a CI step is a
|
||||
// spurious one.
|
||||
before := readAll(t, out)
|
||||
second := mustRun(t, dir, "gen", "skills", "--out", out)
|
||||
second := mustRun(t, dir, "gen", "scaffold", "--out", out)
|
||||
if strings.Contains(second.stdout, "updated") {
|
||||
t.Errorf("a second run rewrote a file:\n%s", second.out())
|
||||
}
|
||||
@@ -76,105 +103,56 @@ func TestGenWritesOneFilePerGroupAndIsIdempotent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
if r := run(t, dir, "gen", "skills", "--out", out, "--check"); r.code != 0 {
|
||||
if r := run(t, dir, "gen", "scaffold", "--out", out, "--check"); r.code != 0 {
|
||||
t.Errorf("--check exited %d on files that were just written:\n%s", r.code, r.out())
|
||||
}
|
||||
}
|
||||
|
||||
// The generator owns a region, not a file. Everything outside the markers is
|
||||
// somebody's prose and comes back exactly as it was.
|
||||
func TestGenLeavesHandWrittenProseAlone(t *testing.T) {
|
||||
// The reversal, and the one behaviour worth stating out loud: these files are
|
||||
// the binary's, whole. The old generator owned a region and left the prose
|
||||
// around it alone, because that prose was somebody's hand-written file. It is
|
||||
// embedded now — there is no hand-written half left to protect, and preserving
|
||||
// local edits would freeze a project's documentation at whatever version first
|
||||
// initialized it.
|
||||
func TestGenReplacesLocalEditsRatherThanPreservingThem(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "skills")
|
||||
mustRun(t, dir, "gen", "skills", "--out", out)
|
||||
out := filepath.Join(dir, "out")
|
||||
mustRun(t, dir, "gen", "scaffold", "--out", out)
|
||||
|
||||
path := filepath.Join(out, "issue", "SKILL.md")
|
||||
raw := readFile(t, path)
|
||||
start := strings.Index(raw, genOpen)
|
||||
end := strings.Index(raw, genClose) + len(genClose)
|
||||
if start < 0 || end < len(genClose) {
|
||||
t.Fatalf("no region in the generated file:\n%s", raw)
|
||||
path := filepath.Join(out, "skills", "kettle-issue", "SKILL.md")
|
||||
pristine := readFile(t, path)
|
||||
edited := strings.Replace(pristine, "# /kettle:issue", "# my own heading", 1)
|
||||
edited = strings.Replace(edited, genClose, "hand-added line\n"+genClose, 1)
|
||||
if edited == pristine {
|
||||
t.Fatal("the fixture did not actually edit anything")
|
||||
}
|
||||
|
||||
const above = "\n## Identity: the slug\n\nThe file name is the id, and it never changes.\n\n"
|
||||
const below = "\n\n## Layering rule\n\nThis skill must keep working with the sync skill deleted.\n"
|
||||
// A description a human tuned, in the frontmatter the generator must not
|
||||
// touch: it is the only thing that decides whether the skill loads at all.
|
||||
edited := strings.Replace(raw[:start], "description: TODO", "description: Work with this project's issues as units of work", 1)
|
||||
edited += above + raw[start:end] + below
|
||||
if err := os.WriteFile(path, []byte(edited), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mustRun(t, dir, "gen", "skills", "--out", out)
|
||||
after := readFile(t, path)
|
||||
if after != edited {
|
||||
t.Errorf("a no-op regeneration did not return the file byte for byte:\n--- want ---\n%s\n--- got ---\n%s", edited, after)
|
||||
// --check is the warning, and it comes before the loss rather than after.
|
||||
if r := run(t, dir, "gen", "scaffold", "--out", out, "--check"); r.code != 1 {
|
||||
t.Errorf("--check exited %d on an edited file, want 1:\n%s", r.code, r.out())
|
||||
}
|
||||
|
||||
// And the prose survives a regeneration that actually rewrites the block.
|
||||
shortened := strings.Replace(after, genClose, "the block was gutted by hand\n"+genClose, 1)
|
||||
if err := os.WriteFile(path, []byte(shortened), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustRun(t, dir, "gen", "skills", "--out", out)
|
||||
restored := readFile(t, path)
|
||||
if restored != edited {
|
||||
t.Error("regenerating the block did not restore it, or did not preserve the prose around it")
|
||||
}
|
||||
if !strings.Contains(restored, "description: Work with this project's issues") {
|
||||
t.Errorf("the hand-tuned description was overwritten:\n%s", firstLines(restored, 5))
|
||||
}
|
||||
if !strings.Contains(restored, above) || !strings.Contains(restored, below) {
|
||||
t.Errorf("hand-written prose outside the markers was lost:\n%s", restored)
|
||||
}
|
||||
}
|
||||
|
||||
// Clobbering somebody's prose because they forgot a marker is the failure this
|
||||
// whole design exists to prevent.
|
||||
func TestGenNeverOverwritesAFileWithoutMarkers(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "skills")
|
||||
path := filepath.Join(out, "issue", "SKILL.md")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const prose = "---\nname: issue\ndescription: hand written, every word of it\n---\n\n# Everything here is somebody's work\n"
|
||||
if err := os.WriteFile(path, []byte(prose), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := mustRun(t, dir, "gen", "skills", "--out", out)
|
||||
if got := readFile(t, path); got != prose {
|
||||
t.Fatalf("a file with no markers was rewritten:\n%s", got)
|
||||
}
|
||||
// Left alone silently is how it drifts unnoticed, so it is reported — and
|
||||
// on stderr, where a warning belongs.
|
||||
if !strings.Contains(r.stderr, path) {
|
||||
t.Errorf("the skipped file was not named on stderr:\n%s", r.out())
|
||||
}
|
||||
if !strings.Contains(r.stdout, "without a region") {
|
||||
t.Errorf("the receipt did not account for it:\n%s", r.stdout)
|
||||
}
|
||||
// The other groups still got written — one unmanaged file stops nothing.
|
||||
if _, err := os.Stat(filepath.Join(out, "sync", "SKILL.md")); err != nil {
|
||||
t.Error("one file without markers stopped the whole run")
|
||||
mustRun(t, dir, "gen", "scaffold", "--out", out)
|
||||
if got := readFile(t, path); got != pristine {
|
||||
t.Errorf("regenerating did not restore the binary's own copy:\n%s", firstLines(got, 8))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenCheckFailsOnAStaleFileAndNamesIt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "skills")
|
||||
mustRun(t, dir, "gen", "skills", "--out", out)
|
||||
out := filepath.Join(dir, "out")
|
||||
mustRun(t, dir, "gen", "scaffold", "--out", out)
|
||||
|
||||
stale := filepath.Join(out, "sync", "SKILL.md")
|
||||
raw := readFile(t, stale)
|
||||
edited := strings.Replace(raw, genClose, "kettle push --thoroughly-renamed-flag\n"+genClose, 1)
|
||||
stale := filepath.Join(out, "skills", "kettle-sync", "SKILL.md")
|
||||
edited := strings.Replace(readFile(t, stale), genClose, "kettle push --thoroughly-renamed-flag\n"+genClose, 1)
|
||||
if err := os.WriteFile(stale, []byte(edited), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := run(t, dir, "gen", "skills", "--out", out, "--check")
|
||||
r := run(t, dir, "gen", "scaffold", "--out", out, "--check")
|
||||
if r.code != 1 {
|
||||
t.Fatalf("--check exited %d, want 1 — this is what a hook or a CI step calls:\n%s", r.code, r.out())
|
||||
}
|
||||
@@ -190,7 +168,7 @@ func TestGenCheckFailsOnAStaleFileAndNamesIt(t *testing.T) {
|
||||
if err := os.Remove(stale); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := run(t, dir, "gen", "skills", "--out", out, "--check"); r.code != 1 {
|
||||
if r := run(t, dir, "gen", "scaffold", "--out", out, "--check"); r.code != 1 {
|
||||
t.Errorf("--check exited %d for a missing file, want 1:\n%s", r.code, r.out())
|
||||
}
|
||||
if _, err := os.Stat(stale); err == nil {
|
||||
@@ -200,9 +178,9 @@ func TestGenCheckFailsOnAStaleFileAndNamesIt(t *testing.T) {
|
||||
|
||||
func TestGenDryRunWritesNothingAtAll(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "skills")
|
||||
out := filepath.Join(dir, "out")
|
||||
|
||||
fresh := mustRun(t, dir, "gen", "skills", "--out", out, "--dry-run")
|
||||
fresh := mustRun(t, dir, "gen", "scaffold", "--out", out, "--dry-run")
|
||||
if !strings.Contains(fresh.stdout, "would create") {
|
||||
t.Errorf("a dry run said nothing about what it would do:\n%s", fresh.out())
|
||||
}
|
||||
@@ -211,14 +189,14 @@ func TestGenDryRunWritesNothingAtAll(t *testing.T) {
|
||||
}
|
||||
|
||||
// And on an existing tree: the file is described, never touched.
|
||||
mustRun(t, dir, "gen", "skills", "--out", out)
|
||||
path := filepath.Join(out, "issue", "SKILL.md")
|
||||
mustRun(t, dir, "gen", "scaffold", "--out", out)
|
||||
path := filepath.Join(out, "skills", "kettle-issue", "SKILL.md")
|
||||
edited := strings.Replace(readFile(t, path), genClose, "gutted\n"+genClose, 1)
|
||||
if err := os.WriteFile(path, []byte(edited), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := mustRun(t, dir, "gen", "skills", "--out", out, "--dry-run")
|
||||
r := mustRun(t, dir, "gen", "scaffold", "--out", out, "--dry-run")
|
||||
if !strings.Contains(r.stdout, "would update") || !strings.Contains(r.stdout, "nothing was written") {
|
||||
t.Errorf("the dry run did not report the pending change:\n%s", r.out())
|
||||
}
|
||||
@@ -227,12 +205,38 @@ func TestGenDryRunWritesNothingAtAll(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenRefusesAnUnknownTargetAndAMissingOut(t *testing.T) {
|
||||
// Without --out the tree goes under the project marker, resolved by the same
|
||||
// walk every other command uses. No marker is an answer, not a fallback: a
|
||||
// `.claude/` written into a plausible-looking directory is the failure the
|
||||
// marker exists to replace.
|
||||
func TestGenWithoutOutResolvesTheProject(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
sub := filepath.Join(dir, "cli", "internal")
|
||||
if err := os.MkdirAll(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mustRun(t, sub, "gen", "scaffold")
|
||||
if _, err := os.Stat(filepath.Join(dir, ".claude", "skills", "kettle-issue", "SKILL.md")); err != nil {
|
||||
t.Errorf("run from %s, the tree did not land at the project root: %v", sub, err)
|
||||
}
|
||||
|
||||
orphan := t.TempDir()
|
||||
r := run(t, orphan, "gen", "scaffold")
|
||||
if r.code == 0 {
|
||||
t.Fatalf("gen outside a project must stop:\n%s", r.out())
|
||||
}
|
||||
if !strings.Contains(r.stderr, ".kettle") {
|
||||
t.Errorf("the refusal does not name what is missing:\n%s", r.out())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(orphan, ".claude")); err == nil {
|
||||
t.Error("the refused run created a tree anyway")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenRefusesAnUnknownTarget(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
if r := run(t, dir, "gen", "skills"); r.code == 0 || !strings.Contains(r.stderr, "--out") {
|
||||
t.Errorf("gen without --out must stop and say so:\n%s", r.out())
|
||||
}
|
||||
if r := run(t, dir, "gen", "agents", "--out", filepath.Join(dir, "x")); r.code == 0 {
|
||||
t.Errorf("an unknown target must be refused:\n%s", r.out())
|
||||
}
|
||||
|
||||
+153
-22
@@ -1,6 +1,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -9,8 +10,27 @@ import (
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/scaffold"
|
||||
)
|
||||
|
||||
// initOptions is everything init does, as data.
|
||||
//
|
||||
// It exists so that --interactive and the flags are two ways of filling in one
|
||||
// struct rather than two implementations of one command. Every question the
|
||||
// wizard asks has a field here and therefore a flag: a step that could only be
|
||||
// reached by answering a prompt would be a step no script, no CI run and no
|
||||
// agent could take.
|
||||
type initOptions struct {
|
||||
Root string
|
||||
Login string
|
||||
Repo string
|
||||
Scaffold bool
|
||||
ScaffoldOut string
|
||||
MirrorHook bool
|
||||
ForceSettings bool
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// writeConfig creates or updates .kettle/config.yaml, touching only the
|
||||
// settings it was given.
|
||||
//
|
||||
@@ -66,7 +86,8 @@ func init() {
|
||||
Short: "make this directory a project that tracks issues",
|
||||
Long: `Creates ` + "`.kettle/`" + ` — the marker every other command resolves the store from,
|
||||
and ` + "`.kettle/config.yaml`" + `, which says which tracker repository these issues
|
||||
belong to and which login to reach it under.
|
||||
belong to and which login to reach it under — and writes ` + "`.claude/`" + `: the slash
|
||||
commands an operator invokes, the skills a model loads, and the runner subagent.
|
||||
|
||||
The marker is deliberately something an operator makes, not something inferred
|
||||
from the tree: ` + "`.git`" + ` is in every clone, so anything that inferred a root from
|
||||
@@ -77,18 +98,26 @@ machine, outside every working tree, managed with ` + "`kettle auth`" + `.
|
||||
|
||||
All of it is idempotent: it creates .kettle/issues and .kettle/payload, migrates
|
||||
an older store in if it finds one (either layout the tea plugin used, oldest
|
||||
first), writes the config without disturbing settings it was not given, and adds
|
||||
.kettle/ to .gitignore. Each migration is a move, not a copy — two stores is the
|
||||
state the marker exists to prevent — and it refuses to pick a winner when both
|
||||
sides hold a file of the same name.
|
||||
first), writes the config without disturbing settings it was not given, writes
|
||||
the .claude/ tree, and adds .kettle/ to .gitignore. Each migration is a move,
|
||||
not a copy — two stores is the state the marker exists to prevent — and it
|
||||
refuses to pick a winner when both sides hold a file of the same name.
|
||||
|
||||
Do NOT run this inside a linked worktree. A worktree is the same project on
|
||||
another branch and reaches the store by a hop out to the main checkout; a marker
|
||||
here would give one project two stores, and the directory holding the second one
|
||||
disappears with the branch.`,
|
||||
IT REFUSES TO RUN IN A LINKED WORKTREE, and names the main checkout instead. A
|
||||
worktree is the same project on another branch and reaches the store by a hop
|
||||
out to the main checkout; a marker here would give one project two stores, and
|
||||
the directory holding the second one disappears with the branch.
|
||||
|
||||
--interactive walks a person through the whole thing — the login, the token with
|
||||
the echo turned off, the repository, the .claude/ tree and the AGENTS.md mirror
|
||||
hook. IT REQUIRES A TERMINAL and refuses a standard input that is not one, which
|
||||
is deliberate: every question it asks has a flag beside it, so nothing that is
|
||||
not a person ever needs to answer a prompt.`,
|
||||
Examples: []Example{
|
||||
{"kettle init", "initialize the current directory"},
|
||||
{"kettle init --interactive", "be walked through it, at a terminal"},
|
||||
{"kettle init --login noodles --repo claude-skills/marketplace", "and point it at a tracker"},
|
||||
{"kettle init --mirror-hook", "register the AGENTS.md mirror on PreToolUse(Bash)"},
|
||||
{"kettle init --at ~/code/x", "initialize somewhere else"},
|
||||
{"kettle init --dry-run", "say what it would do, touch nothing"},
|
||||
},
|
||||
@@ -96,6 +125,11 @@ disappears with the branch.`,
|
||||
at := fs.String("at", "", "directory to initialize (default: the working directory)")
|
||||
login := fs.String("login", "", "name of a login in the machine-wide file (see `kettle auth`)")
|
||||
repo := fs.String("repo", "", "tracker repository, as owner/name")
|
||||
interactive := fs.Bool("interactive", false, "ask, one question at a time; requires a terminal")
|
||||
noScaffold := fs.Bool("no-scaffold", false, "do not write the .claude/ commands, skills and subagent")
|
||||
scaffoldOut := fs.String("scaffold-out", "", "where the .claude/ tree goes (default: <project>/"+scaffold.Marker+")")
|
||||
mirrorHook := fs.Bool("mirror-hook", false, "register `kettle mirror --hook` on PreToolUse(Bash)")
|
||||
forceSettings := fs.Bool("force-settings", false, "let the hook be merged into an existing settings.json, reformatting it")
|
||||
dryRun := fs.Bool("dry-run", false, "report what would happen; change nothing")
|
||||
|
||||
return func(args []string) error {
|
||||
@@ -115,27 +149,59 @@ disappears with the branch.`,
|
||||
return Fail("%s is not a directory", root)
|
||||
}
|
||||
|
||||
// A second marker inside an existing project gives it a second
|
||||
// store, and the nearer one wins — which is a surprise worth
|
||||
// naming before it happens, not after.
|
||||
if existing := project.Root(root); existing != "" && existing != root {
|
||||
opts := initOptions{
|
||||
Root: root,
|
||||
Login: *login,
|
||||
Repo: *repo,
|
||||
Scaffold: !*noScaffold,
|
||||
ScaffoldOut: *scaffoldOut,
|
||||
MirrorHook: *mirrorHook,
|
||||
ForceSettings: *forceSettings,
|
||||
DryRun: *dryRun,
|
||||
}
|
||||
if *interactive {
|
||||
if err := askInit(&opts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return runInit(opts)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func runInit(opts initOptions) error {
|
||||
// A worktree is the same project on another branch. The rule used to live in
|
||||
// a skill somebody had to read; it is here because the wizard is now the
|
||||
// front door and a front door cannot rely on the reader having read anything.
|
||||
if main := project.MainWorktree(opts.Root); main != "" {
|
||||
return Fail("%s is a linked worktree of the project at %s.\n"+
|
||||
"A worktree reaches that store on its own — the walk crosses to it through the `gitdir:` in the .git file — "+
|
||||
"and a marker here would give one project two stores, the second of which is deleted with the branch.\n"+
|
||||
"Initialize the main checkout instead: kettle init --at %s", opts.Root, main, main)
|
||||
}
|
||||
|
||||
// A second marker inside an existing project gives it a second store, and
|
||||
// the nearer one wins — which is a surprise worth naming before it happens,
|
||||
// not after.
|
||||
if existing := project.Root(opts.Root); existing != "" && existing != opts.Root {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"warning: %s already sits inside the project at %s — a second marker here gives it a second store, and the nearer one wins.\n",
|
||||
root, existing)
|
||||
opts.Root, existing)
|
||||
}
|
||||
|
||||
if *repo != "" {
|
||||
if owner, name, ok := strings.Cut(*repo, "/"); !ok || owner == "" || name == "" {
|
||||
return Fail("--repo %q is not owner/name", *repo)
|
||||
if opts.Repo != "" {
|
||||
if owner, name, ok := strings.Cut(opts.Repo, "/"); !ok || owner == "" || name == "" {
|
||||
return Fail("--repo %q is not owner/name", opts.Repo)
|
||||
}
|
||||
}
|
||||
|
||||
done, err := project.Init(root, *dryRun)
|
||||
done, err := project.Init(opts.Root, opts.DryRun)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
line, err := writeConfig(root, *login, *repo, *dryRun)
|
||||
line, err := writeConfig(opts.Root, opts.Login, opts.Repo, opts.DryRun)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -144,14 +210,79 @@ disappears with the branch.`,
|
||||
}
|
||||
|
||||
prefix := ""
|
||||
if *dryRun {
|
||||
if opts.DryRun {
|
||||
prefix = "would: "
|
||||
}
|
||||
for _, l := range done {
|
||||
fmt.Println(prefix + l)
|
||||
}
|
||||
|
||||
if opts.Scaffold {
|
||||
if err := initScaffold(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if opts.MirrorHook {
|
||||
if err := initMirrorHook(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// scaffoldOut is where the .claude/ tree goes for this run. An explicit
|
||||
// --scaffold-out is used exactly as typed; without one it sits beside the
|
||||
// marker, which is the only place another command can find it again.
|
||||
func (o initOptions) scaffoldOut() string {
|
||||
if o.ScaffoldOut != "" {
|
||||
return o.ScaffoldOut
|
||||
}
|
||||
return filepath.Join(o.Root, scaffold.Marker)
|
||||
}
|
||||
|
||||
func initScaffold(opts initOptions) error {
|
||||
out := opts.scaffoldOut()
|
||||
if err := genScaffold(out, opts.DryRun, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.DryRun {
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// The stamp is written last and is not load-bearing: nothing resolves from
|
||||
// it, and deleting it costs the warning in `kettle config` and nothing else.
|
||||
rec := &config.Scaffold{Version: Version, Out: relativeTo(opts.Root, out)}
|
||||
return config.SaveScaffoldFile(filepath.Join(opts.Root, project.Marker, "scaffold.yaml"), rec)
|
||||
}
|
||||
|
||||
func initMirrorHook(opts initOptions) error {
|
||||
line, err := writeMirrorHook(opts.scaffoldOut(), opts.ForceSettings, opts.DryRun)
|
||||
if err != nil {
|
||||
// An existing settings.json is a decision for the operator, not a
|
||||
// failure of the run: everything before this point already happened and
|
||||
// saying otherwise would send them looking for damage there is none of.
|
||||
if errors.Is(err, errSettingsExist) {
|
||||
fmt.Fprintf(os.Stderr, "kettle init: the mirror hook was not registered — %v\n", err)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if line != "" {
|
||||
prefix := ""
|
||||
if opts.DryRun {
|
||||
prefix = "would: "
|
||||
}
|
||||
fmt.Println(prefix + line)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// relativeTo is path as written down in the scaffold record: relative when it
|
||||
// sits under the project, absolute when the operator sent it somewhere else.
|
||||
func relativeTo(root, path string) string {
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
return path
|
||||
}
|
||||
return rel
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
package cmd_test
|
||||
|
||||
// What `kettle init` writes beyond the marker: the .claude/ tree, the record of
|
||||
// which build wrote it, and the optional mirror hook. Plus the two refusals that
|
||||
// used to be prose in a skill and are now the binary's.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInitWritesTheClaudeTreeByDefault(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
r := mustRun(t, dir, "init")
|
||||
|
||||
for _, rel := range scaffoldFiles {
|
||||
if _, err := os.Stat(filepath.Join(dir, ".claude", filepath.FromSlash(rel))); err != nil {
|
||||
t.Errorf("%s was not written: %v\n%s", rel, err, r.out())
|
||||
}
|
||||
}
|
||||
|
||||
// And the record of what wrote it, which is the only way an operator can
|
||||
// tell a current tree from one four releases old.
|
||||
stamp := readFile(t, filepath.Join(dir, ".kettle", "scaffold.yaml"))
|
||||
for _, want := range []string{"version:", "out: .claude"} {
|
||||
if !strings.Contains(stamp, want) {
|
||||
t.Errorf("the scaffold record is missing %q:\n%s", want, stamp)
|
||||
}
|
||||
}
|
||||
if cfg := mustRun(t, dir, "config"); !strings.Contains(cfg.stdout, "scaffold .claude") {
|
||||
t.Errorf("`kettle config` does not report the tree:\n%s", cfg.stdout)
|
||||
}
|
||||
|
||||
// Idempotent all the way through: a second init rewrites nothing.
|
||||
second := mustRun(t, dir, "init")
|
||||
if strings.Contains(second.stdout, "updated") {
|
||||
t.Errorf("a second init rewrote a document:\n%s", second.out())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitCanBeToldToWriteNoTreeAtAll(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustRun(t, dir, "init", "--no-scaffold")
|
||||
|
||||
if _, err := os.Stat(filepath.Join(dir, ".claude")); err == nil {
|
||||
t.Error("--no-scaffold still wrote the tree")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, ".kettle", "scaffold.yaml")); err == nil {
|
||||
t.Error("--no-scaffold recorded a tree it did not write")
|
||||
}
|
||||
// Nothing else is affected: this is still a project.
|
||||
if _, err := os.Stat(filepath.Join(dir, ".kettle", "issues")); err != nil {
|
||||
t.Errorf("--no-scaffold skipped the store as well: %v", err)
|
||||
}
|
||||
// And `kettle config` says so rather than saying nothing.
|
||||
if cfg := mustRun(t, dir, "config"); !strings.Contains(cfg.stdout, "scaffold (none written") {
|
||||
t.Errorf("`kettle config` is silent about the missing tree:\n%s", cfg.stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitScaffoldOutIsUsedAsTyped(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
elsewhere := filepath.Join(dir, "somewhere", "else")
|
||||
|
||||
mustRun(t, dir, "init", "--scaffold-out", elsewhere)
|
||||
if _, err := os.Stat(filepath.Join(elsewhere, "skills", "kettle-issue", "SKILL.md")); err != nil {
|
||||
t.Errorf("the tree did not go where it was told: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, ".claude")); err == nil {
|
||||
t.Error("it went to the default as well")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitDryRunWritesNoTreeAndNoRecord(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
r := mustRun(t, dir, "init", "--dry-run", "--mirror-hook")
|
||||
if !strings.Contains(r.stdout, "would create") {
|
||||
t.Errorf("the dry run said nothing about the tree:\n%s", r.out())
|
||||
}
|
||||
for _, p := range []string{".claude", ".kettle"} {
|
||||
if _, err := os.Stat(filepath.Join(dir, p)); err == nil {
|
||||
t.Errorf("a dry run created %s", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The mirror hook: offered, never assumed. A project that keeps no AGENTS.md
|
||||
// files wants nothing to do with it.
|
||||
func TestInitRegistersTheMirrorHookOnlyWhenAsked(t *testing.T) {
|
||||
plain := t.TempDir()
|
||||
mustRun(t, plain, "init")
|
||||
if _, err := os.Stat(filepath.Join(plain, ".claude", "settings.json")); err == nil {
|
||||
t.Error("init registered a hook nobody asked for")
|
||||
}
|
||||
|
||||
asked := t.TempDir()
|
||||
mustRun(t, asked, "init", "--mirror-hook")
|
||||
body := readFile(t, filepath.Join(asked, ".claude", "settings.json"))
|
||||
|
||||
var settings struct {
|
||||
Hooks struct {
|
||||
PreToolUse []struct {
|
||||
Matcher string `json:"matcher"`
|
||||
Hooks []struct {
|
||||
Type string `json:"type"`
|
||||
Command string `json:"command"`
|
||||
} `json:"hooks"`
|
||||
} `json:"PreToolUse"`
|
||||
} `json:"hooks"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &settings); err != nil {
|
||||
t.Fatalf("the settings file is not valid JSON: %v\n%s", err, body)
|
||||
}
|
||||
if len(settings.Hooks.PreToolUse) != 1 || settings.Hooks.PreToolUse[0].Matcher != "Bash" {
|
||||
t.Fatalf("the hook is not registered on PreToolUse(Bash):\n%s", body)
|
||||
}
|
||||
command := settings.Hooks.PreToolUse[0].Hooks[0].Command
|
||||
if !strings.Contains(command, "kettle mirror --hook") {
|
||||
t.Errorf("the registered command is %q", command)
|
||||
}
|
||||
// The guard outlives the binary: an operator who uninstalls kettle must not
|
||||
// get "command not found" on every Bash call from a hook they set up months
|
||||
// ago and have stopped thinking about.
|
||||
if !strings.Contains(command, "command -v kettle") {
|
||||
t.Errorf("the registered command has no guard against kettle being gone: %q", command)
|
||||
}
|
||||
}
|
||||
|
||||
// settings.json is a file the operator owns and commits, and Go cannot preserve
|
||||
// its key order. So an existing one is never rewritten without being asked
|
||||
// twice: the run reports what it did not do, prints the snippet, and succeeds.
|
||||
func TestInitRefusesToRewriteAnExistingSettingsFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
settings := filepath.Join(dir, ".claude", "settings.json")
|
||||
if err := os.MkdirAll(filepath.Dir(settings), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const theirs = `{ "permissions": { "allow": ["Bash(ls:*)"] } }`
|
||||
if err := os.WriteFile(settings, []byte(theirs), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := mustRun(t, dir, "init", "--mirror-hook")
|
||||
if got := readFile(t, settings); got != theirs {
|
||||
t.Errorf("the operator's settings file was rewritten:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(r.stderr, "kettle mirror") {
|
||||
t.Errorf("the refusal did not print the snippet to paste:\n%s", r.out())
|
||||
}
|
||||
if !strings.Contains(r.stderr, "--force-settings") {
|
||||
t.Errorf("the refusal did not name the way past it:\n%s", r.out())
|
||||
}
|
||||
// Everything before the hook still happened; this is a decision, not a
|
||||
// failure, and reporting it as one would send somebody looking for damage.
|
||||
if _, err := os.Stat(filepath.Join(dir, ".kettle", "issues")); err != nil {
|
||||
t.Error("the refusal rolled back the rest of the run")
|
||||
}
|
||||
|
||||
// Asked twice, it merges — and keeps what was already in the file.
|
||||
mustRun(t, dir, "init", "--mirror-hook", "--force-settings")
|
||||
merged := readFile(t, settings)
|
||||
if !strings.Contains(merged, "kettle mirror") {
|
||||
t.Errorf("--force-settings did not add the hook:\n%s", merged)
|
||||
}
|
||||
if !strings.Contains(merged, "Bash(ls:*)") {
|
||||
t.Errorf("--force-settings dropped what was already there:\n%s", merged)
|
||||
}
|
||||
|
||||
// And a third run adds nothing: the hook is already there.
|
||||
mustRun(t, dir, "init", "--mirror-hook")
|
||||
if again := readFile(t, settings); again != merged {
|
||||
t.Errorf("a re-run duplicated the hook:\n%s", again)
|
||||
}
|
||||
}
|
||||
|
||||
// The rule that used to be a paragraph in a skill an operator had to read. The
|
||||
// wizard is the front door now, and a front door cannot assume anybody read
|
||||
// anything.
|
||||
func TestInitRefusesALinkedWorktree(t *testing.T) {
|
||||
main := t.TempDir()
|
||||
worktree := filepath.Join(main, "wt")
|
||||
gitdir := filepath.Join(main, ".git", "worktrees", "wt")
|
||||
if err := os.MkdirAll(gitdir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(worktree, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(gitdir, "commondir"), []byte("../..\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(worktree, ".git"), []byte("gitdir: "+gitdir+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := run(t, worktree, "init")
|
||||
if r.code == 0 {
|
||||
t.Fatalf("init in a linked worktree must be refused:\n%s", r.out())
|
||||
}
|
||||
if !strings.Contains(r.stderr, main) {
|
||||
t.Errorf("the refusal does not name the main checkout to use instead:\n%s", r.out())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(worktree, ".kettle")); err == nil {
|
||||
t.Error("the refused run created a second marker anyway")
|
||||
}
|
||||
}
|
||||
|
||||
// --interactive is for a person. Under a test harness, a script or an agent,
|
||||
// standard input is a pipe and the answer is an error that names the flags
|
||||
// rather than a prompt nobody will ever answer.
|
||||
func TestInteractiveRefusesWhatIsNotATerminal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
r := runWith(t, dir, nil, "\n\n\n", "init", "--interactive")
|
||||
if r.code == 0 {
|
||||
t.Fatalf("--interactive succeeded without a terminal:\n%s", r.out())
|
||||
}
|
||||
if !strings.Contains(r.stderr, "terminal") {
|
||||
t.Errorf("the refusal does not say what is missing:\n%s", r.out())
|
||||
}
|
||||
for _, flag := range []string{"--login", "--repo", "--mirror-hook"} {
|
||||
if !strings.Contains(r.stderr, flag) {
|
||||
t.Errorf("the refusal does not name %s, which is the way through:\n%s", flag, r.out())
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, ".kettle")); err == nil {
|
||||
t.Error("the refused run initialized the directory anyway")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/term"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
|
||||
)
|
||||
|
||||
// askInit fills in an initOptions by asking, and is the ONLY thing in this
|
||||
// binary that reads from a terminal.
|
||||
//
|
||||
// Two rules hold it in place, and both are about what it is not allowed to
|
||||
// become. It answers no question the flags cannot answer, so nothing here is a
|
||||
// capability that exists only behind a prompt; and it performs nothing itself —
|
||||
// it fills in the struct and hands it back, so the run that follows an
|
||||
// interactive session is byte for byte the run somebody else gets from flags.
|
||||
func askInit(opts *initOptions) error {
|
||||
if !term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
return Fail("--interactive needs a terminal, and standard input is not one.\n" +
|
||||
"Every question it asks has a flag: --login, --repo, --scaffold-out, --no-scaffold, --mirror-hook.")
|
||||
}
|
||||
in := bufio.NewReader(os.Stdin)
|
||||
|
||||
fmt.Printf("kettle init — %s\n\n", opts.Root)
|
||||
if main := project.MainWorktree(opts.Root); main != "" {
|
||||
// Asked before anything else, because every answer after it would be
|
||||
// about a project that must not be created here.
|
||||
return Fail("%s is a linked worktree of the project at %s — initialize the main checkout instead", opts.Root, main)
|
||||
}
|
||||
|
||||
if err := askLogin(in, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := askRepo(in, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
opts.Scaffold = askYesNo(in, "Write the kettle commands and skills into "+
|
||||
relativeTo(opts.Root, opts.scaffoldOut())+"?", true)
|
||||
opts.MirrorHook = askYesNo(in, "Mirror AGENTS.md to CLAUDE.md in this project? "+
|
||||
"(registers `kettle mirror --hook` on PreToolUse)", false)
|
||||
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
||||
// askLogin offers what the machine already holds, and never invents one.
|
||||
//
|
||||
// Adding a login is offered here rather than left to `kettle auth add` for one
|
||||
// reason: this is a person at a terminal, so the token can be read with the echo
|
||||
// turned off — no shell history, no temp file, no scrollback. That is strictly
|
||||
// better than every other way of getting a secret into this program, and it is
|
||||
// the single strongest argument for the wizard existing at all.
|
||||
func askLogin(in *bufio.Reader, opts *initOptions) error {
|
||||
logins, err := config.LoadLogins()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.Login != "" {
|
||||
fmt.Printf("login %s (given on the command line)\n", opts.Login)
|
||||
return nil
|
||||
}
|
||||
|
||||
names := logins.Names()
|
||||
fmt.Println("Logins on this machine:")
|
||||
if len(names) == 0 {
|
||||
fmt.Println(" (none)")
|
||||
}
|
||||
for i, l := range logins.Logins {
|
||||
fmt.Printf(" %d) %-16s %s\n", i+1, l.Name, l.URL)
|
||||
}
|
||||
fmt.Printf(" a) add one\n s) skip — pin a login later with `kettle init --login <name>`\n")
|
||||
|
||||
for {
|
||||
answer := strings.TrimSpace(ask(in, "Which login should this project run under?", "s"))
|
||||
switch strings.ToLower(answer) {
|
||||
case "s", "skip":
|
||||
return nil
|
||||
case "a", "add":
|
||||
name, err := addLogin(in, logins)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Login = name
|
||||
return nil
|
||||
}
|
||||
if n, err := strconv.Atoi(answer); err == nil && n >= 1 && n <= len(logins.Logins) {
|
||||
opts.Login = logins.Logins[n-1].Name
|
||||
return nil
|
||||
}
|
||||
// A name typed in full is the same answer as its number, and is what
|
||||
// somebody who already knows the login will reach for.
|
||||
if logins.Find(answer) != nil {
|
||||
opts.Login = answer
|
||||
return nil
|
||||
}
|
||||
fmt.Printf(" %q is not one of them.\n", answer)
|
||||
}
|
||||
}
|
||||
|
||||
// addLogin writes one entry into the machine-wide file, token and all.
|
||||
func addLogin(in *bufio.Reader, logins *config.Logins) (string, error) {
|
||||
name := required(in, " Name for this login (a label, not a username)")
|
||||
url := strings.TrimRight(required(in, " Instance URL, e.g. https://git.example.com"), "/")
|
||||
user := ask(in, " Account it belongs to (documentation only)", "")
|
||||
scopes := ask(in, " Scopes it was minted with (documentation only)", "write:issue,write:repository")
|
||||
|
||||
token, err := askSecret(" Token (not echoed)")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if token == "" {
|
||||
return "", Fail("no token given")
|
||||
}
|
||||
|
||||
entry := config.Login{
|
||||
Name: name,
|
||||
URL: url,
|
||||
User: user,
|
||||
Scopes: splitScopes(scopes),
|
||||
Token: token,
|
||||
}
|
||||
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\n", entry.Name, entry.URL, config.LoginsPath())
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// askRepo offers what the git remote says, because that is right nearly every
|
||||
// time and wrong in a way the operator can see immediately.
|
||||
func askRepo(in *bufio.Reader, opts *initOptions) error {
|
||||
if opts.Repo != "" {
|
||||
fmt.Printf("repo %s (given on the command line)\n", opts.Repo)
|
||||
return nil
|
||||
}
|
||||
guess := repoFromGitConfig(opts.Root)
|
||||
answer := strings.TrimSpace(ask(in, "Tracker repository, as owner/name", guess))
|
||||
if answer == "" {
|
||||
return nil
|
||||
}
|
||||
if owner, name, ok := strings.Cut(answer, "/"); !ok || owner == "" || name == "" {
|
||||
return Fail("%q is not owner/name", answer)
|
||||
}
|
||||
opts.Repo = answer
|
||||
return nil
|
||||
}
|
||||
|
||||
// repoFromGitConfig reads owner/name out of `origin` in .git/config, or "".
|
||||
//
|
||||
// Parsed rather than shelled out to, because `git` is one more thing that has to
|
||||
// be installed and one more process to fail in a way this has to interpret. A
|
||||
// guess is worth exactly what it costs, and this costs a file read: anything it
|
||||
// cannot make sense of is no guess at all, and the operator types the answer.
|
||||
func repoFromGitConfig(root string) string {
|
||||
raw, err := os.ReadFile(filepath.Join(root, ".git", "config"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
inOrigin := false
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "[") {
|
||||
inOrigin = strings.HasPrefix(line, `[remote "origin"]`)
|
||||
continue
|
||||
}
|
||||
if !inOrigin {
|
||||
continue
|
||||
}
|
||||
value, ok := strings.CutPrefix(line, "url")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, v, found := strings.Cut(value, "="); found {
|
||||
return ownerName(strings.TrimSpace(v))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ownerName is the last two path elements of a git URL, without any .git.
|
||||
// `git@host:owner/name.git` and `https://host/owner/name` both answer
|
||||
// `owner/name`; anything else answers "".
|
||||
func ownerName(url string) string {
|
||||
url = strings.TrimSuffix(strings.TrimSpace(url), ".git")
|
||||
if _, after, found := strings.Cut(url, "://"); found {
|
||||
url = after
|
||||
if _, path, ok := strings.Cut(url, "/"); ok {
|
||||
url = path
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
} else if _, after, found := strings.Cut(url, ":"); found {
|
||||
url = after
|
||||
}
|
||||
parts := strings.Split(strings.Trim(url, "/"), "/")
|
||||
if len(parts) < 2 {
|
||||
return ""
|
||||
}
|
||||
owner, name := parts[len(parts)-2], parts[len(parts)-1]
|
||||
if owner == "" || name == "" {
|
||||
return ""
|
||||
}
|
||||
return owner + "/" + name
|
||||
}
|
||||
|
||||
// ask prints a question and returns the answer, or def when the line is empty.
|
||||
func ask(in *bufio.Reader, question, def string) string {
|
||||
if def != "" {
|
||||
fmt.Printf("%s [%s]: ", question, def)
|
||||
} else {
|
||||
fmt.Printf("%s: ", question)
|
||||
}
|
||||
line, err := in.ReadString('\n')
|
||||
if err != nil && line == "" {
|
||||
return def
|
||||
}
|
||||
if answer := strings.TrimSpace(line); answer != "" {
|
||||
return answer
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func required(in *bufio.Reader, question string) string {
|
||||
for {
|
||||
if answer := ask(in, question, ""); answer != "" {
|
||||
return answer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// askYesNo is deliberately biased: the default is what an operator gets by
|
||||
// holding down return, so every prompt whose wrong answer costs something
|
||||
// defaults to no.
|
||||
func askYesNo(in *bufio.Reader, question string, def bool) bool {
|
||||
hint := "y/N"
|
||||
if def {
|
||||
hint = "Y/n"
|
||||
}
|
||||
for {
|
||||
fmt.Printf("%s [%s]: ", question, hint)
|
||||
line, err := in.ReadString('\n')
|
||||
if err != nil && line == "" {
|
||||
return def
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(line)) {
|
||||
case "":
|
||||
return def
|
||||
case "y", "yes":
|
||||
return true
|
||||
case "n", "no":
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// askSecret reads a line with the terminal's echo turned off.
|
||||
//
|
||||
// This is the whole reason --interactive needs a terminal rather than merely
|
||||
// preferring one: a token typed at a prompt that echoes is a token in somebody's
|
||||
// scrollback, and a token passed as an argument is a token in their shell
|
||||
// history. Neither is recoverable after the fact.
|
||||
func askSecret(question string) (string, error) {
|
||||
fmt.Print(question + ": ")
|
||||
raw, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Println()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(raw)), nil
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/mirror"
|
||||
)
|
||||
|
||||
// hookPayload is the part of a PreToolUse payload this command reads. Every
|
||||
// other field is somebody else's business and is ignored rather than rejected —
|
||||
// a payload that grows a key must not stop a Bash call.
|
||||
type hookPayload struct {
|
||||
CWD string `json:"cwd"`
|
||||
}
|
||||
|
||||
// hookOutput is what a PreToolUse hook says back. additionalContext is
|
||||
// advisory: it is shown, and it decides nothing.
|
||||
type hookOutput struct {
|
||||
HookSpecificOutput struct {
|
||||
HookEventName string `json:"hookEventName"`
|
||||
AdditionalContext string `json:"additionalContext"`
|
||||
} `json:"hookSpecificOutput"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "mirror",
|
||||
Group: GroupProject,
|
||||
Args: "[<dir>]",
|
||||
Short: "keep CLAUDE.md a symlink to AGENTS.md in every directory below here",
|
||||
Long: `Two agent harnesses read two different filenames for the same document. A
|
||||
repository that keeps both as real files keeps TWO DOCUMENTS, and they drift —
|
||||
silently, until somebody reads the stale one and believes it. This walks a tree
|
||||
and leaves one arrangement behind everywhere:
|
||||
|
||||
AGENTS.md is the real file; CLAUDE.md is a symlink pointing at it.
|
||||
|
||||
The link is relative, so a tree that is moved, copied or cloned keeps working.
|
||||
AGENTS.md is the real one because the convention is not one vendor's: a
|
||||
repository that names its documents after a single tool has picked a side it did
|
||||
not need to pick.
|
||||
|
||||
NOTHING HERE DELETES CONTENT. Six of the seven states it can find are repaired
|
||||
losslessly — a missing link is created, a reversed layout is swapped round, a
|
||||
duplicate whose bytes match its original is replaced by the link. The seventh,
|
||||
two real files whose contents DIFFER, is reported and left exactly as it was:
|
||||
one of them is somebody's writing and no rule here knows which.
|
||||
|
||||
It walks the directory given, or the working directory. node_modules, vendor,
|
||||
venv, __pycache__ and every dot-directory are skipped, because somebody else's
|
||||
tree is somebody else's business.
|
||||
|
||||
--hook is the PreToolUse form: it reads the hook payload on standard input,
|
||||
writes any report back as additionalContext, and ALWAYS EXITS 0 — including when
|
||||
it fails. A tool that broke somebody's Bash call because its documentation
|
||||
helper crashed would be worse than no tool. --check is the opposite end: it
|
||||
writes nothing and exits 1 when the tree is not canonical, which is what a
|
||||
pre-commit hook or a make target calls.
|
||||
|
||||
` + "`kettle init --interactive`" + ` offers to register the --hook form in
|
||||
.claude/settings.json. It is offered rather than assumed: this is one
|
||||
repository's documentation convention, and a project that does not keep AGENTS.md
|
||||
files wants nothing to do with it.`,
|
||||
Examples: []Example{
|
||||
{"kettle mirror", "repair the working directory and everything below it"},
|
||||
{"kettle mirror ~/code/x", "repair somewhere else"},
|
||||
{"kettle mirror --check", "exit 1 if anything is out of place; write nothing"},
|
||||
{"kettle mirror --hook", "the PreToolUse form; reads a payload, always exits 0"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
check := fs.Bool("check", false, "write nothing, exit 1 if the tree is not canonical")
|
||||
hook := fs.Bool("hook", false, "PreToolUse form: payload on stdin, report as additionalContext, always exit 0")
|
||||
quiet := fs.Bool("quiet", false, "repair without printing what was repaired")
|
||||
|
||||
return func(args []string) error {
|
||||
if len(args) > 1 {
|
||||
return Fail("give one directory, or none for the working directory")
|
||||
}
|
||||
explicit := ""
|
||||
if len(args) == 1 {
|
||||
explicit = args[0]
|
||||
}
|
||||
if *hook {
|
||||
runHook(explicit)
|
||||
return nil
|
||||
}
|
||||
|
||||
root, err := mirrorRoot(explicit, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var res mirror.Result
|
||||
if *check {
|
||||
res = mirror.Check(root)
|
||||
} else {
|
||||
res = mirror.Sync(root)
|
||||
}
|
||||
|
||||
if !*quiet {
|
||||
printMirror(os.Stdout, res, *check)
|
||||
}
|
||||
// A conflict is a state a person has to resolve, so --check
|
||||
// reports it as a failure. A repair run says so and carries on:
|
||||
// the six branches it could fix, it fixed.
|
||||
if *check && !res.Clean() {
|
||||
return SilentError{Code: 1}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// mirrorRoot decides which tree to walk.
|
||||
//
|
||||
// An explicit argument wins, then the harness's own idea of the project, then
|
||||
// the working directory. project.Root is deliberately NOT consulted: this
|
||||
// command has nothing to do with issues and must be usable in a tree that has
|
||||
// never seen `kettle init`.
|
||||
func mirrorRoot(explicit, payloadCWD string) (string, error) {
|
||||
for _, candidate := range []string{explicit, os.Getenv("CLAUDE_PROJECT_DIR"), payloadCWD} {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
abs, err := filepath.Abs(candidate)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if fi, err := os.Stat(abs); err == nil && fi.IsDir() {
|
||||
return abs, nil
|
||||
}
|
||||
if candidate == explicit {
|
||||
return "", Fail("%s is not a directory", explicit)
|
||||
}
|
||||
}
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return wd, nil
|
||||
}
|
||||
|
||||
func printMirror(w io.Writer, res mirror.Result, check bool) {
|
||||
prefix := ""
|
||||
if check {
|
||||
prefix = "would: "
|
||||
}
|
||||
for _, line := range res.Fixes {
|
||||
fmt.Fprintln(w, prefix+line)
|
||||
}
|
||||
for _, line := range res.Conflicts {
|
||||
fmt.Fprintln(w, "conflict: "+line)
|
||||
}
|
||||
if res.Clean() {
|
||||
fmt.Fprintln(w, "every AGENTS.md has its CLAUDE.md symlink — nothing to do")
|
||||
}
|
||||
}
|
||||
|
||||
// runHook is the PreToolUse form, and its whole contract is that it cannot fail.
|
||||
//
|
||||
// Every path here returns normally and the caller exits 0: an unreadable
|
||||
// payload, an unwritable tree, a bug in this function. Documentation maintenance
|
||||
// is not permitted to break somebody's build, so silence is the failure mode and
|
||||
// a report is the only output.
|
||||
func runHook(explicit string) {
|
||||
// The one recover in the tree, and it earns its place: this function runs
|
||||
// before every Bash call in every project the hook is registered in, and a
|
||||
// panic here would surface as a failed tool call rather than as a bug in a
|
||||
// documentation helper.
|
||||
defer func() { _ = recover() }()
|
||||
|
||||
var payload hookPayload
|
||||
if raw, err := io.ReadAll(os.Stdin); err == nil && len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &payload)
|
||||
}
|
||||
|
||||
root, err := mirrorRoot(explicit, payload.CWD)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res := mirror.Sync(root)
|
||||
if res.Clean() {
|
||||
return // silence means the tree was already canonical
|
||||
}
|
||||
|
||||
var parts []string
|
||||
if len(res.Fixes) > 0 {
|
||||
parts = append(parts, "kettle mirror fixed:\n "+strings.Join(res.Fixes, "\n "))
|
||||
}
|
||||
if len(res.Conflicts) > 0 {
|
||||
parts = append(parts, "kettle mirror needs manual resolution:\n "+strings.Join(res.Conflicts, "\n "))
|
||||
}
|
||||
|
||||
var out hookOutput
|
||||
out.HookSpecificOutput.HookEventName = "PreToolUse"
|
||||
out.HookSpecificOutput.AdditionalContext = strings.Join(parts, "\n")
|
||||
if encoded, err := json.Marshal(out); err == nil {
|
||||
fmt.Println(string(encoded))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package cmd_test
|
||||
|
||||
// `kettle mirror` is the one command in the tree that has nothing to do with
|
||||
// issues, and the one that must never fail a caller. Both are tested here.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeDoc(t *testing.T, dir, name, body string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func linkTarget(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
target, err := os.Readlink(path)
|
||||
if err != nil {
|
||||
t.Fatalf("%s is not a symlink: %v", path, err)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
// The tree it walks is the working directory, not a project: this must work in
|
||||
// a directory that has never seen `kettle init`, because the convention it
|
||||
// maintains has nothing to do with issues.
|
||||
func TestMirrorNeedsNoProject(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeDoc(t, dir, "AGENTS.md", "root\n")
|
||||
writeDoc(t, filepath.Join(dir, "cli"), "AGENTS.md", "cli\n")
|
||||
|
||||
r := mustRun(t, dir, "mirror")
|
||||
if strings.Contains(r.out(), ".kettle") {
|
||||
t.Errorf("mirror asked for a project:\n%s", r.out())
|
||||
}
|
||||
for _, sub := range []string{".", "cli"} {
|
||||
if got := linkTarget(t, filepath.Join(dir, sub, "CLAUDE.md")); got != "AGENTS.md" {
|
||||
t.Errorf("%s/CLAUDE.md points at %q, want a relative AGENTS.md", sub, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Idempotent, and it says so rather than printing nothing at all.
|
||||
again := mustRun(t, dir, "mirror")
|
||||
if !strings.Contains(again.stdout, "nothing to do") {
|
||||
t.Errorf("a canonical tree did not report itself clean:\n%s", again.out())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMirrorTakesADirectoryArgument(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
elsewhere := filepath.Join(dir, "elsewhere")
|
||||
writeDoc(t, elsewhere, "AGENTS.md", "there\n")
|
||||
|
||||
mustRun(t, dir, "mirror", elsewhere)
|
||||
if _, err := os.Lstat(filepath.Join(elsewhere, "CLAUDE.md")); err != nil {
|
||||
t.Errorf("the named directory was not repaired: %v", err)
|
||||
}
|
||||
|
||||
if r := run(t, dir, "mirror", filepath.Join(dir, "nowhere")); r.code == 0 {
|
||||
t.Errorf("a directory that is not there must be refused:\n%s", r.out())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMirrorCheckWritesNothingAndFailsLoudly(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeDoc(t, dir, "AGENTS.md", "root\n")
|
||||
|
||||
r := run(t, dir, "mirror", "--check")
|
||||
if r.code != 1 {
|
||||
t.Fatalf("--check exited %d on a tree with work to do, want 1:\n%s", r.code, r.out())
|
||||
}
|
||||
if !strings.Contains(r.stdout, "would: ") {
|
||||
t.Errorf("--check did not say what it would do:\n%s", r.out())
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(dir, "CLAUDE.md")); err == nil {
|
||||
t.Error("--check wrote to the tree it was asked about")
|
||||
}
|
||||
|
||||
mustRun(t, dir, "mirror")
|
||||
if r := run(t, dir, "mirror", "--check"); r.code != 0 {
|
||||
t.Errorf("--check exited %d on a canonical tree:\n%s", r.code, r.out())
|
||||
}
|
||||
}
|
||||
|
||||
// The refusal, end to end. One of these two files is somebody's writing and no
|
||||
// rule in this binary knows which, so both survive and the operator is told.
|
||||
func TestMirrorRefusesTwoDifferentRealFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeDoc(t, dir, "AGENTS.md", "mine\n")
|
||||
writeDoc(t, dir, "CLAUDE.md", "theirs\n")
|
||||
|
||||
r := mustRun(t, dir, "mirror")
|
||||
if !strings.Contains(r.stdout, "conflict:") || !strings.Contains(r.stdout, "merge manually") {
|
||||
t.Errorf("the conflict was not reported:\n%s", r.out())
|
||||
}
|
||||
if got := readFile(t, filepath.Join(dir, "AGENTS.md")); got != "mine\n" {
|
||||
t.Errorf("AGENTS.md was changed: %q", got)
|
||||
}
|
||||
if got := readFile(t, filepath.Join(dir, "CLAUDE.md")); got != "theirs\n" {
|
||||
t.Errorf("CLAUDE.md was changed: %q", got)
|
||||
}
|
||||
// A repair run reports the conflict and still exits 0 — the six branches it
|
||||
// could fix, it fixed. --check is the one that turns it into a failure.
|
||||
if r := run(t, dir, "mirror", "--check"); r.code != 1 {
|
||||
t.Errorf("--check exited %d on a conflict, want 1", r.code)
|
||||
}
|
||||
}
|
||||
|
||||
// The hook form's whole contract: it cannot fail a Bash call. Every one of these
|
||||
// is a payload or a state that could plausibly arrive, and every one exits 0.
|
||||
func TestMirrorHookAlwaysExitsZero(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
stdin string
|
||||
setup func(t *testing.T, dir string)
|
||||
}{
|
||||
{name: "no payload at all", stdin: ""},
|
||||
{name: "a payload that is not JSON", stdin: "not json at all"},
|
||||
{name: "an empty object", stdin: "{}"},
|
||||
{name: "a payload with keys it does not know", stdin: `{"cwd":".","tool_name":"Bash","future":{"x":1}}`},
|
||||
{
|
||||
name: "a tree it cannot resolve",
|
||||
stdin: `{"cwd":"/nowhere/at/all"}`,
|
||||
setup: func(t *testing.T, dir string) { writeDoc(t, dir, "AGENTS.md", "root\n") },
|
||||
},
|
||||
{
|
||||
name: "a conflict it must not resolve",
|
||||
stdin: `{}`,
|
||||
setup: func(t *testing.T, dir string) {
|
||||
writeDoc(t, dir, "AGENTS.md", "mine\n")
|
||||
writeDoc(t, dir, "CLAUDE.md", "theirs\n")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if tc.setup != nil {
|
||||
tc.setup(t, dir)
|
||||
}
|
||||
r := runWith(t, dir, nil, tc.stdin, "mirror", "--hook")
|
||||
if r.code != 0 {
|
||||
t.Errorf("exited %d — a documentation helper may not break a Bash call:\n%s", r.code, r.out())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// What it says when it did something, and what it says when it did not.
|
||||
func TestMirrorHookReportsOnlyWhenThereIsSomethingToSay(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeDoc(t, dir, "AGENTS.md", "root\n")
|
||||
|
||||
r := runWith(t, dir, nil, "{}", "mirror", "--hook")
|
||||
var out struct {
|
||||
HookSpecificOutput struct {
|
||||
HookEventName string `json:"hookEventName"`
|
||||
AdditionalContext string `json:"additionalContext"`
|
||||
} `json:"hookSpecificOutput"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(r.stdout), &out); err != nil {
|
||||
t.Fatalf("the hook did not emit JSON: %v\n%s", err, r.out())
|
||||
}
|
||||
if out.HookSpecificOutput.HookEventName != "PreToolUse" {
|
||||
t.Errorf("hookEventName is %q", out.HookSpecificOutput.HookEventName)
|
||||
}
|
||||
if !strings.Contains(out.HookSpecificOutput.AdditionalContext, "CLAUDE.md") {
|
||||
t.Errorf("the report says nothing about what it did:\n%s", out.HookSpecificOutput.AdditionalContext)
|
||||
}
|
||||
|
||||
// Silence means the tree was already canonical. A hook that spoke on every
|
||||
// Bash call would be noise in every transcript.
|
||||
quiet := runWith(t, dir, nil, "{}", "mirror", "--hook")
|
||||
if strings.TrimSpace(quiet.stdout) != "" {
|
||||
t.Errorf("a canonical tree still produced output:\n%s", quiet.stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// CLAUDE_PROJECT_DIR is the harness's own answer to "which tree", and it wins
|
||||
// over the payload's cwd — a Bash call made from a subdirectory must still
|
||||
// repair the whole project rather than the corner it was made in.
|
||||
func TestMirrorHookPrefersTheProjectDirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sub := filepath.Join(dir, "deep", "inside")
|
||||
writeDoc(t, dir, "AGENTS.md", "root\n")
|
||||
writeDoc(t, sub, "AGENTS.md", "inside\n")
|
||||
|
||||
r := runWith(t, sub, []string{"CLAUDE_PROJECT_DIR=" + dir}, `{"cwd":"`+sub+`"}`, "mirror", "--hook")
|
||||
if r.code != 0 {
|
||||
t.Fatalf("exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(dir, "CLAUDE.md")); err != nil {
|
||||
t.Errorf("the project root was not repaired: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package cmd
|
||||
|
||||
// The seam between the registry and the documents written from it. Both halves
|
||||
// are in this package's reach, so it is asserted here rather than inferred from
|
||||
// a generated file downstream.
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/scaffold"
|
||||
)
|
||||
|
||||
// A group with no document is a group whose flag table is written nowhere, and
|
||||
// nothing would say so: gen walks the documents, not the registry, so the
|
||||
// commands would simply be absent. This is the test that makes adding a group a
|
||||
// two-step change rather than a silent one-step mistake.
|
||||
func TestEveryGroupHasSomewhereToBeWritten(t *testing.T) {
|
||||
inRegistry := map[string]bool{}
|
||||
for _, c := range Commands() {
|
||||
if c.Group == "" {
|
||||
t.Errorf("command %q has no group, so it is in no document at all", c.Name)
|
||||
continue
|
||||
}
|
||||
inRegistry[c.Group] = true
|
||||
}
|
||||
|
||||
for _, g := range scaffold.Groups() {
|
||||
if !inRegistry[g] {
|
||||
t.Errorf("scaffold ships a document for group %q, which no command is in", g)
|
||||
}
|
||||
}
|
||||
for g := range inRegistry {
|
||||
if scaffold.PathFor(g) == "" {
|
||||
t.Errorf("group %q has commands and no document — add one to internal/scaffold/assets", g)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every document renders, and the flag table inside it is this binary's. A
|
||||
// command whose Long text spelled a region marker out in full would end the
|
||||
// generated block inside itself, and renderAll is where that is caught.
|
||||
func TestEveryDocumentRendersWithItsOwnFlags(t *testing.T) {
|
||||
files, err := renderAll()
|
||||
if err != nil {
|
||||
t.Fatalf("rendering the tree failed: %v", err)
|
||||
}
|
||||
if len(files) != len(scaffold.Files()) {
|
||||
t.Fatalf("rendered %d of %d documents", len(files), len(scaffold.Files()))
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
if f.Group == "" {
|
||||
continue
|
||||
}
|
||||
for _, c := range commandsIn(f.Group) {
|
||||
if !strings.Contains(f.Body, "## `"+c.Usage()+"`") {
|
||||
t.Errorf("%s does not document `%s`", f.Path, c.Usage())
|
||||
}
|
||||
for _, flag := range c.Flags() {
|
||||
if !strings.Contains(f.Body, "| `--"+flag.Name+"` |") {
|
||||
t.Errorf("%s documents `%s` without --%s", f.Path, c.Name, flag.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.Contains(f.Body, genOpen+"\n"+genOpen) {
|
||||
t.Errorf("%s has a doubled marker", f.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rendering twice produces the same bytes. Everything downstream — --check, the
|
||||
// idempotence of init, a diff in somebody's repository — is built on it.
|
||||
func TestRenderingIsDeterministic(t *testing.T) {
|
||||
a, err := renderAll()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := renderAll()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a) != len(b) {
|
||||
t.Fatalf("two renders produced %d and %d documents", len(a), len(b))
|
||||
}
|
||||
for i := range a {
|
||||
if a[i].Path != b[i].Path {
|
||||
t.Fatalf("document %d is %s then %s", i, a[i].Path, b[i].Path)
|
||||
}
|
||||
if a[i].Body != b[i].Body {
|
||||
t.Errorf("%s differs between two renders", a[i].Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The guess the wizard offers for --repo. It is worth exactly what it costs —
|
||||
// a file read — so anything it cannot make sense of must answer "" and let the
|
||||
// operator type it, rather than proposing half an address.
|
||||
func TestOwnerNameReadsTheSpellingsGitActuallyWrites(t *testing.T) {
|
||||
cases := []struct{ url, want string }{
|
||||
{"git@git.example.com:claude-skills/marketplace.git", "claude-skills/marketplace"},
|
||||
{"git@git.example.com:claude-skills/marketplace", "claude-skills/marketplace"},
|
||||
{"https://git.example.com/claude-skills/marketplace.git", "claude-skills/marketplace"},
|
||||
{"https://git.example.com/claude-skills/marketplace", "claude-skills/marketplace"},
|
||||
{"ssh://git@git.example.com:2222/claude-skills/marketplace.git", "claude-skills/marketplace"},
|
||||
{" https://git.example.com/owner/name.git ", "owner/name"},
|
||||
// Deeper paths: a Gitea instance served under a prefix still ends in
|
||||
// owner/name, and the last two elements are the address.
|
||||
{"https://example.com/git/owner/name.git", "owner/name"},
|
||||
// Nothing that can be read as an address.
|
||||
{"", ""},
|
||||
{"https://git.example.com/", ""},
|
||||
{"https://git.example.com/lonely", ""},
|
||||
{"/a/local/path", "local/path"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := ownerName(tc.url); got != tc.want {
|
||||
t.Errorf("ownerName(%q) = %q, want %q", tc.url, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The document list is stable and sorted, which is what makes a receipt from one
|
||||
// machine comparable with a receipt from another.
|
||||
func TestScaffoldFilesAreSorted(t *testing.T) {
|
||||
files := scaffold.Files()
|
||||
paths := make([]string, len(files))
|
||||
for i, f := range files {
|
||||
paths[i] = f.Path
|
||||
}
|
||||
if !sort.StringsAreSorted(paths) {
|
||||
t.Errorf("the document list is not sorted: %v", paths)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// mirrorHookCommand is what gets registered on PreToolUse(Bash).
|
||||
//
|
||||
// The `command -v` guard is not decoration. This line outlives the binary that
|
||||
// wrote it: an operator who uninstalls kettle, or moves it off PATH, would
|
||||
// otherwise get a "command not found" on every Bash call in this project, from a
|
||||
// hook they set up months ago and have long stopped thinking about. The guard
|
||||
// makes the failure mode silence.
|
||||
const mirrorHookCommand = `command -v kettle >/dev/null && kettle mirror --hook || true`
|
||||
|
||||
// mirrorHookSnippet is what an operator is shown when the merge is not this
|
||||
// command's to make.
|
||||
const mirrorHookSnippet = `{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "` + mirrorHookCommand + `" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}`
|
||||
|
||||
// errSettingsExist means the file is there, does not hold the hook, and merging
|
||||
// it is a decision rather than a step.
|
||||
var errSettingsExist = errors.New("settings file already exists")
|
||||
|
||||
// settingsPath is `<out>/settings.json` — the shared file, not
|
||||
// settings.local.json. The convention this registers is a property of a
|
||||
// repository rather than of one developer's checkout, so it belongs in the file
|
||||
// that is committed.
|
||||
func settingsPath(out string) string { return filepath.Join(out, "settings.json") }
|
||||
|
||||
// writeMirrorHook registers the PreToolUse hook, and refuses to rewrite a file
|
||||
// it did not create.
|
||||
//
|
||||
// Three outcomes, and the third is the interesting one:
|
||||
//
|
||||
// - no file: it is written, hook and all.
|
||||
// - a file already holding a `kettle mirror` hook: nothing happens.
|
||||
// - a file holding something else: REFUSED unless force, and the snippet is
|
||||
// printed for the operator to paste.
|
||||
//
|
||||
// That refusal is deliberate and is the only reason this file is not a dozen
|
||||
// lines shorter. settings.json is a file the operator owns and commits, and Go's
|
||||
// encoding/json cannot preserve key order — so any merge reformats the whole
|
||||
// document, and an operator who asked for a documentation hook would find an
|
||||
// unrelated diff across a file they share with their team. A snippet they paste
|
||||
// costs them ten seconds; a reformat costs them a review.
|
||||
func writeMirrorHook(out string, force, dryRun bool) (string, error) {
|
||||
path := settingsPath(out)
|
||||
rel := filepath.Join(filepath.Base(out), "settings.json")
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
switch {
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
if dryRun {
|
||||
return fmt.Sprintf("created %s (PreToolUse: kettle mirror --hook)", rel), nil
|
||||
}
|
||||
if err := writeFile(path, mirrorHookSnippet+"\n"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("created %s (PreToolUse: kettle mirror --hook)", rel), nil
|
||||
|
||||
case err != nil:
|
||||
return "", err
|
||||
}
|
||||
|
||||
var settings map[string]any
|
||||
if err := json.Unmarshal(raw, &settings); err != nil {
|
||||
return "", Fail("%s is not readable as JSON (%v) — fix it, or add the hook by hand:\n\n%s", path, err, mirrorHookSnippet)
|
||||
}
|
||||
if strings.Contains(string(raw), "kettle mirror") {
|
||||
return "", nil // already registered; nothing to do and nothing to say
|
||||
}
|
||||
if !force {
|
||||
return "", fmt.Errorf("%w: %s. Add this to it, or re-run with --force-settings to have kettle merge it (which reformats the file):\n\n%s",
|
||||
errSettingsExist, path, mirrorHookSnippet)
|
||||
}
|
||||
|
||||
merged, err := mergeMirrorHook(settings)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if dryRun {
|
||||
return fmt.Sprintf("merged the hook into %s (reformatting it)", rel), nil
|
||||
}
|
||||
body, err := json.MarshalIndent(merged, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := writeFile(path, string(body)+"\n"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("merged the hook into %s (reformatting it)", rel), nil
|
||||
}
|
||||
|
||||
// mergeMirrorHook appends the hook to whatever PreToolUse already holds,
|
||||
// creating the path if it is not there. Nothing existing is removed or
|
||||
// reordered — what is lost is key order, which JSON does not carry, and that
|
||||
// is the whole reason this is behind a flag.
|
||||
func mergeMirrorHook(settings map[string]any) (map[string]any, error) {
|
||||
if settings == nil {
|
||||
settings = map[string]any{}
|
||||
}
|
||||
hooks, _ := settings["hooks"].(map[string]any)
|
||||
if hooks == nil {
|
||||
hooks = map[string]any{}
|
||||
}
|
||||
pre, _ := hooks["PreToolUse"].([]any)
|
||||
pre = append(pre, map[string]any{
|
||||
"matcher": "Bash",
|
||||
"hooks": []any{
|
||||
map[string]any{"type": "command", "command": mirrorHookCommand},
|
||||
},
|
||||
})
|
||||
hooks["PreToolUse"] = pre
|
||||
settings["hooks"] = hooks
|
||||
return settings, nil
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
# AGENTS.md — internal/config
|
||||
|
||||
**Two files: what this project is, and who this machine is.** The only package in
|
||||
the tree that imports yaml.
|
||||
**Three files: what this project is, who this machine is, and what was last
|
||||
written into the project's agent-harness tree.** The only package in the tree
|
||||
that imports yaml.
|
||||
|
||||
| file | what is in it |
|
||||
|---|---|
|
||||
| `config.go` | `Project` and `Logins` (the two files), `Resolve`/`ResolveOutsideAProject`/`Require`, `Resolved` with `Complete` and `Redacted`, the `KETTLE_*` overrides, and the 0600 write |
|
||||
| `config.go` | `Project`, `Logins` and `Scaffold` (the three files), `Resolve`/`ResolveOutsideAProject`/`Require`, `Resolved` with `Complete` and `Redacted`, the `KETTLE_*` overrides, and the 0600 write |
|
||||
|
||||
## The split is the whole design
|
||||
|
||||
@@ -13,9 +14,29 @@ the tree that imports yaml.
|
||||
<project>/.kettle/config.yaml login: noodles a NAME, never a token
|
||||
repo: owner/name
|
||||
|
||||
~/.config/kettle/logins.yaml logins: [{name, url, user, token}] mode 0600
|
||||
~/.config/kettle/logins.yaml logins: [{name, url, user, scopes, token}]
|
||||
mode 0600
|
||||
|
||||
<project>/.kettle/scaffold.yaml version: v1.4.0 a note, load-bearing
|
||||
out: .claude for nothing
|
||||
```
|
||||
|
||||
The third file is the newest and the least important, which is exactly why it is
|
||||
a file of its own — see *Unknown keys are an error* below. It records what
|
||||
`kettle gen scaffold` last wrote into the project and which build wrote it, so
|
||||
`kettle config` can say when a project's skills are four releases behind the
|
||||
binary reading them. **Nothing resolves from it.** Delete it and you lose the
|
||||
warning and nothing else.
|
||||
|
||||
`user` and `scopes` are **documentation and nothing else** — nothing is checked
|
||||
against either, and no request is refused because of one. `scopes` is what the
|
||||
token was minted with, as Gitea spells it (`write:issue`, `write:repository`),
|
||||
and it is written down because the instance will not answer the question:
|
||||
`GET /user/tokens` needs basic auth rather than token auth, so a token cannot be
|
||||
asked what it may do. What it buys is a 403 that can be read — `kettle auth list`
|
||||
and `kettle config` show what was recorded, and an empty list means "nobody wrote
|
||||
it down", never "none".
|
||||
|
||||
**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 has to
|
||||
@@ -84,17 +105,32 @@ Not a silent drop. An older binary reading a newer config would otherwise delete
|
||||
setting it did not recognize the next time it wrote the file — which is a data-loss
|
||||
bug that only shows up on the machine running the older build.
|
||||
|
||||
**The price is that a field added here is a one-way door for the file that holds
|
||||
it.** `scopes:` was the first one to prove it: a login file written by a binary
|
||||
that has the field cannot be read by one that does not — the older build stops
|
||||
with "unknown field" rather than dropping the line. That is acceptable for
|
||||
`logins.yaml`, which is machine-local and whose reader is the one binary the
|
||||
operator upgrades; it would **not** be acceptable for `config.yaml`, which is
|
||||
committed and read by whatever version each machine happens to have. Adding a
|
||||
field to the project file means answering that first, out loud, here.
|
||||
|
||||
`scaffold.yaml` exists because that answer came back "no". Recording which build
|
||||
wrote a project's `.claude/` tree wanted two keys, and putting them in
|
||||
`config.yaml` would have made every older `kettle` in the world stop reading a
|
||||
committed file. A separate file written and read by one binary about one
|
||||
directory carries the same cost for nothing: an older build never opens it.
|
||||
|
||||
## What does not belong here
|
||||
|
||||
A request, a store path, an issue. This package reads and writes two files and
|
||||
A request, a store path, an issue. This package reads and writes three files and
|
||||
answers "who am I and where am I pointed"; [`gitea`](../gitea/AGENTS.md) takes the
|
||||
answer and dials, and the paths themselves come from
|
||||
[`project`](../project/AGENTS.md).
|
||||
|
||||
## Keeping this file true
|
||||
|
||||
- **Scope:** `config.go` — the two files, their fields, the overrides, the file modes.
|
||||
- **Update it when** a field is added to either file (both tables above are the
|
||||
- **Scope:** `config.go` — the three files, their fields, the overrides, the file modes.
|
||||
- **Update it when** a field is added to any of them (the block above is the
|
||||
contract), an override is added or renamed, the location or mode of the login file
|
||||
changes, or the unknown-key policy changes.
|
||||
- **Do not** move a credential into the project file, and if that ever changes, the
|
||||
|
||||
@@ -47,6 +47,14 @@ const projectHeader = `# kettle — project configuration
|
||||
# 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.
|
||||
@@ -55,11 +63,40 @@ type Project struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -131,6 +168,43 @@ func SaveProject(path string, p *Project) error {
|
||||
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
|
||||
@@ -216,6 +290,11 @@ type Resolved struct {
|
||||
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.
|
||||
@@ -302,7 +381,7 @@ func merge(p Project) (*Resolved, error) {
|
||||
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 = entry.URL, entry.Token
|
||||
out.URL, out.Token, out.Scopes = entry.URL, entry.Token, entry.Scopes
|
||||
}
|
||||
|
||||
if v := os.Getenv(EnvURL); v != "" {
|
||||
|
||||
@@ -11,11 +11,11 @@ package, not beside it. `TestTransportDoesNotImportTheDomain` is the check.
|
||||
|
||||
| file | what is in it |
|
||||
|---|---|
|
||||
| `client.go` | `New`, `For`, the payload-dumping `RoundTripper`, pagination, `APIError` and `Fail`, `ListIssues` and its budget |
|
||||
| `issues.go` | `GetIssue`, `CreateIssue`, `EditIssue`, `SetLabels`, comments, milestones, dependencies |
|
||||
| `client.go` | `New`, `For`, `Do` and the endpoint rule, the payload-dumping `RoundTripper`, pagination, `APIError` and `Fail` |
|
||||
| `issues.go` | `GetIssue`, `CreateIssue`, `EditIssue`, `SetLabels`, comments, milestones, `ListIssues` and its budget, dependencies |
|
||||
| `labels.go` | `ListLabels`, `CreateLabel`, `EditLabel` |
|
||||
| `remotemap.go` | `RemoteMap` — the number → slug ledger, and why nothing prunes it |
|
||||
| `client_test.go` | pagination, error bodies, the scratchpad, the page budget, the version gate |
|
||||
| `client_test.go` | pagination, error bodies, the scratchpad, the page budget, the version gate, `Do` and what it refuses |
|
||||
| `remotemap_test.go` | load, merge, save |
|
||||
|
||||
## What this package is, now that the SDK exists
|
||||
@@ -102,6 +102,46 @@ somebody types when they want to see what was in it. `ListLabels` is read from t
|
||||
repository and never from a cache — a cache answers "what did we create last time",
|
||||
and the question is "what does this repository have right now".
|
||||
|
||||
## `Do` — the requests the SDK cannot express
|
||||
|
||||
`Do(method, path, body, name)` sends one request and returns the status and the
|
||||
body exactly as they came back. It was here before it was general: the dependency
|
||||
endpoint takes a body the SDK's own `IssueMeta` cannot spell, so a hand-rolled
|
||||
request already existed and `post` is now three lines on top of this one.
|
||||
|
||||
Exporting it is what lets [`kettle api`](../cmd/AGENTS.md) reach a release, a pull
|
||||
request or a webhook **without this package growing a method per entity** and
|
||||
without a second client holding the credentials all over again. It goes through
|
||||
the same `http.Client`, so it gets the same three services as everything else: the
|
||||
body is filed by the same `RoundTripper`, the same `token …` header authenticates
|
||||
it, and a non-2xx is the same `*APIError`.
|
||||
|
||||
Three things it deliberately does not do, and each of them is a way of not lying:
|
||||
|
||||
- **it does not paginate** — one call is one HTTP request, and `?page=`/`?limit=`
|
||||
are the caller's. The pagination below exists for a listing with a budget to
|
||||
spend; a passthrough that stitched pages together would report as one answer
|
||||
something that was several;
|
||||
- **it does not parse or reformat the answer** — bytes in, bytes out;
|
||||
- **it does not know what an issue is** — nothing about it names a domain concept,
|
||||
so `TestTransportDoesNotImportTheDomain` needed no change. A generic transport is
|
||||
no more a domain than a specific one was.
|
||||
|
||||
**The endpoint rule** is `tea api`'s, so a table of endpoints written for that tool
|
||||
still works: a bare path is under `/api/v1/`, a path starting `/api/` is taken as
|
||||
it stands (that is how anything outside v1 is reached), and a full URL is a full
|
||||
URL — **but only on this instance**. That last clause is the one place this is
|
||||
stricter than the tool it replaces, and it is not fussiness: every request carries
|
||||
the project's token in a header, so a URL on another host would hand the token to
|
||||
whatever was typed. Another instance is `KETTLE_URL`.
|
||||
|
||||
**A 403 answers with what to do about it.** Gitea scopes a token as
|
||||
`<read|write>:<category>` and names no scope in the refusal, so `APIError.Error`
|
||||
appends the one sentence that helps — everything outside issues is `repository`,
|
||||
and `kettle auth list` shows what each login recorded. It does not guess a
|
||||
specific scope, because the server did not say and a wrong guess is worse than
|
||||
none.
|
||||
|
||||
## The ledger
|
||||
|
||||
`.remote.json`, **inside the issue store, beside the issues it indexes** — it is
|
||||
@@ -142,9 +182,10 @@ blocking panel and refuse to close a blocked issue first.
|
||||
- **Writing does not.** Gitea's own `IssueMeta` is `{index, owner, repo}` and has
|
||||
been since the endpoint existed; the SDK's is `{index}`, which can only link
|
||||
inside one repository, and a `depends:` entry is allowed to live somewhere else.
|
||||
So one hand-rolled `POST` survives, through the same HTTP client as everything
|
||||
else — same payload dump, same `*APIError`. The URL names the blocked issue and
|
||||
the body the blocker, which is the direction `Dependencies` reads back.
|
||||
So it goes out through `Do` — same HTTP client, same payload dump, same
|
||||
`*APIError`. This was the request `Do` was written for, back when it was the only
|
||||
one. The URL names the blocked issue and the body the blocker, which is the
|
||||
direction `Dependencies` reads back.
|
||||
- **The version gates both.** The routes are absent from Gitea 1.19 and present in
|
||||
1.20, checked against the release tags themselves, so an older instance is
|
||||
answered from the version it already gave us rather than from a 404 — which on an
|
||||
|
||||
+115
-21
@@ -69,8 +69,9 @@ const (
|
||||
type Client struct {
|
||||
// api is the SDK client: one per run, shared by every copy For makes.
|
||||
api *sdk.Client
|
||||
// http is the SDK's transport, kept because AddDependency still sends one
|
||||
// request by hand — see there.
|
||||
// http is the SDK's transport, kept because Do sends requests by hand — the
|
||||
// dependency endpoint the SDK cannot spell, and every endpoint this package
|
||||
// has no method for.
|
||||
http *http.Client
|
||||
// dump is the RoundTripper that files request bodies. Shared with every
|
||||
// copy For makes, because the scratchpad is one directory per run.
|
||||
@@ -186,9 +187,31 @@ func (e *APIError) Error() string {
|
||||
if status != "" {
|
||||
status = " " + status
|
||||
}
|
||||
return fmt.Sprintf("%s %s: %d%s: %s", e.Method, e.URL, e.Status, status, body)
|
||||
out := fmt.Sprintf("%s %s: %d%s: %s", e.Method, e.URL, e.Status, status, body)
|
||||
if e.Status == http.StatusForbidden {
|
||||
out += "\n" + scopeHint
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// scopeHint is what a 403 gets said after it.
|
||||
//
|
||||
// Gitea scopes a token as <read|write>:<category>, and a token minted to file
|
||||
// issues carries `write:issue` and nothing more — which is exactly right until
|
||||
// the first request outside issues, where releases, pull requests, branches and
|
||||
// tags all live under `repository` and the answer is a 403 that names no scope
|
||||
// at all. The server will not say which one is missing, so this does not guess
|
||||
// one; it names the two commands that show what was recorded and let it be
|
||||
// re-recorded.
|
||||
//
|
||||
// Blanket rather than per-call, because the transport does not know which
|
||||
// category an arbitrary endpoint belongs to — and a 403 on a request that had
|
||||
// the scope is a permissions problem on the repository, which this sentence does
|
||||
// not contradict.
|
||||
const scopeHint = "a 403 is usually the token's scopes rather than the request: Gitea scopes a token as " +
|
||||
"<read|write>:<category>, and everything outside issues (releases, pull requests, branches, tags, actions) " +
|
||||
"is `repository`. `kettle auth list` shows what each login on this machine records."
|
||||
|
||||
// StatusIs reports whether err is an API answer with this status code, for the
|
||||
// handful of places where one code means something specific — a 409 from a
|
||||
// dependency link that is already there, say.
|
||||
@@ -404,30 +427,58 @@ func safeName(name string) string {
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// the one request the SDK cannot express
|
||||
// the requests the SDK cannot express
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// post sends one JSON body to a path under this instance's API and ignores
|
||||
// whatever comes back.
|
||||
// Do sends one request to a path under this instance's API and returns the
|
||||
// status and the body exactly as they came back.
|
||||
//
|
||||
// It exists for AddDependency and for nothing else — see there for what the SDK
|
||||
// leaves out. It goes through the same http.Client, so the body is filed and a
|
||||
// failure carries the status and the server's words exactly as every other call
|
||||
// in this package does.
|
||||
func (c *Client) post(path string, body any, name string) error {
|
||||
raw, err := json.Marshal(body)
|
||||
// It is the escape hatch, and it was here before it was one: the dependency
|
||||
// endpoint needed a body the SDK's own type cannot spell (see AddDependency),
|
||||
// so a hand-rolled request already existed. What has changed is that it is
|
||||
// exported, which is what lets `kettle api` reach a release, a pull request or a
|
||||
// webhook without this package growing a method per entity — and without a
|
||||
// second client that would hold the credentials all over again.
|
||||
//
|
||||
// It goes through the same http.Client as everything else, which is the whole
|
||||
// point: the same dump-RoundTripper files the body under `.kettle/payload/`, the
|
||||
// same `token …` header authenticates it, and a non-2xx comes back as the same
|
||||
// *APIError carrying the status AND what the server said.
|
||||
//
|
||||
// THREE THINGS IT DELIBERATELY DOES NOT DO:
|
||||
//
|
||||
// - IT DOES NOT PAGINATE. One call is one HTTP request. The pagination in this
|
||||
// package exists for a listing with a budget to spend, and a passthrough that
|
||||
// quietly stitched pages together would report as one answer something that
|
||||
// was several — `?page=` and `?limit=` are the caller's to spell.
|
||||
// - IT DOES NOT PARSE OR REFORMAT THE ANSWER. Bytes in, bytes out. Whoever
|
||||
// asked knows what they asked for; re-indenting it here would only be a
|
||||
// second opinion about somebody else's JSON.
|
||||
// - IT DOES NOT KNOW WHAT AN ISSUE IS. Nothing about it names a domain concept,
|
||||
// so the layering rule holds unchanged — this is still transport, and a
|
||||
// generic one is no more a domain than a specific one was.
|
||||
//
|
||||
// A nil body sends no body at all, which is what a GET and a DELETE want; the
|
||||
// Content-Type goes on only when there is something to type.
|
||||
func (c *Client) Do(method, path string, body []byte, name string) (int, []byte, error) {
|
||||
endpoint, err := c.endpoint(path)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, nil, err
|
||||
}
|
||||
endpoint := c.base + "/api/v1/" + strings.TrimLeft(path, "/")
|
||||
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(raw))
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
reader = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequest(method, endpoint, reader)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, nil, err
|
||||
}
|
||||
// Gitea's own scheme, and what the `tea` CLI this replaces sent: the word
|
||||
// `token`, not `Bearer`. An instance answers 401 to the other spelling.
|
||||
req.Header.Set("Authorization", "token "+c.token)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
|
||||
@@ -436,14 +487,57 @@ func (c *Client) post(path string, body any, name string) error {
|
||||
if err != nil {
|
||||
// The token travels in a header and never in the URL, so an error is
|
||||
// free to quote the URL in full.
|
||||
return err
|
||||
return 0, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
answer, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return &APIError{Method: http.MethodPost, URL: endpoint, Status: resp.StatusCode, Body: string(answer)}
|
||||
answer, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return resp.StatusCode, nil, fmt.Errorf("%s %s: %d answered with a body that could not be read: %w",
|
||||
method, endpoint, resp.StatusCode, err)
|
||||
}
|
||||
return nil
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return resp.StatusCode, answer, &APIError{
|
||||
Method: method, URL: endpoint, Status: resp.StatusCode, Body: string(answer)}
|
||||
}
|
||||
return resp.StatusCode, answer, nil
|
||||
}
|
||||
|
||||
// endpoint resolves what a caller spelled into an absolute URL on this instance.
|
||||
//
|
||||
// The three spellings are the ones `tea api` accepted, so a table of endpoints
|
||||
// written for that tool still works here: a bare path is under `/api/v1/`, a
|
||||
// path already starting `/api/` is taken as it stands (that is how anything
|
||||
// outside v1 is reached), and a full URL is a full URL.
|
||||
//
|
||||
// A FULL URL MUST BE ON THIS INSTANCE, and that is the one place this is
|
||||
// stricter than the tool it replaces. Every request made here carries the
|
||||
// project's token in a header; a URL pointing somewhere else would hand that
|
||||
// token to whatever host was named, which is a credential leak spelled as a
|
||||
// convenience. Reaching another instance is what KETTLE_URL is for.
|
||||
func (c *Client) endpoint(path string) (string, error) {
|
||||
switch {
|
||||
case strings.HasPrefix(path, "http://"), strings.HasPrefix(path, "https://"):
|
||||
if path != c.base && !strings.HasPrefix(path, c.base+"/") {
|
||||
return "", fmt.Errorf("%s is not on %s — this token belongs to that instance and is sent nowhere else"+
|
||||
" (point %s at the other one instead)", path, c.base, config.EnvURL)
|
||||
}
|
||||
return path, nil
|
||||
case strings.HasPrefix(path, "/api/"):
|
||||
return c.base + path, nil
|
||||
default:
|
||||
return c.base + "/api/v1/" + strings.TrimLeft(path, "/"), nil
|
||||
}
|
||||
}
|
||||
|
||||
// post sends one JSON body to a path under this instance's API and ignores
|
||||
// whatever comes back. AddDependency is what it is for.
|
||||
func (c *Client) post(path string, body any, name string) error {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, err = c.Do(http.MethodPost, path, raw, name)
|
||||
return err
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -487,6 +487,147 @@ func TestDependenciesAreAskedForOnAnInstanceThatHasThem(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The generic request: bytes out, bytes back, and the same three services every
|
||||
// other call in this package gets — the header, the scratchpad, the *APIError.
|
||||
func TestDoAnswersWithWhatTheServerSent(t *testing.T) {
|
||||
root := newProject(t)
|
||||
|
||||
var got struct{ method, uri, auth, ctype string }
|
||||
var sent []byte
|
||||
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||
got.method, got.uri = r.Method, r.URL.RequestURI()
|
||||
got.auth, got.ctype = r.Header.Get("Authorization"), r.Header.Get("Content-Type")
|
||||
sent, _ = io.ReadAll(r.Body)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
io.WriteString(w, `{"tag_name":"v0.2.0"}`)
|
||||
})
|
||||
c := newClient(t, srv.URL)
|
||||
|
||||
// A read: no body out, and nothing filed — the scratchpad holds what was
|
||||
// SENT, and a run that sent nothing leaves no directory behind.
|
||||
code, answer, err := c.Do(http.MethodGet, "repos/acme/widgets/releases?limit=50", nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Do: %v", err)
|
||||
}
|
||||
if code != http.StatusCreated || string(answer) != `{"tag_name":"v0.2.0"}` {
|
||||
t.Errorf("got %d %q, want 201 and the server's bytes", code, answer)
|
||||
}
|
||||
if got.uri != "/api/v1/repos/acme/widgets/releases?limit=50" {
|
||||
t.Errorf("the endpoint was rewritten: %s", got.uri)
|
||||
}
|
||||
if got.auth != "token s3cret" {
|
||||
t.Errorf("Authorization was %q, want %q", got.auth, "token s3cret")
|
||||
}
|
||||
if got.ctype != "" {
|
||||
t.Errorf("a request with no body carried Content-Type %q", got.ctype)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, ".kettle", "payload")); !os.IsNotExist(err) {
|
||||
t.Errorf("a read created the payload directory (%v)", err)
|
||||
}
|
||||
|
||||
// A write: the body goes out verbatim and is filed under the name it was
|
||||
// given, by the same RoundTripper that files every other request.
|
||||
body := []byte(`{"tag_name":"v0.2.0","body":"a & b"}`)
|
||||
if _, _, err := c.Do(http.MethodPost, "/api/v1/repos/acme/widgets/releases", body, "release-v0-2-0"); err != nil {
|
||||
t.Fatalf("Do: %v", err)
|
||||
}
|
||||
if got.method != http.MethodPost || got.ctype != "application/json" {
|
||||
t.Errorf("the write went out as %s %q", got.method, got.ctype)
|
||||
}
|
||||
if string(sent) != string(body) {
|
||||
t.Errorf("the server got %s, want %s — a passthrough reformatted the body", sent, body)
|
||||
}
|
||||
filed, err := os.ReadFile(filepath.Join(root, ".kettle", "payload", "release-v0-2-0.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("the body was not filed: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(filed), `"tag_name": "v0.2.0"`) {
|
||||
t.Errorf("the dump is not the body that was sent:\n%s", filed)
|
||||
}
|
||||
}
|
||||
|
||||
// A refusal comes back as this package's error, with the status and the
|
||||
// server's own words — and the status and body are returned as well, so a
|
||||
// caller that would rather print them than wrap them can.
|
||||
func TestDoReportsAStatusAndTheServersWords(t *testing.T) {
|
||||
newProject(t)
|
||||
|
||||
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
io.WriteString(w, `{"message":"release does not exist"}`)
|
||||
})
|
||||
|
||||
code, answer, err := newClient(t, srv.URL).Do(http.MethodGet, "repos/acme/widgets/releases/9", nil, "")
|
||||
if err == nil {
|
||||
t.Fatal("a 404 came back as success")
|
||||
}
|
||||
var apiErr *gitea.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("error is %T, want *gitea.APIError: %v", err, err)
|
||||
}
|
||||
if code != http.StatusNotFound || !strings.Contains(string(answer), "release does not exist") {
|
||||
t.Errorf("got %d %q; the status and the body are the caller's too", code, answer)
|
||||
}
|
||||
for _, want := range []string{"404", "release does not exist", "GET", "/api/v1/repos/acme/widgets/releases/9"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the error does not mention %q:\n%s", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A 403 is answered with what to do about it, because Gitea's own 403 names no
|
||||
// scope and a token minted for issues is the usual reason.
|
||||
func TestAForbiddenAnswerNamesTheScopeItMightBe(t *testing.T) {
|
||||
newProject(t)
|
||||
|
||||
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
io.WriteString(w, `{"message":"token does not have at least one of required scope(s)"}`)
|
||||
})
|
||||
|
||||
_, _, err := newClient(t, srv.URL).Do(http.MethodPost, "repos/acme/widgets/releases", []byte(`{}`), "")
|
||||
if err == nil {
|
||||
t.Fatal("a 403 came back as success")
|
||||
}
|
||||
for _, want := range []string{"403", "kettle auth list", "repository"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("a 403 does not say %q — the server named no scope, so this has to:\n%s", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The token is this instance's. A full URL somewhere else is refused before a
|
||||
// socket is opened, because sending it would hand the credential to whatever
|
||||
// host was typed.
|
||||
func TestDoRefusesAURLOnAnotherHost(t *testing.T) {
|
||||
newProject(t)
|
||||
|
||||
asked := 0
|
||||
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||
asked++
|
||||
writeJSON(t, w, map[string]any{})
|
||||
})
|
||||
c := newClient(t, srv.URL)
|
||||
|
||||
_, _, err := c.Do(http.MethodGet, "https://gitea.example.invalid/api/v1/user", nil, "")
|
||||
if err == nil {
|
||||
t.Fatal("a request to another host was allowed — that sends this project's token to it")
|
||||
}
|
||||
if strings.Contains(err.Error(), "s3cret") {
|
||||
t.Errorf("the refusal quotes the token:\n%s", err)
|
||||
}
|
||||
if asked != 0 {
|
||||
t.Errorf("%d request(s) went out for an endpoint that was refused", asked)
|
||||
}
|
||||
// A full URL on the instance itself is the same request as the bare path.
|
||||
if _, _, err := c.Do(http.MethodGet, srv.URL+"/api/v1/user", nil, ""); err != nil {
|
||||
t.Errorf("a full URL on this instance was refused: %v", err)
|
||||
}
|
||||
if asked != 1 {
|
||||
t.Errorf("%d request(s) went out, want 1", asked)
|
||||
}
|
||||
}
|
||||
|
||||
// A half-filled configuration is refused here rather than at the first 401,
|
||||
// because a 401 names nothing an operator can act on — and before the client is
|
||||
// built at all, because building one dials.
|
||||
|
||||
@@ -167,7 +167,7 @@ chip and not what an issue is.
|
||||
- **Update it when** a metadata field is added to `DomainKeys`, a type or severity
|
||||
is added to the taxonomy, a required or expected section changes, a file appears
|
||||
or goes in the table above, or any rule about what may be deleted changes. The
|
||||
format's operator-facing statement of intent lives in the plugin
|
||||
(`plugins/kettle/skills/issue/references/format.md`) — when the taxonomy moves,
|
||||
both change.
|
||||
format's operator-facing statement of intent is embedded in the binary
|
||||
(`cli/internal/scaffold/assets/skills/kettle-issue/references/format.md`) — when
|
||||
the taxonomy moves, both change.
|
||||
- **Do not** document how any of this reaches a tracker.
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# AGENTS.md — internal/mirror
|
||||
|
||||
**One filesystem invariant, in every directory of a tree:**
|
||||
|
||||
```
|
||||
AGENTS.md is the real file; CLAUDE.md is a symlink pointing at it.
|
||||
```
|
||||
|
||||
**Imports the standard library and nothing else** — no issue, no tracker, no
|
||||
configuration, no login. That is not a stylistic preference: this walks whatever
|
||||
directory it is pointed at, on any machine, and a package that reached for a
|
||||
project's configuration could not be run outside a project. Two tests hold it,
|
||||
see [`internal/AGENTS.md`](../AGENTS.md).
|
||||
|
||||
| file | what is in it |
|
||||
|---|---|
|
||||
| `mirror.go` | `Sync`, `Check`, `Result`, and `fixDir` — the seven branches |
|
||||
| `mirror_test.go` | one case per branch, plus the walk, the skip list and idempotence |
|
||||
| `layering_test.go` | the two tests that keep this package at the bottom |
|
||||
|
||||
## Why the invariant
|
||||
|
||||
Two agent harnesses read two different filenames for the same document. A
|
||||
repository that keeps both as real files keeps **two documents**, and they drift —
|
||||
silently, until somebody reads the stale one and believes it. One real file with a
|
||||
link beside it is the only arrangement where that cannot happen.
|
||||
|
||||
`AGENTS.md` is the real file rather than `CLAUDE.md` because the convention is not
|
||||
one vendor's: a repository that names its documents after a single tool has picked
|
||||
a side it did not need to pick.
|
||||
|
||||
## The seven branches
|
||||
|
||||
Every one of them is either lossless or a refusal. **Nothing here deletes content.**
|
||||
|
||||
| starting state | what happens |
|
||||
|---|---|
|
||||
| `AGENTS.md` real, no `CLAUDE.md` | create the symlink |
|
||||
| `CLAUDE.md` real, no `AGENTS.md` | rename to `AGENTS.md`, link back |
|
||||
| `CLAUDE.md` symlink → `AGENTS.md` | canonical; nothing to do |
|
||||
| `CLAUDE.md` symlink elsewhere | re-point it |
|
||||
| `AGENTS.md` symlink → real `CLAUDE.md` | reversed layout; swap it round |
|
||||
| both real, identical content | replace `CLAUDE.md` with the symlink |
|
||||
| both real, **different** content | **refuse**, and name the directory |
|
||||
|
||||
The last row is the reason the other six can be automatic. One of those two files
|
||||
is somebody's writing and no rule here knows which, so a merge is not attempted and
|
||||
not offered — the conflict is reported and the directory is left exactly as it was.
|
||||
|
||||
Two smaller refusals sit beside it, both about a symlink with no target: a broken
|
||||
`AGENTS.md` with no `CLAUDE.md` beside it, and a `CLAUDE.md` pointing at something
|
||||
that is gone. Neither is repairable without inventing content.
|
||||
|
||||
**The link target is relative.** `CLAUDE.md -> AGENTS.md`, never an absolute path:
|
||||
a tree that is moved, copied, cloned or mounted somewhere else keeps working, and
|
||||
an absolute link would point at wherever the repair happened to run.
|
||||
|
||||
## Two properties the tests hold
|
||||
|
||||
**A repair that fails halfway reports nothing.** An unwritable directory, a race
|
||||
with an editor — the fix is abandoned and no line is added. Claiming a repair that
|
||||
did not happen is worse than silence: the next run finds the same state, and the
|
||||
operator has now been told twice that it was handled.
|
||||
|
||||
**Sync converges in one pass.** Every case in the table runs `Sync` twice and fails
|
||||
if the second run still has work. This matters more here than it looks: the command
|
||||
that wraps this package runs on `PreToolUse(Bash)`, so a state that reported itself
|
||||
fixed without converging would re-report on every Bash call, forever.
|
||||
|
||||
`Check` is the same walk with the writes turned off — one code path, not a second
|
||||
implementation that might disagree — so a check that says nothing is a promise
|
||||
about the run that follows it.
|
||||
|
||||
## What is skipped
|
||||
|
||||
`node_modules`, `__pycache__`, `venv`, `vendor`, and every dot-directory.
|
||||
|
||||
Somebody else's tree is somebody else's business: a vendored dependency's
|
||||
`AGENTS.md` rewritten here is a diff nobody asked for. `.git` gets the same
|
||||
treatment for a second reason — it is not a place to be creating symlinks.
|
||||
|
||||
## What does not belong here
|
||||
|
||||
The JSON a `PreToolUse` hook reads and writes, the decision to run at all, and the
|
||||
exit status. Those are [`cmd`](../cmd/AGENTS.md)'s, in `mirror.go` there; this
|
||||
package takes a directory and returns a `Result`. That split is what lets the
|
||||
repair be tested against a temp directory without a hook payload anywhere near it.
|
||||
|
||||
## Keeping this file true
|
||||
|
||||
- **Scope:** `mirror.go` and its tests — the invariant, the seven branches, the
|
||||
skip list, and the two properties above.
|
||||
- **Update it when** a branch is added or its outcome changes (the table is the
|
||||
contract), a directory is added to or dropped from the skip list, or the link
|
||||
stops being relative.
|
||||
- **Do not** document the command, the hook payload or the exit codes here.
|
||||
[`internal/cmd`](../cmd/AGENTS.md) owns those.
|
||||
@@ -0,0 +1,57 @@
|
||||
package mirror
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// This package repairs a filesystem layout and knows nothing else. It has no
|
||||
// business with an issue, a tracker, a login or a configuration file, and the
|
||||
// moment it imports one of them it stops being a thing that can be run over any
|
||||
// directory on the machine.
|
||||
//
|
||||
// The dependency walk, so a helper pulled in three packages deep is caught as
|
||||
// the same violation as one written at the top of a file.
|
||||
func TestMirrorDependsOnNothing(t *testing.T) {
|
||||
out, err := exec.Command("go", "list", "-deps", ".").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("go list: %v", err)
|
||||
}
|
||||
for _, dep := range strings.Fields(string(out)) {
|
||||
if dep == "git.noodles.cam/claude-skills/marketplace/cli/internal/mirror" {
|
||||
continue
|
||||
}
|
||||
// A standard-library import path has no dot in its first element,
|
||||
// because it has no domain name in front of it.
|
||||
if first, _, _ := strings.Cut(dep, "/"); strings.Contains(first, ".") {
|
||||
t.Errorf("mirror imports %s — this walks a directory, and nothing else belongs here", dep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The other half: os and net/http are standard library, so "no third-party
|
||||
// imports" would not catch a request or a shell-out written by hand here. os
|
||||
// itself is the point of this package, so it is the one that is allowed.
|
||||
//
|
||||
// DIRECT imports, not the dependency walk — fmt reaches os on its own, and the
|
||||
// question this asks is what THIS package reaches for.
|
||||
func TestMirrorNeitherDialsNorShellsOut(t *testing.T) {
|
||||
forbidden := map[string]string{
|
||||
"net/http": "a documentation convention is not fetched from anywhere",
|
||||
"net": "a documentation convention is not fetched from anywhere",
|
||||
"os/exec": "the repair is syscalls, not a shell — that is the whole reason it left bash",
|
||||
"encoding/json": "the hook's JSON is the command layer's business, not this one's",
|
||||
"time": "nothing here has a clock in it",
|
||||
}
|
||||
|
||||
out, err := exec.Command("go", "list", "-f", `{{join .Imports "\n"}}`, ".").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("go list: %v", err)
|
||||
}
|
||||
for _, dep := range strings.Fields(string(out)) {
|
||||
if why, bad := forbidden[dep]; bad {
|
||||
t.Errorf("mirror imports %s — %s", dep, why)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// Package mirror enforces one filesystem invariant, in every directory of a
|
||||
// tree:
|
||||
//
|
||||
// AGENTS.md is the real file; CLAUDE.md is a symlink pointing at it.
|
||||
//
|
||||
// Two agent harnesses read two different filenames for the same document, and a
|
||||
// repository that keeps both as real files keeps two documents — which drift,
|
||||
// silently, until somebody reads the stale one and believes it. One real file
|
||||
// with a link beside it is the only arrangement where that cannot happen.
|
||||
//
|
||||
// This package depends on nothing but the standard library. It performs no
|
||||
// merge and DELETES NO CONTENT: every branch is either a lossless repair or a
|
||||
// report, and the one case it refuses to resolve — two real files whose contents
|
||||
// differ — is the one where a wrong guess would destroy somebody's writing.
|
||||
package mirror
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The two names, and the link's target. The target is written relative on
|
||||
// purpose: a tree that is moved, copied or mounted somewhere else keeps working,
|
||||
// and an absolute link would point at wherever the repair happened to run.
|
||||
const (
|
||||
Agents = "AGENTS.md"
|
||||
Claude = "CLAUDE.md"
|
||||
)
|
||||
|
||||
// skipDirs are never descended into. Each holds somebody else's tree — a
|
||||
// vendored dependency's AGENTS.md is that dependency's business, and rewriting
|
||||
// it would show up as a diff nobody asked for. Dot-directories are skipped by
|
||||
// the same argument and by a second one: `.git` is not a place to be creating
|
||||
// symlinks.
|
||||
var skipDirs = map[string]bool{
|
||||
"node_modules": true,
|
||||
"__pycache__": true,
|
||||
"venv": true,
|
||||
"vendor": true,
|
||||
}
|
||||
|
||||
// Result is what one walk found. Both halves are ordered by directory, because
|
||||
// the walk is, so two runs over the same tree report in the same order.
|
||||
type Result struct {
|
||||
// Fixes are the repairs made — or, from Check, the repairs that would be.
|
||||
Fixes []string
|
||||
// Conflicts are the directories this package refuses to resolve. A conflict
|
||||
// is reported identically by both entry points: nothing about it is a write.
|
||||
Conflicts []string
|
||||
}
|
||||
|
||||
// Clean reports whether the tree was already canonical.
|
||||
func (r Result) Clean() bool { return len(r.Fixes) == 0 && len(r.Conflicts) == 0 }
|
||||
|
||||
// Sync walks root and repairs every directory under it.
|
||||
func Sync(root string) Result { return walk(root, true) }
|
||||
|
||||
// Check walks root and reports what Sync would do, writing nothing.
|
||||
//
|
||||
// The two share one code path with the writes turned off, so a check that says
|
||||
// nothing is a promise about the run that follows it rather than a second
|
||||
// implementation that might disagree.
|
||||
func Check(root string) Result { return walk(root, false) }
|
||||
|
||||
func walk(root string, apply bool) Result {
|
||||
var res Result
|
||||
abs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return res
|
||||
}
|
||||
|
||||
_ = filepath.WalkDir(abs, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
// An unreadable directory is skipped, never fatal: this runs over
|
||||
// somebody's whole working tree and one bad mode must not stop it.
|
||||
if d != nil && d.IsDir() {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if path != abs {
|
||||
if name := d.Name(); skipDirs[name] || strings.HasPrefix(name, ".") {
|
||||
return fs.SkipDir
|
||||
}
|
||||
}
|
||||
fixDir(path, abs, apply, &res)
|
||||
return nil
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
// fixDir applies the invariant to one directory.
|
||||
//
|
||||
// The seven cases, and every one of them is either lossless or a refusal:
|
||||
//
|
||||
// AGENTS.md real, no CLAUDE.md ........ create the symlink
|
||||
// CLAUDE.md real, no AGENTS.md ........ rename to AGENTS.md, link back
|
||||
// CLAUDE.md symlink -> AGENTS.md ...... canonical, nothing to do
|
||||
// CLAUDE.md symlink elsewhere ......... re-point it
|
||||
// AGENTS.md symlink -> real CLAUDE.md . reversed layout, swap it round
|
||||
// both real, identical content ........ replace CLAUDE.md with the symlink
|
||||
// both real, different content ........ REFUSE, and say which directory
|
||||
//
|
||||
// A repair that fails halfway — an unwritable directory, a race with an editor —
|
||||
// reports nothing rather than a fix it did not make. Claiming a repair that did
|
||||
// not happen is worse than silence, because the next run would find the same
|
||||
// state and the operator would have been told twice that it was handled.
|
||||
func fixDir(dir, root string, apply bool, res *Result) {
|
||||
agents := filepath.Join(dir, Agents)
|
||||
claude := filepath.Join(dir, Claude)
|
||||
|
||||
aInfo, aErr := os.Lstat(agents)
|
||||
cInfo, cErr := os.Lstat(claude)
|
||||
a, c := aErr == nil, cErr == nil
|
||||
if !a && !c {
|
||||
return
|
||||
}
|
||||
aLink := a && aInfo.Mode()&os.ModeSymlink != 0
|
||||
cLink := c && cInfo.Mode()&os.ModeSymlink != 0
|
||||
|
||||
rel := func(p string) string {
|
||||
r, err := filepath.Rel(root, p)
|
||||
if err != nil {
|
||||
return p
|
||||
}
|
||||
if r == "." {
|
||||
return "<root>"
|
||||
}
|
||||
return r
|
||||
}
|
||||
conflict := func(format string, v ...any) {
|
||||
res.Conflicts = append(res.Conflicts, fmt.Sprintf(format, v...))
|
||||
}
|
||||
// fix runs the repair unless this is a check, and records it only if every
|
||||
// step of it succeeded.
|
||||
fix := func(msg string, steps ...func() error) {
|
||||
if apply {
|
||||
for _, step := range steps {
|
||||
if err := step(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
res.Fixes = append(res.Fixes, msg)
|
||||
}
|
||||
link := func() error { return os.Symlink(Agents, claude) }
|
||||
|
||||
switch {
|
||||
case a && !c:
|
||||
if aLink && !exists(agents) {
|
||||
conflict("%s: broken symlink and no %s", rel(agents), Claude)
|
||||
return
|
||||
}
|
||||
fix(fmt.Sprintf("%s: created symlink -> %s", rel(claude), Agents), link)
|
||||
|
||||
case c && !a:
|
||||
if cLink {
|
||||
target, _ := os.Readlink(claude)
|
||||
conflict("%s: symlink to missing target (%s)", rel(claude), target)
|
||||
return
|
||||
}
|
||||
fix(fmt.Sprintf("%s: renamed to %s, symlink left in place", rel(claude), Agents),
|
||||
func() error { return os.Rename(claude, agents) }, link)
|
||||
|
||||
case cLink:
|
||||
if sameFile(claude, agents) {
|
||||
return // canonical
|
||||
}
|
||||
old, _ := os.Readlink(claude)
|
||||
fix(fmt.Sprintf("%s: re-pointed symlink (%s -> %s)", rel(claude), old, Agents),
|
||||
func() error { return os.Remove(claude) }, link)
|
||||
|
||||
case aLink:
|
||||
// Reversed layout: AGENTS.md is the link and CLAUDE.md the real file.
|
||||
if !sameFile(agents, claude) {
|
||||
conflict("%s: symlink elsewhere while %s is a real file", rel(agents), Claude)
|
||||
return
|
||||
}
|
||||
fix(fmt.Sprintf("%s: swapped — %s is now the real file", rel(agents), Agents),
|
||||
func() error { return os.Remove(agents) },
|
||||
func() error { return os.Rename(claude, agents) }, link)
|
||||
|
||||
default:
|
||||
// Both are real files, and only their contents decide what happens.
|
||||
if !identical(agents, claude) {
|
||||
conflict("%s: %s and %s are different real files — merge manually",
|
||||
rel(dir), Agents, Claude)
|
||||
return
|
||||
}
|
||||
fix(fmt.Sprintf("%s: identical to %s, replaced with symlink", rel(claude), Agents),
|
||||
func() error { return os.Remove(claude) }, link)
|
||||
}
|
||||
}
|
||||
|
||||
func exists(p string) bool {
|
||||
_, err := os.Stat(p)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// sameFile reports whether two paths resolve to one file.
|
||||
func sameFile(a, b string) bool {
|
||||
ra, err := filepath.EvalSymlinks(a)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
rb, err := filepath.EvalSymlinks(b)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return ra == rb
|
||||
}
|
||||
|
||||
// identical compares two files by content, not by size or mtime. The whole
|
||||
// point of the comparison is to decide whether one of them may be deleted.
|
||||
func identical(a, b string) bool {
|
||||
ba, err := os.ReadFile(a)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
bb, err := os.ReadFile(b)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return bytes.Equal(ba, bb)
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package mirror
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// state is what one directory looks like after a walk: what kind of thing each
|
||||
// name is, and what the real file holds. It is the assertion for every case
|
||||
// below, because every case is a statement about exactly this.
|
||||
type state struct {
|
||||
agentsIsFile bool
|
||||
claudeIsLink bool
|
||||
linkTarget string
|
||||
body string
|
||||
}
|
||||
|
||||
func read(t *testing.T, dir string) state {
|
||||
t.Helper()
|
||||
var s state
|
||||
if fi, err := os.Lstat(filepath.Join(dir, Agents)); err == nil {
|
||||
s.agentsIsFile = fi.Mode().IsRegular()
|
||||
}
|
||||
if fi, err := os.Lstat(filepath.Join(dir, Claude)); err == nil {
|
||||
if fi.Mode()&os.ModeSymlink != 0 {
|
||||
s.claudeIsLink = true
|
||||
s.linkTarget, _ = os.Readlink(filepath.Join(dir, Claude))
|
||||
}
|
||||
}
|
||||
if b, err := os.ReadFile(filepath.Join(dir, Agents)); err == nil {
|
||||
s.body = string(b)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// canonical is the one arrangement this package exists to produce.
|
||||
func canonical(body string) state {
|
||||
return state{agentsIsFile: true, claudeIsLink: true, linkTarget: Agents, body: body}
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func symlink(t *testing.T, target, path string) {
|
||||
t.Helper()
|
||||
if err := os.Symlink(target, path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// The seven branches, each named by the state it starts in.
|
||||
func TestEveryBranchIsLosslessOrARefusal(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
// setup builds one directory in the state under test.
|
||||
setup func(t *testing.T, dir string)
|
||||
// want is the state afterwards; a conflict case wants no change at all.
|
||||
want state
|
||||
// fixes and conflicts are how many of each the walk reports.
|
||||
fixes, conflicts int
|
||||
// says is a fragment the one report has to contain, so a message that
|
||||
// stops naming what happened fails here rather than in somebody's
|
||||
// terminal a year from now.
|
||||
says string
|
||||
}{
|
||||
{
|
||||
name: "AGENTS.md alone gets a link beside it",
|
||||
setup: func(t *testing.T, dir string) {
|
||||
writeFile(t, filepath.Join(dir, Agents), "doc\n")
|
||||
},
|
||||
want: canonical("doc\n"),
|
||||
fixes: 1,
|
||||
says: "created symlink",
|
||||
},
|
||||
{
|
||||
name: "CLAUDE.md alone is renamed and linked back",
|
||||
setup: func(t *testing.T, dir string) {
|
||||
writeFile(t, filepath.Join(dir, Claude), "doc\n")
|
||||
},
|
||||
want: canonical("doc\n"),
|
||||
fixes: 1,
|
||||
says: "renamed to " + Agents,
|
||||
},
|
||||
{
|
||||
name: "already canonical is left completely alone",
|
||||
setup: func(t *testing.T, dir string) {
|
||||
writeFile(t, filepath.Join(dir, Agents), "doc\n")
|
||||
symlink(t, Agents, filepath.Join(dir, Claude))
|
||||
},
|
||||
want: canonical("doc\n"),
|
||||
},
|
||||
{
|
||||
name: "a link pointing elsewhere is re-pointed",
|
||||
setup: func(t *testing.T, dir string) {
|
||||
writeFile(t, filepath.Join(dir, Agents), "doc\n")
|
||||
writeFile(t, filepath.Join(dir, "OTHER.md"), "other\n")
|
||||
symlink(t, "OTHER.md", filepath.Join(dir, Claude))
|
||||
},
|
||||
want: canonical("doc\n"),
|
||||
fixes: 1,
|
||||
says: "re-pointed symlink",
|
||||
},
|
||||
{
|
||||
name: "the reversed layout is swapped round",
|
||||
setup: func(t *testing.T, dir string) {
|
||||
writeFile(t, filepath.Join(dir, Claude), "doc\n")
|
||||
symlink(t, Claude, filepath.Join(dir, Agents))
|
||||
},
|
||||
want: canonical("doc\n"),
|
||||
fixes: 1,
|
||||
says: "swapped",
|
||||
},
|
||||
{
|
||||
name: "two real files with one content lose the copy",
|
||||
setup: func(t *testing.T, dir string) {
|
||||
writeFile(t, filepath.Join(dir, Agents), "doc\n")
|
||||
writeFile(t, filepath.Join(dir, Claude), "doc\n")
|
||||
},
|
||||
want: canonical("doc\n"),
|
||||
fixes: 1,
|
||||
says: "replaced with symlink",
|
||||
},
|
||||
{
|
||||
// The one case that must never be resolved: one of the two is
|
||||
// somebody's writing, and no rule here knows which.
|
||||
name: "two real files with different content are refused",
|
||||
setup: func(t *testing.T, dir string) {
|
||||
writeFile(t, filepath.Join(dir, Agents), "mine\n")
|
||||
writeFile(t, filepath.Join(dir, Claude), "theirs\n")
|
||||
},
|
||||
want: state{agentsIsFile: true, body: "mine\n"},
|
||||
conflicts: 1,
|
||||
says: "merge manually",
|
||||
},
|
||||
{
|
||||
name: "a broken link with nothing beside it is reported, not replaced",
|
||||
setup: func(t *testing.T, dir string) {
|
||||
symlink(t, "GONE.md", filepath.Join(dir, Agents))
|
||||
},
|
||||
want: state{},
|
||||
conflicts: 1,
|
||||
says: "broken symlink",
|
||||
},
|
||||
{
|
||||
name: "a CLAUDE.md link to nothing is reported",
|
||||
setup: func(t *testing.T, dir string) {
|
||||
symlink(t, "GONE.md", filepath.Join(dir, Claude))
|
||||
},
|
||||
want: state{claudeIsLink: true, linkTarget: "GONE.md"},
|
||||
conflicts: 1,
|
||||
says: "missing target",
|
||||
},
|
||||
{
|
||||
name: "a directory holding neither is not touched",
|
||||
setup: func(t *testing.T, dir string) {},
|
||||
want: state{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
tc.setup(t, dir)
|
||||
|
||||
res := Sync(dir)
|
||||
if len(res.Fixes) != tc.fixes {
|
||||
t.Errorf("fixes = %d (%v), want %d", len(res.Fixes), res.Fixes, tc.fixes)
|
||||
}
|
||||
if len(res.Conflicts) != tc.conflicts {
|
||||
t.Errorf("conflicts = %d (%v), want %d", len(res.Conflicts), res.Conflicts, tc.conflicts)
|
||||
}
|
||||
if tc.says != "" {
|
||||
all := strings.Join(append(res.Fixes, res.Conflicts...), "\n")
|
||||
if !strings.Contains(all, tc.says) {
|
||||
t.Errorf("no report mentions %q; got:\n%s", tc.says, all)
|
||||
}
|
||||
}
|
||||
if got := read(t, dir); got != tc.want {
|
||||
t.Errorf("after Sync:\n got %+v\nwant %+v", got, tc.want)
|
||||
}
|
||||
|
||||
// A second run must find nothing left to do. A repair that reported
|
||||
// itself fixed and did not converge would loop forever inside a
|
||||
// PreToolUse hook, once per Bash call.
|
||||
if again := Sync(dir); len(again.Fixes) != 0 {
|
||||
t.Errorf("not idempotent — a second run still fixes %v", again.Fixes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check is the same walk with the writes turned off, and the promise it makes is
|
||||
// that the run after it does exactly what it said.
|
||||
func TestCheckReportsWithoutWriting(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFile(t, filepath.Join(dir, Agents), "doc\n")
|
||||
|
||||
before := read(t, dir)
|
||||
res := Check(dir)
|
||||
if len(res.Fixes) != 1 {
|
||||
t.Fatalf("Check found %d fixes, want 1", len(res.Fixes))
|
||||
}
|
||||
if after := read(t, dir); after != before {
|
||||
t.Errorf("Check wrote to the tree: %+v -> %+v", before, after)
|
||||
}
|
||||
|
||||
sync := Sync(dir)
|
||||
if len(sync.Fixes) != len(res.Fixes) || sync.Fixes[0] != res.Fixes[0] {
|
||||
t.Errorf("Sync did not do what Check said:\n check %v\n sync %v", res.Fixes, sync.Fixes)
|
||||
}
|
||||
if !Check(dir).Clean() {
|
||||
t.Error("the tree is still not canonical after Sync")
|
||||
}
|
||||
}
|
||||
|
||||
// Somebody else's tree is somebody else's business. A vendored dependency's
|
||||
// AGENTS.md rewritten here is a diff nobody asked for, and `.git` is not a place
|
||||
// to be creating symlinks.
|
||||
func TestSkippedDirectoriesAreNotTouched(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, skip := range []string{"vendor", "node_modules", ".git", ".claude"} {
|
||||
sub := filepath.Join(dir, skip, "pkg")
|
||||
if err := os.MkdirAll(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, filepath.Join(sub, Agents), "theirs\n")
|
||||
}
|
||||
|
||||
if res := Sync(dir); !res.Clean() {
|
||||
t.Errorf("walked into a skipped directory: %+v", res)
|
||||
}
|
||||
for _, skip := range []string{"vendor", "node_modules", ".git", ".claude"} {
|
||||
p := filepath.Join(dir, skip, "pkg", Claude)
|
||||
if _, err := os.Lstat(p); err == nil {
|
||||
t.Errorf("%s was created inside %s", Claude, skip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The walk is a walk: a document six directories down is as canonical as one at
|
||||
// the top, and the report says where it was.
|
||||
func TestTheWholeTreeIsWalked(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
deep := filepath.Join(dir, "cli", "internal", "issue")
|
||||
if err := os.MkdirAll(deep, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, Agents), "root\n")
|
||||
writeFile(t, filepath.Join(deep, Agents), "issue\n")
|
||||
|
||||
res := Sync(dir)
|
||||
if len(res.Fixes) != 2 {
|
||||
t.Fatalf("fixes = %v, want one per directory", res.Fixes)
|
||||
}
|
||||
joined := strings.Join(res.Fixes, "\n")
|
||||
if !strings.Contains(joined, filepath.Join("cli", "internal", "issue", Claude)) {
|
||||
t.Errorf("the report does not name the nested directory:\n%s", joined)
|
||||
}
|
||||
if got := read(t, deep); got != canonical("issue\n") {
|
||||
t.Errorf("nested directory not canonical: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ one in the tree with no other package below it.
|
||||
|
||||
| file | what is in it |
|
||||
|---|---|
|
||||
| `project.go` | `Marker`, `Anchors`, `Parents`, `GitDirOf`, `MainWorktree`, `Root`, and the paths resolved from it — `StoreRoot`, `PayloadRoot`, `ConfigPath` — plus `NotFoundError` |
|
||||
| `project.go` | `Marker`, `Anchors`, `Parents`, `GitDirOf`, `MainWorktree`, `Root`, and the paths resolved from it — `StoreRoot`, `PayloadRoot`, `ConfigPath`, `ScaffoldPath` — plus `NotFoundError` |
|
||||
| `init.go` | `Init`: creates the marker, migrates an older layout in, gitignores `.kettle/`. `ClashError` is its refusal |
|
||||
| `project_test.go` | the walk, including the worktree hop and the "no marker anywhere" answer |
|
||||
|
||||
@@ -87,6 +87,12 @@ A non-empty `start` overrides both anchors and exists so resolution can be
|
||||
exercised against a scratch tree — which is what the test suite does, and why
|
||||
every fixture also strips `CLAUDE_PROJECT_DIR`.
|
||||
|
||||
`MainWorktree` has a second caller now, and it is the one that made the function
|
||||
worth having in public: `kettle init` refuses to run where it answers, and names
|
||||
the main checkout instead. That rule used to be a paragraph in a skill somebody
|
||||
had to read, which stopped being good enough the moment an interactive wizard
|
||||
became the front door — a front door cannot assume anybody read anything.
|
||||
|
||||
## What does not belong here
|
||||
|
||||
Anything that reads or writes an issue, a config file or a socket. This package
|
||||
|
||||
@@ -32,6 +32,7 @@ var (
|
||||
storeParts = []string{Marker, "issues"}
|
||||
payloadParts = []string{Marker, "payload"}
|
||||
configParts = []string{Marker, "config.yaml"}
|
||||
scaffoldParts = []string{Marker, "scaffold.yaml"}
|
||||
)
|
||||
|
||||
// Anchors are the directories a root search starts from, in order, first hit
|
||||
@@ -200,6 +201,17 @@ func PayloadRoot(start string) string { return under(start, payloadParts) }
|
||||
// ConfigPath is the absolute path of the project's tracker config, or "".
|
||||
func ConfigPath(start string) string { return under(start, configParts) }
|
||||
|
||||
// ScaffoldPath is the absolute path of the record of what was written into the
|
||||
// project's `.claude/` tree, or "".
|
||||
//
|
||||
// A file of its own rather than a field in config.yaml, and the reason is a rule
|
||||
// stated in [config]: an unknown key in config.yaml is an error rather than a
|
||||
// silent drop, so a field added there is a one-way door for a file that may be
|
||||
// committed and read by whatever version each machine happens to have. This
|
||||
// record is written and read by one binary about one directory, so it can carry
|
||||
// that cost where the shared file cannot.
|
||||
func ScaffoldPath(start string) string { return under(start, scaffoldParts) }
|
||||
|
||||
func under(start string, parts []string) string {
|
||||
root := Root(start)
|
||||
if root == "" {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# AGENTS.md — internal/scaffold
|
||||
|
||||
**The documents kettle writes into a project**: the slash commands an operator
|
||||
invokes, the skills a model loads, and the runner subagent. Embedded with
|
||||
`//go:embed`, handed out as bytes.
|
||||
|
||||
**Imports the standard library and nothing else** — and, unusually, is forbidden
|
||||
`os` as well. These documents travel *inside* the binary; one `os.ReadFile` and
|
||||
they are back to being files on a machine that may not have them, which is the
|
||||
entire failure this package exists to make impossible. Two tests hold it, see
|
||||
[`internal/AGENTS.md`](../AGENTS.md).
|
||||
|
||||
| file | what is in it |
|
||||
|---|---|
|
||||
| `scaffold.go` | `Marker`, `File`, `Files`, `Groups`, `PathFor`, `Dirs`, and the `generated` map |
|
||||
| `assets/` | every document, exactly as it is written out |
|
||||
| `scaffold_test.go` | frontmatter, names, the region declaration, determinism |
|
||||
| `layering_test.go` | the two tests that keep this package at the bottom |
|
||||
|
||||
## Why these are not a plugin
|
||||
|
||||
They were, and the plugin shipped on its own release cadence. **Nothing on an
|
||||
operator's machine ever checked that the plugin they had installed described the
|
||||
binary they had installed** — so a renamed flag could ship with documentation
|
||||
recommending the old one, which is the exact failure the generated block was
|
||||
invented to prevent, one hop further downstream.
|
||||
|
||||
Prose that travels inside the binary cannot be a version behind it. That is the
|
||||
whole argument, and everything else here follows from it: the generator writes
|
||||
files whole rather than splicing a region, `kettle init` writes the tree, and
|
||||
`kettle config` reports which build wrote the one on disk.
|
||||
|
||||
What was lost is worth naming. The marketplace was discovery — `/plugin` is a
|
||||
shop window and a README URL is not — and a plugin's `/kettle:…` prefix came free
|
||||
where a project's does not. The second was recoverable and is recovered below;
|
||||
the first was not, and the trade was made anyway, because a document that
|
||||
describes the wrong binary is worse than one nobody found.
|
||||
|
||||
## The layout, and where the namespace went
|
||||
|
||||
```
|
||||
commands/kettle/*.md → /kettle:init, /kettle:auth, /kettle:issue, …
|
||||
skills/kettle-*/SKILL.md → loaded by description, never by name
|
||||
skills/kettle-issue/references/format.md
|
||||
agents/kettle-runner.md
|
||||
```
|
||||
|
||||
A project's skills have **no namespace**: `.claude/skills/` is flat, and a skill
|
||||
called `issue` would collide with every other tool's. So the prefix is spelled
|
||||
into the directory name — `kettle-issue`, not `issue` — and the `name:` in the
|
||||
frontmatter matches it, because the harness resolves one from the other.
|
||||
|
||||
**Commands do have a namespace, and it is free.** A subdirectory under
|
||||
`.claude/commands/` becomes the prefix, so `commands/kettle/init.md` is
|
||||
`/kettle:init` — the same spelling the plugin had, with no plugin. Every
|
||||
`/kettle:…` in the embedded prose is still true because of this, which is why
|
||||
four of the six command files exist at all: `issue`, `sync`, `api` and `project`
|
||||
are three lines each, pointing at the skill that holds the substance. They are
|
||||
not duplication; they are the reason 1,600 lines of cross-references did not have
|
||||
to be rewritten.
|
||||
|
||||
**`init` and `auth` are commands with no skill.** That is not an omission. They
|
||||
were skills carrying `disable-model-invocation: true` — a frontmatter flag saying
|
||||
"an operator invokes this, not a model". A command *is* operator-invoked, so the
|
||||
flag disappeared into the shape of the thing. `init` in particular must not be
|
||||
routable: which directory is the project is the one question this binary exists
|
||||
to have a person answer.
|
||||
|
||||
## The generated region
|
||||
|
||||
Four documents carry `<!-- kettle:gen -->` markers around a flag table rendered
|
||||
from the command registry. Which four is declared in the `generated` map, and
|
||||
declared rather than derived from the directory name — the group ⇄ skill
|
||||
correspondence is a decision, and one that has never been one-to-one: `project`
|
||||
is a single skill covering five commands, and `init` and `auth` are commands
|
||||
inside it with no skill of their own.
|
||||
|
||||
`scaffold_test.go` asserts the map and the markers agree in both directions, and
|
||||
[`internal/cmd`](../cmd/AGENTS.md) asserts every group in the registry is named
|
||||
here. A group added without a document would have its commands silently written
|
||||
nowhere — the generator walks the documents, not the registry.
|
||||
|
||||
**This package does not render.** It hands out embedded bytes and says which of
|
||||
them carry a region; the splice is `internal/cmd`'s, because the registry is. The
|
||||
split is what keeps the import arrow pointing one way.
|
||||
|
||||
## Bootstrapping, which is circular and is meant to be
|
||||
|
||||
`assets/` holds the block already spliced in — the files here are what a project
|
||||
gets, byte for byte. So:
|
||||
|
||||
```bash
|
||||
make check # gen scaffold --check, against assets/
|
||||
dist/kettle gen scaffold --out internal/scaffold/assets # the fix
|
||||
```
|
||||
|
||||
A flag change makes the render differ from the embedded copy, `--check` exits 1,
|
||||
regenerating updates `assets/`, and the binary has to be **rebuilt** to pick the
|
||||
new bytes up. That loop is the same one the plugin's SKILL.md files were in; what
|
||||
changed is that its output now ships with the binary rather than beside it.
|
||||
|
||||
## Editing a document
|
||||
|
||||
Edit it here, under `assets/`, then `make check`. Never edit the copy in a
|
||||
project: it is replaced whole on the next `kettle gen scaffold`, and the run says
|
||||
so before it does it.
|
||||
|
||||
The one line no generator may write is `description:` — it is what decides
|
||||
whether a model loads a skill at all, and it is prose tuned against real
|
||||
failures. `scaffold_test.go` fails on an empty one; nothing checks that it is
|
||||
*good*, and nothing can.
|
||||
|
||||
## Keeping this file true
|
||||
|
||||
- **Scope:** `scaffold.go`, everything under `assets/`, and the two seams — the
|
||||
`generated` map, and the commands ⇄ skills split.
|
||||
- **Update it when** a document is added or removed, a group gains or loses its
|
||||
file, the output layout changes, or the argument for embedding rather than
|
||||
shipping a plugin stops being the argument.
|
||||
- **Do not** restate what any individual document says. Each one is its own
|
||||
procedure and says it in full.
|
||||
+8
-4
@@ -23,6 +23,7 @@ Load the skill, do not remember the flags:
|
||||
`sync-evict`
|
||||
- `/kettle:issue` — `new`, `check`, `ac`, `tree`, `index`, `evict`
|
||||
- `/kettle:project` — `config`, `auth list`
|
||||
- `/kettle:api` — `api`, for the Gitea entities that have no command of their own
|
||||
|
||||
Invoke `Skill` with the one that owns the task at the start and use the generated
|
||||
command reference it carries verbatim. That block is written from the binary's
|
||||
@@ -33,10 +34,13 @@ truth if you need it in a hurry.
|
||||
|
||||
## Hard rules
|
||||
|
||||
1. **`kettle` only.** No raw `tea`, no `tea api`, no curl at a tracker. The
|
||||
binary carries the project's credentials; there is no login for you to name
|
||||
and none for you to choose. If a task needs an entity `kettle` does not cover,
|
||||
that is a finding for the caller, not a command for you to improvise.
|
||||
1. **`kettle` only.** No curl at a tracker, no other CLI, no request you composed
|
||||
yourself. The binary carries the project's credentials; there is no login for
|
||||
you to name and none for you to choose. `kettle api` is a kettle command and
|
||||
is yours to run **as the caller spelled it** — endpoint, method and body come
|
||||
from the task, and a `-X DELETE` is never something you add. An entity nobody
|
||||
named an endpoint for is a finding for the caller, not a request for you to
|
||||
improvise.
|
||||
2. **No writing to issue files.** You have no `Edit` and no `Write`. Commands
|
||||
write files; you do not. If a task needs a body edited or a metadata field
|
||||
changed by hand, stop and say which file and which field. `kettle ac` is the
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
description: Reach everything Gitea has that is not an issue — pull requests, releases, tags, milestones, branches, commits, actions, webhooks — with `kettle api`.
|
||||
argument-hint: "[the endpoint, or what you want from the tracker]"
|
||||
---
|
||||
|
||||
Load the **`kettle-api`** skill and follow it. Which endpoint answers which
|
||||
question, which of them paginate, and what `-X DELETE` requires are all mapped
|
||||
there.
|
||||
|
||||
$ARGUMENTS
|
||||
|
||||
One request, under the login this project already pins — no second tool and no
|
||||
second credential. Issues are not handled here: `/kettle:issue` works on them
|
||||
offline and `/kettle:sync` moves them.
|
||||
+46
-14
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: auth
|
||||
description: Give `kettle` a Gitea credential and choose which login this project runs under — `kettle auth list/add/remove` manages the machine-wide token file, `kettle init --login <name>` pins one of those names into `.kettle/config.yaml`. Load when a sync command reports no login, a 401, or an unknown login name, or when the user asks to switch the account this project's issues are pushed under. The OPERATOR picks the login; you never type a token.
|
||||
description: Give `kettle` a Gitea credential and choose which login this project runs under — `kettle auth list/add/remove` manages the machine-wide token file, `kettle init --login <name>` pins one of those names into `.kettle/config.yaml`. Invoke when a sync command reports no login, a 401, or an unknown login name, or to switch the account this project's issues are pushed under. The OPERATOR picks the login; you never type a token.
|
||||
allowed-tools: Bash(kettle auth list:*), Bash(kettle config:*)
|
||||
---
|
||||
|
||||
# /kettle:auth — the credential a project runs under
|
||||
@@ -19,10 +19,11 @@ eventually, and a secret that has ever been pushed has to be rotated.
|
||||
`KETTLE_LOGIN`, `KETTLE_URL` and `KETTLE_TOKEN` override it outright, which is
|
||||
how CI runs with no token on disk.
|
||||
|
||||
**There is no login pinned in `.claude/settings.local.json` any more, and no hook
|
||||
that rewrites a `--login` argument.** That mechanism is gone with the Python
|
||||
scripts; nothing here reads Claude's settings. If you find a `GITEA_LOGIN` in a
|
||||
settings file, it is dead weight from the old plugin.
|
||||
**No credential is pinned in `.claude/` anywhere, and no hook rewrites a
|
||||
`--login` argument.** That mechanism is gone with the Python scripts. If you find
|
||||
a `GITEA_LOGIN` in a settings file, it is dead weight from the old plugin — the
|
||||
only thing `kettle` ever writes into `.claude/settings.json` is the optional
|
||||
`AGENTS.md` mirror hook, which holds no identity of any kind.
|
||||
|
||||
## The one hard rule: the operator chooses, and holds the token
|
||||
|
||||
@@ -47,11 +48,16 @@ settings file, it is dead weight from the old plugin.
|
||||
2. **Nothing there** — stop and hand the operator the command to run themselves:
|
||||
|
||||
```bash
|
||||
kettle init --interactive # prompts for it, echo off
|
||||
kettle auth add --name noodles --url https://git.example.com --user naudachu < token.txt
|
||||
pass show gitea/token | kettle auth add --name noodles --url https://git.example.com
|
||||
```
|
||||
|
||||
`--user` is documentation only. `kettle auth remove <name>` forgets one.
|
||||
The first is the one to suggest to a person at a terminal: it reads the token
|
||||
with the echo turned off, so it is in no history, no file and no scrollback.
|
||||
**It is not a command for you to run** — it refuses a stdin that is not a
|
||||
terminal, and that refusal is correct. `--user` is documentation only, and
|
||||
`kettle auth remove <name>` forgets one.
|
||||
|
||||
3. **Pin the choice into the project.** Ask `kettle config` first and only
|
||||
proceed if it answers with a project — `kettle init` in a directory that is
|
||||
@@ -85,14 +91,40 @@ repo claude-skills/marketplace
|
||||
| `401` / `403` from a sync command | report it verbatim. Do **not** try another login, and do not edit or remove one to route around it — that is somebody's identity, not a setting |
|
||||
| `token none` in `kettle config` | a name is pinned but no credential answers to it |
|
||||
|
||||
**`tea` does not read any of this.** The `tea` CLI keeps its own configuration
|
||||
under `$XDG_CONFIG_HOME/tea` and its own logins (`tea logins list`), and
|
||||
configuring one tool configures nothing in the other — see `/kettle:use`.
|
||||
## Scopes: what the token is allowed to do
|
||||
|
||||
**No `kettle` on PATH?** `command not found: kettle` is the whole story. Stop and
|
||||
tell the operator to install it: `cd cli && go build -o ~/.local/bin/kettle
|
||||
./cmd/kettle` in the marketplace repository (go.mod requires **go 1.26**), or
|
||||
`go install git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest`.
|
||||
Gitea mints a token with scopes, spelled `<read|write>:<category>`. A token made
|
||||
for issues carries `write:issue` — and that is enough for everything
|
||||
`/kettle:issue` and `/kettle:sync` do, and **not** enough for anything
|
||||
`/kettle:api` reaches: releases, pull requests, branches, tags and actions all
|
||||
sit under `repository`.
|
||||
|
||||
| doing | needs |
|
||||
|---|---|
|
||||
| pull, push, comment, close, evict | `write:issue` |
|
||||
| `kettle labels` | `write:issue` |
|
||||
| `kettle api` on releases, PRs, tags, branches, actions | `write:repository` too |
|
||||
| reading any of those without writing | the `read:` half is enough |
|
||||
|
||||
`kettle auth add --scopes write:issue,write:repository` writes that down beside
|
||||
the login. **It is a note and nothing else** — 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 rather
|
||||
than a token, so a token cannot be asked what it may do. `kettle auth list` and
|
||||
`kettle config` show what was recorded; `(not recorded)` means nobody wrote it
|
||||
down, never "none".
|
||||
|
||||
A **403** from a sync command or from `kettle api` is usually this and says so.
|
||||
Minting a new token is the operator's job in the web UI — the same flow as step
|
||||
2 above, with both scopes ticked this time. Never remove or re-point a login to
|
||||
route around a 403.
|
||||
|
||||
**No `kettle` on PATH?** `command not found: kettle` is the whole story — and it
|
||||
means the binary that wrote this file has since moved or been removed, because
|
||||
nothing else could have put it here. Stop and tell the operator to reinstall it:
|
||||
`go install git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest`, or
|
||||
`cd cli && make install` in a checkout of that repository (go.mod requires
|
||||
**go 1.26**).
|
||||
|
||||
The full flag table for `auth`, `config` and `init` is the generated block in
|
||||
`/kettle:project`.
|
||||
+36
-25
@@ -1,8 +1,6 @@
|
||||
---
|
||||
name: init
|
||||
description: Make THIS directory a project that tracks issues — run `kettle init`, which creates the `.kettle/` marker every other command resolves the store from, migrates an older store in, and gitignores it. Operator-invoked only; carries the rules the binary cannot enforce — never inside a linked worktree, never an `--at` nobody named, never a migration clash resolved for them.
|
||||
argument-hint: "[--at DIR] [--login NAME] [--repo owner/name] [--dry-run]"
|
||||
disable-model-invocation: true
|
||||
description: Make THIS directory a project that tracks issues — run `kettle init`, which creates the `.kettle/` marker every other command resolves the store from, migrates an older store in, gitignores it, and writes this command file and its skills. Carries the rules the binary cannot enforce — never an `--at` nobody named, never a migration clash resolved for them.
|
||||
argument-hint: "[--at DIR] [--interactive] [--login NAME] [--repo owner/name] [--dry-run]"
|
||||
allowed-tools: Bash(kettle init:*), Bash(kettle config:*), Bash(git rev-parse:*)
|
||||
---
|
||||
|
||||
@@ -14,24 +12,17 @@ and a tool that inferred its root from one wrote other projects' issues into its
|
||||
own versioned cache. It is answered once, by a person, and every command
|
||||
downstream reads the answer instead of guessing.
|
||||
|
||||
The binary does the work and is idempotent. What this skill carries is the three
|
||||
things it cannot decide for itself.
|
||||
**This is a command, not a skill, and that is the point.** It is invoked by an
|
||||
operator who typed it. There is no `description:` here for a model to match on,
|
||||
because "which directory is the project" is not a question a model gets to answer
|
||||
on its own.
|
||||
|
||||
The binary does the work and is idempotent. What this file carries is what it
|
||||
cannot decide.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Refuse inside a linked worktree.** Two different paths mean one:
|
||||
|
||||
```bash
|
||||
git rev-parse --path-format=absolute --git-dir --git-common-dir
|
||||
```
|
||||
|
||||
Stop and say so. `.kettle/` is gitignored, so a worktree has no marker by
|
||||
design and reaches the main checkout's store on its own — the walk crosses to
|
||||
it through the `gitdir:` in the `.git` *file*. A marker here gives one project
|
||||
two stores, and the second is deleted with the branch. If anything needs
|
||||
initializing it is the main checkout, which is the second path's parent.
|
||||
|
||||
2. Run it, passing the operator's arguments through unchanged:
|
||||
1. Run it, passing the operator's arguments through unchanged:
|
||||
|
||||
```bash
|
||||
kettle init $ARGUMENTS
|
||||
@@ -42,7 +33,7 @@ things it cannot decide for itself.
|
||||
question this command exists to have a person answer; picking a plausible one
|
||||
is the failure the marker replaces.
|
||||
|
||||
3. Report what it printed, verbatim. `already initialized — nothing to do` is a
|
||||
2. Report what it printed, verbatim. `already initialized — nothing to do` is a
|
||||
success, not something to work around.
|
||||
|
||||
`--login` pins a login by name and `--repo` names the tracker repository; both
|
||||
@@ -50,8 +41,25 @@ are optional and both can be added later by running `init` again — it writes t
|
||||
config without disturbing settings it was not given. Neither is a credential:
|
||||
the tokens live in one file per machine, `/kettle:auth`.
|
||||
|
||||
## `--interactive` is for a person at a terminal
|
||||
|
||||
It walks the operator through the login, the repository, this `.claude/` tree and
|
||||
the `AGENTS.md` mirror hook, one question at a time.
|
||||
|
||||
**Do not run it.** It refuses a stdin that is not a terminal and says so, which is
|
||||
the right failure — but the right move is not to reach for it in the first place.
|
||||
Every question it asks has a flag: `--login`, `--repo`, `--scaffold`,
|
||||
`--mirror-hook`. Pass the flags the operator named and let the binary answer the
|
||||
rest.
|
||||
|
||||
## When it stops
|
||||
|
||||
- **A linked worktree.** The binary refuses, and it is right to: `.kettle/` is
|
||||
gitignored, so a worktree has no marker by design and reaches the main
|
||||
checkout's store on its own — the walk crosses to it through the `gitdir:` in
|
||||
the `.git` *file*. A marker here would give one project two stores, and the
|
||||
second is deleted with the branch. What needs initializing, if anything, is the
|
||||
main checkout, and the refusal names it.
|
||||
- **A name clash on the migration** — the same file name on both sides. It exits
|
||||
having changed nothing and names the files. Report that. Do **not** move,
|
||||
delete, or merge either side: one of them may be an `origin: local` issue,
|
||||
@@ -62,10 +70,12 @@ the tokens live in one file per machine, `/kettle:auth`.
|
||||
before going ahead; usually they are standing in a subdirectory and there is
|
||||
nothing to do.
|
||||
|
||||
**No `kettle` on PATH?** `command not found: kettle` is the whole story. Stop and
|
||||
tell the operator to install it: `cd cli && go build -o ~/.local/bin/kettle
|
||||
./cmd/kettle` in the marketplace repository (go.mod requires **go 1.26**), or
|
||||
`go install git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest`.
|
||||
**No `kettle` on PATH?** `command not found: kettle` is the whole story — and it
|
||||
means the binary that wrote this file has since moved or been removed, because
|
||||
nothing else could have put it here. Stop and tell the operator to reinstall it:
|
||||
`go install git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest`, or
|
||||
`cd cli && make install` in a checkout of that repository (go.mod requires
|
||||
**go 1.26**).
|
||||
|
||||
## After
|
||||
|
||||
@@ -74,6 +84,7 @@ tell the operator to install it: `cd cli && go build -o ~/.local/bin/kettle
|
||||
runs under; needed only for the tracker side, `/kettle:sync`.
|
||||
- `kettle config` prints every path and setting this directory resolved to, and
|
||||
is the first thing to run when something looks like it landed in the wrong
|
||||
place.
|
||||
place. It also reports when this `.claude/` tree was written by an older
|
||||
`kettle` than the one now installed.
|
||||
|
||||
The full flag table for `init` is the generated block in `/kettle:project`.
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
description: Work on this project's issues offline — create, read, grep, validate, tick checkboxes, evict, and walk the dependency graph.
|
||||
argument-hint: "[what you want to do with an issue]"
|
||||
---
|
||||
|
||||
Load the **`kettle-issue`** skill and follow it. Everything about what an issue is
|
||||
— the format, the taxonomy, validation, checkboxes, the dependency graph — lives
|
||||
there, along with the canonical format reference it points at.
|
||||
|
||||
$ARGUMENTS
|
||||
|
||||
This half is entirely offline: issues are markdown files under
|
||||
`<project>/.kettle/issues/` and need no login and no network. Moving them to or
|
||||
from the tracker is `/kettle:sync`.
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
description: Look up the exact flags and defaults of the project-level kettle commands — init, auth, config, gen, mirror.
|
||||
argument-hint: "[the command whose flags you want]"
|
||||
---
|
||||
|
||||
Load the **`kettle-project`** skill and follow it. It carries the generated flag
|
||||
table for every project-level command, written from the registry the binary was
|
||||
built from.
|
||||
|
||||
$ARGUMENTS
|
||||
|
||||
`kettle help <command>` prints the same thing in a terminal, and is the faster
|
||||
answer when the operator is already in one.
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
description: Move issues between this project's local store and its Gitea tracker — pull, push, list, comment, close, evict.
|
||||
argument-hint: "[what to pull, push, comment on or close]"
|
||||
---
|
||||
|
||||
Load the **`kettle-sync`** skill and follow it. The round trip and the rule that
|
||||
holds it — the store keeps what has not left this machine, so a successful push
|
||||
deletes the local file — are stated there in full.
|
||||
|
||||
$ARGUMENTS
|
||||
|
||||
What an issue *is* belongs to `/kettle:issue` and needs no network. Everything
|
||||
Gitea has that is not an issue is `/kettle:api`.
|
||||
@@ -0,0 +1,246 @@
|
||||
---
|
||||
name: kettle-api
|
||||
description: Everything Gitea has that is not an issue — pull requests, releases, tags, branches, milestones, labels, commits, actions, webhooks, notifications, tracked times, users, repositories — reached with `kettle api`, one endpoint per request, under the login the project already pins. Load when the user asks to open or review a PR, cut or edit a release, make a milestone or a tag, look at branches or commits, read notifications or actions, or hit any Gitea endpoint by hand. Issues are NOT handled here: /kettle:issue works on them offline and /kettle:sync moves them to and from the tracker.
|
||||
---
|
||||
|
||||
# /kettle:api — Gitea beyond issues
|
||||
|
||||
`kettle api <endpoint>` is one authenticated request to the Gitea this project is
|
||||
pinned to. No second tool, no second login: the URL, the token and the repository
|
||||
are the ones `/kettle:auth` and `kettle init` already resolved, and the request
|
||||
body is filed under `.kettle/payload/` like every other request kettle makes.
|
||||
|
||||
This skill is the map of what to ask for. `kettle help api` is the flag
|
||||
reference, and it is generated from the binary — the block at the bottom of this
|
||||
file is the same text.
|
||||
|
||||
## Issues are somewhere else
|
||||
|
||||
`kettle api` can reach an issue and must not be used to. An issue read this way
|
||||
comes back as a full JSON payload — every label object, every URL, the whole
|
||||
comment thread — which is precisely what the other two skills exist to keep out
|
||||
of the context window.
|
||||
|
||||
| Skill | Scope |
|
||||
|---|---|
|
||||
| `/kettle:issue` | issues as units of work — create, read, grep, validate, tick, dependency graph. Offline. |
|
||||
| `/kettle:sync` | moving issues between the local store and the tracker — pull, push, comment, close, evict. |
|
||||
|
||||
The one exception is an issue endpoint that is not about the issue's content:
|
||||
`issues/{n}/comments` is also **a pull request's** comment thread, and
|
||||
`PATCH issues/{n}` is also how a pull request's title and body are edited. Gitea
|
||||
numbers issues and pull requests in one sequence and serves both under
|
||||
`/issues/`.
|
||||
|
||||
## What is a command and what is a request
|
||||
|
||||
Reach for the command where there is one: it knows the format, the store and the
|
||||
round trip. Everything else is an endpoint.
|
||||
|
||||
| Subject | How to reach it | Pages? |
|
||||
|---|---|---|
|
||||
| issues (create, read, tick, validate) | `/kettle:issue` — offline, no request at all | — |
|
||||
| issues (pull, push, comment, close, evict) | `/kettle:sync` | handled |
|
||||
| the canonical `type/*` and `severity/*` labels | `kettle labels` | handled |
|
||||
| this repository's own releases, with binaries | `cd cli && make release TAG=v1.2.3` | — |
|
||||
| everything below | `kettle api` | see the column |
|
||||
|
||||
| Entity | Endpoint | Pages? |
|
||||
|---|---|---|
|
||||
| pull requests | `repos/{owner}/{repo}/pulls` | **yes** |
|
||||
| one pull request | `repos/{owner}/{repo}/pulls/{n}` | no |
|
||||
| create a pull request | `POST repos/{owner}/{repo}/pulls` | no |
|
||||
| edit a PR's title or body | `PATCH repos/{owner}/{repo}/issues/{n}` | no |
|
||||
| a PR's or issue's comments | `repos/{owner}/{repo}/issues/{n}/comments` | **yes** |
|
||||
| edit one comment | `PATCH repos/{owner}/{repo}/issues/comments/{id}` | no |
|
||||
| reviews on a PR | `repos/{owner}/{repo}/pulls/{n}/reviews` | **yes** |
|
||||
| merge a PR | `POST repos/{owner}/{repo}/pulls/{n}/merge` | no |
|
||||
| releases | `repos/{owner}/{repo}/releases` | **yes** |
|
||||
| one release by tag | `repos/{owner}/{repo}/releases/tags/{tag}` | no |
|
||||
| tags | `repos/{owner}/{repo}/tags` | **yes** |
|
||||
| branches | `repos/{owner}/{repo}/branches` | **yes** |
|
||||
| commits | `repos/{owner}/{repo}/commits` | **yes** |
|
||||
| milestones | `repos/{owner}/{repo}/milestones` | **yes** |
|
||||
| labels (all of them, not just canonical) | `repos/{owner}/{repo}/labels` | **yes** |
|
||||
| webhooks | `repos/{owner}/{repo}/hooks` | **yes** |
|
||||
| action tasks | `repos/{owner}/{repo}/actions/tasks` | **yes** |
|
||||
| tracked times | `repos/{owner}/{repo}/times` | **yes** |
|
||||
| the repository itself | `repos/{owner}/{repo}` | no |
|
||||
| notifications | `notifications` | **yes** |
|
||||
| who this token is | `user` | no |
|
||||
| an organization's repositories | `orgs/{org}/repos` | **yes** |
|
||||
|
||||
`{owner}` and `{repo}` are filled in from the project's configuration. A path
|
||||
that names a repository in full is left alone — `repos/other-owner/other/releases`
|
||||
reads another repository on the same instance, which is why there is no `--repo`
|
||||
flag. Another **instance** is `KETTLE_URL` and `KETTLE_TOKEN`, not a flag.
|
||||
|
||||
What the instance actually serves is its own version's business; its API docs at
|
||||
`<instance-url>/api/swagger` are the authority when an endpoint answers 404.
|
||||
|
||||
## Pagination is yours
|
||||
|
||||
**One invocation is one HTTP request.** `kettle api` never follows a list to its
|
||||
end, because a passthrough that silently stitched pages together would report as
|
||||
one answer something that was several.
|
||||
|
||||
So for every row marked **yes** above:
|
||||
|
||||
```bash
|
||||
kettle api 'repos/{owner}/{repo}/pulls?state=open&limit=50' # first page, 50 rows
|
||||
kettle api 'repos/{owner}/{repo}/pulls?state=open&limit=50&page=2'
|
||||
```
|
||||
|
||||
- `limit` is capped by the instance (`MAX_RESPONSE_ITEMS`, 50 by default); the
|
||||
default page size is 30.
|
||||
- **A short page is the last one.** Ask for 50, count what came back: fewer than
|
||||
50 means there is no page 3. That is the same rule the binary's own listings
|
||||
use, and it needs no response headers.
|
||||
- Quote any endpoint holding `?` or `&`, or the shell takes it apart.
|
||||
- Walking many pages of anything into your own context is a mistake before it is
|
||||
a request. Narrow the query (`state=`, `since=`, `q=`), or pipe through `jq`
|
||||
and keep the two fields you needed.
|
||||
|
||||
## Writing a body
|
||||
|
||||
Two ways, and the choice is about the body, not the endpoint:
|
||||
|
||||
```bash
|
||||
# small and flat: every value is a string
|
||||
kettle api --field body=lgtm repos/{owner}/{repo}/issues/7/comments
|
||||
|
||||
# anything real — multi-line, markdown, booleans, numbers, nesting
|
||||
mkdir -p tmp/release
|
||||
cat > tmp/release/v0-2-0.json <<'EOF'
|
||||
{"tag_name": "v0.2.0", "name": "v0.2.0", "draft": false,
|
||||
"body": "## Changes\n\nMulti-line markdown with `code`."}
|
||||
EOF
|
||||
kettle api --data @tmp/release/v0-2-0.json repos/{owner}/{repo}/releases
|
||||
```
|
||||
|
||||
- A body implies `POST`; anything else is `-X PUT`, `-X PATCH`, `-X DELETE`.
|
||||
- Newlines inside a JSON string are `\n`. Composing from a file:
|
||||
`jq -Rs '{body: .}' < body.md > tmp/pull/x.json`.
|
||||
- `--field` values are **always strings**. A `draft: false` or a number is a
|
||||
`--data` body — guessing types is how a `tag_name` of `1.0` goes up as a
|
||||
number.
|
||||
- Keep `tmp/` gitignored and keep the file: a `PATCH` is usually the same body
|
||||
with one line changed. `kettle` files its own copy under `.kettle/payload/`
|
||||
automatically; that directory is the transport's and nothing hand-made goes in
|
||||
it.
|
||||
- Attachments are `multipart/form-data` and this command sends JSON. Upload
|
||||
release binaries with the release tooling (`make release`), or the web UI.
|
||||
|
||||
## Deleting
|
||||
|
||||
`-X DELETE` needs `--yes` in the same invocation, and the refusal happens before
|
||||
anything is sent:
|
||||
|
||||
```bash
|
||||
kettle api -X DELETE --yes repos/{owner}/{repo}/releases/12
|
||||
```
|
||||
|
||||
That flag is the whole gate. **Whether a thing should be deleted is the
|
||||
operator's call, not a step in a plan** — ask, do not assume, and never widen a
|
||||
deletion past what was named.
|
||||
|
||||
## When it says 403
|
||||
|
||||
Gitea scopes a token as `<read|write>:<category>`, and a token minted to file
|
||||
issues carries `write:issue` and nothing else. Releases, pull requests, branches,
|
||||
tags and actions are all `repository`, so that token answers **403 on the first
|
||||
`kettle api` outside issues** — and the 403 names no scope.
|
||||
|
||||
`kettle auth list` shows what each login on this machine recorded; `kettle config`
|
||||
shows what this project resolved. Nothing can be read back off the instance
|
||||
(Gitea's own token listing needs a password, not a token), so a scope that was
|
||||
never written down is a scope nobody knows. Minting a new token is the operator's
|
||||
job, in the web UI — `/kettle:auth` has the procedure.
|
||||
|
||||
## What is not an API call at all
|
||||
|
||||
| Want | Do |
|
||||
|---|---|
|
||||
| check out a PR branch, clone, push | `git`. This is git's job and always was |
|
||||
| who am I | `kettle api user` |
|
||||
| open something in a browser | nothing here; hand the user the URL |
|
||||
| add a login, list logins, ssh keys | `/kettle:auth`, and adding one is the operator's |
|
||||
| administer users or the instance | nothing here. Not an agent's work |
|
||||
|
||||
The canonical issue format lives in
|
||||
[`../issue/references/format.md`](../issue/references/format.md) — it describes
|
||||
local files, not requests.
|
||||
|
||||
<!-- kettle:gen -->
|
||||
**Generated from the kettle command registry by `kettle gen scaffold`.** Everything between the two markers is replaced on the next run — the prose around it is embedded in the binary and replaced with it.
|
||||
|
||||
## `kettle api <endpoint>`
|
||||
|
||||
one request to this project's Gitea, for everything that is not an issue
|
||||
|
||||
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.
|
||||
|
||||
| flag | default | what it does |
|
||||
| --- | --- | --- |
|
||||
| `--X` | — | the same flag as --method, spelled the way curl and the tool this replaces spell it |
|
||||
| `--data` | — | the request body: @file, @- for standard input, or the JSON itself |
|
||||
| `--field` | — | key=value, added to a JSON body as a string; repeatable |
|
||||
| `--method` | — | GET, POST, PUT, PATCH or DELETE (default GET, or POST when there is a body) |
|
||||
| `--status` | `false` | print the status line on standard error |
|
||||
| `--yes` | `false` | confirm a DELETE |
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
<!-- /kettle:gen -->
|
||||
+4
-3
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: issue
|
||||
name: kettle-issue
|
||||
description: Work with this project's issues as units of work — create, read, grep, validate, tick checkboxes, evict closed ones, and walk their dependency graph, with the `kettle` binary's offline commands (new, check, ac, tree, index, evict). Entirely offline; issues are local markdown files in `.kettle/issues/` and need no tracker, no login and no network. Load when the user asks to file or create an issue, read or find issues, check one against the format, or see what depends on what. Pushing to or pulling from Gitea is /kettle:sync.
|
||||
---
|
||||
|
||||
@@ -20,7 +20,8 @@ an issue. It is the single source of truth for identity, metadata, types,
|
||||
labels, templates, and language rules.
|
||||
|
||||
**No `kettle` on PATH?** `command not found: kettle` is the whole story — the
|
||||
Python scripts this plugin used to ship are gone and `tea` is not a substitute.
|
||||
Python scripts this plugin used to ship are gone and no other CLI is a
|
||||
substitute.
|
||||
Stop and tell the operator to install it: `cd cli && go build -o
|
||||
~/.local/bin/kettle ./cmd/kettle` in the marketplace repository (go.mod requires
|
||||
**go 1.26**), or `go install
|
||||
@@ -241,7 +242,7 @@ with the binary; `kettle help <command>` prints the same text. Editing them here
|
||||
changes nothing.
|
||||
|
||||
<!-- kettle:gen -->
|
||||
**Generated from the kettle command registry by `kettle gen skills`.** Everything between the two markers is replaced on the next run — hand-written prose belongs outside them.
|
||||
**Generated from the kettle command registry by `kettle gen scaffold`.** Everything between the two markers is replaced on the next run — the prose around it is embedded in the binary and replaced with it.
|
||||
|
||||
## `kettle ac <id>`
|
||||
|
||||
+2
-2
@@ -43,10 +43,10 @@ milestone: v0.2
|
||||
depends: [migrate-schema]
|
||||
origin: gitea
|
||||
branch: feat/wire-sqlc
|
||||
gitea: claude-skills/tea#42
|
||||
gitea: claude-skills/marketplace#42
|
||||
remote-updated: 2026-08-09T18:24:01Z
|
||||
synced: 2026-08-09T18:40:00Z
|
||||
url: https://git.noodles.cam/claude-skills/tea/issues/42
|
||||
url: https://git.noodles.cam/claude-skills/marketplace/issues/42
|
||||
---
|
||||
# Wire sqlc into the appclick repo layer
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
---
|
||||
name: kettle-project
|
||||
description: Generated flag reference for the project-level `kettle` commands — `kettle init`, `kettle auth`, `kettle config`, `kettle gen`, `kettle mirror`. Load it to look up the exact flags and defaults of one of those, or when a command answers "no project" / "no login" and you need `kettle config` to say what this directory resolved to. The rules around initializing are /kettle:init and the credential workflow is /kettle:auth; this file is the flag table both of them point at.
|
||||
---
|
||||
|
||||
# kettle project — the project itself
|
||||
|
||||
`kettle` resolves everything from one marker. `<project>/.kettle/` is created by
|
||||
`kettle init` and never inferred: `.git` is in every clone, so a tool that
|
||||
guessed a root from one would write issues into whatever tree it happened to be
|
||||
standing in. With no marker anywhere the command stops and names the directories
|
||||
it searched — that is an answer, not a fallback.
|
||||
|
||||
Two configuration files, and the split is the point. `<project>/.kettle/config.yaml`
|
||||
holds the tracker repository and the **name** of a login; the name is worth
|
||||
nothing on its own, which is what makes it safe inside a working tree.
|
||||
`~/.config/kettle/logins.yaml` (0600, one per machine, `$KETTLE_CONFIG_HOME` or
|
||||
`$XDG_CONFIG_HOME` move it) holds the tokens. `KETTLE_LOGIN`, `KETTLE_REPO`,
|
||||
`KETTLE_URL` and `KETTLE_TOKEN` each override the file they shadow.
|
||||
|
||||
**No `kettle` on PATH?** `command not found: kettle` is the whole story — no
|
||||
script and no other CLI substitutes for it, and it means the binary that wrote
|
||||
this file has since moved or been removed, because nothing else could have put it
|
||||
here. Stop and tell the operator to reinstall it:
|
||||
`go install git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest`, or
|
||||
`cd cli && make install` in a checkout of that repository (go.mod requires
|
||||
**go 1.26**).
|
||||
|
||||
**This file is written by the binary and is replaced whole on the next
|
||||
`kettle gen scaffold`.** Every word of it — the prose as much as the flag table
|
||||
below — is embedded in the `kettle` that wrote it, which is why the two cannot
|
||||
disagree about a flag. Editing it here changes nothing durable; the fix for a
|
||||
wrong sentence is a newer `kettle`, and the fix for a stale one is
|
||||
`kettle gen scaffold`. `kettle config` says which version wrote this tree.
|
||||
|
||||
<!-- kettle:gen -->
|
||||
**Generated from the kettle command registry by `kettle gen scaffold`.** Everything between the two markers is replaced on the next run — the prose around it is embedded in the binary and replaced with it.
|
||||
|
||||
## `kettle auth list | add | remove <name>`
|
||||
|
||||
manage the tokens this machine holds
|
||||
|
||||
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 <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.
|
||||
|
||||
| flag | default | what it does |
|
||||
| --- | --- | --- |
|
||||
| `--name` | — | login name (add) |
|
||||
| `--scopes` | — | what the token was minted with, comma separated, e.g. write:issue,write:repository; documentation only (add) |
|
||||
| `--token` | — | token, if you would rather not use stdin (add) |
|
||||
| `--url` | — | instance URL, e.g. https://git.example.com (add) |
|
||||
| `--user` | — | account this token belongs to; documentation only (add) |
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
## `kettle config`
|
||||
|
||||
show what this project resolved to
|
||||
|
||||
Every path and every setting, with the overrides already applied, so a run that
|
||||
went somewhere unexpected can be explained without guessing.
|
||||
|
||||
The token is never printed — only whether one was found.
|
||||
|
||||
This is the command to reach for when the store looks empty, when a push says
|
||||
401, or when two directories disagree about which project they are in.
|
||||
|
||||
```bash
|
||||
kettle config # resolved paths and settings
|
||||
```
|
||||
|
||||
## `kettle gen scaffold`
|
||||
|
||||
write this project's .claude/ commands, skills and subagent
|
||||
|
||||
A skill tells an agent how to invoke this binary, and a command is how an
|
||||
operator invokes one by hand. Both are written from here, whole, because both
|
||||
travel INSIDE the binary: the prose is embedded next to the code it describes
|
||||
and the flag tables are rendered from the command registry the binary is built
|
||||
from, so neither can be a version behind the other.
|
||||
|
||||
That is the whole reason these documents are not a plugin any more. A plugin
|
||||
ships on its own cadence, and nothing on an operator's machine ever checked that
|
||||
the one they installed described the binary they installed — so a renamed flag
|
||||
could still arrive with documentation recommending the old one, which is exactly
|
||||
the failure the generated block was invented to prevent, one hop further
|
||||
downstream.
|
||||
|
||||
EVERY FILE IS WRITTEN WHOLE, and that is a deliberate reversal. The old
|
||||
generator owned a region and left every byte outside it alone, because the prose
|
||||
around the block was somebody's hand-written file. It is not any more: it is
|
||||
embedded, so there is no hand-written half left to protect, and preserving local
|
||||
edits would mean freezing a project's documentation at whatever version first
|
||||
initialized it. The markers stay in the output so a reader can see which half
|
||||
came from the registry.
|
||||
|
||||
WHAT THIS MEANS FOR A LOCAL EDIT: it does not survive. Run --check before an
|
||||
upgrade if you have made one; the fix for a sentence that is wrong is a newer
|
||||
kettle, not a patch that the next run silently discards.
|
||||
|
||||
The output is deterministic to the byte — no timestamps, no map iteration — so
|
||||
regenerating something that has not changed produces no diff. --check is that
|
||||
property made useful: it writes nothing and exits 1 when any file on disk
|
||||
differs from what would be written, which is what a pre-commit hook or a CI step
|
||||
calls. It wins over --dry-run when both are given.
|
||||
|
||||
| flag | default | what it does |
|
||||
| --- | --- | --- |
|
||||
| `--check` | `false` | write nothing, exit 1 if anything is out of date |
|
||||
| `--dry-run` | `false` | print what would change; write nothing |
|
||||
| `--out` | — | directory to write into (default: <project>/.claude) |
|
||||
|
||||
```bash
|
||||
kettle gen scaffold # write .claude/ under this project
|
||||
kettle gen scaffold --out ~/code/x/.claude # write it somewhere else
|
||||
kettle gen scaffold --dry-run # print what would change; write nothing
|
||||
kettle gen scaffold --check # exit 1 if the documents are out of date
|
||||
```
|
||||
|
||||
## `kettle init`
|
||||
|
||||
make this directory a project that tracks issues
|
||||
|
||||
Creates `.kettle/` — the marker every other command resolves the store from,
|
||||
and `.kettle/config.yaml`, which says which tracker repository these issues
|
||||
belong to and which login to reach it under — and writes `.claude/`: the slash
|
||||
commands an operator invokes, the skills a model loads, and the runner subagent.
|
||||
|
||||
The marker is deliberately something an operator makes, not something inferred
|
||||
from the tree: `.git` is in every clone, so anything that inferred a root from
|
||||
one would write issues into whatever it happened to be installed in.
|
||||
|
||||
--login pins a name, never a credential. The tokens live in one file per
|
||||
machine, outside every working tree, managed with `kettle auth`.
|
||||
|
||||
All of it is idempotent: it creates .kettle/issues and .kettle/payload, migrates
|
||||
an older store in if it finds one (either layout the tea plugin used, oldest
|
||||
first), writes the config without disturbing settings it was not given, writes
|
||||
the .claude/ tree, and adds .kettle/ to .gitignore. Each migration is a move,
|
||||
not a copy — two stores is the state the marker exists to prevent — and it
|
||||
refuses to pick a winner when both sides hold a file of the same name.
|
||||
|
||||
IT REFUSES TO RUN IN A LINKED WORKTREE, and names the main checkout instead. A
|
||||
worktree is the same project on another branch and reaches the store by a hop
|
||||
out to the main checkout; a marker here would give one project two stores, and
|
||||
the directory holding the second one disappears with the branch.
|
||||
|
||||
--interactive walks a person through the whole thing — the login, the token with
|
||||
the echo turned off, the repository, the .claude/ tree and the AGENTS.md mirror
|
||||
hook. IT REQUIRES A TERMINAL and refuses a standard input that is not one, which
|
||||
is deliberate: every question it asks has a flag beside it, so nothing that is
|
||||
not a person ever needs to answer a prompt.
|
||||
|
||||
| flag | default | what it does |
|
||||
| --- | --- | --- |
|
||||
| `--at` | — | directory to initialize (default: the working directory) |
|
||||
| `--dry-run` | `false` | report what would happen; change nothing |
|
||||
| `--force-settings` | `false` | let the hook be merged into an existing settings.json, reformatting it |
|
||||
| `--interactive` | `false` | ask, one question at a time; requires a terminal |
|
||||
| `--login` | — | name of a login in the machine-wide file (see `kettle auth`) |
|
||||
| `--mirror-hook` | `false` | register `kettle mirror --hook` on PreToolUse(Bash) |
|
||||
| `--no-scaffold` | `false` | do not write the .claude/ commands, skills and subagent |
|
||||
| `--repo` | — | tracker repository, as owner/name |
|
||||
| `--scaffold-out` | — | where the .claude/ tree goes (default: <project>/.claude) |
|
||||
|
||||
```bash
|
||||
kettle init # initialize the current directory
|
||||
kettle init --interactive # be walked through it, at a terminal
|
||||
kettle init --login noodles --repo claude-skills/marketplace # and point it at a tracker
|
||||
kettle init --mirror-hook # register the AGENTS.md mirror on PreToolUse(Bash)
|
||||
kettle init --at ~/code/x # initialize somewhere else
|
||||
kettle init --dry-run # say what it would do, touch nothing
|
||||
```
|
||||
|
||||
## `kettle mirror [<dir>]`
|
||||
|
||||
keep CLAUDE.md a symlink to AGENTS.md in every directory below here
|
||||
|
||||
Two agent harnesses read two different filenames for the same document. A
|
||||
repository that keeps both as real files keeps TWO DOCUMENTS, and they drift —
|
||||
silently, until somebody reads the stale one and believes it. This walks a tree
|
||||
and leaves one arrangement behind everywhere:
|
||||
|
||||
AGENTS.md is the real file; CLAUDE.md is a symlink pointing at it.
|
||||
|
||||
The link is relative, so a tree that is moved, copied or cloned keeps working.
|
||||
AGENTS.md is the real one because the convention is not one vendor's: a
|
||||
repository that names its documents after a single tool has picked a side it did
|
||||
not need to pick.
|
||||
|
||||
NOTHING HERE DELETES CONTENT. Six of the seven states it can find are repaired
|
||||
losslessly — a missing link is created, a reversed layout is swapped round, a
|
||||
duplicate whose bytes match its original is replaced by the link. The seventh,
|
||||
two real files whose contents DIFFER, is reported and left exactly as it was:
|
||||
one of them is somebody's writing and no rule here knows which.
|
||||
|
||||
It walks the directory given, or the working directory. node_modules, vendor,
|
||||
venv, __pycache__ and every dot-directory are skipped, because somebody else's
|
||||
tree is somebody else's business.
|
||||
|
||||
--hook is the PreToolUse form: it reads the hook payload on standard input,
|
||||
writes any report back as additionalContext, and ALWAYS EXITS 0 — including when
|
||||
it fails. A tool that broke somebody's Bash call because its documentation
|
||||
helper crashed would be worse than no tool. --check is the opposite end: it
|
||||
writes nothing and exits 1 when the tree is not canonical, which is what a
|
||||
pre-commit hook or a make target calls.
|
||||
|
||||
`kettle init --interactive` offers to register the --hook form in
|
||||
.claude/settings.json. It is offered rather than assumed: this is one
|
||||
repository's documentation convention, and a project that does not keep AGENTS.md
|
||||
files wants nothing to do with it.
|
||||
|
||||
| flag | default | what it does |
|
||||
| --- | --- | --- |
|
||||
| `--check` | `false` | write nothing, exit 1 if the tree is not canonical |
|
||||
| `--hook` | `false` | PreToolUse form: payload on stdin, report as additionalContext, always exit 0 |
|
||||
| `--quiet` | `false` | repair without printing what was repaired |
|
||||
|
||||
```bash
|
||||
kettle mirror # repair the working directory and everything below it
|
||||
kettle mirror ~/code/x # repair somewhere else
|
||||
kettle mirror --check # exit 1 if anything is out of place; write nothing
|
||||
kettle mirror --hook # the PreToolUse form; reads a payload, always exits 0
|
||||
```
|
||||
|
||||
## `kettle version`
|
||||
|
||||
print the version this binary was built as
|
||||
|
||||
A binary that cannot say what it is, is a support problem: an operator with an
|
||||
old copy on PATH and a skill written against a newer one has no way to see the
|
||||
mismatch, and neither does anybody reading their transcript.
|
||||
|
||||
The version is stamped at link time. A build from source says "dev" and means
|
||||
it — that is not a placeholder to be edited, it is the answer for a binary that
|
||||
came out of somebody's working tree rather than off a tag.
|
||||
|
||||
The commit is reported when the build recorded one, which `go build` does from
|
||||
git and a build from an unpacked tarball cannot. A tree with uncommitted
|
||||
changes in it says so beside the commit.
|
||||
|
||||
| flag | default | what it does |
|
||||
| --- | --- | --- |
|
||||
| `--short` | `false` | print the version alone, with nothing around it |
|
||||
|
||||
```bash
|
||||
kettle version # the version, the toolchain and the commit
|
||||
kettle version --short # just the version, for a script
|
||||
```
|
||||
<!-- /kettle:gen -->
|
||||
+12
-11
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: sync
|
||||
name: kettle-sync
|
||||
description: Move issues between this project's local store and its Gitea tracker with the `kettle` binary — pull issues into `.kettle/issues/`, push local ones up (which deletes the local file), list what the tracker holds, post comments, close and reopen, and evict what the tracker says is closed. Load when the user asks to fetch or publish an issue, see what exists in the tracker, comment on one, or close one. Writing, grepping, validating and graphing an issue's content is /kettle:issue and needs no network.
|
||||
---
|
||||
|
||||
@@ -12,7 +12,7 @@ second-guesses it. Knowledge flows one way: delete the tracker from the world an
|
||||
the issue domain does not notice.
|
||||
|
||||
**No `kettle` on PATH?** `command not found: kettle` is the whole story — the
|
||||
Python scripts this plugin used to ship are gone and raw `tea` is not a
|
||||
Python scripts this plugin used to ship are gone and no other CLI is a
|
||||
substitute. Stop and tell the operator to install it: `cd cli && go build -o
|
||||
~/.local/bin/kettle ./cmd/kettle` in the marketplace repository (go.mod requires
|
||||
**go 1.26**), or `go install
|
||||
@@ -38,11 +38,12 @@ No login, an unknown login name, a 401: report it and stop — `/kettle:auth`.
|
||||
|
||||
## Never read an issue through a raw API dump
|
||||
|
||||
`tea issues 42 -o json` and `tea api …/issues/42` put the whole payload —
|
||||
avatars, nested user objects, every comment body — into your context whether you
|
||||
need it or not. `kettle pull` writes flat markdown and prints a compact line per
|
||||
issue; `kettle remote` lists the tracker without writing anything at all. Use
|
||||
those.
|
||||
`kettle api repos/{owner}/{repo}/issues/42` will answer, and answering is the
|
||||
problem: the whole payload — avatars, nested user objects, every comment body —
|
||||
lands in your context whether you need it or not. `kettle pull` writes flat
|
||||
markdown and prints a compact line per issue; `kettle remote` lists the tracker
|
||||
without writing anything at all. Use those. `/kettle:api` is for the entities
|
||||
that have no command, and it says the same thing from its side.
|
||||
|
||||
## The round trip is one rule
|
||||
|
||||
@@ -183,9 +184,9 @@ request bodies are debris of the transport, and a scratchpad inside a store make
|
||||
`ls .kettle/issues` lie about what exists. Nothing in it is anybody's only copy —
|
||||
deleting it costs nothing. A run that sends nothing leaves no directory behind.
|
||||
|
||||
For Gitea entities `kettle` does not cover — releases, webhooks, actions, pull
|
||||
requests — the tool is `tea`, and it keeps its own configuration and its own
|
||||
logins. `/kettle:use`.
|
||||
For Gitea entities `kettle` has no command for — releases, webhooks, actions,
|
||||
pull requests — `kettle api` sends the request under this same login, into this
|
||||
same scratchpad. `/kettle:api`.
|
||||
|
||||
The commands themselves follow. Their usage lines, flags, defaults and examples
|
||||
are generated from the binary's own command registry, so they cannot disagree
|
||||
@@ -193,7 +194,7 @@ with the binary; `kettle help <command>` prints the same text. Editing them here
|
||||
changes nothing.
|
||||
|
||||
<!-- kettle:gen -->
|
||||
**Generated from the kettle command registry by `kettle gen skills`.** Everything between the two markers is replaced on the next run — hand-written prose belongs outside them.
|
||||
**Generated from the kettle command registry by `kettle gen scaffold`.** Everything between the two markers is replaced on the next run — the prose around it is embedded in the binary and replaced with it.
|
||||
|
||||
## `kettle close <id|number> [<id|number>…]`
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package scaffold
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// This package is prose and a table of contents. It hands out embedded bytes and
|
||||
// says which of them carry a generated region; it renders nothing, resolves
|
||||
// nothing and reads no file off the disk.
|
||||
//
|
||||
// That matters because of what sits above it: internal/cmd imports this to write
|
||||
// a project's `.claude/` tree, and if this package imported the registry back the
|
||||
// two would be a cycle. The dependency walk, so a helper pulled in three packages
|
||||
// deep is caught as the same violation as one written at the top of a file.
|
||||
func TestScaffoldDependsOnNothing(t *testing.T) {
|
||||
out, err := exec.Command("go", "list", "-deps", ".").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("go list: %v", err)
|
||||
}
|
||||
for _, dep := range strings.Fields(string(out)) {
|
||||
if dep == "git.noodles.cam/claude-skills/marketplace/cli/internal/scaffold" {
|
||||
continue
|
||||
}
|
||||
// A standard-library import path has no dot in its first element,
|
||||
// because it has no domain name in front of it.
|
||||
if first, _, _ := strings.Cut(dep, "/"); strings.Contains(first, ".") {
|
||||
t.Errorf("scaffold imports %s — these are embedded documents, and nothing else belongs here", dep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The other half: os and net/http are standard library, so "no third-party
|
||||
// imports" would not catch a read off the disk written by hand here. The whole
|
||||
// premise is that these documents travel INSIDE the binary — one os.ReadFile and
|
||||
// they are back to being files on a machine that may not have them.
|
||||
//
|
||||
// DIRECT imports, not the dependency walk — embed reaches io/fs on its own, and
|
||||
// the question this asks is what THIS package reaches for.
|
||||
func TestScaffoldReadsNothingOffTheDisk(t *testing.T) {
|
||||
forbidden := map[string]string{
|
||||
"os": "these documents are embedded; a file read here is a file that can be missing",
|
||||
"os/exec": "nothing here shells out",
|
||||
"net/http": "nothing here is fetched",
|
||||
"net": "nothing here is fetched",
|
||||
"time": "a document has no clock in it",
|
||||
}
|
||||
|
||||
out, err := exec.Command("go", "list", "-f", `{{join .Imports "\n"}}`, ".").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("go list: %v", err)
|
||||
}
|
||||
for _, dep := range strings.Fields(string(out)) {
|
||||
if why, bad := forbidden[dep]; bad {
|
||||
t.Errorf("scaffold imports %s — %s", dep, why)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Package scaffold holds the documents kettle writes into a project: the slash
|
||||
// commands an operator invokes, the skills a model loads, and the runner
|
||||
// subagent.
|
||||
//
|
||||
// They live inside the binary rather than beside it. A plugin shipped this prose
|
||||
// once, on its own release cadence, and nothing on an operator's machine checked
|
||||
// that the plugin they had installed described the binary they had installed —
|
||||
// so a renamed flag could ship with documentation recommending the old one,
|
||||
// which is the exact failure the generated block was invented to prevent, one
|
||||
// hop further downstream. Prose that travels inside the binary cannot be a
|
||||
// version behind it.
|
||||
//
|
||||
// This package depends on nothing but the standard library and holds no
|
||||
// rendering logic: it hands out embedded files and says which of them carry a
|
||||
// generated region. Splicing the command registry into that region is
|
||||
// internal/cmd's, because the registry is.
|
||||
package scaffold
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed all:assets
|
||||
var assets embed.FS
|
||||
|
||||
const assetRoot = "assets"
|
||||
|
||||
// Marker is the directory these files are written into, relative to the project
|
||||
// root. It belongs to the agent harness, not to kettle: everything kettle owns
|
||||
// is under `.kettle/`, and this is the one tree it writes that somebody else
|
||||
// defines the shape of.
|
||||
const Marker = ".claude"
|
||||
|
||||
// generated maps an output path to the command group whose flag table belongs
|
||||
// in it. A file that is not in here carries no generated region and is shipped
|
||||
// exactly as embedded.
|
||||
//
|
||||
// Explicit rather than derived from the directory name: the group ⇄ skill
|
||||
// correspondence is a decision, and one that has not always held — `init` and
|
||||
// `auth` are commands with no skill of their own, and `project` is a skill
|
||||
// covering four commands. A test in internal/cmd asserts every group in the
|
||||
// registry is named here exactly once, so adding a group fails loudly rather
|
||||
// than silently shipping a skill nobody can find.
|
||||
var generated = map[string]string{
|
||||
"skills/kettle-project/SKILL.md": "project",
|
||||
"skills/kettle-issue/SKILL.md": "issue",
|
||||
"skills/kettle-sync/SKILL.md": "sync",
|
||||
"skills/kettle-api/SKILL.md": "api",
|
||||
}
|
||||
|
||||
// File is one document, ready to be written under the output directory.
|
||||
type File struct {
|
||||
// Path is relative to the output directory, always with forward slashes:
|
||||
// "commands/kettle/init.md", "skills/kettle-issue/SKILL.md".
|
||||
Path string
|
||||
// Body is the file as embedded — before any generated region is spliced in.
|
||||
Body string
|
||||
// Group is the command group whose flag table belongs in this file, or "".
|
||||
Group string
|
||||
}
|
||||
|
||||
// Files is every document, sorted by path.
|
||||
//
|
||||
// Sorted, not in walk order, because the sort is the promise: two runs of the
|
||||
// same binary produce the same list, so a receipt and a --check diff are
|
||||
// comparable between machines.
|
||||
func Files() []File {
|
||||
var out []File
|
||||
_ = fs.WalkDir(assets, assetRoot, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
rel := strings.TrimPrefix(p, assetRoot+"/")
|
||||
body, err := assets.ReadFile(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, File{Path: rel, Body: string(body), Group: generated[rel]})
|
||||
return nil
|
||||
})
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
|
||||
return out
|
||||
}
|
||||
|
||||
// Groups lists every command group that has a file here, sorted.
|
||||
func Groups() []string {
|
||||
out := make([]string, 0, len(generated))
|
||||
for _, g := range generated {
|
||||
out = append(out, g)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// PathFor is the output path carrying a group's flag table, or "".
|
||||
func PathFor(group string) string {
|
||||
for p, g := range generated {
|
||||
if g == group {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Dirs lists the directories the output tree is made of, parents first, so a
|
||||
// caller can create them in order.
|
||||
func Dirs() []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, f := range Files() {
|
||||
for _, d := range parents(path.Dir(f.Path)) {
|
||||
if !seen[d] {
|
||||
seen[d] = true
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func parents(dir string) []string {
|
||||
if dir == "." || dir == "" {
|
||||
return nil
|
||||
}
|
||||
return append(parents(path.Dir(dir)), dir)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package scaffold
|
||||
|
||||
import (
|
||||
"path"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// frontmatter returns the YAML block at the top of a document, or "" if there
|
||||
// is none. Every file here is read by an agent harness that will not load a
|
||||
// document without one.
|
||||
func frontmatter(body string) string {
|
||||
if !strings.HasPrefix(body, "---\n") {
|
||||
return ""
|
||||
}
|
||||
rest := body[len("---\n"):]
|
||||
end := strings.Index(rest, "\n---\n")
|
||||
if end < 0 {
|
||||
return ""
|
||||
}
|
||||
return rest[:end+1]
|
||||
}
|
||||
|
||||
func field(fm, key string) string {
|
||||
for _, line := range strings.Split(fm, "\n") {
|
||||
if v, ok := strings.CutPrefix(line, key+":"); ok {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// routed reports whether a document is one the harness loads by itself — a
|
||||
// skill, a command, a subagent. Everything under references/ is prose that a
|
||||
// skill names by path and reads in full, so it carries no frontmatter and needs
|
||||
// none.
|
||||
func routed(p string) bool {
|
||||
return !strings.Contains(p, "/references/")
|
||||
}
|
||||
|
||||
// A routed document with no frontmatter, or with an empty description, is a
|
||||
// document the harness either refuses to load or never routes to. Either way it
|
||||
// is dead weight in the binary, and neither failure shows up until somebody's
|
||||
// project is quietly missing a skill.
|
||||
func TestEveryDocumentIsLoadable(t *testing.T) {
|
||||
files := Files()
|
||||
if len(files) == 0 {
|
||||
t.Fatal("no assets embedded — check the //go:embed directive")
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
if !routed(f.Path) {
|
||||
if frontmatter(f.Body) != "" {
|
||||
t.Errorf("%s: a reference is read by path and needs no frontmatter", f.Path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
fm := frontmatter(f.Body)
|
||||
if fm == "" {
|
||||
t.Errorf("%s: no frontmatter", f.Path)
|
||||
continue
|
||||
}
|
||||
if field(fm, "description") == "" {
|
||||
t.Errorf("%s: no description — nothing will route to it", f.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A skill is addressed by its name, and the harness resolves that name from the
|
||||
// directory. The two disagreeing is a skill that cannot be loaded by the name it
|
||||
// calls itself.
|
||||
func TestSkillNamesMatchTheirDirectories(t *testing.T) {
|
||||
for _, f := range Files() {
|
||||
if !strings.HasPrefix(f.Path, "skills/") || path.Base(f.Path) != "SKILL.md" {
|
||||
continue
|
||||
}
|
||||
dir := path.Base(path.Dir(f.Path))
|
||||
if got := field(frontmatter(f.Body), "name"); got != dir {
|
||||
t.Errorf("%s: name is %q, directory is %q", f.Path, got, dir)
|
||||
}
|
||||
if !strings.HasPrefix(dir, "kettle-") {
|
||||
t.Errorf("%s: a project skill has no namespace of its own, so the prefix is the whole of it", f.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A command is invoked by an operator who typed it, so it needs no description
|
||||
// to be routed on — but it gets one anyway, because that is what the operator
|
||||
// reads in the command list. What it must NOT carry is a name: a project command
|
||||
// is named by its filename, and a `name:` here would be a second spelling of the
|
||||
// same identity, free to drift.
|
||||
func TestCommandsAreNamedByTheirFilenames(t *testing.T) {
|
||||
found := 0
|
||||
for _, f := range Files() {
|
||||
if !strings.HasPrefix(f.Path, "commands/") {
|
||||
continue
|
||||
}
|
||||
found++
|
||||
if got := field(frontmatter(f.Body), "name"); got != "" {
|
||||
t.Errorf("%s: carries name: %q — the filename is the name", f.Path, got)
|
||||
}
|
||||
}
|
||||
if found == 0 {
|
||||
t.Error("no commands embedded")
|
||||
}
|
||||
}
|
||||
|
||||
// The generated region and the map that declares it are one fact written twice,
|
||||
// and this is the test that keeps them equal. A file that grew a region without
|
||||
// being declared would have it silently ignored; a file declared without one
|
||||
// would fail at splice time, in somebody's project rather than here.
|
||||
func TestDeclaredRegionsAreTheRealOnes(t *testing.T) {
|
||||
const open, close = "<!-- kettle:gen -->", "<!-- /kettle:gen -->"
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, f := range Files() {
|
||||
has := strings.Contains(f.Body, open)
|
||||
switch {
|
||||
case has && f.Group == "":
|
||||
t.Errorf("%s carries a generated region but is in no group", f.Path)
|
||||
case !has && f.Group != "":
|
||||
t.Errorf("%s is declared for group %q but has no region", f.Path, f.Group)
|
||||
}
|
||||
if has && !strings.Contains(f.Body, close) {
|
||||
t.Errorf("%s opens a region and never closes it", f.Path)
|
||||
}
|
||||
if f.Group != "" {
|
||||
seen[f.Path] = true
|
||||
}
|
||||
}
|
||||
for p := range generated {
|
||||
if !seen[p] {
|
||||
t.Errorf("generated names %s, which is not embedded", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One group, one file. Two files claiming the same group would both be written
|
||||
// from the same registry block, and only one of them would be the one anybody
|
||||
// read.
|
||||
func TestEachGroupHasExactlyOneFile(t *testing.T) {
|
||||
for _, g := range Groups() {
|
||||
var paths []string
|
||||
for p, group := range generated {
|
||||
if group == g {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
}
|
||||
if len(paths) != 1 {
|
||||
t.Errorf("group %q is claimed by %v", g, paths)
|
||||
}
|
||||
if PathFor(g) == "" {
|
||||
t.Errorf("PathFor(%q) found nothing", g)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Two calls, one list. Every receipt, every --check diff and every golden test
|
||||
// downstream is built on this holding.
|
||||
func TestFilesAreDeterministic(t *testing.T) {
|
||||
a, b := Files(), Files()
|
||||
if len(a) != len(b) {
|
||||
t.Fatalf("two calls returned %d and %d files", len(a), len(b))
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
t.Fatalf("call %d differs at %d: %s vs %s", i, i, a[i].Path, b[i].Path)
|
||||
}
|
||||
if i > 0 && a[i-1].Path >= a[i].Path {
|
||||
t.Errorf("not sorted: %s before %s", a[i-1].Path, a[i].Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dirs is what a writer creates before it writes, so a parent that came after
|
||||
// its child would be a mkdir that fails on a cold directory.
|
||||
func TestDirsListsParentsBeforeChildren(t *testing.T) {
|
||||
dirs := Dirs()
|
||||
seen := map[string]bool{}
|
||||
for _, d := range dirs {
|
||||
if parent := path.Dir(d); parent != "." && !seen[parent] {
|
||||
t.Errorf("%s comes before its parent %s", d, parent)
|
||||
}
|
||||
seen[d] = true
|
||||
}
|
||||
|
||||
for _, f := range Files() {
|
||||
if d := path.Dir(f.Path); d != "." && !seen[d] {
|
||||
t.Errorf("%s lives in %s, which Dirs does not list", f.Path, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing authored here may name the plugin it replaced. A path into
|
||||
// `plugins/kettle` is a path that no longer exists, and an operator who follows
|
||||
// one is an operator reading a document that outlived its subject.
|
||||
//
|
||||
// Authored, so the generated region is cut out first: what is between the
|
||||
// markers came from the command registry and is that registry's to get right.
|
||||
// Asserting over it here would fail on a stale embedded block rather than on the
|
||||
// sentence somebody actually wrote.
|
||||
func TestNothingPointsAtTheOldPlugin(t *testing.T) {
|
||||
for _, f := range Files() {
|
||||
body := withoutRegion(f.Body)
|
||||
for _, dead := range []string{"plugins/kettle", "gen skills", "${CLAUDE_PLUGIN_ROOT}"} {
|
||||
if strings.Contains(body, dead) {
|
||||
t.Errorf("%s still mentions %q", f.Path, dead)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func withoutRegion(body string) string {
|
||||
const open, close = "<!-- kettle:gen -->", "<!-- /kettle:gen -->"
|
||||
start := strings.Index(body, open)
|
||||
if start < 0 {
|
||||
return body
|
||||
}
|
||||
end := strings.Index(body, close)
|
||||
if end < 0 {
|
||||
return body[:start]
|
||||
}
|
||||
return body[:start] + body[end+len(close):]
|
||||
}
|
||||
+6
-1
@@ -156,8 +156,12 @@ var Loong64 struct {
|
||||
HasLSX bool // support 128-bit vector extension
|
||||
HasLASX bool // support 256-bit vector extension
|
||||
HasCRC32 bool // support CRC instruction
|
||||
HasLAMCAS bool // support AMCAS[_DB].{B/H/W/D}
|
||||
HasLAM_BH bool // support AM{SWAP/ADD}[_DB].{B/H} instruction
|
||||
HasLAMCAS bool // support AMCAS[_DB].{B/H/W/D} instruction
|
||||
HasLLACQ_SCREL bool // support LLACQ.{W/D}, SCREL.{W/D} instruction
|
||||
HasSCQ bool // support SC.Q instruction
|
||||
HasDBAR_HINTS bool // supports finer-grained DBAR hints
|
||||
|
||||
_ CacheLinePad
|
||||
}
|
||||
|
||||
@@ -232,6 +236,7 @@ var RISCV64 struct {
|
||||
HasZba bool // Address generation instructions extension
|
||||
HasZbb bool // Basic bit-manipulation extension
|
||||
HasZbs bool // Single-bit instructions extension
|
||||
HasZbc bool // Carryless multiplication extension
|
||||
HasZvbb bool // Vector Basic Bit-manipulation
|
||||
HasZvbc bool // Vector Carryless Multiplication
|
||||
HasZvkb bool // Vector Cryptography Bit-manipulation
|
||||
|
||||
+2
@@ -58,6 +58,7 @@ const (
|
||||
riscv_HWPROBE_EXT_ZBA = 0x8
|
||||
riscv_HWPROBE_EXT_ZBB = 0x10
|
||||
riscv_HWPROBE_EXT_ZBS = 0x20
|
||||
riscv_HWPROBE_EXT_ZBC = 0x80
|
||||
riscv_HWPROBE_EXT_ZVBB = 0x20000
|
||||
riscv_HWPROBE_EXT_ZVBC = 0x40000
|
||||
riscv_HWPROBE_EXT_ZVKB = 0x80000
|
||||
@@ -108,6 +109,7 @@ func doinit() {
|
||||
RISCV64.HasZba = isSet(v, riscv_HWPROBE_EXT_ZBA)
|
||||
RISCV64.HasZbb = isSet(v, riscv_HWPROBE_EXT_ZBB)
|
||||
RISCV64.HasZbs = isSet(v, riscv_HWPROBE_EXT_ZBS)
|
||||
RISCV64.HasZbc = isSet(v, riscv_HWPROBE_EXT_ZBC)
|
||||
RISCV64.HasZvbb = isSet(v, riscv_HWPROBE_EXT_ZVBB)
|
||||
RISCV64.HasZvbc = isSet(v, riscv_HWPROBE_EXT_ZVBC)
|
||||
RISCV64.HasZvkb = isSet(v, riscv_HWPROBE_EXT_ZVKB)
|
||||
|
||||
+12
@@ -17,6 +17,11 @@ const (
|
||||
// CPUCFG2 bits
|
||||
cpucfg2_LAM_BH = 1 << 27
|
||||
cpucfg2_LAMCAS = 1 << 28
|
||||
cpucfg2_LLACQ_SCREL = 1 << 29
|
||||
cpucfg2_SCQ = 1 << 30
|
||||
|
||||
// CPUCFG3 bits
|
||||
cpucfg3_DBAR_HINTS = 1 << 17
|
||||
)
|
||||
|
||||
func initOptions() {
|
||||
@@ -26,6 +31,9 @@ func initOptions() {
|
||||
{Name: "crc32", Feature: &Loong64.HasCRC32},
|
||||
{Name: "lam_bh", Feature: &Loong64.HasLAM_BH},
|
||||
{Name: "lamcas", Feature: &Loong64.HasLAMCAS},
|
||||
{Name: "llacq_screl", Feature: &Loong64.HasLLACQ_SCREL},
|
||||
{Name: "scq", Feature: &Loong64.HasSCQ},
|
||||
{Name: "dbar_hints", Feature: &Loong64.HasDBAR_HINTS},
|
||||
}
|
||||
|
||||
// The CPUCFG data on Loong64 only reflects the hardware capabilities,
|
||||
@@ -37,10 +45,14 @@ func initOptions() {
|
||||
// through CPUCFG
|
||||
cfg1 := get_cpucfg(1)
|
||||
cfg2 := get_cpucfg(2)
|
||||
cfg3 := get_cpucfg(3)
|
||||
|
||||
Loong64.HasCRC32 = cfgIsSet(cfg1, cpucfg1_CRC32)
|
||||
Loong64.HasLAMCAS = cfgIsSet(cfg2, cpucfg2_LAMCAS)
|
||||
Loong64.HasLAM_BH = cfgIsSet(cfg2, cpucfg2_LAM_BH)
|
||||
Loong64.HasLLACQ_SCREL = cfgIsSet(cfg2, cpucfg2_LLACQ_SCREL)
|
||||
Loong64.HasSCQ = cfgIsSet(cfg2, cpucfg2_SCQ)
|
||||
Loong64.HasDBAR_HINTS = cfgIsSet(cfg3, cpucfg3_DBAR_HINTS)
|
||||
}
|
||||
|
||||
func get_cpucfg(reg uint32) uint32
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build !darwin && !linux && !netbsd && !openbsd && arm64
|
||||
//go:build !darwin && !linux && !netbsd && !openbsd && !windows && arm64
|
||||
|
||||
package cpu
|
||||
|
||||
|
||||
+1
@@ -16,6 +16,7 @@ func initOptions() {
|
||||
{Name: "zba", Feature: &RISCV64.HasZba},
|
||||
{Name: "zbb", Feature: &RISCV64.HasZbb},
|
||||
{Name: "zbs", Feature: &RISCV64.HasZbs},
|
||||
{Name: "zbc", Feature: &RISCV64.HasZbc},
|
||||
// RISC-V Cryptography Extensions
|
||||
{Name: "zvbb", Feature: &RISCV64.HasZvbb},
|
||||
{Name: "zvbc", Feature: &RISCV64.HasZvbc},
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
// Copyright 2026 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package cpu
|
||||
|
||||
//go:generate go run golang.org/x/sys/windows/mkwinsyscall -systemdll=false -output zcpu_windows.go cpu_windows.go
|
||||
|
||||
//sys isProcessorFeaturePresent(ProcessorFeature uint32) (ret bool) = kernel32.IsProcessorFeaturePresent
|
||||
|
||||
// The processor features to be tested for IsProcessorFeaturePresent, see
|
||||
// https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-isprocessorfeaturepresent
|
||||
const (
|
||||
_PF_ARM_V8_CRYPTO_INSTRUCTIONS_AVAILABLE = 30
|
||||
_PF_ARM_V8_CRC32_INSTRUCTIONS_AVAILABLE = 31
|
||||
_PF_ARM_V81_ATOMIC_INSTRUCTIONS_AVAILABLE = 34
|
||||
_PF_ARM_V82_DP_INSTRUCTIONS_AVAILABLE = 43
|
||||
|
||||
_PF_ARM_V83_JSCVT_INSTRUCTIONS_AVAILABLE = 44
|
||||
_PF_ARM_V83_LRCPC_INSTRUCTIONS_AVAILABLE = 45
|
||||
_PF_ARM_SVE_INSTRUCTIONS_AVAILABLE = 46
|
||||
_PF_ARM_SVE2_INSTRUCTIONS_AVAILABLE = 47
|
||||
|
||||
_PF_ARM_SHA3_INSTRUCTIONS_AVAILABLE = 64
|
||||
_PF_ARM_SHA512_INSTRUCTIONS_AVAILABLE = 65
|
||||
)
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
// Copyright 2026 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package cpu
|
||||
|
||||
func doinit() {
|
||||
// set HasASIMD and HasFP to true as per
|
||||
// https://learn.microsoft.com/en-us/cpp/build/arm64-windows-abi-conventions?view=msvc-170#base-requirements
|
||||
//
|
||||
// The ARM64 version of Windows always presupposes that it's running on an ARMv8 or later architecture.
|
||||
// Both floating-point and NEON support are presumed to be present in hardware.
|
||||
//
|
||||
ARM64.HasASIMD = true
|
||||
ARM64.HasFP = true
|
||||
|
||||
if isProcessorFeaturePresent(_PF_ARM_V8_CRYPTO_INSTRUCTIONS_AVAILABLE) {
|
||||
ARM64.HasAES = true
|
||||
ARM64.HasPMULL = true
|
||||
ARM64.HasSHA1 = true
|
||||
ARM64.HasSHA2 = true
|
||||
}
|
||||
ARM64.HasSHA3 = isProcessorFeaturePresent(_PF_ARM_SHA3_INSTRUCTIONS_AVAILABLE)
|
||||
ARM64.HasCRC32 = isProcessorFeaturePresent(_PF_ARM_V8_CRC32_INSTRUCTIONS_AVAILABLE)
|
||||
ARM64.HasSHA512 = isProcessorFeaturePresent(_PF_ARM_SHA512_INSTRUCTIONS_AVAILABLE)
|
||||
ARM64.HasATOMICS = isProcessorFeaturePresent(_PF_ARM_V81_ATOMIC_INSTRUCTIONS_AVAILABLE)
|
||||
if isProcessorFeaturePresent(_PF_ARM_V82_DP_INSTRUCTIONS_AVAILABLE) {
|
||||
ARM64.HasASIMDDP = true
|
||||
ARM64.HasASIMDRDM = true
|
||||
}
|
||||
if isProcessorFeaturePresent(_PF_ARM_V83_LRCPC_INSTRUCTIONS_AVAILABLE) {
|
||||
ARM64.HasLRCPC = true
|
||||
ARM64.HasSM3 = true
|
||||
}
|
||||
ARM64.HasSVE = isProcessorFeaturePresent(_PF_ARM_SVE_INSTRUCTIONS_AVAILABLE)
|
||||
ARM64.HasSVE2 = isProcessorFeaturePresent(_PF_ARM_SVE2_INSTRUCTIONS_AVAILABLE)
|
||||
ARM64.HasJSCVT = isProcessorFeaturePresent(_PF_ARM_V83_JSCVT_INSTRUCTIONS_AVAILABLE)
|
||||
}
|
||||
+37
-25
@@ -6,38 +6,50 @@ package cpu
|
||||
|
||||
import "strconv"
|
||||
|
||||
// parseRelease parses a dot-separated version number. It follows the semver
|
||||
// syntax, but allows the minor and patch versions to be elided.
|
||||
// parseRelease parses a dot-separated version number from the prefix
|
||||
// of rel. It returns ok=true only if at least the major and minor
|
||||
// components were successfully parsed; the patch component is
|
||||
// best-effort. Trailing vendor or build suffixes such as
|
||||
// "-generic", "+", "_hi3535", or "-rc1" are ignored.
|
||||
//
|
||||
// This is a copy of the Go runtime's parseRelease from
|
||||
// https://golang.org/cl/209597.
|
||||
// https://golang.org/cl/209597, updated in https://golang.org/cl/781800.
|
||||
func parseRelease(rel string) (major, minor, patch int, ok bool) {
|
||||
// Strip anything after a dash or plus.
|
||||
for i := range len(rel) {
|
||||
if rel[i] == '-' || rel[i] == '+' {
|
||||
rel = rel[:i]
|
||||
break
|
||||
// next consumes a run of decimal digits from the front of rel,
|
||||
// returning the parsed value. If the digits are followed by a
|
||||
// '.', it is consumed and more is set so the caller knows to
|
||||
// parse another component; otherwise scanning terminates and
|
||||
// the rest of rel is discarded.
|
||||
next := func() (n int, more, ok bool) {
|
||||
i := 0
|
||||
for i < len(rel) && rel[i] >= '0' && rel[i] <= '9' {
|
||||
i++
|
||||
}
|
||||
if i == 0 {
|
||||
return 0, false, false
|
||||
}
|
||||
n, err := strconv.Atoi(rel[:i])
|
||||
if err != nil {
|
||||
return 0, false, false
|
||||
}
|
||||
if i < len(rel) && rel[i] == '.' {
|
||||
rel = rel[i+1:]
|
||||
return n, true, true
|
||||
}
|
||||
rel = ""
|
||||
return n, false, true
|
||||
}
|
||||
|
||||
next := func() (int, bool) {
|
||||
for i := range len(rel) {
|
||||
if rel[i] == '.' {
|
||||
ver, err := strconv.Atoi(rel[:i])
|
||||
rel = rel[i+1:]
|
||||
return ver, err == nil
|
||||
var more bool
|
||||
if major, more, ok = next(); !ok || !more {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
if minor, more, ok = next(); !ok {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
ver, err := strconv.Atoi(rel)
|
||||
rel = ""
|
||||
return ver, err == nil
|
||||
if !more {
|
||||
return major, minor, 0, true
|
||||
}
|
||||
if major, ok = next(); !ok || rel == "" {
|
||||
return
|
||||
}
|
||||
if minor, ok = next(); !ok || rel == "" {
|
||||
return
|
||||
}
|
||||
patch, ok = next()
|
||||
return
|
||||
patch, _, _ = next()
|
||||
return major, minor, patch, true
|
||||
}
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
// Code generated by 'go generate'; DO NOT EDIT.
|
||||
|
||||
package cpu
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
var _ unsafe.Pointer
|
||||
|
||||
// Do the interface allocations only once for common
|
||||
// Errno values.
|
||||
const (
|
||||
errnoERROR_IO_PENDING = 997
|
||||
)
|
||||
|
||||
var (
|
||||
errERROR_IO_PENDING error = syscall.Errno(errnoERROR_IO_PENDING)
|
||||
errERROR_EINVAL error = syscall.EINVAL
|
||||
)
|
||||
|
||||
// errnoErr returns common boxed Errno values, to prevent
|
||||
// allocations at runtime.
|
||||
func errnoErr(e syscall.Errno) error {
|
||||
switch e {
|
||||
case 0:
|
||||
return errERROR_EINVAL
|
||||
case errnoERROR_IO_PENDING:
|
||||
return errERROR_IO_PENDING
|
||||
}
|
||||
// TODO: add more here, after collecting data on the common
|
||||
// error values see on Windows. (perhaps when running
|
||||
// all.bat?)
|
||||
return e
|
||||
}
|
||||
|
||||
var (
|
||||
modkernel32 = syscall.NewLazyDLL("kernel32.dll")
|
||||
|
||||
procIsProcessorFeaturePresent = modkernel32.NewProc("IsProcessorFeaturePresent")
|
||||
)
|
||||
|
||||
func isProcessorFeaturePresent(ProcessorFeature uint32) (ret bool) {
|
||||
r0, _, _ := syscall.SyscallN(procIsProcessorFeaturePresent.Addr(), uintptr(ProcessorFeature))
|
||||
ret = r0 != 0
|
||||
return
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
// Copyright 2014 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
#include "textflag.h"
|
||||
|
||||
TEXT ·use(SB),NOSPLIT,$0
|
||||
RET
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
// Copyright 2009 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
#include "textflag.h"
|
||||
|
||||
//
|
||||
// System call support for 386, Plan 9
|
||||
//
|
||||
|
||||
// Just jump to package syscall's implementation for all these functions.
|
||||
// The runtime may know about them.
|
||||
|
||||
TEXT ·Syscall(SB),NOSPLIT,$0-32
|
||||
JMP syscall·Syscall(SB)
|
||||
|
||||
TEXT ·Syscall6(SB),NOSPLIT,$0-44
|
||||
JMP syscall·Syscall6(SB)
|
||||
|
||||
TEXT ·RawSyscall(SB),NOSPLIT,$0-28
|
||||
JMP syscall·RawSyscall(SB)
|
||||
|
||||
TEXT ·RawSyscall6(SB),NOSPLIT,$0-40
|
||||
JMP syscall·RawSyscall6(SB)
|
||||
|
||||
TEXT ·seek(SB),NOSPLIT,$0-36
|
||||
JMP syscall·seek(SB)
|
||||
|
||||
TEXT ·exit(SB),NOSPLIT,$4-4
|
||||
JMP syscall·exit(SB)
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
// Copyright 2009 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
#include "textflag.h"
|
||||
|
||||
//
|
||||
// System call support for amd64, Plan 9
|
||||
//
|
||||
|
||||
// Just jump to package syscall's implementation for all these functions.
|
||||
// The runtime may know about them.
|
||||
|
||||
TEXT ·Syscall(SB),NOSPLIT,$0-64
|
||||
JMP syscall·Syscall(SB)
|
||||
|
||||
TEXT ·Syscall6(SB),NOSPLIT,$0-88
|
||||
JMP syscall·Syscall6(SB)
|
||||
|
||||
TEXT ·RawSyscall(SB),NOSPLIT,$0-56
|
||||
JMP syscall·RawSyscall(SB)
|
||||
|
||||
TEXT ·RawSyscall6(SB),NOSPLIT,$0-80
|
||||
JMP syscall·RawSyscall6(SB)
|
||||
|
||||
TEXT ·seek(SB),NOSPLIT,$0-56
|
||||
JMP syscall·seek(SB)
|
||||
|
||||
TEXT ·exit(SB),NOSPLIT,$8-8
|
||||
JMP syscall·exit(SB)
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
// Copyright 2009 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
#include "textflag.h"
|
||||
|
||||
// System call support for plan9 on arm
|
||||
|
||||
// Just jump to package syscall's implementation for all these functions.
|
||||
// The runtime may know about them.
|
||||
|
||||
TEXT ·Syscall(SB),NOSPLIT,$0-32
|
||||
JMP syscall·Syscall(SB)
|
||||
|
||||
TEXT ·Syscall6(SB),NOSPLIT,$0-44
|
||||
JMP syscall·Syscall6(SB)
|
||||
|
||||
TEXT ·RawSyscall(SB),NOSPLIT,$0-28
|
||||
JMP syscall·RawSyscall(SB)
|
||||
|
||||
TEXT ·RawSyscall6(SB),NOSPLIT,$0-40
|
||||
JMP syscall·RawSyscall6(SB)
|
||||
|
||||
TEXT ·seek(SB),NOSPLIT,$0-36
|
||||
JMP syscall·exit(SB)
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package plan9
|
||||
|
||||
// Plan 9 Constants
|
||||
|
||||
// Open modes
|
||||
const (
|
||||
O_RDONLY = 0
|
||||
O_WRONLY = 1
|
||||
O_RDWR = 2
|
||||
O_TRUNC = 16
|
||||
O_CLOEXEC = 32
|
||||
O_EXCL = 0x1000
|
||||
)
|
||||
|
||||
// Rfork flags
|
||||
const (
|
||||
RFNAMEG = 1 << 0
|
||||
RFENVG = 1 << 1
|
||||
RFFDG = 1 << 2
|
||||
RFNOTEG = 1 << 3
|
||||
RFPROC = 1 << 4
|
||||
RFMEM = 1 << 5
|
||||
RFNOWAIT = 1 << 6
|
||||
RFCNAMEG = 1 << 10
|
||||
RFCENVG = 1 << 11
|
||||
RFCFDG = 1 << 12
|
||||
RFREND = 1 << 13
|
||||
RFNOMNT = 1 << 14
|
||||
)
|
||||
|
||||
// Qid.Type bits
|
||||
const (
|
||||
QTDIR = 0x80
|
||||
QTAPPEND = 0x40
|
||||
QTEXCL = 0x20
|
||||
QTMOUNT = 0x10
|
||||
QTAUTH = 0x08
|
||||
QTTMP = 0x04
|
||||
QTFILE = 0x00
|
||||
)
|
||||
|
||||
// Dir.Mode bits
|
||||
const (
|
||||
DMDIR = 0x80000000
|
||||
DMAPPEND = 0x40000000
|
||||
DMEXCL = 0x20000000
|
||||
DMMOUNT = 0x10000000
|
||||
DMAUTH = 0x08000000
|
||||
DMTMP = 0x04000000
|
||||
DMREAD = 0x4
|
||||
DMWRITE = 0x2
|
||||
DMEXEC = 0x1
|
||||
)
|
||||
|
||||
const (
|
||||
STATMAX = 65535
|
||||
ERRMAX = 128
|
||||
STATFIXLEN = 49
|
||||
)
|
||||
|
||||
// Mount and bind flags
|
||||
const (
|
||||
MREPL = 0x0000
|
||||
MBEFORE = 0x0001
|
||||
MAFTER = 0x0002
|
||||
MORDER = 0x0003
|
||||
MCREATE = 0x0004
|
||||
MCACHE = 0x0010
|
||||
MMASK = 0x0017
|
||||
)
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
// Copyright 2012 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Plan 9 directory marshalling. See intro(5).
|
||||
|
||||
package plan9
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrShortStat = errors.New("stat buffer too short")
|
||||
ErrBadStat = errors.New("malformed stat buffer")
|
||||
ErrBadName = errors.New("bad character in file name")
|
||||
)
|
||||
|
||||
// A Qid represents a 9P server's unique identification for a file.
|
||||
type Qid struct {
|
||||
Path uint64 // the file server's unique identification for the file
|
||||
Vers uint32 // version number for given Path
|
||||
Type uint8 // the type of the file (plan9.QTDIR for example)
|
||||
}
|
||||
|
||||
// A Dir contains the metadata for a file.
|
||||
type Dir struct {
|
||||
// system-modified data
|
||||
Type uint16 // server type
|
||||
Dev uint32 // server subtype
|
||||
|
||||
// file data
|
||||
Qid Qid // unique id from server
|
||||
Mode uint32 // permissions
|
||||
Atime uint32 // last read time
|
||||
Mtime uint32 // last write time
|
||||
Length int64 // file length
|
||||
Name string // last element of path
|
||||
Uid string // owner name
|
||||
Gid string // group name
|
||||
Muid string // last modifier name
|
||||
}
|
||||
|
||||
var nullDir = Dir{
|
||||
Type: ^uint16(0),
|
||||
Dev: ^uint32(0),
|
||||
Qid: Qid{
|
||||
Path: ^uint64(0),
|
||||
Vers: ^uint32(0),
|
||||
Type: ^uint8(0),
|
||||
},
|
||||
Mode: ^uint32(0),
|
||||
Atime: ^uint32(0),
|
||||
Mtime: ^uint32(0),
|
||||
Length: ^int64(0),
|
||||
}
|
||||
|
||||
// Null assigns special "don't touch" values to members of d to
|
||||
// avoid modifying them during plan9.Wstat.
|
||||
func (d *Dir) Null() { *d = nullDir }
|
||||
|
||||
// Marshal encodes a 9P stat message corresponding to d into b
|
||||
//
|
||||
// If there isn't enough space in b for a stat message, ErrShortStat is returned.
|
||||
func (d *Dir) Marshal(b []byte) (n int, err error) {
|
||||
n = STATFIXLEN + len(d.Name) + len(d.Uid) + len(d.Gid) + len(d.Muid)
|
||||
if n > len(b) {
|
||||
return n, ErrShortStat
|
||||
}
|
||||
|
||||
for _, c := range d.Name {
|
||||
if c == '/' {
|
||||
return n, ErrBadName
|
||||
}
|
||||
}
|
||||
|
||||
b = pbit16(b, uint16(n)-2)
|
||||
b = pbit16(b, d.Type)
|
||||
b = pbit32(b, d.Dev)
|
||||
b = pbit8(b, d.Qid.Type)
|
||||
b = pbit32(b, d.Qid.Vers)
|
||||
b = pbit64(b, d.Qid.Path)
|
||||
b = pbit32(b, d.Mode)
|
||||
b = pbit32(b, d.Atime)
|
||||
b = pbit32(b, d.Mtime)
|
||||
b = pbit64(b, uint64(d.Length))
|
||||
b = pstring(b, d.Name)
|
||||
b = pstring(b, d.Uid)
|
||||
b = pstring(b, d.Gid)
|
||||
b = pstring(b, d.Muid)
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// UnmarshalDir decodes a single 9P stat message from b and returns the resulting Dir.
|
||||
//
|
||||
// If b is too small to hold a valid stat message, ErrShortStat is returned.
|
||||
//
|
||||
// If the stat message itself is invalid, ErrBadStat is returned.
|
||||
func UnmarshalDir(b []byte) (*Dir, error) {
|
||||
if len(b) < STATFIXLEN {
|
||||
return nil, ErrShortStat
|
||||
}
|
||||
size, buf := gbit16(b)
|
||||
if len(b) != int(size)+2 {
|
||||
return nil, ErrBadStat
|
||||
}
|
||||
b = buf
|
||||
|
||||
var d Dir
|
||||
d.Type, b = gbit16(b)
|
||||
d.Dev, b = gbit32(b)
|
||||
d.Qid.Type, b = gbit8(b)
|
||||
d.Qid.Vers, b = gbit32(b)
|
||||
d.Qid.Path, b = gbit64(b)
|
||||
d.Mode, b = gbit32(b)
|
||||
d.Atime, b = gbit32(b)
|
||||
d.Mtime, b = gbit32(b)
|
||||
|
||||
n, b := gbit64(b)
|
||||
d.Length = int64(n)
|
||||
|
||||
var ok bool
|
||||
if d.Name, b, ok = gstring(b); !ok {
|
||||
return nil, ErrBadStat
|
||||
}
|
||||
if d.Uid, b, ok = gstring(b); !ok {
|
||||
return nil, ErrBadStat
|
||||
}
|
||||
if d.Gid, b, ok = gstring(b); !ok {
|
||||
return nil, ErrBadStat
|
||||
}
|
||||
if d.Muid, b, ok = gstring(b); !ok {
|
||||
return nil, ErrBadStat
|
||||
}
|
||||
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// pbit8 copies the 8-bit number v to b and returns the remaining slice of b.
|
||||
func pbit8(b []byte, v uint8) []byte {
|
||||
b[0] = byte(v)
|
||||
return b[1:]
|
||||
}
|
||||
|
||||
// pbit16 copies the 16-bit number v to b in little-endian order and returns the remaining slice of b.
|
||||
func pbit16(b []byte, v uint16) []byte {
|
||||
b[0] = byte(v)
|
||||
b[1] = byte(v >> 8)
|
||||
return b[2:]
|
||||
}
|
||||
|
||||
// pbit32 copies the 32-bit number v to b in little-endian order and returns the remaining slice of b.
|
||||
func pbit32(b []byte, v uint32) []byte {
|
||||
b[0] = byte(v)
|
||||
b[1] = byte(v >> 8)
|
||||
b[2] = byte(v >> 16)
|
||||
b[3] = byte(v >> 24)
|
||||
return b[4:]
|
||||
}
|
||||
|
||||
// pbit64 copies the 64-bit number v to b in little-endian order and returns the remaining slice of b.
|
||||
func pbit64(b []byte, v uint64) []byte {
|
||||
b[0] = byte(v)
|
||||
b[1] = byte(v >> 8)
|
||||
b[2] = byte(v >> 16)
|
||||
b[3] = byte(v >> 24)
|
||||
b[4] = byte(v >> 32)
|
||||
b[5] = byte(v >> 40)
|
||||
b[6] = byte(v >> 48)
|
||||
b[7] = byte(v >> 56)
|
||||
return b[8:]
|
||||
}
|
||||
|
||||
// pstring copies the string s to b, prepending it with a 16-bit length in little-endian order, and
|
||||
// returning the remaining slice of b..
|
||||
func pstring(b []byte, s string) []byte {
|
||||
b = pbit16(b, uint16(len(s)))
|
||||
n := copy(b, s)
|
||||
return b[n:]
|
||||
}
|
||||
|
||||
// gbit8 reads an 8-bit number from b and returns it with the remaining slice of b.
|
||||
func gbit8(b []byte) (uint8, []byte) {
|
||||
return uint8(b[0]), b[1:]
|
||||
}
|
||||
|
||||
// gbit16 reads a 16-bit number in little-endian order from b and returns it with the remaining slice of b.
|
||||
func gbit16(b []byte) (uint16, []byte) {
|
||||
return uint16(b[0]) | uint16(b[1])<<8, b[2:]
|
||||
}
|
||||
|
||||
// gbit32 reads a 32-bit number in little-endian order from b and returns it with the remaining slice of b.
|
||||
func gbit32(b []byte) (uint32, []byte) {
|
||||
return uint32(b[0]) | uint32(b[1])<<8 | uint32(b[2])<<16 | uint32(b[3])<<24, b[4:]
|
||||
}
|
||||
|
||||
// gbit64 reads a 64-bit number in little-endian order from b and returns it with the remaining slice of b.
|
||||
func gbit64(b []byte) (uint64, []byte) {
|
||||
lo := uint32(b[0]) | uint32(b[1])<<8 | uint32(b[2])<<16 | uint32(b[3])<<24
|
||||
hi := uint32(b[4]) | uint32(b[5])<<8 | uint32(b[6])<<16 | uint32(b[7])<<24
|
||||
return uint64(lo) | uint64(hi)<<32, b[8:]
|
||||
}
|
||||
|
||||
// gstring reads a string from b, prefixed with a 16-bit length in little-endian order.
|
||||
// It returns the string with the remaining slice of b and a boolean. If the length is
|
||||
// greater than the number of bytes in b, the boolean will be false.
|
||||
func gstring(b []byte) (string, []byte, bool) {
|
||||
n, b := gbit16(b)
|
||||
if int(n) > len(b) {
|
||||
return "", b, false
|
||||
}
|
||||
return string(b[:n]), b[n:], true
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
// Copyright 2011 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Plan 9 environment variables.
|
||||
|
||||
package plan9
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func Getenv(key string) (value string, found bool) {
|
||||
return syscall.Getenv(key)
|
||||
}
|
||||
|
||||
func Setenv(key, value string) error {
|
||||
return syscall.Setenv(key, value)
|
||||
}
|
||||
|
||||
func Clearenv() {
|
||||
syscall.Clearenv()
|
||||
}
|
||||
|
||||
func Environ() []string {
|
||||
return syscall.Environ()
|
||||
}
|
||||
|
||||
func Unsetenv(key string) error {
|
||||
return syscall.Unsetenv(key)
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
// Copyright 2011 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package plan9
|
||||
|
||||
import "syscall"
|
||||
|
||||
// Constants
|
||||
const (
|
||||
// Invented values to support what package os expects.
|
||||
O_CREAT = 0x02000
|
||||
O_APPEND = 0x00400
|
||||
O_NOCTTY = 0x00000
|
||||
O_NONBLOCK = 0x00000
|
||||
O_SYNC = 0x00000
|
||||
O_ASYNC = 0x00000
|
||||
|
||||
S_IFMT = 0x1f000
|
||||
S_IFIFO = 0x1000
|
||||
S_IFCHR = 0x2000
|
||||
S_IFDIR = 0x4000
|
||||
S_IFBLK = 0x6000
|
||||
S_IFREG = 0x8000
|
||||
S_IFLNK = 0xa000
|
||||
S_IFSOCK = 0xc000
|
||||
)
|
||||
|
||||
// Errors
|
||||
var (
|
||||
EINVAL = syscall.NewError("bad arg in system call")
|
||||
ENOTDIR = syscall.NewError("not a directory")
|
||||
EISDIR = syscall.NewError("file is a directory")
|
||||
ENOENT = syscall.NewError("file does not exist")
|
||||
EEXIST = syscall.NewError("file already exists")
|
||||
EMFILE = syscall.NewError("no free file descriptors")
|
||||
EIO = syscall.NewError("i/o error")
|
||||
ENAMETOOLONG = syscall.NewError("file name too long")
|
||||
EINTR = syscall.NewError("interrupted")
|
||||
EPERM = syscall.NewError("permission denied")
|
||||
EBUSY = syscall.NewError("no free devices")
|
||||
ETIMEDOUT = syscall.NewError("connection timed out")
|
||||
EPLAN9 = syscall.NewError("not supported by plan 9")
|
||||
|
||||
// The following errors do not correspond to any
|
||||
// Plan 9 system messages. Invented to support
|
||||
// what package os and others expect.
|
||||
EACCES = syscall.NewError("access permission denied")
|
||||
EAFNOSUPPORT = syscall.NewError("address family not supported by protocol")
|
||||
)
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copyright 2009 The Go Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style
|
||||
# license that can be found in the LICENSE file.
|
||||
|
||||
# The plan9 package provides access to the raw system call
|
||||
# interface of the underlying operating system. Porting Go to
|
||||
# a new architecture/operating system combination requires
|
||||
# some manual effort, though there are tools that automate
|
||||
# much of the process. The auto-generated files have names
|
||||
# beginning with z.
|
||||
#
|
||||
# This script runs or (given -n) prints suggested commands to generate z files
|
||||
# for the current system. Running those commands is not automatic.
|
||||
# This script is documentation more than anything else.
|
||||
#
|
||||
# * asm_${GOOS}_${GOARCH}.s
|
||||
#
|
||||
# This hand-written assembly file implements system call dispatch.
|
||||
# There are three entry points:
|
||||
#
|
||||
# func Syscall(trap, a1, a2, a3 uintptr) (r1, r2, err uintptr);
|
||||
# func Syscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2, err uintptr);
|
||||
# func RawSyscall(trap, a1, a2, a3 uintptr) (r1, r2, err uintptr);
|
||||
#
|
||||
# The first and second are the standard ones; they differ only in
|
||||
# how many arguments can be passed to the kernel.
|
||||
# The third is for low-level use by the ForkExec wrapper;
|
||||
# unlike the first two, it does not call into the scheduler to
|
||||
# let it know that a system call is running.
|
||||
#
|
||||
# * syscall_${GOOS}.go
|
||||
#
|
||||
# This hand-written Go file implements system calls that need
|
||||
# special handling and lists "//sys" comments giving prototypes
|
||||
# for ones that can be auto-generated. Mksyscall reads those
|
||||
# comments to generate the stubs.
|
||||
#
|
||||
# * syscall_${GOOS}_${GOARCH}.go
|
||||
#
|
||||
# Same as syscall_${GOOS}.go except that it contains code specific
|
||||
# to ${GOOS} on one particular architecture.
|
||||
#
|
||||
# * types_${GOOS}.c
|
||||
#
|
||||
# This hand-written C file includes standard C headers and then
|
||||
# creates typedef or enum names beginning with a dollar sign
|
||||
# (use of $ in variable names is a gcc extension). The hardest
|
||||
# part about preparing this file is figuring out which headers to
|
||||
# include and which symbols need to be #defined to get the
|
||||
# actual data structures that pass through to the kernel system calls.
|
||||
# Some C libraries present alternate versions for binary compatibility
|
||||
# and translate them on the way in and out of system calls, but
|
||||
# there is almost always a #define that can get the real ones.
|
||||
# See types_darwin.c and types_linux.c for examples.
|
||||
#
|
||||
# * zerror_${GOOS}_${GOARCH}.go
|
||||
#
|
||||
# This machine-generated file defines the system's error numbers,
|
||||
# error strings, and signal numbers. The generator is "mkerrors.sh".
|
||||
# Usually no arguments are needed, but mkerrors.sh will pass its
|
||||
# arguments on to godefs.
|
||||
#
|
||||
# * zsyscall_${GOOS}_${GOARCH}.go
|
||||
#
|
||||
# Generated by mksyscall.pl; see syscall_${GOOS}.go above.
|
||||
#
|
||||
# * zsysnum_${GOOS}_${GOARCH}.go
|
||||
#
|
||||
# Generated by mksysnum_${GOOS}.
|
||||
#
|
||||
# * ztypes_${GOOS}_${GOARCH}.go
|
||||
#
|
||||
# Generated by godefs; see types_${GOOS}.c above.
|
||||
|
||||
GOOSARCH="${GOOS}_${GOARCH}"
|
||||
|
||||
# defaults
|
||||
mksyscall="go run mksyscall.go"
|
||||
mkerrors="./mkerrors.sh"
|
||||
zerrors="zerrors_$GOOSARCH.go"
|
||||
mksysctl=""
|
||||
zsysctl="zsysctl_$GOOSARCH.go"
|
||||
mksysnum=
|
||||
mktypes=
|
||||
run="sh"
|
||||
|
||||
case "$1" in
|
||||
-syscalls)
|
||||
for i in zsyscall*go
|
||||
do
|
||||
sed 1q $i | sed 's;^// ;;' | sh > _$i && gofmt < _$i > $i
|
||||
rm _$i
|
||||
done
|
||||
exit 0
|
||||
;;
|
||||
-n)
|
||||
run="cat"
|
||||
shift
|
||||
esac
|
||||
|
||||
case "$#" in
|
||||
0)
|
||||
;;
|
||||
*)
|
||||
echo 'usage: mkall.sh [-n]' 1>&2
|
||||
exit 2
|
||||
esac
|
||||
|
||||
case "$GOOSARCH" in
|
||||
_* | *_ | _)
|
||||
echo 'undefined $GOOS_$GOARCH:' "$GOOSARCH" 1>&2
|
||||
exit 1
|
||||
;;
|
||||
plan9_386)
|
||||
mkerrors=
|
||||
mksyscall="go run mksyscall.go -l32 -plan9 -tags plan9,386"
|
||||
mksysnum="./mksysnum_plan9.sh /n/sources/plan9/sys/src/libc/9syscall/sys.h"
|
||||
mktypes="XXX"
|
||||
;;
|
||||
plan9_amd64)
|
||||
mkerrors=
|
||||
mksyscall="go run mksyscall.go -l32 -plan9 -tags plan9,amd64"
|
||||
mksysnum="./mksysnum_plan9.sh /n/sources/plan9/sys/src/libc/9syscall/sys.h"
|
||||
mktypes="XXX"
|
||||
;;
|
||||
plan9_arm)
|
||||
mkerrors=
|
||||
mksyscall="go run mksyscall.go -l32 -plan9 -tags plan9,arm"
|
||||
mksysnum="./mksysnum_plan9.sh /n/sources/plan9/sys/src/libc/9syscall/sys.h"
|
||||
mktypes="XXX"
|
||||
;;
|
||||
*)
|
||||
echo 'unrecognized $GOOS_$GOARCH: ' "$GOOSARCH" 1>&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
(
|
||||
if [ -n "$mkerrors" ]; then echo "$mkerrors |gofmt >$zerrors"; fi
|
||||
case "$GOOS" in
|
||||
plan9)
|
||||
syscall_goos="syscall_$GOOS.go"
|
||||
if [ -n "$mksyscall" ]; then echo "$mksyscall $syscall_goos |gofmt >zsyscall_$GOOSARCH.go"; fi
|
||||
;;
|
||||
esac
|
||||
if [ -n "$mksysctl" ]; then echo "$mksysctl |gofmt >$zsysctl"; fi
|
||||
if [ -n "$mksysnum" ]; then echo "$mksysnum |gofmt >zsysnum_$GOOSARCH.go"; fi
|
||||
if [ -n "$mktypes" ]; then echo "$mktypes types_$GOOS.go |gofmt >ztypes_$GOOSARCH.go"; fi
|
||||
) | $run
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copyright 2009 The Go Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style
|
||||
# license that can be found in the LICENSE file.
|
||||
|
||||
# Generate Go code listing errors and other #defined constant
|
||||
# values (ENAMETOOLONG etc.), by asking the preprocessor
|
||||
# about the definitions.
|
||||
|
||||
unset LANG
|
||||
export LC_ALL=C
|
||||
export LC_CTYPE=C
|
||||
|
||||
CC=${CC:-gcc}
|
||||
|
||||
uname=$(uname)
|
||||
|
||||
includes='
|
||||
#include <sys/types.h>
|
||||
#include <sys/file.h>
|
||||
#include <fcntl.h>
|
||||
#include <dirent.h>
|
||||
#include <sys/socket.h>
|
||||
#include <netinet/in.h>
|
||||
#include <netinet/ip.h>
|
||||
#include <netinet/ip6.h>
|
||||
#include <netinet/tcp.h>
|
||||
#include <errno.h>
|
||||
#include <sys/signal.h>
|
||||
#include <signal.h>
|
||||
#include <sys/resource.h>
|
||||
'
|
||||
|
||||
ccflags="$@"
|
||||
|
||||
# Write go tool cgo -godefs input.
|
||||
(
|
||||
echo package plan9
|
||||
echo
|
||||
echo '/*'
|
||||
indirect="includes_$(uname)"
|
||||
echo "${!indirect} $includes"
|
||||
echo '*/'
|
||||
echo 'import "C"'
|
||||
echo
|
||||
echo 'const ('
|
||||
|
||||
# The gcc command line prints all the #defines
|
||||
# it encounters while processing the input
|
||||
echo "${!indirect} $includes" | $CC -x c - -E -dM $ccflags |
|
||||
awk '
|
||||
$1 != "#define" || $2 ~ /\(/ || $3 == "" {next}
|
||||
|
||||
$2 ~ /^E([ABCD]X|[BIS]P|[SD]I|S|FL)$/ {next} # 386 registers
|
||||
$2 ~ /^(SIGEV_|SIGSTKSZ|SIGRT(MIN|MAX))/ {next}
|
||||
$2 ~ /^(SCM_SRCRT)$/ {next}
|
||||
$2 ~ /^(MAP_FAILED)$/ {next}
|
||||
|
||||
$2 !~ /^ETH_/ &&
|
||||
$2 !~ /^EPROC_/ &&
|
||||
$2 !~ /^EQUIV_/ &&
|
||||
$2 !~ /^EXPR_/ &&
|
||||
$2 ~ /^E[A-Z0-9_]+$/ ||
|
||||
$2 ~ /^B[0-9_]+$/ ||
|
||||
$2 ~ /^V[A-Z0-9]+$/ ||
|
||||
$2 ~ /^CS[A-Z0-9]/ ||
|
||||
$2 ~ /^I(SIG|CANON|CRNL|EXTEN|MAXBEL|STRIP|UTF8)$/ ||
|
||||
$2 ~ /^IGN/ ||
|
||||
$2 ~ /^IX(ON|ANY|OFF)$/ ||
|
||||
$2 ~ /^IN(LCR|PCK)$/ ||
|
||||
$2 ~ /(^FLU?SH)|(FLU?SH$)/ ||
|
||||
$2 ~ /^C(LOCAL|READ)$/ ||
|
||||
$2 == "BRKINT" ||
|
||||
$2 == "HUPCL" ||
|
||||
$2 == "PENDIN" ||
|
||||
$2 == "TOSTOP" ||
|
||||
$2 ~ /^PAR/ ||
|
||||
$2 ~ /^SIG[^_]/ ||
|
||||
$2 ~ /^O[CNPFP][A-Z]+[^_][A-Z]+$/ ||
|
||||
$2 ~ /^IN_/ ||
|
||||
$2 ~ /^LOCK_(SH|EX|NB|UN)$/ ||
|
||||
$2 ~ /^(AF|SOCK|SO|SOL|IPPROTO|IP|IPV6|ICMP6|TCP|EVFILT|NOTE|EV|SHUT|PROT|MAP|PACKET|MSG|SCM|MCL|DT|MADV|PR)_/ ||
|
||||
$2 == "ICMPV6_FILTER" ||
|
||||
$2 == "SOMAXCONN" ||
|
||||
$2 == "NAME_MAX" ||
|
||||
$2 == "IFNAMSIZ" ||
|
||||
$2 ~ /^CTL_(MAXNAME|NET|QUERY)$/ ||
|
||||
$2 ~ /^SYSCTL_VERS/ ||
|
||||
$2 ~ /^(MS|MNT)_/ ||
|
||||
$2 ~ /^TUN(SET|GET|ATTACH|DETACH)/ ||
|
||||
$2 ~ /^(O|F|FD|NAME|S|PTRACE|PT)_/ ||
|
||||
$2 ~ /^LINUX_REBOOT_CMD_/ ||
|
||||
$2 ~ /^LINUX_REBOOT_MAGIC[12]$/ ||
|
||||
$2 !~ "NLA_TYPE_MASK" &&
|
||||
$2 ~ /^(NETLINK|NLM|NLMSG|NLA|IFA|IFAN|RT|RTCF|RTN|RTPROT|RTNH|ARPHRD|ETH_P)_/ ||
|
||||
$2 ~ /^SIOC/ ||
|
||||
$2 ~ /^TIOC/ ||
|
||||
$2 !~ "RTF_BITS" &&
|
||||
$2 ~ /^(IFF|IFT|NET_RT|RTM|RTF|RTV|RTA|RTAX)_/ ||
|
||||
$2 ~ /^BIOC/ ||
|
||||
$2 ~ /^RUSAGE_(SELF|CHILDREN|THREAD)/ ||
|
||||
$2 ~ /^RLIMIT_(AS|CORE|CPU|DATA|FSIZE|NOFILE|STACK)|RLIM_INFINITY/ ||
|
||||
$2 ~ /^PRIO_(PROCESS|PGRP|USER)/ ||
|
||||
$2 ~ /^CLONE_[A-Z_]+/ ||
|
||||
$2 !~ /^(BPF_TIMEVAL)$/ &&
|
||||
$2 ~ /^(BPF|DLT)_/ ||
|
||||
$2 !~ "WMESGLEN" &&
|
||||
$2 ~ /^W[A-Z0-9]+$/ {printf("\t%s = C.%s\n", $2, $2)}
|
||||
$2 ~ /^__WCOREFLAG$/ {next}
|
||||
$2 ~ /^__W[A-Z0-9]+$/ {printf("\t%s = C.%s\n", substr($2,3), $2)}
|
||||
|
||||
{next}
|
||||
' | sort
|
||||
|
||||
echo ')'
|
||||
) >_const.go
|
||||
|
||||
# Pull out the error names for later.
|
||||
errors=$(
|
||||
echo '#include <errno.h>' | $CC -x c - -E -dM $ccflags |
|
||||
awk '$1=="#define" && $2 ~ /^E[A-Z0-9_]+$/ { print $2 }' |
|
||||
sort
|
||||
)
|
||||
|
||||
# Pull out the signal names for later.
|
||||
signals=$(
|
||||
echo '#include <signal.h>' | $CC -x c - -E -dM $ccflags |
|
||||
awk '$1=="#define" && $2 ~ /^SIG[A-Z0-9]+$/ { print $2 }' |
|
||||
grep -v 'SIGSTKSIZE\|SIGSTKSZ\|SIGRT' |
|
||||
sort
|
||||
)
|
||||
|
||||
# Again, writing regexps to a file.
|
||||
echo '#include <errno.h>' | $CC -x c - -E -dM $ccflags |
|
||||
awk '$1=="#define" && $2 ~ /^E[A-Z0-9_]+$/ { print "^\t" $2 "[ \t]*=" }' |
|
||||
sort >_error.grep
|
||||
echo '#include <signal.h>' | $CC -x c - -E -dM $ccflags |
|
||||
awk '$1=="#define" && $2 ~ /^SIG[A-Z0-9]+$/ { print "^\t" $2 "[ \t]*=" }' |
|
||||
grep -v 'SIGSTKSIZE\|SIGSTKSZ\|SIGRT' |
|
||||
sort >_signal.grep
|
||||
|
||||
echo '// mkerrors.sh' "$@"
|
||||
echo '// Code generated by the command above; DO NOT EDIT.'
|
||||
echo
|
||||
go tool cgo -godefs -- "$@" _const.go >_error.out
|
||||
cat _error.out | grep -vf _error.grep | grep -vf _signal.grep
|
||||
echo
|
||||
echo '// Errors'
|
||||
echo 'const ('
|
||||
cat _error.out | grep -f _error.grep | sed 's/=\(.*\)/= Errno(\1)/'
|
||||
echo ')'
|
||||
|
||||
echo
|
||||
echo '// Signals'
|
||||
echo 'const ('
|
||||
cat _error.out | grep -f _signal.grep | sed 's/=\(.*\)/= Signal(\1)/'
|
||||
echo ')'
|
||||
|
||||
# Run C program to print error and syscall strings.
|
||||
(
|
||||
echo -E "
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <errno.h>
|
||||
#include <ctype.h>
|
||||
#include <string.h>
|
||||
#include <signal.h>
|
||||
|
||||
#define nelem(x) (sizeof(x)/sizeof((x)[0]))
|
||||
|
||||
enum { A = 'A', Z = 'Z', a = 'a', z = 'z' }; // avoid need for single quotes below
|
||||
|
||||
int errors[] = {
|
||||
"
|
||||
for i in $errors
|
||||
do
|
||||
echo -E ' '$i,
|
||||
done
|
||||
|
||||
echo -E "
|
||||
};
|
||||
|
||||
int signals[] = {
|
||||
"
|
||||
for i in $signals
|
||||
do
|
||||
echo -E ' '$i,
|
||||
done
|
||||
|
||||
# Use -E because on some systems bash builtin interprets \n itself.
|
||||
echo -E '
|
||||
};
|
||||
|
||||
static int
|
||||
intcmp(const void *a, const void *b)
|
||||
{
|
||||
return *(int*)a - *(int*)b;
|
||||
}
|
||||
|
||||
int
|
||||
main(void)
|
||||
{
|
||||
int i, j, e;
|
||||
char buf[1024], *p;
|
||||
|
||||
printf("\n\n// Error table\n");
|
||||
printf("var errors = [...]string {\n");
|
||||
qsort(errors, nelem(errors), sizeof errors[0], intcmp);
|
||||
for(i=0; i<nelem(errors); i++) {
|
||||
e = errors[i];
|
||||
if(i > 0 && errors[i-1] == e)
|
||||
continue;
|
||||
strcpy(buf, strerror(e));
|
||||
// lowercase first letter: Bad -> bad, but STREAM -> STREAM.
|
||||
if(A <= buf[0] && buf[0] <= Z && a <= buf[1] && buf[1] <= z)
|
||||
buf[0] += a - A;
|
||||
printf("\t%d: \"%s\",\n", e, buf);
|
||||
}
|
||||
printf("}\n\n");
|
||||
|
||||
printf("\n\n// Signal table\n");
|
||||
printf("var signals = [...]string {\n");
|
||||
qsort(signals, nelem(signals), sizeof signals[0], intcmp);
|
||||
for(i=0; i<nelem(signals); i++) {
|
||||
e = signals[i];
|
||||
if(i > 0 && signals[i-1] == e)
|
||||
continue;
|
||||
strcpy(buf, strsignal(e));
|
||||
// lowercase first letter: Bad -> bad, but STREAM -> STREAM.
|
||||
if(A <= buf[0] && buf[0] <= Z && a <= buf[1] && buf[1] <= z)
|
||||
buf[0] += a - A;
|
||||
// cut trailing : number.
|
||||
p = strrchr(buf, ":"[0]);
|
||||
if(p)
|
||||
*p = '\0';
|
||||
printf("\t%d: \"%s\",\n", e, buf);
|
||||
}
|
||||
printf("}\n\n");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
'
|
||||
) >_errors.c
|
||||
|
||||
$CC $ccflags -o _errors _errors.c && $GORUN ./_errors && rm -f _errors.c _errors _const.go _error.grep _signal.grep _error.out
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
# Copyright 2009 The Go Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style
|
||||
# license that can be found in the LICENSE file.
|
||||
|
||||
COMMAND="mksysnum_plan9.sh $@"
|
||||
|
||||
cat <<EOF
|
||||
// $COMMAND
|
||||
// MACHINE GENERATED BY THE ABOVE COMMAND; DO NOT EDIT
|
||||
|
||||
package plan9
|
||||
|
||||
const(
|
||||
EOF
|
||||
|
||||
SP='[ ]' # space or tab
|
||||
sed "s/^#define${SP}\\([A-Z0-9_][A-Z0-9_]*\\)${SP}${SP}*\\([0-9][0-9]*\\)/SYS_\\1=\\2/g" \
|
||||
< $1 | grep -v SYS__
|
||||
|
||||
cat <<EOF
|
||||
)
|
||||
EOF
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
// Copyright 2015 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package plan9
|
||||
|
||||
import "syscall"
|
||||
|
||||
func fixwd() {
|
||||
syscall.Fixwd()
|
||||
}
|
||||
|
||||
func Getwd() (wd string, err error) {
|
||||
return syscall.Getwd()
|
||||
}
|
||||
|
||||
func Chdir(path string) error {
|
||||
return syscall.Chdir(path)
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
// Copyright 2012 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build plan9 && race
|
||||
|
||||
package plan9
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const raceenabled = true
|
||||
|
||||
func raceAcquire(addr unsafe.Pointer) {
|
||||
runtime.RaceAcquire(addr)
|
||||
}
|
||||
|
||||
func raceReleaseMerge(addr unsafe.Pointer) {
|
||||
runtime.RaceReleaseMerge(addr)
|
||||
}
|
||||
|
||||
func raceReadRange(addr unsafe.Pointer, len int) {
|
||||
runtime.RaceReadRange(addr, len)
|
||||
}
|
||||
|
||||
func raceWriteRange(addr unsafe.Pointer, len int) {
|
||||
runtime.RaceWriteRange(addr, len)
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
// Copyright 2012 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build plan9 && !race
|
||||
|
||||
package plan9
|
||||
|
||||
import (
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const raceenabled = false
|
||||
|
||||
func raceAcquire(addr unsafe.Pointer) {
|
||||
}
|
||||
|
||||
func raceReleaseMerge(addr unsafe.Pointer) {
|
||||
}
|
||||
|
||||
func raceReadRange(addr unsafe.Pointer, len int) {
|
||||
}
|
||||
|
||||
func raceWriteRange(addr unsafe.Pointer, len int) {
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
// Copyright 2009 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build plan9
|
||||
|
||||
package plan9
|
||||
|
||||
func itoa(val int) string { // do it here rather than with fmt to avoid dependency
|
||||
if val < 0 {
|
||||
return "-" + itoa(-val)
|
||||
}
|
||||
var buf [32]byte // big enough for int64
|
||||
i := len(buf) - 1
|
||||
for val >= 10 {
|
||||
buf[i] = byte(val%10 + '0')
|
||||
i--
|
||||
val /= 10
|
||||
}
|
||||
buf[i] = byte(val + '0')
|
||||
return string(buf[i:])
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
// Copyright 2009 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build plan9
|
||||
|
||||
// Package plan9 contains an interface to the low-level operating system
|
||||
// primitives. OS details vary depending on the underlying system, and
|
||||
// by default, godoc will display the OS-specific documentation for the current
|
||||
// system. If you want godoc to display documentation for another
|
||||
// system, set $GOOS and $GOARCH to the desired system. For example, if
|
||||
// you want to view documentation for freebsd/arm on linux/amd64, set $GOOS
|
||||
// to freebsd and $GOARCH to arm.
|
||||
//
|
||||
// The primary use of this package is inside other packages that provide a more
|
||||
// portable interface to the system, such as "os", "time" and "net". Use
|
||||
// those packages rather than this one if you can.
|
||||
//
|
||||
// For details of the functions and data types in this package consult
|
||||
// the manuals for the appropriate operating system.
|
||||
//
|
||||
// These calls return err == nil to indicate success; otherwise
|
||||
// err represents an operating system error describing the failure and
|
||||
// holds a value of type syscall.ErrorString.
|
||||
package plan9 // import "golang.org/x/sys/plan9"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// ByteSliceFromString returns a NUL-terminated slice of bytes
|
||||
// containing the text of s. If s contains a NUL byte at any
|
||||
// location, it returns (nil, EINVAL).
|
||||
func ByteSliceFromString(s string) ([]byte, error) {
|
||||
if strings.IndexByte(s, 0) != -1 {
|
||||
return nil, EINVAL
|
||||
}
|
||||
a := make([]byte, len(s)+1)
|
||||
copy(a, s)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// BytePtrFromString returns a pointer to a NUL-terminated array of
|
||||
// bytes containing the text of s. If s contains a NUL byte at any
|
||||
// location, it returns (nil, EINVAL).
|
||||
func BytePtrFromString(s string) (*byte, error) {
|
||||
a, err := ByteSliceFromString(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a[0], nil
|
||||
}
|
||||
|
||||
// ByteSliceToString returns a string form of the text represented by the slice s, with a terminating NUL and any
|
||||
// bytes after the NUL removed.
|
||||
func ByteSliceToString(s []byte) string {
|
||||
if i := bytes.IndexByte(s, 0); i != -1 {
|
||||
s = s[:i]
|
||||
}
|
||||
return string(s)
|
||||
}
|
||||
|
||||
// BytePtrToString takes a pointer to a sequence of text and returns the corresponding string.
|
||||
// If the pointer is nil, it returns the empty string. It assumes that the text sequence is terminated
|
||||
// at a zero byte; if the zero byte is not present, the program may crash.
|
||||
func BytePtrToString(p *byte) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
if *p == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Find NUL terminator.
|
||||
n := 0
|
||||
for ptr := unsafe.Pointer(p); *(*byte)(ptr) != 0; n++ {
|
||||
ptr = unsafe.Pointer(uintptr(ptr) + 1)
|
||||
}
|
||||
|
||||
return string(unsafe.Slice(p, n))
|
||||
}
|
||||
|
||||
// Single-word zero for use when we need a valid pointer to 0 bytes.
|
||||
// See mksyscall.pl.
|
||||
var _zero uintptr
|
||||
|
||||
func (ts *Timespec) Unix() (sec int64, nsec int64) {
|
||||
return int64(ts.Sec), int64(ts.Nsec)
|
||||
}
|
||||
|
||||
func (tv *Timeval) Unix() (sec int64, nsec int64) {
|
||||
return int64(tv.Sec), int64(tv.Usec) * 1000
|
||||
}
|
||||
|
||||
func (ts *Timespec) Nano() int64 {
|
||||
return int64(ts.Sec)*1e9 + int64(ts.Nsec)
|
||||
}
|
||||
|
||||
func (tv *Timeval) Nano() int64 {
|
||||
return int64(tv.Sec)*1e9 + int64(tv.Usec)*1000
|
||||
}
|
||||
|
||||
// use is a no-op, but the compiler cannot see that it is.
|
||||
// Calling use(p) ensures that p is kept live until that point.
|
||||
//
|
||||
//go:noescape
|
||||
func use(p unsafe.Pointer)
|
||||
+355
@@ -0,0 +1,355 @@
|
||||
// Copyright 2011 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Plan 9 system calls.
|
||||
// This file is compiled as ordinary Go code,
|
||||
// but it is also input to mksyscall,
|
||||
// which parses the //sys lines and generates system call stubs.
|
||||
// Note that sometimes we use a lowercase //sys name and
|
||||
// wrap it in our own nicer implementation.
|
||||
|
||||
package plan9
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// A Note is a string describing a process note.
|
||||
// It implements the os.Signal interface.
|
||||
type Note = syscall.Note
|
||||
|
||||
var (
|
||||
Stdin = 0
|
||||
Stdout = 1
|
||||
Stderr = 2
|
||||
)
|
||||
|
||||
// For testing: clients can set this flag to force
|
||||
// creation of IPv6 sockets to return EAFNOSUPPORT.
|
||||
var SocketDisableIPv6 bool
|
||||
|
||||
func Syscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err syscall.ErrorString)
|
||||
func Syscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err syscall.ErrorString)
|
||||
func RawSyscall(trap, a1, a2, a3 uintptr) (r1, r2, err uintptr)
|
||||
func RawSyscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2, err uintptr)
|
||||
|
||||
func atoi(b []byte) (n uint) {
|
||||
n = 0
|
||||
for i := 0; i < len(b); i++ {
|
||||
n = n*10 + uint(b[i]-'0')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func cstring(s []byte) string {
|
||||
i := bytes.IndexByte(s, 0)
|
||||
if i == -1 {
|
||||
i = len(s)
|
||||
}
|
||||
return string(s[:i])
|
||||
}
|
||||
|
||||
func errstr() string {
|
||||
var buf [ERRMAX]byte
|
||||
|
||||
RawSyscall(SYS_ERRSTR, uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)), 0)
|
||||
|
||||
buf[len(buf)-1] = 0
|
||||
return cstring(buf[:])
|
||||
}
|
||||
|
||||
// Implemented in assembly to import from runtime.
|
||||
func exit(code int)
|
||||
|
||||
func Exit(code int) { exit(code) }
|
||||
|
||||
func readnum(path string) (uint, error) {
|
||||
var b [12]byte
|
||||
|
||||
fd, e := Open(path, O_RDONLY)
|
||||
if e != nil {
|
||||
return 0, e
|
||||
}
|
||||
defer Close(fd)
|
||||
|
||||
n, e := Pread(fd, b[:], 0)
|
||||
|
||||
if e != nil {
|
||||
return 0, e
|
||||
}
|
||||
|
||||
m := 0
|
||||
for ; m < n && b[m] == ' '; m++ {
|
||||
}
|
||||
|
||||
return atoi(b[m : n-1]), nil
|
||||
}
|
||||
|
||||
func Getpid() (pid int) {
|
||||
n, _ := readnum("#c/pid")
|
||||
return int(n)
|
||||
}
|
||||
|
||||
func Getppid() (ppid int) {
|
||||
n, _ := readnum("#c/ppid")
|
||||
return int(n)
|
||||
}
|
||||
|
||||
func Read(fd int, p []byte) (n int, err error) {
|
||||
return Pread(fd, p, -1)
|
||||
}
|
||||
|
||||
func Write(fd int, p []byte) (n int, err error) {
|
||||
return Pwrite(fd, p, -1)
|
||||
}
|
||||
|
||||
var ioSync int64
|
||||
|
||||
//sys fd2path(fd int, buf []byte) (err error)
|
||||
|
||||
func Fd2path(fd int) (path string, err error) {
|
||||
var buf [512]byte
|
||||
|
||||
e := fd2path(fd, buf[:])
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
return cstring(buf[:]), nil
|
||||
}
|
||||
|
||||
//sys pipe(p *[2]int32) (err error)
|
||||
|
||||
func Pipe(p []int) (err error) {
|
||||
if len(p) != 2 {
|
||||
return syscall.ErrorString("bad arg in system call")
|
||||
}
|
||||
var pp [2]int32
|
||||
err = pipe(&pp)
|
||||
if err == nil {
|
||||
p[0] = int(pp[0])
|
||||
p[1] = int(pp[1])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Underlying system call writes to newoffset via pointer.
|
||||
// Implemented in assembly to avoid allocation.
|
||||
func seek(placeholder uintptr, fd int, offset int64, whence int) (newoffset int64, err string)
|
||||
|
||||
func Seek(fd int, offset int64, whence int) (newoffset int64, err error) {
|
||||
newoffset, e := seek(0, fd, offset, whence)
|
||||
|
||||
if newoffset == -1 {
|
||||
err = syscall.ErrorString(e)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func Mkdir(path string, mode uint32) (err error) {
|
||||
fd, err := Create(path, O_RDONLY, DMDIR|mode)
|
||||
|
||||
if fd != -1 {
|
||||
Close(fd)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
type Waitmsg struct {
|
||||
Pid int
|
||||
Time [3]uint32
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (w Waitmsg) Exited() bool { return true }
|
||||
func (w Waitmsg) Signaled() bool { return false }
|
||||
|
||||
func (w Waitmsg) ExitStatus() int {
|
||||
if len(w.Msg) == 0 {
|
||||
// a normal exit returns no message
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
//sys await(s []byte) (n int, err error)
|
||||
|
||||
func Await(w *Waitmsg) (err error) {
|
||||
var buf [512]byte
|
||||
var f [5][]byte
|
||||
|
||||
n, err := await(buf[:])
|
||||
|
||||
if err != nil || w == nil {
|
||||
return
|
||||
}
|
||||
|
||||
nf := 0
|
||||
p := 0
|
||||
for i := 0; i < n && nf < len(f)-1; i++ {
|
||||
if buf[i] == ' ' {
|
||||
f[nf] = buf[p:i]
|
||||
p = i + 1
|
||||
nf++
|
||||
}
|
||||
}
|
||||
f[nf] = buf[p:]
|
||||
nf++
|
||||
|
||||
if nf != len(f) {
|
||||
return syscall.ErrorString("invalid wait message")
|
||||
}
|
||||
w.Pid = int(atoi(f[0]))
|
||||
w.Time[0] = uint32(atoi(f[1]))
|
||||
w.Time[1] = uint32(atoi(f[2]))
|
||||
w.Time[2] = uint32(atoi(f[3]))
|
||||
w.Msg = cstring(f[4])
|
||||
if w.Msg == "''" {
|
||||
// await() returns '' for no error
|
||||
w.Msg = ""
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func Unmount(name, old string) (err error) {
|
||||
fixwd()
|
||||
oldp, err := BytePtrFromString(old)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
oldptr := uintptr(unsafe.Pointer(oldp))
|
||||
|
||||
var r0 uintptr
|
||||
var e syscall.ErrorString
|
||||
|
||||
// bind(2) man page: If name is zero, everything bound or mounted upon old is unbound or unmounted.
|
||||
if name == "" {
|
||||
r0, _, e = Syscall(SYS_UNMOUNT, _zero, oldptr, 0)
|
||||
} else {
|
||||
namep, err := BytePtrFromString(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r0, _, e = Syscall(SYS_UNMOUNT, uintptr(unsafe.Pointer(namep)), oldptr, 0)
|
||||
}
|
||||
|
||||
if int32(r0) == -1 {
|
||||
err = e
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func Fchdir(fd int) (err error) {
|
||||
path, err := Fd2path(fd)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return Chdir(path)
|
||||
}
|
||||
|
||||
type Timespec struct {
|
||||
Sec int32
|
||||
Nsec int32
|
||||
}
|
||||
|
||||
type Timeval struct {
|
||||
Sec int32
|
||||
Usec int32
|
||||
}
|
||||
|
||||
func NsecToTimeval(nsec int64) (tv Timeval) {
|
||||
nsec += 999 // round up to microsecond
|
||||
tv.Usec = int32(nsec % 1e9 / 1e3)
|
||||
tv.Sec = int32(nsec / 1e9)
|
||||
return
|
||||
}
|
||||
|
||||
func nsec() int64 {
|
||||
var scratch int64
|
||||
|
||||
r0, _, _ := Syscall(SYS_NSEC, uintptr(unsafe.Pointer(&scratch)), 0, 0)
|
||||
// TODO(aram): remove hack after I fix _nsec in the pc64 kernel.
|
||||
if r0 == 0 {
|
||||
return scratch
|
||||
}
|
||||
return int64(r0)
|
||||
}
|
||||
|
||||
func Gettimeofday(tv *Timeval) error {
|
||||
nsec := nsec()
|
||||
*tv = NsecToTimeval(nsec)
|
||||
return nil
|
||||
}
|
||||
|
||||
func Getpagesize() int { return 0x1000 }
|
||||
|
||||
func Getegid() (egid int) { return -1 }
|
||||
func Geteuid() (euid int) { return -1 }
|
||||
func Getgid() (gid int) { return -1 }
|
||||
func Getuid() (uid int) { return -1 }
|
||||
|
||||
func Getgroups() (gids []int, err error) {
|
||||
return make([]int, 0), nil
|
||||
}
|
||||
|
||||
//sys open(path string, mode int) (fd int, err error)
|
||||
|
||||
func Open(path string, mode int) (fd int, err error) {
|
||||
fixwd()
|
||||
return open(path, mode)
|
||||
}
|
||||
|
||||
//sys create(path string, mode int, perm uint32) (fd int, err error)
|
||||
|
||||
func Create(path string, mode int, perm uint32) (fd int, err error) {
|
||||
fixwd()
|
||||
return create(path, mode, perm)
|
||||
}
|
||||
|
||||
//sys remove(path string) (err error)
|
||||
|
||||
func Remove(path string) error {
|
||||
fixwd()
|
||||
return remove(path)
|
||||
}
|
||||
|
||||
//sys stat(path string, edir []byte) (n int, err error)
|
||||
|
||||
func Stat(path string, edir []byte) (n int, err error) {
|
||||
fixwd()
|
||||
return stat(path, edir)
|
||||
}
|
||||
|
||||
//sys bind(name string, old string, flag int) (err error)
|
||||
|
||||
func Bind(name string, old string, flag int) (err error) {
|
||||
fixwd()
|
||||
return bind(name, old, flag)
|
||||
}
|
||||
|
||||
//sys mount(fd int, afd int, old string, flag int, aname string) (err error)
|
||||
|
||||
func Mount(fd int, afd int, old string, flag int, aname string) (err error) {
|
||||
fixwd()
|
||||
return mount(fd, afd, old, flag, aname)
|
||||
}
|
||||
|
||||
//sys wstat(path string, edir []byte) (err error)
|
||||
|
||||
func Wstat(path string, edir []byte) (err error) {
|
||||
fixwd()
|
||||
return wstat(path, edir)
|
||||
}
|
||||
|
||||
//sys chdir(path string) (err error)
|
||||
//sys Dup(oldfd int, newfd int) (fd int, err error)
|
||||
//sys Pread(fd int, p []byte, offset int64) (n int, err error)
|
||||
//sys Pwrite(fd int, p []byte, offset int64) (n int, err error)
|
||||
//sys Close(fd int) (err error)
|
||||
//sys Fstat(fd int, edir []byte) (n int, err error)
|
||||
//sys Fwstat(fd int, edir []byte) (err error)
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
// go run mksyscall.go -l32 -plan9 -tags plan9,386 syscall_plan9.go
|
||||
// Code generated by the command above; see README.md. DO NOT EDIT.
|
||||
|
||||
//go:build plan9 && 386
|
||||
|
||||
package plan9
|
||||
|
||||
import "unsafe"
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func fd2path(fd int, buf []byte) (err error) {
|
||||
var _p0 unsafe.Pointer
|
||||
if len(buf) > 0 {
|
||||
_p0 = unsafe.Pointer(&buf[0])
|
||||
} else {
|
||||
_p0 = unsafe.Pointer(&_zero)
|
||||
}
|
||||
r0, _, e1 := Syscall(SYS_FD2PATH, uintptr(fd), uintptr(_p0), uintptr(len(buf)))
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func pipe(p *[2]int32) (err error) {
|
||||
r0, _, e1 := Syscall(SYS_PIPE, uintptr(unsafe.Pointer(p)), 0, 0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func await(s []byte) (n int, err error) {
|
||||
var _p0 unsafe.Pointer
|
||||
if len(s) > 0 {
|
||||
_p0 = unsafe.Pointer(&s[0])
|
||||
} else {
|
||||
_p0 = unsafe.Pointer(&_zero)
|
||||
}
|
||||
r0, _, e1 := Syscall(SYS_AWAIT, uintptr(_p0), uintptr(len(s)), 0)
|
||||
n = int(r0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func open(path string, mode int) (fd int, err error) {
|
||||
var _p0 *byte
|
||||
_p0, err = BytePtrFromString(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r0, _, e1 := Syscall(SYS_OPEN, uintptr(unsafe.Pointer(_p0)), uintptr(mode), 0)
|
||||
fd = int(r0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func create(path string, mode int, perm uint32) (fd int, err error) {
|
||||
var _p0 *byte
|
||||
_p0, err = BytePtrFromString(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r0, _, e1 := Syscall(SYS_CREATE, uintptr(unsafe.Pointer(_p0)), uintptr(mode), uintptr(perm))
|
||||
fd = int(r0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func remove(path string) (err error) {
|
||||
var _p0 *byte
|
||||
_p0, err = BytePtrFromString(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r0, _, e1 := Syscall(SYS_REMOVE, uintptr(unsafe.Pointer(_p0)), 0, 0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func stat(path string, edir []byte) (n int, err error) {
|
||||
var _p0 *byte
|
||||
_p0, err = BytePtrFromString(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var _p1 unsafe.Pointer
|
||||
if len(edir) > 0 {
|
||||
_p1 = unsafe.Pointer(&edir[0])
|
||||
} else {
|
||||
_p1 = unsafe.Pointer(&_zero)
|
||||
}
|
||||
r0, _, e1 := Syscall(SYS_STAT, uintptr(unsafe.Pointer(_p0)), uintptr(_p1), uintptr(len(edir)))
|
||||
n = int(r0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func bind(name string, old string, flag int) (err error) {
|
||||
var _p0 *byte
|
||||
_p0, err = BytePtrFromString(name)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var _p1 *byte
|
||||
_p1, err = BytePtrFromString(old)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r0, _, e1 := Syscall(SYS_BIND, uintptr(unsafe.Pointer(_p0)), uintptr(unsafe.Pointer(_p1)), uintptr(flag))
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func mount(fd int, afd int, old string, flag int, aname string) (err error) {
|
||||
var _p0 *byte
|
||||
_p0, err = BytePtrFromString(old)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var _p1 *byte
|
||||
_p1, err = BytePtrFromString(aname)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r0, _, e1 := Syscall6(SYS_MOUNT, uintptr(fd), uintptr(afd), uintptr(unsafe.Pointer(_p0)), uintptr(flag), uintptr(unsafe.Pointer(_p1)), 0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func wstat(path string, edir []byte) (err error) {
|
||||
var _p0 *byte
|
||||
_p0, err = BytePtrFromString(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var _p1 unsafe.Pointer
|
||||
if len(edir) > 0 {
|
||||
_p1 = unsafe.Pointer(&edir[0])
|
||||
} else {
|
||||
_p1 = unsafe.Pointer(&_zero)
|
||||
}
|
||||
r0, _, e1 := Syscall(SYS_WSTAT, uintptr(unsafe.Pointer(_p0)), uintptr(_p1), uintptr(len(edir)))
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func chdir(path string) (err error) {
|
||||
var _p0 *byte
|
||||
_p0, err = BytePtrFromString(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r0, _, e1 := Syscall(SYS_CHDIR, uintptr(unsafe.Pointer(_p0)), 0, 0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func Dup(oldfd int, newfd int) (fd int, err error) {
|
||||
r0, _, e1 := Syscall(SYS_DUP, uintptr(oldfd), uintptr(newfd), 0)
|
||||
fd = int(r0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func Pread(fd int, p []byte, offset int64) (n int, err error) {
|
||||
var _p0 unsafe.Pointer
|
||||
if len(p) > 0 {
|
||||
_p0 = unsafe.Pointer(&p[0])
|
||||
} else {
|
||||
_p0 = unsafe.Pointer(&_zero)
|
||||
}
|
||||
r0, _, e1 := Syscall6(SYS_PREAD, uintptr(fd), uintptr(_p0), uintptr(len(p)), uintptr(offset), uintptr(offset>>32), 0)
|
||||
n = int(r0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func Pwrite(fd int, p []byte, offset int64) (n int, err error) {
|
||||
var _p0 unsafe.Pointer
|
||||
if len(p) > 0 {
|
||||
_p0 = unsafe.Pointer(&p[0])
|
||||
} else {
|
||||
_p0 = unsafe.Pointer(&_zero)
|
||||
}
|
||||
r0, _, e1 := Syscall6(SYS_PWRITE, uintptr(fd), uintptr(_p0), uintptr(len(p)), uintptr(offset), uintptr(offset>>32), 0)
|
||||
n = int(r0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func Close(fd int) (err error) {
|
||||
r0, _, e1 := Syscall(SYS_CLOSE, uintptr(fd), 0, 0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func Fstat(fd int, edir []byte) (n int, err error) {
|
||||
var _p0 unsafe.Pointer
|
||||
if len(edir) > 0 {
|
||||
_p0 = unsafe.Pointer(&edir[0])
|
||||
} else {
|
||||
_p0 = unsafe.Pointer(&_zero)
|
||||
}
|
||||
r0, _, e1 := Syscall(SYS_FSTAT, uintptr(fd), uintptr(_p0), uintptr(len(edir)))
|
||||
n = int(r0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func Fwstat(fd int, edir []byte) (err error) {
|
||||
var _p0 unsafe.Pointer
|
||||
if len(edir) > 0 {
|
||||
_p0 = unsafe.Pointer(&edir[0])
|
||||
} else {
|
||||
_p0 = unsafe.Pointer(&_zero)
|
||||
}
|
||||
r0, _, e1 := Syscall(SYS_FWSTAT, uintptr(fd), uintptr(_p0), uintptr(len(edir)))
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
// go run mksyscall.go -l32 -plan9 -tags plan9,amd64 syscall_plan9.go
|
||||
// Code generated by the command above; see README.md. DO NOT EDIT.
|
||||
|
||||
//go:build plan9 && amd64
|
||||
|
||||
package plan9
|
||||
|
||||
import "unsafe"
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func fd2path(fd int, buf []byte) (err error) {
|
||||
var _p0 unsafe.Pointer
|
||||
if len(buf) > 0 {
|
||||
_p0 = unsafe.Pointer(&buf[0])
|
||||
} else {
|
||||
_p0 = unsafe.Pointer(&_zero)
|
||||
}
|
||||
r0, _, e1 := Syscall(SYS_FD2PATH, uintptr(fd), uintptr(_p0), uintptr(len(buf)))
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func pipe(p *[2]int32) (err error) {
|
||||
r0, _, e1 := Syscall(SYS_PIPE, uintptr(unsafe.Pointer(p)), 0, 0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func await(s []byte) (n int, err error) {
|
||||
var _p0 unsafe.Pointer
|
||||
if len(s) > 0 {
|
||||
_p0 = unsafe.Pointer(&s[0])
|
||||
} else {
|
||||
_p0 = unsafe.Pointer(&_zero)
|
||||
}
|
||||
r0, _, e1 := Syscall(SYS_AWAIT, uintptr(_p0), uintptr(len(s)), 0)
|
||||
n = int(r0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func open(path string, mode int) (fd int, err error) {
|
||||
var _p0 *byte
|
||||
_p0, err = BytePtrFromString(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r0, _, e1 := Syscall(SYS_OPEN, uintptr(unsafe.Pointer(_p0)), uintptr(mode), 0)
|
||||
fd = int(r0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func create(path string, mode int, perm uint32) (fd int, err error) {
|
||||
var _p0 *byte
|
||||
_p0, err = BytePtrFromString(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r0, _, e1 := Syscall(SYS_CREATE, uintptr(unsafe.Pointer(_p0)), uintptr(mode), uintptr(perm))
|
||||
fd = int(r0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func remove(path string) (err error) {
|
||||
var _p0 *byte
|
||||
_p0, err = BytePtrFromString(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r0, _, e1 := Syscall(SYS_REMOVE, uintptr(unsafe.Pointer(_p0)), 0, 0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func stat(path string, edir []byte) (n int, err error) {
|
||||
var _p0 *byte
|
||||
_p0, err = BytePtrFromString(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var _p1 unsafe.Pointer
|
||||
if len(edir) > 0 {
|
||||
_p1 = unsafe.Pointer(&edir[0])
|
||||
} else {
|
||||
_p1 = unsafe.Pointer(&_zero)
|
||||
}
|
||||
r0, _, e1 := Syscall(SYS_STAT, uintptr(unsafe.Pointer(_p0)), uintptr(_p1), uintptr(len(edir)))
|
||||
n = int(r0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func bind(name string, old string, flag int) (err error) {
|
||||
var _p0 *byte
|
||||
_p0, err = BytePtrFromString(name)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var _p1 *byte
|
||||
_p1, err = BytePtrFromString(old)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r0, _, e1 := Syscall(SYS_BIND, uintptr(unsafe.Pointer(_p0)), uintptr(unsafe.Pointer(_p1)), uintptr(flag))
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func mount(fd int, afd int, old string, flag int, aname string) (err error) {
|
||||
var _p0 *byte
|
||||
_p0, err = BytePtrFromString(old)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var _p1 *byte
|
||||
_p1, err = BytePtrFromString(aname)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r0, _, e1 := Syscall6(SYS_MOUNT, uintptr(fd), uintptr(afd), uintptr(unsafe.Pointer(_p0)), uintptr(flag), uintptr(unsafe.Pointer(_p1)), 0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func wstat(path string, edir []byte) (err error) {
|
||||
var _p0 *byte
|
||||
_p0, err = BytePtrFromString(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var _p1 unsafe.Pointer
|
||||
if len(edir) > 0 {
|
||||
_p1 = unsafe.Pointer(&edir[0])
|
||||
} else {
|
||||
_p1 = unsafe.Pointer(&_zero)
|
||||
}
|
||||
r0, _, e1 := Syscall(SYS_WSTAT, uintptr(unsafe.Pointer(_p0)), uintptr(_p1), uintptr(len(edir)))
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func chdir(path string) (err error) {
|
||||
var _p0 *byte
|
||||
_p0, err = BytePtrFromString(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r0, _, e1 := Syscall(SYS_CHDIR, uintptr(unsafe.Pointer(_p0)), 0, 0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func Dup(oldfd int, newfd int) (fd int, err error) {
|
||||
r0, _, e1 := Syscall(SYS_DUP, uintptr(oldfd), uintptr(newfd), 0)
|
||||
fd = int(r0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func Pread(fd int, p []byte, offset int64) (n int, err error) {
|
||||
var _p0 unsafe.Pointer
|
||||
if len(p) > 0 {
|
||||
_p0 = unsafe.Pointer(&p[0])
|
||||
} else {
|
||||
_p0 = unsafe.Pointer(&_zero)
|
||||
}
|
||||
r0, _, e1 := Syscall6(SYS_PREAD, uintptr(fd), uintptr(_p0), uintptr(len(p)), uintptr(offset), uintptr(offset>>32), 0)
|
||||
n = int(r0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func Pwrite(fd int, p []byte, offset int64) (n int, err error) {
|
||||
var _p0 unsafe.Pointer
|
||||
if len(p) > 0 {
|
||||
_p0 = unsafe.Pointer(&p[0])
|
||||
} else {
|
||||
_p0 = unsafe.Pointer(&_zero)
|
||||
}
|
||||
r0, _, e1 := Syscall6(SYS_PWRITE, uintptr(fd), uintptr(_p0), uintptr(len(p)), uintptr(offset), uintptr(offset>>32), 0)
|
||||
n = int(r0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func Close(fd int) (err error) {
|
||||
r0, _, e1 := Syscall(SYS_CLOSE, uintptr(fd), 0, 0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func Fstat(fd int, edir []byte) (n int, err error) {
|
||||
var _p0 unsafe.Pointer
|
||||
if len(edir) > 0 {
|
||||
_p0 = unsafe.Pointer(&edir[0])
|
||||
} else {
|
||||
_p0 = unsafe.Pointer(&_zero)
|
||||
}
|
||||
r0, _, e1 := Syscall(SYS_FSTAT, uintptr(fd), uintptr(_p0), uintptr(len(edir)))
|
||||
n = int(r0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func Fwstat(fd int, edir []byte) (err error) {
|
||||
var _p0 unsafe.Pointer
|
||||
if len(edir) > 0 {
|
||||
_p0 = unsafe.Pointer(&edir[0])
|
||||
} else {
|
||||
_p0 = unsafe.Pointer(&_zero)
|
||||
}
|
||||
r0, _, e1 := Syscall(SYS_FWSTAT, uintptr(fd), uintptr(_p0), uintptr(len(edir)))
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
// go run mksyscall.go -l32 -plan9 -tags plan9,arm syscall_plan9.go
|
||||
// Code generated by the command above; see README.md. DO NOT EDIT.
|
||||
|
||||
//go:build plan9 && arm
|
||||
|
||||
package plan9
|
||||
|
||||
import "unsafe"
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func fd2path(fd int, buf []byte) (err error) {
|
||||
var _p0 unsafe.Pointer
|
||||
if len(buf) > 0 {
|
||||
_p0 = unsafe.Pointer(&buf[0])
|
||||
} else {
|
||||
_p0 = unsafe.Pointer(&_zero)
|
||||
}
|
||||
r0, _, e1 := Syscall(SYS_FD2PATH, uintptr(fd), uintptr(_p0), uintptr(len(buf)))
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func pipe(p *[2]int32) (err error) {
|
||||
r0, _, e1 := Syscall(SYS_PIPE, uintptr(unsafe.Pointer(p)), 0, 0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func await(s []byte) (n int, err error) {
|
||||
var _p0 unsafe.Pointer
|
||||
if len(s) > 0 {
|
||||
_p0 = unsafe.Pointer(&s[0])
|
||||
} else {
|
||||
_p0 = unsafe.Pointer(&_zero)
|
||||
}
|
||||
r0, _, e1 := Syscall(SYS_AWAIT, uintptr(_p0), uintptr(len(s)), 0)
|
||||
n = int(r0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func open(path string, mode int) (fd int, err error) {
|
||||
var _p0 *byte
|
||||
_p0, err = BytePtrFromString(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r0, _, e1 := Syscall(SYS_OPEN, uintptr(unsafe.Pointer(_p0)), uintptr(mode), 0)
|
||||
fd = int(r0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func create(path string, mode int, perm uint32) (fd int, err error) {
|
||||
var _p0 *byte
|
||||
_p0, err = BytePtrFromString(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r0, _, e1 := Syscall(SYS_CREATE, uintptr(unsafe.Pointer(_p0)), uintptr(mode), uintptr(perm))
|
||||
fd = int(r0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func remove(path string) (err error) {
|
||||
var _p0 *byte
|
||||
_p0, err = BytePtrFromString(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r0, _, e1 := Syscall(SYS_REMOVE, uintptr(unsafe.Pointer(_p0)), 0, 0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func stat(path string, edir []byte) (n int, err error) {
|
||||
var _p0 *byte
|
||||
_p0, err = BytePtrFromString(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var _p1 unsafe.Pointer
|
||||
if len(edir) > 0 {
|
||||
_p1 = unsafe.Pointer(&edir[0])
|
||||
} else {
|
||||
_p1 = unsafe.Pointer(&_zero)
|
||||
}
|
||||
r0, _, e1 := Syscall(SYS_STAT, uintptr(unsafe.Pointer(_p0)), uintptr(_p1), uintptr(len(edir)))
|
||||
n = int(r0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func bind(name string, old string, flag int) (err error) {
|
||||
var _p0 *byte
|
||||
_p0, err = BytePtrFromString(name)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var _p1 *byte
|
||||
_p1, err = BytePtrFromString(old)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r0, _, e1 := Syscall(SYS_BIND, uintptr(unsafe.Pointer(_p0)), uintptr(unsafe.Pointer(_p1)), uintptr(flag))
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func mount(fd int, afd int, old string, flag int, aname string) (err error) {
|
||||
var _p0 *byte
|
||||
_p0, err = BytePtrFromString(old)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var _p1 *byte
|
||||
_p1, err = BytePtrFromString(aname)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r0, _, e1 := Syscall6(SYS_MOUNT, uintptr(fd), uintptr(afd), uintptr(unsafe.Pointer(_p0)), uintptr(flag), uintptr(unsafe.Pointer(_p1)), 0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func wstat(path string, edir []byte) (err error) {
|
||||
var _p0 *byte
|
||||
_p0, err = BytePtrFromString(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var _p1 unsafe.Pointer
|
||||
if len(edir) > 0 {
|
||||
_p1 = unsafe.Pointer(&edir[0])
|
||||
} else {
|
||||
_p1 = unsafe.Pointer(&_zero)
|
||||
}
|
||||
r0, _, e1 := Syscall(SYS_WSTAT, uintptr(unsafe.Pointer(_p0)), uintptr(_p1), uintptr(len(edir)))
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func chdir(path string) (err error) {
|
||||
var _p0 *byte
|
||||
_p0, err = BytePtrFromString(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r0, _, e1 := Syscall(SYS_CHDIR, uintptr(unsafe.Pointer(_p0)), 0, 0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func Dup(oldfd int, newfd int) (fd int, err error) {
|
||||
r0, _, e1 := Syscall(SYS_DUP, uintptr(oldfd), uintptr(newfd), 0)
|
||||
fd = int(r0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func Pread(fd int, p []byte, offset int64) (n int, err error) {
|
||||
var _p0 unsafe.Pointer
|
||||
if len(p) > 0 {
|
||||
_p0 = unsafe.Pointer(&p[0])
|
||||
} else {
|
||||
_p0 = unsafe.Pointer(&_zero)
|
||||
}
|
||||
r0, _, e1 := Syscall6(SYS_PREAD, uintptr(fd), uintptr(_p0), uintptr(len(p)), uintptr(offset), uintptr(offset>>32), 0)
|
||||
n = int(r0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func Pwrite(fd int, p []byte, offset int64) (n int, err error) {
|
||||
var _p0 unsafe.Pointer
|
||||
if len(p) > 0 {
|
||||
_p0 = unsafe.Pointer(&p[0])
|
||||
} else {
|
||||
_p0 = unsafe.Pointer(&_zero)
|
||||
}
|
||||
r0, _, e1 := Syscall6(SYS_PWRITE, uintptr(fd), uintptr(_p0), uintptr(len(p)), uintptr(offset), uintptr(offset>>32), 0)
|
||||
n = int(r0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func Close(fd int) (err error) {
|
||||
r0, _, e1 := Syscall(SYS_CLOSE, uintptr(fd), 0, 0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func Fstat(fd int, edir []byte) (n int, err error) {
|
||||
var _p0 unsafe.Pointer
|
||||
if len(edir) > 0 {
|
||||
_p0 = unsafe.Pointer(&edir[0])
|
||||
} else {
|
||||
_p0 = unsafe.Pointer(&_zero)
|
||||
}
|
||||
r0, _, e1 := Syscall(SYS_FSTAT, uintptr(fd), uintptr(_p0), uintptr(len(edir)))
|
||||
n = int(r0)
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
|
||||
|
||||
func Fwstat(fd int, edir []byte) (err error) {
|
||||
var _p0 unsafe.Pointer
|
||||
if len(edir) > 0 {
|
||||
_p0 = unsafe.Pointer(&edir[0])
|
||||
} else {
|
||||
_p0 = unsafe.Pointer(&_zero)
|
||||
}
|
||||
r0, _, e1 := Syscall(SYS_FWSTAT, uintptr(fd), uintptr(_p0), uintptr(len(edir)))
|
||||
if int32(r0) == -1 {
|
||||
err = e1
|
||||
}
|
||||
return
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
// mksysnum_plan9.sh /opt/plan9/sys/src/libc/9syscall/sys.h
|
||||
// MACHINE GENERATED BY THE ABOVE COMMAND; DO NOT EDIT
|
||||
|
||||
package plan9
|
||||
|
||||
const (
|
||||
SYS_SYSR1 = 0
|
||||
SYS_BIND = 2
|
||||
SYS_CHDIR = 3
|
||||
SYS_CLOSE = 4
|
||||
SYS_DUP = 5
|
||||
SYS_ALARM = 6
|
||||
SYS_EXEC = 7
|
||||
SYS_EXITS = 8
|
||||
SYS_FAUTH = 10
|
||||
SYS_SEGBRK = 12
|
||||
SYS_OPEN = 14
|
||||
SYS_OSEEK = 16
|
||||
SYS_SLEEP = 17
|
||||
SYS_RFORK = 19
|
||||
SYS_PIPE = 21
|
||||
SYS_CREATE = 22
|
||||
SYS_FD2PATH = 23
|
||||
SYS_BRK_ = 24
|
||||
SYS_REMOVE = 25
|
||||
SYS_NOTIFY = 28
|
||||
SYS_NOTED = 29
|
||||
SYS_SEGATTACH = 30
|
||||
SYS_SEGDETACH = 31
|
||||
SYS_SEGFREE = 32
|
||||
SYS_SEGFLUSH = 33
|
||||
SYS_RENDEZVOUS = 34
|
||||
SYS_UNMOUNT = 35
|
||||
SYS_SEMACQUIRE = 37
|
||||
SYS_SEMRELEASE = 38
|
||||
SYS_SEEK = 39
|
||||
SYS_FVERSION = 40
|
||||
SYS_ERRSTR = 41
|
||||
SYS_STAT = 42
|
||||
SYS_FSTAT = 43
|
||||
SYS_WSTAT = 44
|
||||
SYS_FWSTAT = 45
|
||||
SYS_MOUNT = 46
|
||||
SYS_AWAIT = 47
|
||||
SYS_PREAD = 50
|
||||
SYS_PWRITE = 51
|
||||
SYS_TSEMACQUIRE = 52
|
||||
SYS_NSEC = 53
|
||||
)
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
_obj/
|
||||
unix.test
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
# Building `sys/unix`
|
||||
|
||||
The sys/unix package provides access to the raw system call interface of the
|
||||
underlying operating system. See: https://godoc.org/golang.org/x/sys/unix
|
||||
|
||||
Porting Go to a new architecture/OS combination or adding syscalls, types, or
|
||||
constants to an existing architecture/OS pair requires some manual effort;
|
||||
however, there are tools that automate much of the process.
|
||||
|
||||
## Build Systems
|
||||
|
||||
There are currently two ways we generate the necessary files. We are currently
|
||||
migrating the build system to use containers so the builds are reproducible.
|
||||
This is being done on an OS-by-OS basis. Please update this documentation as
|
||||
components of the build system change.
|
||||
|
||||
### Old Build System (currently for `GOOS != "linux"`)
|
||||
|
||||
The old build system generates the Go files based on the C header files
|
||||
present on your system. This means that files
|
||||
for a given GOOS/GOARCH pair must be generated on a system with that OS and
|
||||
architecture. This also means that the generated code can differ from system
|
||||
to system, based on differences in the header files.
|
||||
|
||||
To avoid this, if you are using the old build system, only generate the Go
|
||||
files on an installation with unmodified header files. It is also important to
|
||||
keep track of which version of the OS the files were generated from (ex.
|
||||
Darwin 14 vs Darwin 15). This makes it easier to track the progress of changes
|
||||
and have each OS upgrade correspond to a single change.
|
||||
|
||||
To build the files for your current OS and architecture, make sure GOOS and
|
||||
GOARCH are set correctly and run `mkall.sh`. This will generate the files for
|
||||
your specific system. Running `mkall.sh -n` shows the commands that will be run.
|
||||
|
||||
Requirements: bash, go
|
||||
|
||||
### New Build System (currently for `GOOS == "linux"`)
|
||||
|
||||
The new build system uses a Docker container to generate the go files directly
|
||||
from source checkouts of the kernel and various system libraries. This means
|
||||
that on any platform that supports Docker, all the files using the new build
|
||||
system can be generated at once, and generated files will not change based on
|
||||
what the person running the scripts has installed on their computer.
|
||||
|
||||
The OS specific files for the new build system are located in the `${GOOS}`
|
||||
directory, and the build is coordinated by the `${GOOS}/mkall.go` program. When
|
||||
the kernel or system library updates, modify the Dockerfile at
|
||||
`${GOOS}/Dockerfile` to checkout the new release of the source.
|
||||
|
||||
To build all the files under the new build system, you must be on an amd64/Linux
|
||||
system and have your GOOS and GOARCH set accordingly. Running `mkall.sh` will
|
||||
then generate all of the files for all of the GOOS/GOARCH pairs in the new build
|
||||
system. Running `mkall.sh -n` shows the commands that will be run.
|
||||
|
||||
Requirements: bash, go, docker
|
||||
|
||||
## Component files
|
||||
|
||||
This section describes the various files used in the code generation process.
|
||||
It also contains instructions on how to modify these files to add a new
|
||||
architecture/OS or to add additional syscalls, types, or constants. Note that
|
||||
if you are using the new build system, the scripts/programs cannot be called normally.
|
||||
They must be called from within the docker container.
|
||||
|
||||
### asm files
|
||||
|
||||
The hand-written assembly file at `asm_${GOOS}_${GOARCH}.s` implements system
|
||||
call dispatch. There are three entry points:
|
||||
```
|
||||
func Syscall(trap, a1, a2, a3 uintptr) (r1, r2, err uintptr)
|
||||
func Syscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2, err uintptr)
|
||||
func RawSyscall(trap, a1, a2, a3 uintptr) (r1, r2, err uintptr)
|
||||
```
|
||||
The first and second are the standard ones; they differ only in how many
|
||||
arguments can be passed to the kernel. The third is for low-level use by the
|
||||
ForkExec wrapper. Unlike the first two, it does not call into the scheduler to
|
||||
let it know that a system call is running.
|
||||
|
||||
When porting Go to a new architecture/OS, this file must be implemented for
|
||||
each GOOS/GOARCH pair.
|
||||
|
||||
### mksysnum
|
||||
|
||||
Mksysnum is a Go program located at `${GOOS}/mksysnum.go` (or `mksysnum_${GOOS}.go`
|
||||
for the old system). This program takes in a list of header files containing the
|
||||
syscall number declarations and parses them to produce the corresponding list of
|
||||
Go numeric constants. See `zsysnum_${GOOS}_${GOARCH}.go` for the generated
|
||||
constants.
|
||||
|
||||
Adding new syscall numbers is mostly done by running the build on a sufficiently
|
||||
new installation of the target OS (or updating the source checkouts for the
|
||||
new build system). However, depending on the OS, you may need to update the
|
||||
parsing in mksysnum.
|
||||
|
||||
### mksyscall.go
|
||||
|
||||
The `syscall.go`, `syscall_${GOOS}.go`, `syscall_${GOOS}_${GOARCH}.go` are
|
||||
hand-written Go files which implement system calls (for unix, the specific OS,
|
||||
or the specific OS/Architecture pair respectively) that need special handling
|
||||
and list `//sys` comments giving prototypes for ones that can be generated.
|
||||
|
||||
The mksyscall.go program takes the `//sys` and `//sysnb` comments and converts
|
||||
them into syscalls. This requires the name of the prototype in the comment to
|
||||
match a syscall number in the `zsysnum_${GOOS}_${GOARCH}.go` file. The function
|
||||
prototype can be exported (capitalized) or not.
|
||||
|
||||
Adding a new syscall often just requires adding a new `//sys` function prototype
|
||||
with the desired arguments and a capitalized name so it is exported. However, if
|
||||
you want the interface to the syscall to be different, often one will make an
|
||||
unexported `//sys` prototype, and then write a custom wrapper in
|
||||
`syscall_${GOOS}.go`.
|
||||
|
||||
### types files
|
||||
|
||||
For each OS, there is a hand-written Go file at `${GOOS}/types.go` (or
|
||||
`types_${GOOS}.go` on the old system). This file includes standard C headers and
|
||||
creates Go type aliases to the corresponding C types. The file is then fed
|
||||
through godef to get the Go compatible definitions. Finally, the generated code
|
||||
is fed though mkpost.go to format the code correctly and remove any hidden or
|
||||
private identifiers. This cleaned-up code is written to
|
||||
`ztypes_${GOOS}_${GOARCH}.go`.
|
||||
|
||||
The hardest part about preparing this file is figuring out which headers to
|
||||
include and which symbols need to be `#define`d to get the actual data
|
||||
structures that pass through to the kernel system calls. Some C libraries
|
||||
preset alternate versions for binary compatibility and translate them on the
|
||||
way in and out of system calls, but there is almost always a `#define` that can
|
||||
get the real ones.
|
||||
See `types_darwin.go` and `linux/types.go` for examples.
|
||||
|
||||
To add a new type, add in the necessary include statement at the top of the
|
||||
file (if it is not already there) and add in a type alias line. Note that if
|
||||
your type is significantly different on different architectures, you may need
|
||||
some `#if/#elif` macros in your include statements.
|
||||
|
||||
### mkerrors.sh
|
||||
|
||||
This script is used to generate the system's various constants. This doesn't
|
||||
just include the error numbers and error strings, but also the signal numbers
|
||||
and a wide variety of miscellaneous constants. The constants come from the list
|
||||
of include files in the `includes_${uname}` variable. A regex then picks out
|
||||
the desired `#define` statements, and generates the corresponding Go constants.
|
||||
The error numbers and strings are generated from `#include <errno.h>`, and the
|
||||
signal numbers and strings are generated from `#include <signal.h>`. All of
|
||||
these constants are written to `zerrors_${GOOS}_${GOARCH}.go` via a C program,
|
||||
`_errors.c`, which prints out all the constants.
|
||||
|
||||
To add a constant, add the header that includes it to the appropriate variable.
|
||||
Then, edit the regex (if necessary) to match the desired constant. Avoid making
|
||||
the regex too broad to avoid matching unintended constants.
|
||||
|
||||
### internal/mkmerge
|
||||
|
||||
This program is used to extract duplicate const, func, and type declarations
|
||||
from the generated architecture-specific files listed below, and merge these
|
||||
into a common file for each OS.
|
||||
|
||||
The merge is performed in the following steps:
|
||||
1. Construct the set of common code that is identical in all architecture-specific files.
|
||||
2. Write this common code to the merged file.
|
||||
3. Remove the common code from all architecture-specific files.
|
||||
|
||||
|
||||
## Generated files
|
||||
|
||||
### `zerrors_${GOOS}_${GOARCH}.go`
|
||||
|
||||
A file containing all of the system's generated error numbers, error strings,
|
||||
signal numbers, and constants. Generated by `mkerrors.sh` (see above).
|
||||
|
||||
### `zsyscall_${GOOS}_${GOARCH}.go`
|
||||
|
||||
A file containing all the generated syscalls for a specific GOOS and GOARCH.
|
||||
Generated by `mksyscall.go` (see above).
|
||||
|
||||
### `zsysnum_${GOOS}_${GOARCH}.go`
|
||||
|
||||
A list of numeric constants for all the syscall number of the specific GOOS
|
||||
and GOARCH. Generated by mksysnum (see above).
|
||||
|
||||
### `ztypes_${GOOS}_${GOARCH}.go`
|
||||
|
||||
A file containing Go types for passing into (or returning from) syscalls.
|
||||
Generated by godefs and the types file (see above).
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
// Copyright 2018 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// CPU affinity functions
|
||||
|
||||
package unix
|
||||
|
||||
import (
|
||||
"math/bits"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const cpuSetSize = _CPU_SETSIZE / _NCPUBITS
|
||||
|
||||
// CPUSet represents a bit mask of CPUs, to be used with [SchedGetaffinity], [SchedSetaffinity],
|
||||
// and [SetMemPolicy].
|
||||
//
|
||||
// Note this type can only represent CPU IDs 0 through 1023.
|
||||
// Use [CPUSetDynamic]/[NewCPUSet] instead to avoid this limit.
|
||||
type CPUSet [cpuSetSize]cpuMask
|
||||
|
||||
// CPUSetDynamic represents a bit mask of CPUs, to be used with [SchedGetaffinityDynamic],
|
||||
// [SchedSetaffinityDynamic], and [SetMemPolicyDynamic]. Use [NewCPUSet] to allocate.
|
||||
type CPUSetDynamic []cpuMask
|
||||
|
||||
func schedAffinity(trap uintptr, pid int, size uintptr, ptr unsafe.Pointer) error {
|
||||
_, _, e := RawSyscall(trap, uintptr(pid), uintptr(size), uintptr(ptr))
|
||||
if e != 0 {
|
||||
return errnoErr(e)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SchedGetaffinity gets the CPU affinity mask of the thread specified by pid.
|
||||
// If pid is 0 the calling thread is used.
|
||||
func SchedGetaffinity(pid int, set *CPUSet) error {
|
||||
return schedAffinity(SYS_SCHED_GETAFFINITY, pid, unsafe.Sizeof(*set), unsafe.Pointer(set))
|
||||
}
|
||||
|
||||
// SchedSetaffinity sets the CPU affinity mask of the thread specified by pid.
|
||||
// If pid is 0 the calling thread is used.
|
||||
func SchedSetaffinity(pid int, set *CPUSet) error {
|
||||
return schedAffinity(SYS_SCHED_SETAFFINITY, pid, unsafe.Sizeof(*set), unsafe.Pointer(set))
|
||||
}
|
||||
|
||||
// Zero clears the set s, so that it contains no CPUs.
|
||||
func (s *CPUSet) Zero() {
|
||||
clear(s[:])
|
||||
}
|
||||
|
||||
// Fill adds all possible CPU bits to the set s. On Linux, [SchedSetaffinity]
|
||||
// will silently ignore any invalid CPU bits in [CPUSet] so this is an
|
||||
// efficient way of resetting the CPU affinity of a process.
|
||||
func (s *CPUSet) Fill() {
|
||||
cpuMaskFill(s[:])
|
||||
}
|
||||
|
||||
func cpuBitsIndex(cpu int) int {
|
||||
return cpu / _NCPUBITS
|
||||
}
|
||||
|
||||
func cpuBitsMask(cpu int) cpuMask {
|
||||
return cpuMask(1 << (uint(cpu) % _NCPUBITS))
|
||||
}
|
||||
|
||||
func cpuMaskFill(s []cpuMask) {
|
||||
for i := range s {
|
||||
s[i] = ^cpuMask(0)
|
||||
}
|
||||
}
|
||||
|
||||
func cpuMaskSet(s []cpuMask, cpu int) {
|
||||
i := cpuBitsIndex(cpu)
|
||||
if i < len(s) {
|
||||
s[i] |= cpuBitsMask(cpu)
|
||||
}
|
||||
}
|
||||
|
||||
func cpuMaskClear(s []cpuMask, cpu int) {
|
||||
i := cpuBitsIndex(cpu)
|
||||
if i < len(s) {
|
||||
s[i] &^= cpuBitsMask(cpu)
|
||||
}
|
||||
}
|
||||
|
||||
func cpuMaskIsSet(s []cpuMask, cpu int) bool {
|
||||
i := cpuBitsIndex(cpu)
|
||||
if i < len(s) {
|
||||
return s[i]&cpuBitsMask(cpu) != 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func cpuMaskCount(s []cpuMask) int {
|
||||
c := 0
|
||||
for _, b := range s {
|
||||
c += bits.OnesCount64(uint64(b))
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Set adds cpu to the set s. If cpu is out of bounds for s, no action is taken.
|
||||
func (s *CPUSet) Set(cpu int) {
|
||||
cpuMaskSet(s[:], cpu)
|
||||
}
|
||||
|
||||
// Clear removes cpu from the set s. If cpu is out of bounds for s, no action is taken.
|
||||
func (s *CPUSet) Clear(cpu int) {
|
||||
cpuMaskClear(s[:], cpu)
|
||||
}
|
||||
|
||||
// IsSet reports whether cpu is in the set s.
|
||||
func (s *CPUSet) IsSet(cpu int) bool {
|
||||
return cpuMaskIsSet(s[:], cpu)
|
||||
}
|
||||
|
||||
// Count returns the number of CPUs in the set s.
|
||||
func (s *CPUSet) Count() int {
|
||||
return cpuMaskCount(s[:])
|
||||
}
|
||||
|
||||
// NewCPUSet creates a CPU affinity mask capable of representing CPU IDs
|
||||
// up to maxCPU (exclusive).
|
||||
func NewCPUSet(maxCPU int) CPUSetDynamic {
|
||||
numMasks := (maxCPU + _NCPUBITS - 1) / _NCPUBITS
|
||||
if numMasks == 0 {
|
||||
numMasks = 1
|
||||
}
|
||||
return make(CPUSetDynamic, numMasks)
|
||||
}
|
||||
|
||||
// Zero clears the set s, so that it contains no CPUs.
|
||||
func (s CPUSetDynamic) Zero() {
|
||||
clear(s)
|
||||
}
|
||||
|
||||
// Fill adds all possible CPU bits to the set s. On Linux, [SchedSetaffinityDynamic]
|
||||
// will silently ignore any invalid CPU bits in [CPUSetDynamic] so this is an
|
||||
// efficient way of resetting the CPU affinity of a process.
|
||||
func (s CPUSetDynamic) Fill() {
|
||||
cpuMaskFill(s)
|
||||
}
|
||||
|
||||
// Set adds cpu to the set s. If cpu is out of bounds for s, no action is taken.
|
||||
func (s CPUSetDynamic) Set(cpu int) {
|
||||
cpuMaskSet(s, cpu)
|
||||
}
|
||||
|
||||
// Clear removes cpu from the set s. If cpu is out of bounds for s, no action is taken.
|
||||
func (s CPUSetDynamic) Clear(cpu int) {
|
||||
cpuMaskClear(s, cpu)
|
||||
}
|
||||
|
||||
// IsSet reports whether cpu is in the set s.
|
||||
func (s CPUSetDynamic) IsSet(cpu int) bool {
|
||||
return cpuMaskIsSet(s, cpu)
|
||||
}
|
||||
|
||||
// Count returns the number of CPUs in the set s.
|
||||
func (s CPUSetDynamic) Count() int {
|
||||
return cpuMaskCount(s)
|
||||
}
|
||||
|
||||
func (s CPUSetDynamic) size() uintptr {
|
||||
return uintptr(len(s)) * unsafe.Sizeof(cpuMask(0))
|
||||
}
|
||||
|
||||
func (s CPUSetDynamic) pointer() unsafe.Pointer {
|
||||
if len(s) == 0 {
|
||||
return nil
|
||||
}
|
||||
return unsafe.Pointer(&s[0])
|
||||
}
|
||||
|
||||
// SchedGetaffinityDynamic gets the CPU affinity mask of the thread specified by pid.
|
||||
// If pid is 0 the calling thread is used.
|
||||
//
|
||||
// If the set is smaller than the size of the affinity mask used by the kernel,
|
||||
// [EINVAL] is returned.
|
||||
func SchedGetaffinityDynamic(pid int, set CPUSetDynamic) error {
|
||||
return schedAffinity(SYS_SCHED_GETAFFINITY, pid, set.size(), set.pointer())
|
||||
}
|
||||
|
||||
// SchedSetaffinityDynamic sets the CPU affinity mask of the thread specified by pid.
|
||||
// If pid is 0 the calling thread is used.
|
||||
func SchedSetaffinityDynamic(pid int, set CPUSetDynamic) error {
|
||||
return schedAffinity(SYS_SCHED_SETAFFINITY, pid, set.size(), set.pointer())
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
// Copyright 2018 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || zos
|
||||
|
||||
package unix
|
||||
|
||||
import "syscall"
|
||||
|
||||
type Signal = syscall.Signal
|
||||
type Errno = syscall.Errno
|
||||
type SysProcAttr = syscall.SysProcAttr
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
// Copyright 2018 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build gc
|
||||
|
||||
#include "textflag.h"
|
||||
|
||||
//
|
||||
// System calls for ppc64, AIX are implemented in runtime/syscall_aix.go
|
||||
//
|
||||
|
||||
TEXT ·syscall6(SB),NOSPLIT,$0-88
|
||||
JMP syscall·syscall6(SB)
|
||||
|
||||
TEXT ·rawSyscall6(SB),NOSPLIT,$0-88
|
||||
JMP syscall·rawSyscall6(SB)
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
// Copyright 2021 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build (freebsd || netbsd || openbsd) && gc
|
||||
|
||||
#include "textflag.h"
|
||||
|
||||
// System call support for 386 BSD
|
||||
|
||||
// Just jump to package syscall's implementation for all these functions.
|
||||
// The runtime may know about them.
|
||||
|
||||
TEXT ·Syscall(SB),NOSPLIT,$0-28
|
||||
JMP syscall·Syscall(SB)
|
||||
|
||||
TEXT ·Syscall6(SB),NOSPLIT,$0-40
|
||||
JMP syscall·Syscall6(SB)
|
||||
|
||||
TEXT ·Syscall9(SB),NOSPLIT,$0-52
|
||||
JMP syscall·Syscall9(SB)
|
||||
|
||||
TEXT ·RawSyscall(SB),NOSPLIT,$0-28
|
||||
JMP syscall·RawSyscall(SB)
|
||||
|
||||
TEXT ·RawSyscall6(SB),NOSPLIT,$0-40
|
||||
JMP syscall·RawSyscall6(SB)
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
// Copyright 2021 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build (darwin || dragonfly || freebsd || netbsd || openbsd) && gc
|
||||
|
||||
#include "textflag.h"
|
||||
|
||||
// System call support for AMD64 BSD
|
||||
|
||||
// Just jump to package syscall's implementation for all these functions.
|
||||
// The runtime may know about them.
|
||||
|
||||
TEXT ·Syscall(SB),NOSPLIT,$0-56
|
||||
JMP syscall·Syscall(SB)
|
||||
|
||||
TEXT ·Syscall6(SB),NOSPLIT,$0-80
|
||||
JMP syscall·Syscall6(SB)
|
||||
|
||||
TEXT ·Syscall9(SB),NOSPLIT,$0-104
|
||||
JMP syscall·Syscall9(SB)
|
||||
|
||||
TEXT ·RawSyscall(SB),NOSPLIT,$0-56
|
||||
JMP syscall·RawSyscall(SB)
|
||||
|
||||
TEXT ·RawSyscall6(SB),NOSPLIT,$0-80
|
||||
JMP syscall·RawSyscall6(SB)
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
// Copyright 2021 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build (freebsd || netbsd || openbsd) && gc
|
||||
|
||||
#include "textflag.h"
|
||||
|
||||
// System call support for ARM BSD
|
||||
|
||||
// Just jump to package syscall's implementation for all these functions.
|
||||
// The runtime may know about them.
|
||||
|
||||
TEXT ·Syscall(SB),NOSPLIT,$0-28
|
||||
B syscall·Syscall(SB)
|
||||
|
||||
TEXT ·Syscall6(SB),NOSPLIT,$0-40
|
||||
B syscall·Syscall6(SB)
|
||||
|
||||
TEXT ·Syscall9(SB),NOSPLIT,$0-52
|
||||
B syscall·Syscall9(SB)
|
||||
|
||||
TEXT ·RawSyscall(SB),NOSPLIT,$0-28
|
||||
B syscall·RawSyscall(SB)
|
||||
|
||||
TEXT ·RawSyscall6(SB),NOSPLIT,$0-40
|
||||
B syscall·RawSyscall6(SB)
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
// Copyright 2021 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build (darwin || freebsd || netbsd || openbsd) && gc
|
||||
|
||||
#include "textflag.h"
|
||||
|
||||
// System call support for ARM64 BSD
|
||||
|
||||
// Just jump to package syscall's implementation for all these functions.
|
||||
// The runtime may know about them.
|
||||
|
||||
TEXT ·Syscall(SB),NOSPLIT,$0-56
|
||||
JMP syscall·Syscall(SB)
|
||||
|
||||
TEXT ·Syscall6(SB),NOSPLIT,$0-80
|
||||
JMP syscall·Syscall6(SB)
|
||||
|
||||
TEXT ·Syscall9(SB),NOSPLIT,$0-104
|
||||
JMP syscall·Syscall9(SB)
|
||||
|
||||
TEXT ·RawSyscall(SB),NOSPLIT,$0-56
|
||||
JMP syscall·RawSyscall(SB)
|
||||
|
||||
TEXT ·RawSyscall6(SB),NOSPLIT,$0-80
|
||||
JMP syscall·RawSyscall6(SB)
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
// Copyright 2022 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build (darwin || freebsd || netbsd || openbsd) && gc
|
||||
|
||||
#include "textflag.h"
|
||||
|
||||
//
|
||||
// System call support for ppc64, BSD
|
||||
//
|
||||
|
||||
// Just jump to package syscall's implementation for all these functions.
|
||||
// The runtime may know about them.
|
||||
|
||||
TEXT ·Syscall(SB),NOSPLIT,$0-56
|
||||
JMP syscall·Syscall(SB)
|
||||
|
||||
TEXT ·Syscall6(SB),NOSPLIT,$0-80
|
||||
JMP syscall·Syscall6(SB)
|
||||
|
||||
TEXT ·Syscall9(SB),NOSPLIT,$0-104
|
||||
JMP syscall·Syscall9(SB)
|
||||
|
||||
TEXT ·RawSyscall(SB),NOSPLIT,$0-56
|
||||
JMP syscall·RawSyscall(SB)
|
||||
|
||||
TEXT ·RawSyscall6(SB),NOSPLIT,$0-80
|
||||
JMP syscall·RawSyscall6(SB)
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
// Copyright 2021 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build (darwin || freebsd || netbsd || openbsd) && gc
|
||||
|
||||
#include "textflag.h"
|
||||
|
||||
// System call support for RISCV64 BSD
|
||||
|
||||
// Just jump to package syscall's implementation for all these functions.
|
||||
// The runtime may know about them.
|
||||
|
||||
TEXT ·Syscall(SB),NOSPLIT,$0-56
|
||||
JMP syscall·Syscall(SB)
|
||||
|
||||
TEXT ·Syscall6(SB),NOSPLIT,$0-80
|
||||
JMP syscall·Syscall6(SB)
|
||||
|
||||
TEXT ·Syscall9(SB),NOSPLIT,$0-104
|
||||
JMP syscall·Syscall9(SB)
|
||||
|
||||
TEXT ·RawSyscall(SB),NOSPLIT,$0-56
|
||||
JMP syscall·RawSyscall(SB)
|
||||
|
||||
TEXT ·RawSyscall6(SB),NOSPLIT,$0-80
|
||||
JMP syscall·RawSyscall6(SB)
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
// Copyright 2009 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build gc
|
||||
|
||||
#include "textflag.h"
|
||||
|
||||
//
|
||||
// System calls for 386, Linux
|
||||
//
|
||||
|
||||
// See ../runtime/sys_linux_386.s for the reason why we always use int 0x80
|
||||
// instead of the glibc-specific "CALL 0x10(GS)".
|
||||
#define INVOKE_SYSCALL INT $0x80
|
||||
|
||||
// Just jump to package syscall's implementation for all these functions.
|
||||
// The runtime may know about them.
|
||||
|
||||
TEXT ·Syscall(SB),NOSPLIT,$0-28
|
||||
JMP syscall·Syscall(SB)
|
||||
|
||||
TEXT ·Syscall6(SB),NOSPLIT,$0-40
|
||||
JMP syscall·Syscall6(SB)
|
||||
|
||||
TEXT ·SyscallNoError(SB),NOSPLIT,$0-24
|
||||
CALL runtime·entersyscall(SB)
|
||||
MOVL trap+0(FP), AX // syscall entry
|
||||
MOVL a1+4(FP), BX
|
||||
MOVL a2+8(FP), CX
|
||||
MOVL a3+12(FP), DX
|
||||
MOVL $0, SI
|
||||
MOVL $0, DI
|
||||
INVOKE_SYSCALL
|
||||
MOVL AX, r1+16(FP)
|
||||
MOVL DX, r2+20(FP)
|
||||
CALL runtime·exitsyscall(SB)
|
||||
RET
|
||||
|
||||
TEXT ·RawSyscall(SB),NOSPLIT,$0-28
|
||||
JMP syscall·RawSyscall(SB)
|
||||
|
||||
TEXT ·RawSyscall6(SB),NOSPLIT,$0-40
|
||||
JMP syscall·RawSyscall6(SB)
|
||||
|
||||
TEXT ·RawSyscallNoError(SB),NOSPLIT,$0-24
|
||||
MOVL trap+0(FP), AX // syscall entry
|
||||
MOVL a1+4(FP), BX
|
||||
MOVL a2+8(FP), CX
|
||||
MOVL a3+12(FP), DX
|
||||
MOVL $0, SI
|
||||
MOVL $0, DI
|
||||
INVOKE_SYSCALL
|
||||
MOVL AX, r1+16(FP)
|
||||
MOVL DX, r2+20(FP)
|
||||
RET
|
||||
|
||||
TEXT ·socketcall(SB),NOSPLIT,$0-36
|
||||
JMP syscall·socketcall(SB)
|
||||
|
||||
TEXT ·rawsocketcall(SB),NOSPLIT,$0-36
|
||||
JMP syscall·rawsocketcall(SB)
|
||||
|
||||
TEXT ·seek(SB),NOSPLIT,$0-28
|
||||
JMP syscall·seek(SB)
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
// Copyright 2009 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build gc
|
||||
|
||||
#include "textflag.h"
|
||||
|
||||
//
|
||||
// System calls for AMD64, Linux
|
||||
//
|
||||
|
||||
// Just jump to package syscall's implementation for all these functions.
|
||||
// The runtime may know about them.
|
||||
|
||||
TEXT ·Syscall(SB),NOSPLIT,$0-56
|
||||
JMP syscall·Syscall(SB)
|
||||
|
||||
TEXT ·Syscall6(SB),NOSPLIT,$0-80
|
||||
JMP syscall·Syscall6(SB)
|
||||
|
||||
TEXT ·SyscallNoError(SB),NOSPLIT,$0-48
|
||||
CALL runtime·entersyscall(SB)
|
||||
MOVQ a1+8(FP), DI
|
||||
MOVQ a2+16(FP), SI
|
||||
MOVQ a3+24(FP), DX
|
||||
MOVQ $0, R10
|
||||
MOVQ $0, R8
|
||||
MOVQ $0, R9
|
||||
MOVQ trap+0(FP), AX // syscall entry
|
||||
SYSCALL
|
||||
MOVQ AX, r1+32(FP)
|
||||
MOVQ DX, r2+40(FP)
|
||||
CALL runtime·exitsyscall(SB)
|
||||
RET
|
||||
|
||||
TEXT ·RawSyscall(SB),NOSPLIT,$0-56
|
||||
JMP syscall·RawSyscall(SB)
|
||||
|
||||
TEXT ·RawSyscall6(SB),NOSPLIT,$0-80
|
||||
JMP syscall·RawSyscall6(SB)
|
||||
|
||||
TEXT ·RawSyscallNoError(SB),NOSPLIT,$0-48
|
||||
MOVQ a1+8(FP), DI
|
||||
MOVQ a2+16(FP), SI
|
||||
MOVQ a3+24(FP), DX
|
||||
MOVQ $0, R10
|
||||
MOVQ $0, R8
|
||||
MOVQ $0, R9
|
||||
MOVQ trap+0(FP), AX // syscall entry
|
||||
SYSCALL
|
||||
MOVQ AX, r1+32(FP)
|
||||
MOVQ DX, r2+40(FP)
|
||||
RET
|
||||
|
||||
TEXT ·gettimeofday(SB),NOSPLIT,$0-16
|
||||
JMP syscall·gettimeofday(SB)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user