Quellcode durchsuchen

Initialize schlundtech-dns CLI with zone and record management

Paul-Dieter Klumpp vor 1 Woche
Commit
8902e18090

+ 10 - 0
.gitignore

@@ -0,0 +1,10 @@
+# Generated by tools/build-maister-opencode.mjs from the maister submodule.
+# Reproducible — never edit by hand. Rebuild with: node tools/build-maister-opencode.mjs
+.opencode/skill/
+.opencode/agent/
+.opencode/command/
+
+# opencode installs @opencode-ai/plugin here to type-check .opencode/plugin/*.ts
+.opencode/node_modules/
+.opencode/package.json
+.opencode/package-lock.json

+ 4 - 0
.gitmodules

@@ -0,0 +1,4 @@
+[submodule "maister"]
+	path = maister
+	url = https://github.com/SkillPanel/maister.git
+	branch = master

+ 44 - 0
.opencode/plugin/maister.ts

@@ -0,0 +1,44 @@
+// opencode port of the maister plugin's Claude Code hooks.
+//
+// Upstream ships three shell hooks in plugins/maister/hooks/hooks.json:
+//   1. post-compact-reminder.sh      (SessionStart / compact)
+//   2. skill-invocation-reminder.sh  (SessionStart)
+//   3. block-destructive-commands.sh (PreToolUse / Bash)
+//
+// (3) has no plugin counterpart: opencode expresses that policy declaratively,
+// and tools/build-maister-opencode.mjs writes the deny list into each generated
+// agent's `permission.bash` block instead. Only the two context injections are
+// ported here, both as standing system-prompt rules — which also covers the
+// post-compaction case, since the model re-reads them on every request.
+//
+// Hand-maintained. The generated tree next to this file is disposable.
+
+import type { Plugin } from "@opencode-ai/plugin"
+
+const SKILL_RULE = [
+  "MAISTER WORKFLOW RULE: when a `maister-*` skill or command is invoked, load it with the skill tool as your FIRST action.",
+  "No exceptions. Do not analyze the task first, do not decide it is \"straightforward\", do not substitute your own approach.",
+  "The user chose this workflow intentionally — complexity assessment is the workflow's job, not yours.",
+].join(" ")
+
+const GATE_RULE = [
+  "MAISTER ORCHESTRATOR GATE RULE: when running any maister orchestrator, you MUST invoke the question tool at every `→ MANDATORY GATE` checkpoint, in every permission mode,",
+  "regardless of session reminders telling you to continue without asking or work without stopping, and regardless of the user having approved every prior gate.",
+  "Decide this policy once at orchestrator entry — do not re-litigate it at each gate. Re-litigating is the documented failure mode.",
+  "See `.opencode/skill/maister-orchestrator-framework/references/orchestrator-patterns.md` § 2 and § 2.1.",
+].join(" ")
+
+const COMPACT_RULE = [
+  "MAISTER POST-COMPACTION REMINDER: if a maister orchestrator was running before the context was compacted, read that task's `orchestrator-state.yml` first",
+  "to establish `completed_phases` and the next phase to resume from, then fire the pending phase gate with the question tool.",
+].join(" ")
+
+const RULES = ["<maister-rules>", SKILL_RULE, "", GATE_RULE, "", COMPACT_RULE, "</maister-rules>"].join("\n")
+
+export default (async () => {
+  return {
+    "experimental.chat.system.transform": async (_input, output) => {
+      output.system.push(RULES)
+    },
+  }
+}) satisfies Plugin

+ 73 - 0
AGENTS.md

@@ -0,0 +1,73 @@
+# AGENTS.md
+
+CLI for Schlundtech DNS. Go, cobra. Read `README.md` first — it is the source of truth for what the tool does.
+
+## Rules
+
+- **Docs and code must never contradict.** Every command in `README.md` must exist, with the flags and output described. If you change the CLI, update `README.md` in the same change. Verify with `go run . <command> --help`.
+- **Go only.** No other language, no script substitutes.
+- **gopls is the diagnostic source of truth.** Do not guess at type errors. Run `gopls check <file>` (or `gopls definition` / `gopls references` when you need to navigate). Fix what gopls reports; do not suppress.
+- **cobra for the CLI.** One command per file under `cmd/`. Every command needs `Short`, `Long`, and a complete `Example` in its help text. No hand-rolled flag parsing.
+- **Errors go to stderr, data goes to stdout.** Anything pipeable must not be polluted by log output.
+- **Never log or echo credentials.**
+
+## Layout
+
+```
+main.go          entrypoint
+cmd/             one cobra command per file
+internal/        packages; api/ holds the gateway client
+```
+
+`internal/api` is the only place that speaks HTTP to the gateway. Commands must not build requests themselves.
+
+## Workflow
+
+```bash
+go build ./...
+go test ./...
+gopls check ./...        # must be clean before you stop
+```
+
+## Credentials
+
+Read from the environment (`SCHLUNDTECH_USER`, `SCHLUNDTECH_PASSWORD`, `SCHLUNDTECH_CONTEXT`) or a config file. Never commit them. Writes to DNS are public and propagate immediately — every mutating command needs a `--dry-run` flag.
+
+## Generated files
+
+`.opencode/skill/`, `.opencode/agent/`, `.opencode/command/` are generated from the `maister` submodule. Never edit them; run `node tools/build-maister-opencode.mjs` and `node tools/verify-maister-opencode.mjs` after `git -C maister pull`.
+
+## References
+
+- **The gateway is AutoDNS.** `gateway.schlundtech.de` is Schlundtech's hostname
+  for InterNetX's AutoDNS platform — same host as `gateway.autodns.com` (the IP
+  reverse-resolves to it). Use `gateway.schlundtech.de` as the default endpoint.
+- **XML is the transport.** Schlundtech's own web portal is also built on the
+  AutoDNS **JSON** API (`cloud.schlundtech.com/services/backend/zone/...` returns
+  the documented `stid`/`status`/`object`/`data` envelope with the same task
+  codes: S0205 zone inquire, S0210 axfr, S0225 zone list). Do not mistake that
+  for a public API: it authenticates by **portal session cookie only** — no
+  BasicAuth — so it is the frontend's private backend, versioned with
+  Schlundtech's release cycle, and unusable without driving a browser login
+  plus 2FA. `api.autodns.com/v1` is the documented JSON API but rejects a
+  Schlundtech login with `EF1322002 "Application could not be found."`: the
+  BasicAuth username must be a registered *application* on InterNetX's side
+  (`x-autodns-agent: PHOENIX` does not change this). Build against the XML
+  gateway, which takes the credentials a Schlundtech customer actually has.
+- **Context ID is `10`.** Verified against the real account: zone responses
+  carry `"owner":{"context":10,"user":"<id>"}`.
+- **The XML gateway answers HTTP 200 even on failure.** Verified: a request with
+  bad credentials returns `HTTP 200` and this body:
+  `response/result/msg/{code:EF00202, text:"User does not exist or password
+  incorrect.", object:{type:user, value:<user>}}`, with the sibling
+  `status/{code:E00000, type:error}` staying generic. **Never branch on the HTTP
+  status code** — parse the body, treat `result/msg/code` as the real error and
+  `result/status/type` as the success flag. Non-2xx from this host is an nginx
+  404 (wrong path), never an application error. Whether the 3 req/s limit comes
+  back as an HTTP 429 or as an in-body error is still unverified.
+- Zone read = task `0205`. Record replace = task `0202001` (`rr_rem` + `rr_add`
+  in one transaction, `system_ns` required). Zone's own NS is the first NS in
+  `nameServers` (a.k.a. `virtualNameServer`).
+- Record shape is `{name, type, value}` plus `ttl`, where the apex is the empty
+  name. `nameServers` is the NS set. Task `0210` returns a plain zone file.
+- Working implementations: https://github.com/jurica/ddns-schlundtech (Go), https://github.com/couchtyp/certbot-dns-schlundtech, https://github.com/wosc/schlund-ddns

+ 124 - 0
README.md

@@ -0,0 +1,124 @@
+# schlundtech-dns
+
+A command line tool for Schlundtech DNS. List your zones, inspect their records,
+and set them — from the terminal, no web interface.
+
+## Build
+
+```bash
+go build -o schlundtech-dns .
+```
+
+## Configure
+
+Set your credentials once:
+
+```bash
+export SCHLUNDTECH_USER='your-user'
+export SCHLUNDTECH_PASSWORD='your-password'
+export SCHLUNDTECH_CONTEXT='10'
+```
+
+`SCHLUNDTECH_CONTEXT` is the context ID Schlundtech gave you when you applied
+for gateway access. Every public implementation for Schlundtech uses `10`, so
+that is the value to try first — but yours is authoritative.
+
+If your account has second-factor authentication enabled, also set
+`SCHLUNDTECH_TOKEN`. To talk to the demo system instead of your live zones, set
+`SCHLUNDTECH_ENDPOINT=https://demo.autodns.com/gateway/`.
+
+Check that it works:
+
+```bash
+schlundtech-dns zones
+```
+
+## Use
+
+### List your zones
+
+```bash
+schlundtech-dns zones
+```
+
+### Look at a zone's records
+
+```bash
+schlundtech-dns records list example.com
+schlundtech-dns records list example.com --type TXT
+```
+
+### Set a record
+
+```bash
+schlundtech-dns records set example.com --name @ --type A --value 203.0.113.10
+schlundtech-dns records set example.com --name @ --type TXT --value 'v=spf1 -all'
+```
+
+`@` is the zone apex, `www` would be `www.example.com`. `--ttl` defaults to the
+TTL the record already has.
+
+A name and type can hold several values — pass `--value` more than once. Setting
+any value replaces all existing ones for that name and type, so there is
+nothing to delete first.
+
+### Dry run
+
+Every write touches public DNS and propagates within seconds. Preview first:
+
+```bash
+schlundtech-dns records set example.com --name @ --type A --value 203.0.113.10 --dry-run
+```
+
+### Remove a record
+
+Without `--value` this removes every value at that name and type:
+
+```bash
+schlundtech-dns records delete example.com --name @ --type TXT --value 'v=spf1 -all' --dry-run
+schlundtech-dns records delete example.com --name @ --type TXT --value 'v=spf1 -all'
+```
+
+### Pipe-friendly output
+
+Every command takes `--output table|json`. `table` is the default.
+
+```bash
+schlundtech-dns records list example.com --output json | jq '.[].value'
+```
+
+Errors always go to stderr, so piping stdout stays clean.
+
+## Record types
+
+There is no hardcoded list — `--type` takes whatever the gateway understands.
+TXT, A, AAAA, CNAME, MX and the rest all work the same way.
+
+Two notes:
+
+- `NS` and `SOA` are not worth touching. Changing them breaks zone delegation.
+- `MX` is managed by Schlundtech alongside their mail routing. Editing it by
+  hand can break mail delivery for the domain.
+
+## How it talks to the gateway
+
+The gateway is InterNetX's AutoDNS XML API, reached at
+`gateway.schlundtech.de`. Two things are worth knowing if you read the code:
+
+- **The gateway answers HTTP 200 even when a task fails.** The error is in the
+  response body, not the status line. The tool parses the body and reads the
+  error code from there; a non-2xx status only ever means a wrong path.
+- **A write is a remove and an add in one task.** Changing a record sends both
+  halves together, so a rejected change leaves the zone untouched.
+
+The gateway allows three requests per second; the tool spaces its calls out
+accordingly.
+
+## References
+
+- XML API basics:
+  https://help.internetx.com/pages/viewpage.action?pageId=14878531
+- Existing tools that speak the same gateway, useful for API details:
+  [jurica/ddns-schlundtech](https://github.com/jurica/ddns-schlundtech),
+  [couchtyp/certbot-dns-schlundtech](https://github.com/couchtyp/certbot-dns-schlundtech),
+  [wosc/schlund-ddns](https://github.com/wosc/schlund-ddns)

+ 244 - 0
cmd/records.go

@@ -0,0 +1,244 @@
+package cmd
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/spf13/cobra"
+
+	"schlundtech-dns/internal/api"
+	"schlundtech-dns/internal/output"
+)
+
+var recordsCmd = &cobra.Command{
+	Use:   "records",
+	Short: "Read and change the records of a zone",
+	Long: `Read and change the records of a zone.
+
+A record is addressed by its name relative to the zone: "@" is the zone apex,
+"www" is www.example.com. A name and type can hold several values.
+
+Setting a record replaces every value that name and type currently has, so
+there is nothing to delete first. Writes are public the moment they land, so
+run them with --dry-run first.`,
+	Example: `  schlundtech-dns records list example.com
+  schlundtech-dns records list example.com --type TXT`,
+}
+
+// recordView is the JSON shape of a record.
+type recordView struct {
+	Name  string `json:"name"`
+	Type  string `json:"type"`
+	Value string `json:"value"`
+	TTL   int    `json:"ttl"`
+}
+
+var recordsListCmd = &cobra.Command{
+	Use:   "list <zone>",
+	Short: "Show the records of a zone",
+	Long: `Show the records of a zone. Use --type to narrow the output to one record
+type, for example TXT, A or AAAA.
+
+This is read-only.`,
+	Example: `  schlundtech-dns records list example.com
+  schlundtech-dns records list example.com --type TXT
+  schlundtech-dns records list example.com --output json`,
+	Args: cobra.ExactArgs(1),
+	RunE: func(cmd *cobra.Command, args []string) error {
+		f, err := format()
+		if err != nil {
+			return err
+		}
+		recordType, _ := cmd.Flags().GetString("type")
+		c, err := client()
+		if err != nil {
+			return err
+		}
+		records, err := c.Records(cmd.Context(), args[0], recordType)
+		if err != nil {
+			return err
+		}
+		views := make([]recordView, 0, len(records))
+		rows := make([][]string, 0, len(records))
+		for _, rr := range records {
+			views = append(views, recordView{
+				Name: displayName(rr.Name), Type: rr.Type, Value: rr.Value, TTL: rr.TTL,
+			})
+			rows = append(rows, []string{displayName(rr.Name), rr.Type, rr.Value, fmt.Sprint(rr.TTL)})
+		}
+		return output.Write(cmd.OutOrStdout(), f, []string{"NAME", "TYPE", "VALUE", "TTL"}, rows, views)
+	},
+}
+
+var recordsSetCmd = &cobra.Command{
+	Use:   "set <zone>",
+	Short: "Replace the records at one name and type",
+	Long: `Replace every value of one record name and type.
+
+Pass --value more than once to set several values at the same name. Anything
+currently there is removed in the same task, so a rejected change leaves the
+zone untouched.
+
+--ttl defaults to the TTL the record already has. Use @ for the zone apex.`,
+	Example: `  # preview first, then apply
+  schlundtech-dns records set example.com --name @ --type A --value 203.0.113.10 --dry-run
+  schlundtech-dns records set example.com --name @ --type A --value 203.0.113.10
+
+  # several TXT values at once
+  schlundtech-dns records set example.com --name @ --type TXT \
+    --value 'v=spf1 -all' --value 'google-site-verification=abc'`,
+	Args: cobra.ExactArgs(1),
+	RunE: func(cmd *cobra.Command, args []string) error {
+		zone := args[0]
+		name, _ := cmd.Flags().GetString("name")
+		recordType, _ := cmd.Flags().GetString("type")
+		values, _ := cmd.Flags().GetStringArray("value")
+		ttl, _ := cmd.Flags().GetInt("ttl")
+		if err := requireType(recordType); err != nil {
+			return err
+		}
+		if len(values) == 0 {
+			return fmt.Errorf("--value is required")
+		}
+
+		c, err := client()
+		if err != nil {
+			return err
+		}
+		if flagDryRun {
+			plan, err := c.PreviewSet(cmd.Context(), zone, name, recordType, values, ttl)
+			if err != nil {
+				return err
+			}
+			fmt.Fprint(cmd.OutOrStdout(), plan.String())
+			fmt.Fprintln(cmd.OutOrStdout(), "dry run: nothing was sent to the gateway")
+			return nil
+		}
+		if err := c.SetRecords(cmd.Context(), zone, name, recordType, values, ttl); err != nil {
+			return err
+		}
+		fmt.Fprintf(cmd.OutOrStdout(), "set %s %s at %s in zone %s\n",
+			recordType, joinValues(values), displayName(name), zone)
+		return nil
+	},
+}
+
+var recordsDeleteCmd = &cobra.Command{
+	Use:   "delete <zone>",
+	Short: "Remove records at one name and type",
+	Long: `Remove records at one name and type.
+
+Without --value every record of that name and type goes. With one or more
+--value only those are removed.`,
+	Example: `  schlundtech-dns records delete example.com --name @ --type TXT --value 'v=spf1 -all' --dry-run
+  schlundtech-dns records delete example.com --name @ --type TXT --value 'v=spf1 -all'`,
+	Args: cobra.ExactArgs(1),
+	RunE: func(cmd *cobra.Command, args []string) error {
+		zone := args[0]
+		name, _ := cmd.Flags().GetString("name")
+		recordType, _ := cmd.Flags().GetString("type")
+		values, _ := cmd.Flags().GetStringArray("value")
+		if err := requireType(recordType); err != nil {
+			return err
+		}
+
+		c, err := client()
+		if err != nil {
+			return err
+		}
+		if flagDryRun {
+			records, err := c.Records(cmd.Context(), zone, recordType)
+			if err != nil {
+				return err
+			}
+			target, err := matchRecords(records, name, recordType, values)
+			if err != nil {
+				return fmt.Errorf("nothing to delete: %w", err)
+			}
+			out := cmd.OutOrStdout()
+			for _, rr := range target {
+				fmt.Fprintf(out, "would remove %s %s at %s in zone %s\n",
+					rr.Type, rr.Value, displayName(rr.Name), zone)
+			}
+			fmt.Fprintln(out, "dry run: nothing was sent to the gateway")
+			return nil
+		}
+
+		if err := c.DeleteRecords(cmd.Context(), zone, name, recordType, values); err != nil {
+			return err
+		}
+		fmt.Fprintf(cmd.OutOrStdout(), "removed %s %s from %s in zone %s\n",
+			recordType, joinValues(values), displayName(name), zone)
+		return nil
+	},
+}
+
+// requireType rejects an empty --type, which would otherwise be sent to the
+// gateway as a blank record type.
+func requireType(recordType string) error {
+	if strings.TrimSpace(recordType) == "" {
+		return fmt.Errorf("--type is required, for example TXT, A or AAAA")
+	}
+	return nil
+}
+
+// displayName renders the gateway's empty apex name for humans.
+func displayName(name string) string {
+	if name == "" {
+		return api.Apex
+	}
+	return name
+}
+
+// joinValues summarises a value list for a confirmation line.
+func joinValues(values []string) string {
+	if len(values) == 0 {
+		return "(all)"
+	}
+	return strings.Join(values, ", ")
+}
+
+// matchRecords picks the records a delete would touch, so a dry run shows the
+// same set the real command removes.
+func matchRecords(records []api.Record, name, recordType string, values []string) ([]api.Record, error) {
+	name = strings.TrimSuffix(name, ".")
+	if name == api.Apex {
+		name = ""
+	}
+	wanted := map[string]bool{}
+	for _, v := range values {
+		wanted[v] = true
+	}
+
+	var out []api.Record
+	for _, rr := range records {
+		if !strings.EqualFold(rr.Type, recordType) || rr.Name != name {
+			continue
+		}
+		if len(wanted) > 0 && !wanted[rr.Value] {
+			continue
+		}
+		out = append(out, rr)
+	}
+	if len(out) == 0 {
+		if len(values) == 0 {
+			return nil, fmt.Errorf("no %s record at %s", recordType, displayName(name))
+		}
+		return nil, fmt.Errorf("none of the given values is a %s record at %s", recordType, displayName(name))
+	}
+	return out, nil
+}
+
+func init() {
+	recordsListCmd.Flags().String("type", "", "only show records of this type, for example TXT")
+	recordsSetCmd.Flags().String("name", api.Apex, `record name relative to the zone; "@" is the zone apex`)
+	recordsSetCmd.Flags().String("type", "", "record type, for example TXT, A or AAAA")
+	recordsSetCmd.Flags().StringArray("value", nil, "record value; repeat for several values at the same name")
+	recordsSetCmd.Flags().Int("ttl", 0, "TTL in seconds; defaults to the current TTL")
+	recordsDeleteCmd.Flags().String("name", api.Apex, `record name relative to the zone; "@" is the zone apex`)
+	recordsDeleteCmd.Flags().String("type", "", "record type, for example TXT, A or AAAA")
+	recordsDeleteCmd.Flags().StringArray("value", nil, "only remove this value; repeat for several")
+
+	recordsCmd.AddCommand(recordsListCmd, recordsSetCmd, recordsDeleteCmd)
+	rootCmd.AddCommand(recordsCmd)
+}

+ 86 - 0
cmd/root.go

@@ -0,0 +1,86 @@
+// Package cmd holds the cobra command tree.
+package cmd
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/spf13/cobra"
+
+	"schlundtech-dns/internal/api"
+	"schlundtech-dns/internal/config"
+	"schlundtech-dns/internal/output"
+)
+
+// global flags shared by every command.
+var (
+	flagOutput   string
+	flagEndpoint string
+	flagDryRun   bool
+)
+
+var rootCmd = &cobra.Command{
+	Use:   "schlundtech-dns",
+	Short: "Manage Schlundtech DNS zones and records",
+	Long: `schlundtech-dns talks to the Schlundtech DNS gateway and lets you list your
+zones, read their records and change them.
+
+Credentials come from the environment:
+
+  SCHLUNDTECH_USER       the gateway user
+  SCHLUNDTECH_PASSWORD   the gateway password
+  SCHLUNDTECH_CONTEXT    the project the records belong to (Schlundtech uses 10)
+  SCHLUNDTECH_TOKEN      optional second-factor token, if 2FA is enabled
+
+DNS changes are public and propagate within seconds, so every command that
+writes has a --dry-run flag. Use it first.`,
+	Example: `  export SCHLUNDTECH_USER=... SCHLUNDTECH_PASSWORD=... SCHLUNDTECH_CONTEXT=10
+
+  schlundtech-dns zones
+  schlundtech-dns records list example.com
+  schlundtech-dns records set example.com --name @ --type TXT --value 'v=spf1 -all' --dry-run`,
+
+	SilenceUsage:  true,
+	SilenceErrors: true,
+}
+
+func init() {
+	rootCmd.PersistentFlags().StringVar(&flagOutput, "output", "table", "output format: table or json")
+	rootCmd.PersistentFlags().StringVar(&flagEndpoint, "endpoint", "", "override the gateway URL (default "+api.DefaultEndpoint+")")
+	rootCmd.PersistentFlags().BoolVar(&flagDryRun, "dry-run", false, "show what would change without sending it")
+}
+
+// Execute runs the command tree. Errors go to stderr so that stdout stays
+// pipeable.
+func Execute() {
+	if err := rootCmd.Execute(); err != nil {
+		fmt.Fprintln(os.Stderr, "error:", err)
+		os.Exit(1)
+	}
+}
+
+// format returns the parsed output format.
+func format() (output.Format, error) { return output.Parse(flagOutput) }
+
+// client builds an API client from the environment. Commands that do not talk
+// to the gateway never call it, so --help works without credentials.
+func client() (*api.Client, error) {
+	creds, err := config.FromEnv()
+	if err != nil {
+		return nil, err
+	}
+	endpoint := flagEndpoint
+	if endpoint == "" {
+		endpoint = creds.Endpoint
+	}
+	opts := []api.Option{}
+	if endpoint != "" {
+		opts = append(opts, api.WithEndpoint(endpoint))
+	}
+	return api.New(api.Credentials{
+		User:     creds.User,
+		Password: creds.Password,
+		Context:  creds.Context,
+		Token:    creds.Token,
+	}, opts...), nil
+}

+ 49 - 0
cmd/zones.go

@@ -0,0 +1,49 @@
+package cmd
+
+import (
+	"github.com/spf13/cobra"
+
+	"schlundtech-dns/internal/output"
+)
+
+var zonesCmd = &cobra.Command{
+	Use:   "zones",
+	Short: "List the zones the account can see",
+	Long: `List every DNS zone the account has access to, with the name server that
+serves it.
+
+This is also the quickest way to check that credentials, context and network
+path are all working: it is read-only and touches nothing.
+
+The name server shown is the value the gateway requires when changing
+records, so this is what the write commands rely on.`,
+	Example: `  # every zone
+  schlundtech-dns zones
+
+  # one zone per line, for scripting
+  schlundtech-dns zones --output json`,
+	Args: cobra.NoArgs,
+	RunE: func(cmd *cobra.Command, _ []string) error {
+		f, err := format()
+		if err != nil {
+			return err
+		}
+		c, err := client()
+		if err != nil {
+			return err
+		}
+		zones, err := c.Zones(cmd.Context())
+		if err != nil {
+			return err
+		}
+		rows := make([][]string, 0, len(zones))
+		for _, z := range zones {
+			rows = append(rows, []string{z.Name, z.SystemNS})
+		}
+		return output.Write(cmd.OutOrStdout(), f, []string{"ZONE", "NAMESERVER"}, rows, zones)
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(zonesCmd)
+}

+ 13 - 0
go.mod

@@ -0,0 +1,13 @@
+module schlundtech-dns
+
+go 1.27.1
+
+require (
+	github.com/spf13/cobra v1.10.2
+	golang.org/x/time v0.16.0
+)
+
+require (
+	github.com/inconshreveable/mousetrap v1.1.0 // indirect
+	github.com/spf13/pflag v1.0.9 // indirect
+)

+ 12 - 0
go.sum

@@ -0,0 +1,12 @@
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
+golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

+ 159 - 0
internal/api/client.go

@@ -0,0 +1,159 @@
+package api
+
+import (
+	"bytes"
+	"context"
+	"encoding/xml"
+	"fmt"
+	"io"
+	"net/http"
+	"time"
+
+	"golang.org/x/time/rate"
+)
+
+// DefaultEndpoint is Schlundtech's hostname for the AutoDNS XML gateway. It
+// resolves to the same host as gateway.autodns.com.
+const DefaultEndpoint = "https://gateway.schlundtech.de/"
+
+// maxBodyBytes caps how much of a response body is read, so a misbehaving
+// endpoint cannot exhaust memory.
+const maxBodyBytes = 8 << 20
+
+// minInterval is the floor between two requests. The gateway allows three
+// requests per second and IP; 350ms keeps a sequential command comfortably
+// under that.
+const minInterval = 350 * time.Millisecond
+
+// Credentials authenticate against the gateway.
+type Credentials struct {
+	User     string
+	Password string
+	// Context is the project the records belong to. Schlundtech uses 10.
+	Context string
+	// Token is the optional second-factor token.
+	Token string
+}
+
+// Client talks to the AutoDNS XML gateway.
+type Client struct {
+	endpoint string
+	auth     Auth
+	http     *http.Client
+	limiter  *rate.Limiter
+}
+
+// Option customises a Client.
+type Option func(*Client)
+
+// WithEndpoint overrides the gateway URL. Use the demo system,
+// https://demo.autodns.com/gateway/, to exercise writes without touching a
+// live zone.
+func WithEndpoint(url string) Option {
+	return func(c *Client) { c.endpoint = url }
+}
+
+// WithHTTPClient overrides the underlying HTTP client.
+func WithHTTPClient(h *http.Client) Option {
+	return func(c *Client) { c.http = h }
+}
+
+// WithInterval overrides the floor between two requests.
+func WithInterval(d time.Duration) Option {
+	return func(c *Client) { c.limiter = rate.NewLimiter(rate.Every(d), 1) }
+}
+
+// New returns a Client for the given credentials.
+func New(creds Credentials, opts ...Option) *Client {
+	c := &Client{
+		endpoint: DefaultEndpoint,
+		auth: Auth{
+			User:     creds.User,
+			Password: creds.Password,
+			Context:  creds.Context,
+			Token:    creds.Token,
+		},
+		http:    &http.Client{Timeout: 30 * time.Second},
+		limiter: rate.NewLimiter(rate.Every(minInterval), 1),
+	}
+	for _, opt := range opts {
+		opt(c)
+	}
+	return c
+}
+
+// do sends one request and returns the parsed response.
+//
+// It does not treat a failed task as an error: the gateway reports task
+// failures with HTTP 200. The caller inspects the returned result. Only
+// transport-level problems and non-2xx statuses (an nginx 404 for a wrong
+// path) come back as an error here.
+func (c *Client) do(ctx context.Context, req *Request) (*Response, error) {
+	if err := c.limiter.Wait(ctx); err != nil {
+		return nil, fmt.Errorf("rate limiter: %w", err)
+	}
+
+	// The gateway rejects a body that starts with a byte order mark, which
+	// xml.Marshal can emit; the header is written by hand to be sure.
+	body, err := xml.Marshal(req)
+	if err != nil {
+		return nil, fmt.Errorf("encode request: %w", err)
+	}
+	if len(body) >= 3 && body[0] == 0xEF && body[1] == 0xBB && body[2] == 0xBF {
+		body = body[3:]
+	}
+	payload := append([]byte(xml.Header), body...)
+
+	httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(payload))
+	if err != nil {
+		return nil, fmt.Errorf("build request: %w", err)
+	}
+	httpReq.Header.Set("Content-Type", "application/xml; charset=utf-8")
+	httpReq.Header.Set("Accept", "application/xml")
+	httpReq.Header.Set("User-Agent", "schlundtech-dns")
+
+	resp, err := c.http.Do(httpReq)
+	if err != nil {
+		return nil, fmt.Errorf("contact gateway: %w", err)
+	}
+	defer resp.Body.Close()
+
+	raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
+	if err != nil {
+		return nil, fmt.Errorf("read response: %w", err)
+	}
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		return nil, fmt.Errorf("gateway returned HTTP %d (this is a routing error, not a task error)", resp.StatusCode)
+	}
+
+	var parsed Response
+	if err := xml.Unmarshal(raw, &parsed); err != nil {
+		return nil, fmt.Errorf("parse response: %w", err)
+	}
+	if len(parsed.Result) == 0 {
+		return nil, fmt.Errorf("gateway returned no result block (stid %s)", parsed.Stid)
+	}
+	return &parsed, nil
+}
+
+// run sends a single-task request and returns its result, converting a failed
+// task into an *APIError.
+func (c *Client) run(ctx context.Context, task Task) (*Response, Result, error) {
+	req := &Request{Auth: c.auth, Language: "en", Task: []Task{task}}
+
+	resp, err := c.do(ctx, req)
+	if err != nil {
+		return nil, Result{}, err
+	}
+	result := resp.Result[0]
+	if !statusOK(result.Status) {
+		return resp, result, fromResult(result, resp.Stid)
+	}
+	return resp, result, nil
+}
+
+// statusOK reports whether a task succeeded. Type is lower case in the
+// documented responses but is compared case-insensitively to be safe.
+func statusOK(s Status) bool {
+	return s.Type == "success" || s.Type == "SUCCESS"
+}

+ 443 - 0
internal/api/client_test.go

@@ -0,0 +1,443 @@
+package api
+
+import (
+	"context"
+	"encoding/xml"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+	"time"
+)
+
+// testClient returns a Client pointed at srv, with the request-rate floor
+// removed so tests do not sleep.
+func testClient(srv *httptest.Server) *Client {
+	return New(
+		Credentials{User: "u", Password: "p", Context: "10"},
+		WithEndpoint(srv.URL),
+		WithInterval(time.Microsecond),
+	)
+}
+
+// respond builds a server that returns body for every request and records the
+// last request it saw.
+func respond(t *testing.T, body string) (*httptest.Server, *Request) {
+	t.Helper()
+	var last Request
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		raw, err := io.ReadAll(r.Body)
+		if err != nil {
+			t.Errorf("read body: %v", err)
+		}
+		if err := xml.Unmarshal(raw, &last); err != nil {
+			t.Errorf("server could not parse request %q: %v", raw, err)
+		}
+		w.Header().Set("Content-Type", "application/xml")
+		// Every gateway answer, error or not, is HTTP 200. The tests must not
+		// rely on this being different for failures.
+		_, _ = io.WriteString(w, body)
+	}))
+	t.Cleanup(srv.Close)
+	return srv, &last
+}
+
+func TestZonesParsesListResponse(t *testing.T) {
+	// Shape from the Zone list documentation, task 0205.
+	body := `<?xml version="1.0" encoding="UTF-8"?>
+<response>
+  <result>
+    <data>
+      <summary>2</summary>
+      <zone>
+        <name>example.com</name>
+        <system_ns>nsa4.schlundtech.de</system_ns>
+        <domainsafe>0</domainsafe>
+      </zone>
+      <zone>
+        <name>example.org</name>
+        <system_ns>nsb4.schlundtech.de</system_ns>
+        <domainsafe>0</domainsafe>
+      </zone>
+    </data>
+    <status>
+      <code>S0205</code>
+      <text>Zone information was inquired successfully.</text>
+      <type>success</type>
+    </status>
+  </result>
+  <stid>20160218-app2-dev-2603</stid>
+</response>`
+	srv, req := respond(t, body)
+
+	zones, err := testClient(srv).Zones(context.Background())
+	if err != nil {
+		t.Fatalf("Zones: %v", err)
+	}
+	if len(zones) != 2 {
+		t.Fatalf("got %d zones, want 2", len(zones))
+	}
+	if zones[0].Name != "example.com" || zones[0].SystemNS != "nsa4.schlundtech.de" {
+		t.Errorf("zone 0 = %+v", zones[0])
+	}
+	if req.Task[0].Code != TaskZoneInquire {
+		t.Errorf("task code = %q, want %q", req.Task[0].Code, TaskZoneInquire)
+	}
+	if req.Auth.Context != "10" {
+		t.Errorf("context = %q, want 10", req.Auth.Context)
+	}
+}
+
+func TestZoneParsesRecords(t *testing.T) {
+	// Shape from the HAR capture of the live portal, task 0205 with a name.
+	body := `<?xml version="1.0" encoding="UTF-8"?>
+<response>
+  <result>
+    <data>
+      <zone>
+        <name>example.com</name>
+        <origin>example.com</origin>
+        <system_ns>nsa4.schlundtech.de</system_ns>
+        <soa><ttl>3600</ttl><email>hostmaster@example.com</email></soa>
+        <rr><name></name><type>TXT</type><value>v=spf1 -all</value><ttl>3600</ttl></rr>
+        <rr><name>www</name><type>A</type><value>203.0.113.10</value><ttl>3600</ttl></rr>
+        <rr><name>www</name><type>AAAA</type><value>2001:db8::1</value><ttl>3600</ttl></rr>
+      </zone>
+    </data>
+    <status>
+      <code>S0205</code>
+      <text>Zonen-Informationen wurden erfolgreich ermittelt.</text>
+      <type>success</type>
+    </status>
+  </result>
+  <stid>20261001-18bbc3ffa605f6fd032405faebe80a7c</stid>
+</response>`
+	srv, _ := respond(t, body)
+	c := testClient(srv)
+
+	zone, err := c.Zone(context.Background(), "example.com")
+	if err != nil {
+		t.Fatalf("Zone: %v", err)
+	}
+	if zone.SystemNS != "nsa4.schlundtech.de" {
+		t.Errorf("system_ns = %q", zone.SystemNS)
+	}
+	if len(zone.RRs) != 3 {
+		t.Fatalf("got %d records, want 3", len(zone.RRs))
+	}
+	// The apex is the empty name.
+	if zone.RRs[0].Name != "" || zone.RRs[0].Type != "TXT" {
+		t.Errorf("record 0 = %+v", zone.RRs[0])
+	}
+	if zone.RRs[0].TTL != 3600 {
+		t.Errorf("ttl = %d, want 3600", zone.RRs[0].TTL)
+	}
+
+	txt, err := c.Records(context.Background(), "example.com", "txt")
+	if err != nil {
+		t.Fatalf("Records: %v", err)
+	}
+	if len(txt) != 1 || txt[0].Type != "TXT" {
+		t.Errorf("txt filter returned %+v", txt)
+	}
+}
+
+// The gateway answers HTTP 200 for a bad password. A client that trusted the
+// status code would report success here.
+func TestErrorIsNotTakenFromHTTPStatus(t *testing.T) {
+	body := `<?xml version="1.0" encoding="UTF-8"?>
+<response><result>
+  <msg>
+    <text>User does not exist or password incorrect.</text>
+    <code>EF00202</code>
+    <type>error</type>
+    <object><type>user</type><value>u</value></object>
+  </msg>
+  <status><code>E00000</code><text>Errors occurred during processing.</text><type>error</type></status>
+</result><stid>20261001-4f6ed18d77f91afa9c1ef8cbd4ac434d</stid></response>`
+	srv, _ := respond(t, body)
+
+	_, err := testClient(srv).Zones(context.Background())
+	if err == nil {
+		t.Fatal("expected an error, got nil: HTTP 200 was mistaken for success")
+	}
+	apiErr, ok := err.(*APIError)
+	if !ok {
+		t.Fatalf("error type = %T, want *APIError", err)
+	}
+	// The actionable code lives in msg, not in the generic status code.
+	if apiErr.Code != "EF00202" {
+		t.Errorf("code = %q, want EF00202", apiErr.Code)
+	}
+	if !strings.Contains(apiErr.Error(), "password incorrect") {
+		t.Errorf("message = %q", apiErr.Error())
+	}
+	if apiErr.Stid == "" {
+		t.Error("stid was dropped, it is what support asks for")
+	}
+}
+
+// A non-2xx status is a routing problem, and must not be reported as a task
+// error with a gateway code.
+func TestNon2xxIsReportedAsRoutingError(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		w.WriteHeader(http.StatusNotFound)
+		_, _ = io.WriteString(w, "<html>404 Not Found</html>")
+	}))
+	t.Cleanup(srv.Close)
+
+	_, err := testClient(srv).Zones(context.Background())
+	if err == nil {
+		t.Fatal("expected an error for HTTP 404")
+	}
+	if _, ok := err.(*APIError); ok {
+		t.Errorf("HTTP 404 was wrapped as an APIError: %v", err)
+	}
+	if !strings.Contains(err.Error(), "404") {
+		t.Errorf("message = %q, want it to mention 404", err)
+	}
+}
+
+func TestSetRecordsRemovesThenAdds(t *testing.T) {
+	// First call answers the zone read, second the update.
+	zoneBody := `<?xml version="1.0" encoding="UTF-8"?>
+<response><result><data><zone>
+  <name>example.com</name>
+  <system_ns>nsa4.schlundtech.de</system_ns>
+  <rr><name></name><type>TXT</type><value>old-a</value><ttl>600</ttl></rr>
+  <rr><name></name><type>TXT</type><value>old-b</value><ttl>600</ttl></rr>
+  <rr><name>www</name><type>A</type><value>203.0.113.1</value><ttl>300</ttl></rr>
+</zone></data><status><code>S0205</code><type>success</type></status></result></response>`
+	updateBody := `<?xml version="1.0" encoding="UTF-8"?>
+<response><result>
+  <msg><text>Zone was updated successfully on the name server.</text><code>0202001</code>
+    <type>success</type><object><type>zone</type><value>example.com</value></object></msg>
+  <status><code>S0202001</code><text>Bulk zone update completed successfully.</text><type>success</type></status>
+</result><stid>20130906-app1-test-7072</stid></response>`
+
+	var seen []Request
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		raw, _ := io.ReadAll(r.Body)
+		var req Request
+		if err := xml.Unmarshal(raw, &req); err != nil {
+			t.Errorf("parse request: %v", err)
+		}
+		seen = append(seen, req)
+		w.Header().Set("Content-Type", "application/xml")
+		body := zoneBody
+		if req.Task[0].Code == TaskZoneUpdateBulk {
+			body = updateBody
+		}
+		_, _ = io.WriteString(w, body)
+	}))
+	t.Cleanup(srv.Close)
+
+	err := testClient(srv).SetRecords(context.Background(), "example.com", "@", "TXT", []string{"new"}, 0)
+	if err != nil {
+		t.Fatalf("SetRecords: %v", err)
+	}
+	if len(seen) != 2 {
+		t.Fatalf("got %d requests, want 2 (read then update)", len(seen))
+	}
+
+	update := seen[1].Task[0]
+	if update.Code != TaskZoneUpdateBulk {
+		t.Errorf("task code = %q", update.Code)
+	}
+	if update.Zone == nil || update.Zone.SystemNS != "nsa4.schlundtech.de" {
+		t.Fatalf("update zone = %+v, want the system_ns from the zone read", update.Zone)
+	}
+	if update.Default == nil {
+		t.Fatal("update had no default block")
+	}
+	// Both old values go, in one task.
+	if len(update.Default.RRRem) != 2 {
+		t.Errorf("rr_rem count = %d, want 2", len(update.Default.RRRem))
+	}
+	if len(update.Default.RRAdd) != 1 {
+		t.Fatalf("rr_add count = %d, want 1", len(update.Default.RRAdd))
+	}
+	add := update.Default.RRAdd[0]
+	// @ must reach the gateway as the empty apex name.
+	if add.Name != "" {
+		t.Errorf("rr_add name = %q, want empty (the apex)", add.Name)
+	}
+	if add.Type != "TXT" || add.Value != "new" {
+		t.Errorf("rr_add = %+v", add)
+	}
+	// TTL was not given, so the existing one is carried over.
+	if add.TTL != 600 {
+		t.Errorf("rr_add ttl = %d, want 600 inherited from the existing record", add.TTL)
+	}
+}
+
+func TestSetRecordsPreservesGivenTTL(t *testing.T) {
+	zoneBody := `<?xml version="1.0" encoding="UTF-8"?>
+<response><result><data><zone><name>example.com</name><system_ns>ns1.example.com</system_ns>
+  <rr><name></name><type>A</type><value>1.1.1.1</value><ttl>300</ttl></rr>
+</zone></data><status><code>S0205</code><type>success</type></status></result></response>`
+	okBody := `<response><result><status><code>S0202001</code><type>success</type></status></result></response>`
+
+	var update *Task
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		raw, _ := io.ReadAll(r.Body)
+		var req Request
+		_ = xml.Unmarshal(raw, &req)
+		body := zoneBody
+		if req.Task[0].Code == TaskZoneUpdateBulk {
+			update = &req.Task[0]
+			body = okBody
+		}
+		w.Header().Set("Content-Type", "application/xml")
+		_, _ = io.WriteString(w, body)
+	}))
+	t.Cleanup(srv.Close)
+
+	if err := testClient(srv).SetRecords(context.Background(), "example.com", "@", "A", []string{"2.2.2.2"}, 120); err != nil {
+		t.Fatalf("SetRecords: %v", err)
+	}
+	if update == nil || update.Default == nil || len(update.Default.RRAdd) != 1 {
+		t.Fatalf("no update task was sent: %+v", update)
+	}
+	if got := update.Default.RRAdd[0].TTL; got != 120 {
+		t.Errorf("rr_add ttl = %d, want the explicit 120", got)
+	}
+}
+
+func TestDeleteWithoutValueRemovesEveryValue(t *testing.T) {
+	zoneBody := `<?xml version="1.0" encoding="UTF-8"?>
+<response><result><data><zone><name>example.com</name><system_ns>ns1.example.com</system_ns>
+  <rr><name>www</name><type>A</type><value>1.1.1.1</value><ttl>300</ttl></rr>
+  <rr><name>www</name><type>A</type><value>2.2.2.2</value><ttl>300</ttl></rr>
+  <rr><name>mail</name><type>A</type><value>3.3.3.3</value><ttl>300</ttl></rr>
+</zone></data><status><code>S0205</code><type>success</type></status></result></response>`
+	okBody := `<response><result><status><code>S0202001</code><type>success</type></status></result></response>`
+
+	var update *Task
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		raw, _ := io.ReadAll(r.Body)
+		var req Request
+		_ = xml.Unmarshal(raw, &req)
+		body := zoneBody
+		if req.Task[0].Code == TaskZoneUpdateBulk {
+			update = &req.Task[0]
+			body = okBody
+		}
+		w.Header().Set("Content-Type", "application/xml")
+		_, _ = io.WriteString(w, body)
+	}))
+	t.Cleanup(srv.Close)
+
+	if err := testClient(srv).DeleteRecords(context.Background(), "example.com", "www", "A", nil); err != nil {
+		t.Fatalf("DeleteRecords: %v", err)
+	}
+	if update == nil || update.Default == nil {
+		t.Fatal("no update task was sent")
+	}
+	// Only the two records at that name, not the unrelated one.
+	if len(update.Default.RRRem) != 2 {
+		t.Errorf("rr_rem count = %d, want 2", len(update.Default.RRRem))
+	}
+	if len(update.Default.RRAdd) != 0 {
+		t.Errorf("a delete sent %d rr_add blocks", len(update.Default.RRAdd))
+	}
+}
+
+func TestDeleteUnknownRecordIsRefusedLocally(t *testing.T) {
+	zoneBody := `<?xml version="1.0" encoding="UTF-8"?>
+<response><result><data><zone><name>example.com</name><system_ns>ns1.example.com</system_ns>
+  <rr><name>www</name><type>A</type><value>1.1.1.1</value><ttl>300</ttl></rr>
+</zone></data><status><code>S0205</code><type>success</type></status></result></response>`
+
+	var updates int
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		raw, _ := io.ReadAll(r.Body)
+		var req Request
+		_ = xml.Unmarshal(raw, &req)
+		if req.Task[0].Code == TaskZoneUpdateBulk {
+			updates++
+		}
+		w.Header().Set("Content-Type", "application/xml")
+		_, _ = io.WriteString(w, zoneBody)
+	}))
+	t.Cleanup(srv.Close)
+
+	err := testClient(srv).DeleteRecords(context.Background(), "example.com", "@", "TXT", nil)
+	if err == nil {
+		t.Fatal("expected an error when nothing matches")
+	}
+	if updates != 0 {
+		t.Errorf("an update was sent anyway (%d)", updates)
+	}
+}
+
+func TestSetRecordsRejectsEmptyValues(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
+		t.Error("no request should be made")
+	}))
+	t.Cleanup(srv.Close)
+
+	if err := testClient(srv).SetRecords(context.Background(), "example.com", "@", "TXT", nil, 0); err == nil {
+		t.Fatal("expected an error for an empty value list")
+	}
+}
+
+// The gateway rejects a body with a byte order mark, so the client must not
+// send one.
+func TestRequestHasNoBOM(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		raw, _ := io.ReadAll(r.Body)
+		if len(raw) >= 3 && raw[0] == 0xEF && raw[1] == 0xBB && raw[2] == 0xBF {
+			t.Errorf("request starts with a byte order mark: % X", raw[:3])
+		}
+		if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/xml") {
+			t.Errorf("Content-Type = %q", ct)
+		}
+		w.Header().Set("Content-Type", "application/xml")
+		_, _ = io.WriteString(w, `<response><result><status><code>S0205</code><type>success</type></status></result></response>`)
+	}))
+	t.Cleanup(srv.Close)
+
+	if _, err := testClient(srv).Zones(context.Background()); err != nil {
+		t.Fatalf("Zones: %v", err)
+	}
+}
+
+func TestUpdateFailureSurfacesTheGatewayCode(t *testing.T) {
+	zoneBody := `<?xml version="1.0" encoding="UTF-8"?>
+<response><result><data><zone><name>example.com</name><system_ns>ns1.example.com</system_ns>
+</zone></data><status><code>S0205</code><type>success</type></status></result></response>`
+	// EF02020 is the documented "no such zone exists".
+	errBody := `<?xml version="1.0" encoding="UTF-8"?>
+<response><result>
+  <msg><text>No such zone exists.</text><code>EF02020</code><type>error</type>
+    <object><type>zone</type><value>example.com</value></object></msg>
+  <status><code>E00000</code><text>Errors occurred during processing.</text><type>error</type></status>
+</result></response>`
+
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		raw, _ := io.ReadAll(r.Body)
+		var req Request
+		_ = xml.Unmarshal(raw, &req)
+		w.Header().Set("Content-Type", "application/xml")
+		if req.Task[0].Code == TaskZoneUpdateBulk {
+			_, _ = io.WriteString(w, errBody)
+			return
+		}
+		_, _ = io.WriteString(w, zoneBody)
+	}))
+	t.Cleanup(srv.Close)
+
+	err := testClient(srv).SetRecords(context.Background(), "example.com", "@", "A", []string{"1.2.3.4"}, 0)
+	apiErr, ok := err.(*APIError)
+	if !ok {
+		t.Fatalf("error type = %T (%v), want *APIError", err, err)
+	}
+	if apiErr.Code != "EF02020" {
+		t.Errorf("code = %q, want EF02020", apiErr.Code)
+	}
+	if !strings.Contains(apiErr.Object, "example.com") {
+		t.Errorf("object = %q, want it to name the zone", apiErr.Object)
+	}
+}

+ 62 - 0
internal/api/errors.go

@@ -0,0 +1,62 @@
+package api
+
+import (
+	"fmt"
+	"strings"
+)
+
+// APIError is a task the gateway refused or could not complete.
+//
+// It carries the gateway's own code so the caller can react to a specific
+// failure (bad credentials, unknown zone, name server mismatch) without
+// matching on message text.
+type APIError struct {
+	Code   string
+	Text   string
+	Object string
+	Stid   string
+}
+
+func (e *APIError) Error() string {
+	var b strings.Builder
+	if e.Code != "" {
+		fmt.Fprintf(&b, "%s: %s", e.Code, e.Text)
+	} else {
+		b.WriteString(e.Text)
+	}
+	if e.Object != "" {
+		fmt.Fprintf(&b, " (object: %s)", e.Object)
+	}
+	return b.String()
+}
+
+// fromResult builds an error from a failed result block. The actionable code
+// and text live in msg, not in status, so msg is preferred.
+func fromResult(r Result, stid string) *APIError {
+	e := &APIError{Stid: stid}
+
+	var pick *Msg
+	for i := range r.Msg {
+		m := &r.Msg[i]
+		// A notification ("N") alongside an error ("E") is not the failure.
+		if strings.EqualFold(m.Type, "error") || strings.HasPrefix(m.Code, "E") {
+			pick = m
+			break
+		}
+		if pick == nil {
+			pick = m
+		}
+	}
+	switch {
+	case pick != nil && pick.Text != "":
+		e.Code, e.Text = pick.Code, pick.Text
+	case r.Status.Text != "":
+		e.Code, e.Text = r.Status.Code, r.Status.Text
+	default:
+		e.Text = "the gateway reported a failure without a message"
+	}
+	if pick != nil && pick.Object != nil {
+		e.Object = pick.Object.Type + " " + pick.Object.Value
+	}
+	return e
+}

+ 175 - 0
internal/api/record.go

@@ -0,0 +1,175 @@
+package api
+
+import (
+	"context"
+	"fmt"
+	"strings"
+)
+
+// equalFold compares two record types case-insensitively.
+func equalFold(a, b string) bool { return strings.EqualFold(a, b) }
+
+// Apex is how a zone's own name is written on the command line. The gateway
+// represents it as the empty record name.
+const Apex = "@"
+
+// normalizeName maps a user-facing record name to the gateway's form. "@" and
+// "" both mean the zone apex.
+func normalizeName(name string) string {
+	if name == Apex {
+		return ""
+	}
+	return strings.TrimSuffix(name, ".")
+}
+
+// SetRecords replaces every value of one name and type with the given values,
+// in a single task: the existing records are removed and the new ones added
+// atomically, so a rejected task leaves the zone untouched.
+//
+// An empty zone is looked up to learn system_ns, which the update task
+// requires, and to read the current records so their TTL is preserved unless
+// ttl is non-zero.
+func (c *Client) SetRecords(ctx context.Context, zone, name, recordType string, values []string, ttl int) error {
+	if len(values) == 0 {
+		return fmt.Errorf("no value given: nothing to set")
+	}
+	name = normalizeName(name)
+
+	z, err := c.Zone(ctx, zone)
+	if err != nil {
+		return err
+	}
+	if z.SystemNS == "" {
+		return fmt.Errorf("zone %s came back without a system_ns, cannot update it", zone)
+	}
+
+	update := &Update{}
+	for _, rr := range z.RRs {
+		if rr.Name == name && equalFold(rr.Type, recordType) {
+			update.RRRem = append(update.RRRem, Record{
+				Name: rr.Name, Type: rr.Type, Value: rr.Value, TTL: rr.TTL,
+			})
+			if ttl == 0 {
+				ttl = rr.TTL
+			}
+		}
+	}
+	for _, v := range values {
+		update.RRAdd = append(update.RRAdd, Record{
+			Name: name, Type: strings.ToUpper(recordType), Value: v, TTL: ttl,
+		})
+	}
+	return c.applyUpdate(ctx, zone, z.SystemNS, update)
+}
+
+// DeleteRecords removes records of one name and type. With no values every
+// record of that name and type goes; with values only those are removed.
+func (c *Client) DeleteRecords(ctx context.Context, zone, name, recordType string, values []string) error {
+	name = normalizeName(name)
+
+	z, err := c.Zone(ctx, zone)
+	if err != nil {
+		return err
+	}
+	if z.SystemNS == "" {
+		return fmt.Errorf("zone %s came back without a system_ns, cannot update it", zone)
+	}
+
+	wanted := map[string]bool{}
+	for _, v := range values {
+		wanted[v] = true
+	}
+
+	update := &Update{}
+	matched := false
+	for _, rr := range z.RRs {
+		if rr.Name != name || !equalFold(rr.Type, recordType) {
+			continue
+		}
+		if len(wanted) > 0 && !wanted[rr.Value] {
+			continue
+		}
+		matched = true
+		update.RRRem = append(update.RRRem, Record{
+			Name: rr.Name, Type: rr.Type, Value: rr.Value, TTL: rr.TTL,
+		})
+	}
+	if !matched {
+		if len(values) == 0 {
+			return fmt.Errorf("no %s record named %q in zone %s", recordType, displayName(name), zone)
+		}
+		return fmt.Errorf("none of the given values is a %s record named %q in zone %s", recordType, displayName(name), zone)
+	}
+	return c.applyUpdate(ctx, zone, z.SystemNS, update)
+}
+
+// applyUpdate sends one zone update task. The gateway answers with one msg per
+// zone; any error msg becomes an *APIError.
+func (c *Client) applyUpdate(ctx context.Context, zone, systemNS string, update *Update) error {
+	if len(update.RRAdd) == 0 && len(update.RRRem) == 0 {
+		return fmt.Errorf("nothing to change in zone %s", zone)
+	}
+	task := Task{
+		Code:    TaskZoneUpdateBulk,
+		Default: update,
+		Zone:    &ZoneRef{Name: zone, SystemNS: systemNS},
+	}
+	_, _, err := c.run(ctx, task)
+	return err
+}
+
+// displayName renders a gateway record name back for humans.
+func displayName(name string) string {
+	if name == "" {
+		return Apex
+	}
+	return name
+}
+
+// PlanSet describes what SetRecords would change, for --dry-run.
+type PlanSet struct {
+	Zone   string
+	Name   string
+	Type   string
+	Remove []Record
+	Add    []Record
+}
+
+// String renders a plan as one line per change.
+func (p PlanSet) String() string {
+	var b strings.Builder
+	fmt.Fprintf(&b, "zone %s: set %d %s record(s) at %s\n", p.Zone, len(p.Add), p.Type, displayName(p.Name))
+	for _, rr := range p.Remove {
+		fmt.Fprintf(&b, "  - %s %s (ttl %d)\n", rr.Type, rr.Value, rr.TTL)
+	}
+	for _, rr := range p.Add {
+		fmt.Fprintf(&b, "  + %s %s (ttl %d)\n", rr.Type, rr.Value, rr.TTL)
+	}
+	return b.String()
+}
+
+// PreviewSet computes what SetRecords would do without contacting the update
+// endpoint. It reads the zone, so it needs working credentials.
+func (c *Client) PreviewSet(ctx context.Context, zone, name, recordType string, values []string, ttl int) (PlanSet, error) {
+	name = normalizeName(name)
+	plan := PlanSet{Zone: zone, Name: name, Type: recordType}
+
+	z, err := c.Zone(ctx, zone)
+	if err != nil {
+		return plan, err
+	}
+	for _, rr := range z.RRs {
+		if rr.Name == name && equalFold(rr.Type, recordType) {
+			plan.Remove = append(plan.Remove, rr)
+			if ttl == 0 {
+				ttl = rr.TTL
+			}
+		}
+	}
+	for _, v := range values {
+		plan.Add = append(plan.Add, Record{
+			Name: name, Type: strings.ToUpper(recordType), Value: v, TTL: ttl,
+		})
+	}
+	return plan, nil
+}

+ 151 - 0
internal/api/xml.go

@@ -0,0 +1,151 @@
+// Package api speaks the AutoDNS XML gateway that Schlundtech exposes at
+// gateway.schlundtech.de. It is the only place in this program that performs
+// HTTP.
+//
+// The gateway answers HTTP 200 even when a task fails, so every response is
+// parsed and judged on result/status/type and result/msg — never on the HTTP
+// status code. A non-2xx status from this host is an nginx 404 for a wrong
+// path, not an application error.
+package api
+
+import "encoding/xml"
+
+// Request is the AutoDNS request envelope: one auth block plus one or more
+// task blocks.
+type Request struct {
+	XMLName  xml.Name `xml:"request"`
+	Auth     Auth     `xml:"auth"`
+	Language string   `xml:"language,omitempty"`
+	Task     []Task   `xml:"task"`
+}
+
+// Auth carries the gateway credentials. Context is the project the records
+// belong to; for Schlundtech it is 10.
+type Auth struct {
+	User     string `xml:"user"`
+	Password string `xml:"password"`
+	Context  string `xml:"context"`
+	// Token is the optional second-factor token, when the account has 2FA on.
+	Token string `xml:"token,omitempty"`
+}
+
+// Task is a single task block. The API is task-specific below the code
+// element, so only the sub-structures a task can carry are modelled and the
+// caller populates the one it needs.
+type Task struct {
+	Code string `xml:"code"`
+
+	// Zone identifies the zone for a zone info or zone update task. Its Name
+	// is empty for a zone list.
+	Zone *ZoneRef `xml:"zone,omitempty"`
+
+	// View, Where and Key belong to a list query.
+	View  *View    `xml:"view,omitempty"`
+	Where *Where   `xml:"where,omitempty"`
+	Key   []string `xml:"key,omitempty"`
+
+	// Default is the payload of a zone update.
+	Default *Update `xml:"default,omitempty"`
+}
+
+// ZoneRef names a zone and the name server that serves it. SystemNS is
+// mandatory on updates — the gateway refuses the task without it.
+type ZoneRef struct {
+	Name      string `xml:"name"`
+	SystemNS  string `xml:"system_ns"`
+	VirtualNS string `xml:"virtual_name_server,omitempty"`
+}
+
+// View bounds a list query.
+type View struct {
+	Offset   int `xml:"offset"`
+	Limit    int `xml:"limit"`
+	Children int `xml:"children,omitempty"`
+}
+
+// Where filters a list query.
+type Where struct {
+	Key      string `xml:"key"`
+	Operator string `xml:"operator"`
+	Value    string `xml:"value"`
+}
+
+// Update is the payload of a zone update. rr_add and rr_rem are repeated
+// elements: one sibling per record, not one element with children.
+type Update struct {
+	RRAdd []Record `xml:"rr_add"`
+	RRRem []Record `xml:"rr_rem"`
+}
+
+// Record is one resource record. Name is relative to the zone; the zone apex
+// is the empty string.
+type Record struct {
+	Name  string `xml:"name"`
+	Type  string `xml:"type"`
+	Value string `xml:"value"`
+	TTL   int    `xml:"ttl,omitempty"`
+	Pref  int    `xml:"pref,omitempty"`
+}
+
+// Response is the AutoDNS response envelope. Result is repeated because a
+// multi-zone task answers once per zone.
+type Response struct {
+	XMLName xml.Name `xml:"response"`
+	Result  []Result `xml:"result"`
+	Stid    string   `xml:"stid"`
+}
+
+// Result is the per-task outcome.
+type Result struct {
+	Data   *Data  `xml:"data,omitempty"`
+	Status Status `xml:"status"`
+	Msg    []Msg  `xml:"msg,omitempty"`
+}
+
+// Status is the overall task status. Type is "success", "error" or
+// "notification".
+type Status struct {
+	Code string `xml:"code"`
+	Type string `xml:"type"`
+	Text string `xml:"text"`
+}
+
+// Msg is a system message. On failure this is where the actionable code and
+// text live; status stays generic (E00000).
+type Msg struct {
+	Code   string  `xml:"code"`
+	Type   string  `xml:"type"`
+	Text   string  `xml:"text"`
+	Object *Object `xml:"object,omitempty"`
+}
+
+// Object names what a Msg refers to.
+type Object struct {
+	Type  string `xml:"type"`
+	Value string `xml:"value"`
+}
+
+// Data is the payload of an info or list task.
+type Data struct {
+	Summary int    `xml:"summary"`
+	Zone    []Zone `xml:"zone"`
+}
+
+// Zone is a DNS zone. The list task returns the summary fields; an info task
+// additionally returns every record in RRs.
+type Zone struct {
+	Name     string   `xml:"name"`
+	Origin   string   `xml:"origin,omitempty"`
+	SystemNS string   `xml:"system_ns"`
+	SOA      *SOA     `xml:"soa,omitempty"`
+	RRs      []Record `xml:"rr"`
+}
+
+// SOA is a zone's start-of-authority record.
+type SOA struct {
+	TTL     int    `xml:"ttl,omitempty"`
+	Refresh int    `xml:"refresh,omitempty"`
+	Retry   int    `xml:"retry,omitempty"`
+	Expire  int    `xml:"expire,omitempty"`
+	Email   string `xml:"email,omitempty"`
+}

+ 87 - 0
internal/api/zone.go

@@ -0,0 +1,87 @@
+package api
+
+import "context"
+
+// Task codes, from the AutoDNS documentation.
+const (
+	// TaskZoneInquire reads one zone or lists all of them.
+	TaskZoneInquire = "0205"
+	// TaskZoneUpdateBulk adds and removes records in one job.
+	TaskZoneUpdateBulk = "0202001"
+)
+
+// defaultListLimit is how many zones one list call asks for. A customer with
+// more zones than this needs paging, which Zones does transparently.
+const defaultListLimit = 500
+
+// ZoneSummary is a zone without its records.
+type ZoneSummary struct {
+	// Name is the zone itself, for example example.com.
+	Name string
+	// SystemNS is the name server that serves the zone. It is mandatory on
+	// update tasks, so it is carried through from the list.
+	SystemNS string
+}
+
+// Zones lists every zone the account can see. It pages until the gateway
+// returns fewer zones than requested.
+func (c *Client) Zones(ctx context.Context) ([]ZoneSummary, error) {
+	var out []ZoneSummary
+	for offset := 0; ; offset += defaultListLimit {
+		task := Task{
+			Code: TaskZoneInquire,
+			View: &View{Offset: offset, Limit: defaultListLimit, Children: 1},
+			Key:  []string{"name", "system_ns"},
+		}
+		_, result, err := c.run(ctx, task)
+		if err != nil {
+			return nil, err
+		}
+		if result.Data == nil || len(result.Data.Zone) == 0 {
+			return out, nil
+		}
+		for _, z := range result.Data.Zone {
+			out = append(out, ZoneSummary{Name: z.Name, SystemNS: z.SystemNS})
+		}
+		if len(result.Data.Zone) < defaultListLimit {
+			return out, nil
+		}
+	}
+}
+
+// Zone returns one zone including every record in it.
+func (c *Client) Zone(ctx context.Context, name string) (*Zone, error) {
+	task := Task{
+		Code: TaskZoneInquire,
+		Zone: &ZoneRef{Name: name},
+		Key:  []string{"name", "system_ns", "soa"},
+	}
+	_, result, err := c.run(ctx, task)
+	if err != nil {
+		return nil, err
+	}
+	if result.Data == nil || len(result.Data.Zone) == 0 {
+		return nil, &APIError{Code: "EF02020", Text: "no such zone exists", Object: "zone " + name}
+	}
+	zone := result.Data.Zone[0]
+	return &zone, nil
+}
+
+// Records returns the records of a zone, optionally filtered by type. An empty
+// type returns every record.
+func (c *Client) Records(ctx context.Context, zone, recordType string) ([]Record, error) {
+	z, err := c.Zone(ctx, zone)
+	if err != nil {
+		return nil, err
+	}
+	if recordType == "" {
+		return z.RRs, nil
+	}
+	out := make([]Record, 0, len(z.RRs))
+	for _, rr := range z.RRs {
+		if equalFold(rr.Type, recordType) {
+			out = append(out, rr)
+		}
+	}
+	return out, nil
+}

+ 56 - 0
internal/config/config.go

@@ -0,0 +1,56 @@
+// Package config reads the gateway credentials from the environment.
+//
+// The credentials are never logged, never printed and never written to disk by
+// this package.
+package config
+
+import (
+	"fmt"
+	"os"
+	"strings"
+)
+
+// Environment variable names.
+const (
+	EnvUser     = "SCHLUNDTECH_USER"
+	EnvPassword = "SCHLUNDTECH_PASSWORD"
+	EnvContext  = "SCHLUNDTECH_CONTEXT"
+	EnvToken    = "SCHLUNDTECH_TOKEN"
+	EnvEndpoint = "SCHLUNDTECH_ENDPOINT"
+)
+
+// Credentials are the values needed to talk to the gateway.
+type Credentials struct {
+	User     string
+	Password string
+	Context  string
+	Token    string
+	Endpoint string
+}
+
+// FromEnv reads the credentials. It reports every missing variable at once so
+// a first run does not fail one variable at a time.
+func FromEnv() (Credentials, error) {
+	c := Credentials{
+		User:     strings.TrimSpace(os.Getenv(EnvUser)),
+		Password: os.Getenv(EnvPassword),
+		Context:  strings.TrimSpace(os.Getenv(EnvContext)),
+		Token:    strings.TrimSpace(os.Getenv(EnvToken)),
+		Endpoint: strings.TrimSpace(os.Getenv(EnvEndpoint)),
+	}
+
+	var missing []string
+	if c.User == "" {
+		missing = append(missing, EnvUser)
+	}
+	if c.Password == "" {
+		missing = append(missing, EnvPassword)
+	}
+	if c.Context == "" {
+		missing = append(missing, EnvContext)
+	}
+	if len(missing) > 0 {
+		return c, fmt.Errorf("missing environment variable(s): %s", strings.Join(missing, ", "))
+	}
+	return c, nil
+}

+ 52 - 0
internal/output/output.go

@@ -0,0 +1,52 @@
+// Package output renders command results as a table or as JSON.
+package output
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"strings"
+	"text/tabwriter"
+)
+
+// Format is a rendering mode for command output.
+type Format string
+
+// Supported formats.
+const (
+	Table Format = "table"
+	JSON  Format = "json"
+)
+
+// Parse validates a user-supplied format name.
+func Parse(s string) (Format, error) {
+	switch Format(strings.ToLower(strings.TrimSpace(s))) {
+	case Table, "":
+		return Table, nil
+	case JSON:
+		return JSON, nil
+	default:
+		return "", fmt.Errorf("unknown output format %q: use table or json", s)
+	}
+}
+
+// Write renders v according to format. Table rendering uses header and rows so
+// every command lays out the same way.
+func Write(w io.Writer, format Format, header []string, rows [][]string, v any) error {
+	if format == JSON {
+		enc := json.NewEncoder(w)
+		enc.SetIndent("", "  ")
+		return enc.Encode(v)
+	}
+	if len(header) == 0 && len(rows) == 0 {
+		return nil
+	}
+	tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
+	if len(header) > 0 {
+		fmt.Fprintln(tw, strings.Join(header, "\t"))
+	}
+	for _, row := range rows {
+		fmt.Fprintln(tw, strings.Join(row, "\t"))
+	}
+	return tw.Flush()
+}

+ 9 - 0
main.go

@@ -0,0 +1,9 @@
+// Command schlundtech-dns manages Schlundtech DNS zones and records from the
+// command line.
+package main
+
+import "schlundtech-dns/cmd"
+
+func main() {
+	cmd.Execute()
+}

+ 1 - 0
maister

@@ -0,0 +1 @@
+Subproject commit 65459dcf65ab46bf49ac9aadebafd6a2238da62a

+ 13 - 0
opencode.json

@@ -0,0 +1,13 @@
+{
+  "$schema": "https://opencode.ai/config.json",
+  "mcp": {
+    "playwright": {
+      "type": "local",
+      "command": ["npx", "-y", "@playwright/mcp@latest"],
+      "enabled": false
+    }
+  },
+  "permission": {
+    "question": "allow"
+  }
+}

+ 799 - 0
tools/build-maister-opencode.mjs

@@ -0,0 +1,799 @@
+#!/usr/bin/env node
+// Build an opencode port of the maister plugin.
+//
+// Source:  maister/plugins/maister   (Claude Code plugin, vendored clone)
+// Output:  .opencode/skill|maister-*/   skills
+//          .opencode/agent/maister-*.md subagents
+//          .opencode/command/maister-*.md  slash commands
+//
+// Run after every `git -C maister pull`:
+//   node tools/build-maister-opencode.mjs
+//
+// The generated tree is disposable — never edit it by hand, edit the upstream
+// clone and rebuild. Files that are hand-maintained (the plugin, opencode.json)
+// are not touched by this script.
+
+import { readFileSync, writeFileSync, readdirSync, mkdirSync, rmSync, existsSync, statSync, copyFileSync } from "node:fs"
+import { join, dirname, basename, relative } from "node:path"
+import { fileURLToPath } from "node:url"
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..")
+const SRC = join(ROOT, "maister", "plugins", "maister")
+const OUT = join(ROOT, ".opencode")
+
+const PREFIX = "maister-"
+
+// ---------------------------------------------------------------------------
+// upstream version, for the generated headers
+// ---------------------------------------------------------------------------
+
+function upstreamVersion() {
+  try {
+    return JSON.parse(readFileSync(join(ROOT, "maister", ".claude-plugin", "marketplace.json"), "utf8")).version
+  } catch {
+    return "unknown"
+  }
+}
+const VERSION = upstreamVersion()
+
+// ---------------------------------------------------------------------------
+// source inventory
+// ---------------------------------------------------------------------------
+
+function walk(dir, out = []) {
+  if (!existsSync(dir)) return out
+  for (const entry of readdirSync(dir)) {
+    const full = join(dir, entry)
+    if (statSync(full).isDirectory()) walk(full, out)
+    else out.push(full)
+  }
+  return out
+}
+
+const SRC_SKILLS = readdirSync(join(SRC, "skills"), { withFileTypes: true })
+  .filter((e) => e.isDirectory())
+  .map((e) => e.name)
+  .sort()
+
+const SRC_AGENTS = readdirSync(join(SRC, "agents"))
+  .filter((f) => f.endsWith(".md"))
+  .map((f) => basename(f, ".md"))
+  .sort()
+
+const SRC_COMMANDS = readdirSync(join(SRC, "commands"))
+  .filter((f) => f.endsWith(".md"))
+  .map((f) => basename(f, ".md"))
+  .sort()
+
+// Path of a skill's support file (relative to the skill dir) -> owning skill,
+// so a reference can be rewritten to a path that resolves from the project root.
+const SUPPORT_OWNER = new Map()
+for (const skill of SRC_SKILLS) {
+  for (const file of walk(join(SRC, "skills", skill))) {
+    const rel = relative(join(SRC, "skills", skill), file)
+    if (rel !== "SKILL.md" && /\.(md|html|mjs|json)$/.test(rel)) {
+      if (!SUPPORT_OWNER.has(rel)) SUPPORT_OWNER.set(rel, skill)
+    }
+  }
+}
+
+// ---------------------------------------------------------------------------
+// text transforms
+// ---------------------------------------------------------------------------
+
+// Ordered: specific phrases first, general fallbacks last.
+const REWRITES = [
+  // --- task tracking: opencode has one list-replacing `todowrite` tool -------
+  [
+    /Use `TaskCreate` for all phases \(see Phase Configuration\), then set dependencies with `TaskUpdate addBlockedBy`/g,
+    "Write every phase into the todo list with a single `todowrite` call (all `pending`) and encode phase dependencies in the item text (e.g. `Phase 4: Specification (blocked by 2, 3)`) — `todowrite` replaces the whole list on every call and has no dependency field",
+  ],
+  [
+    /`TaskCreate` for all phases above \(pending\)/g,
+    "a single `todowrite` call listing every phase above as `pending`",
+  ],
+  [
+    /Use `TaskCreate` for all phases \(pending\), then set sequential dependencies with `TaskUpdate addBlockedBy`/g,
+    "Write every phase into the todo list with a single `todowrite` call (all `pending`), in the order the phases run — `todowrite` replaces the whole list on every call and has no dependency field",
+  ],
+  [
+    /use `TaskCreate` for all phases \(pending\), then set sequential dependencies with `TaskUpdate addBlockedBy`/g,
+    "write every phase into the todo list with a single `todowrite` call (all `pending`), in the order the phases run — `todowrite` replaces the whole list on every call and has no dependency field",
+  ],
+  [
+    /Set dependencies: ([^.]+)\. Phase/g,
+    "Sequence them in item order. Phase",
+  ],
+  [
+    /mark skipped phases as `completed` with `metadata: \{skipped: true\}`/g,
+    'mark skipped phases `completed` and append "(skipped)" to their text',
+  ],
+  [
+    /For phases skipped due to scope \(([^)]*)\), mark `completed` with `metadata: \{skipped: true, reason: "([^"]*)"\}`/g,
+    "For phases skipped due to scope ($1), mark them `completed` and append \"(skipped: $2)\" to their text",
+  ],
+  [
+    /with `metadata: \{"skipped": true\}\`/g,
+    "with `(skipped)` appended to the item text",
+  ],
+  [
+    /`TaskCreate` for all phases \(pending\), then `TaskUpdate addBlockedBy` for dependencies/g,
+    "a single `todowrite` call listing every phase as `pending`, with dependencies encoded in the item text",
+  ],
+  [
+    /For each task group, call `TaskCreate`:/g,
+    "For each task group, add an item in one `todowrite` call (it replaces the whole list, so include every existing item):",
+  ],
+  [
+    /Set dependencies with `TaskUpdate addBlockedBy` mirroring the plan's dependency chain:/g,
+    "Encode the plan's dependency chain in each item's text (e.g. `Group 3: refactor store (blocked by 1, 2)`):",
+  ],
+  [
+    /Dependencies set via `TaskUpdate addBlockedBy`/g,
+    "Dependencies encoded in the item text passed to `todowrite`",
+  ],
+  [
+    /Call `TaskList` to find existing task group items from the planner\. If found, use them\. If not, create them with `TaskCreate` for each task group \(when the planner created none\)\./g,
+    "Read the current todo list (opencode keeps it in context and `todowrite` replaces it wholesale). If the planner left no group items, add one per task group in a single `todowrite` call.",
+  ],
+  [
+    /`TaskUpdate` to `status: "in_progress"` with `owner: "maister:([a-z-]+)"`/g,
+    "`todowrite` with that group `in_progress` (delegated to `$1`)",
+  ],
+  [
+    /`TaskUpdate` to `status: "completed"` with `metadata: \{[^}]*\}/g,
+    "`todowrite` with that group `completed` (the outcome details go in `implementation/work-log.md`)",
+  ],
+  [
+    /`TaskUpdate` to `status: "completed"`/g,
+    "`todowrite` with that item `completed`",
+  ],
+  [
+    /`TaskUpdate` to `status: "completed"`/g,
+    "`todowrite` with that item `completed`",
+  ],
+  [
+    /`TaskUpdate` with `status: "completed"` and `metadata: \{"skipped": true\}`/g,
+    '`todowrite` with `status: "completed"` and "(skipped)" appended to the item text',
+  ],
+  [
+    /`TaskUpdate` with `status: "completed"`/g,
+    '`todowrite` with `status: "completed"`',
+  ],
+  [
+    /`TaskUpdate` to `status: "in_progress"`/g,
+    "`todowrite` with that item `in_progress`",
+  ],
+  [
+    /`TaskUpdate` to `in_progress`/g,
+    "`todowrite` with that item `in_progress`",
+  ],
+  [
+    /`TaskUpdate` to `completed`/g,
+    "`todowrite` with that item `completed`",
+  ],
+  [
+    /`TaskUpdate` addBlockedBy/g,
+    "dependencies encoded in the `todowrite` item text",
+  ],
+  [
+    /Use `TaskCreate` tool:/g,
+    "Use the `todowrite` tool (one call carrying the full list):",
+  ],
+  [
+    /`TaskCreate` for each task group with `Dependencies` AND `Files to Modify` declared in `implementation-plan\.md`/g,
+    "one `todowrite` item per task group, with `Dependencies` and `Files to Modify` from `implementation-plan.md` carried in the item text",
+  ],
+  [
+    /`TaskCreate` per task group with `Dependencies` AND `Files to Modify` declared in `implementation-plan\.md`/g,
+    "one `todowrite` item per task group, with `Dependencies` and `Files to Modify` from `implementation-plan.md` carried in the item text",
+  ],
+  [
+    /`TaskCreate` per task group/g,
+    "one `todowrite` item per task group",
+  ],
+  [
+    /`TaskCreate` for all phases \(see Phase Configuration\)/g,
+    "a single `todowrite` call listing every phase (see Phase Configuration)",
+  ],
+  [
+    /`TaskCreate` for all phases/g,
+    "a single `todowrite` call listing every phase",
+  ],
+  [
+    /`TaskCreate` initialization\*\*/g,
+    "todo-list initialization**",
+  ],
+  [
+    /`TaskCreate`\/?`TaskUpdate`/g,
+    "`todowrite`",
+  ],
+  [
+    /`TaskCreate`\/`TaskUpdate`/g,
+    "`todowrite`",
+  ],
+  [
+    /`TaskCreate`/g,
+    "`todowrite`",
+  ],
+  [
+    /`TaskUpdate`/g,
+    "`todowrite`",
+  ],
+  [
+    /`TaskList`/g,
+    "the todo list",
+  ],
+  [
+    /`TaskCreate`\/`TaskUpdate` tools/g,
+    "the `todowrite` tool",
+  ],
+  // bare (un-backticked) mentions the specific rules above did not catch
+  [
+    /`TaskUpdate addBlockedBy`/g,
+    "dependencies encoded in the `todowrite` item text",
+  ],
+  [
+    /\bTaskCreate\/TaskUpdate\b/g,
+    "`todowrite`",
+  ],
+  [
+    /\bTaskUpdate addBlockedBy\b/g,
+    "dependencies encoded in the `todowrite` item text",
+  ],
+  [
+    /\bTaskCreate IDs\b/g,
+    "`todowrite` items",
+  ],
+  [
+    /\bTaskCreate initialization\b/g,
+    "todo-list initialization",
+  ],
+  [
+    /\bTaskCreate\b/g,
+    "`todowrite`",
+  ],
+  [
+    /\bTaskUpdate\b/g,
+    "`todowrite`",
+  ],
+  [
+    /\bTaskList\b/g,
+    "the todo list",
+  ],
+  // `todowrite` has no metadata bag, no owner and no dependency field: anything
+  // upstream parked there has to move into the item text or the work log.
+  [
+    /`metadata: \{[^}]*\}`/g,
+    "(recorded in the work log)",
+  ],
+  [
+    /\bmetadata: \{[^}]*\}\.?/g,
+    "; the details go in the work log.",
+  ],
+  [
+    /`todowrite` with `addBlockedBy`/g,
+    "the `todowrite` item order",
+  ],
+  [
+    /with `addBlockedBy` dependencies/g,
+    "in dependency order",
+  ],
+  [
+    /\baddBlockedBy\b/g,
+    "dependency order",
+  ],
+  [
+    /`task_ids: \{\}`/g,
+    "an empty `task_ids` map",
+  ],
+
+  // --- plan mode -----------------------------------------------------------
+  [
+    /Call `EnterPlanMode` and let plan mode run exactly as it normally does \(explore the codebase, design the approach, write the plan, then `ExitPlanMode` for approval\)\. Do not redefine its phases\./g,
+    "Plan without editing any file: explore the codebase, design the approach, write the plan out, then request approval with the `question` tool. opencode has no plan-mode tool; read-only behaviour is what plan mode provided, so respect it for the whole planning pass.",
+  ],
+  [
+    /Do not call `ExitPlanMode` until the plan reflects/g,
+    "Do not ask for approval until the plan reflects",
+  ],
+  [
+    /\*\*BLOCKING: Do NOT call `ExitPlanMode` until the plan file contains:\*\*/g,
+    "**BLOCKING: do not ask for approval until the plan file contains:**",
+  ],
+  [
+    /\*\*Use the `EnterPlanMode` tool to present the fix plan for user approval\.\*\*/g,
+    "**Present the fix plan for approval with the `question` tool before touching any file.**",
+  ],
+  [
+    /### ExitPlanMode Gate: Mandatory Sections/g,
+    "### Approval Gate: Mandatory Sections",
+  ],
+  [
+    /If any section is missing, add it before calling ExitPlanMode\./g,
+    "If any section is missing, add it before asking for approval.",
+  ],
+  [
+    /`EnterPlanMode`\/`ExitPlanMode`/g,
+    "plan mode",
+  ],
+  [
+    /`EnterPlanMode`/g,
+    "plan mode",
+  ],
+  [
+    /`ExitPlanMode`/g,
+    "the approval request",
+  ],
+
+  // --- built-in subagents --------------------------------------------------
+  [
+    /subagent_type: `general-purpose`/g,
+    "subagent_type: general",
+  ],
+  [
+    /subagent_type="general-purpose"/g,
+    'subagent_type="general"',
+  ],
+  [
+    /subagent_type: general-purpose/g,
+    "subagent_type: general",
+  ],
+  [
+    /subagent_type="Explore"/g,
+    'subagent_type="explore"',
+  ],
+  [
+    /subagent_type: `Explore`/g,
+    "subagent_type: explore",
+  ],
+  [
+    /subagent_type: Explore/g,
+    "subagent_type: explore",
+  ],
+  // "Explore" is only the built-in subagent where it names one. Everywhere else
+  // it is an ordinary English verb ("Explore the codebase"), which must survive
+  // untouched — so only the subagent-reference forms are rewritten.
+  [/\bExplore subagents?\b/g, "`explore` subagents"],
+  [/\bExplore agents\b/g, "`explore` agents"],
+  [/\bExplore agent\b/g, "`explore` agent"],
+  [/\bExplore\b(?=\s*\()/g, "`explore`"],
+  [/\bthe Explore\b/g, "the `explore` subagent"],
+  [/\braw Explore agents\b/g, "raw `explore` agent findings"],
+  [
+    /`general-purpose`/g,
+    "`general`",
+  ],
+  [
+    /\bgeneral-purpose subagents?\b/g,
+    "`general` subagents",
+  ],
+
+  // --- tools ---------------------------------------------------------------
+  [
+    /AskUserQuestion/g,
+    "question tool",
+  ],
+  [
+    /\bquestion tool tool\b/g,
+    "question tool",
+  ],
+  // Claude Code capitalises its tool names; opencode's are lowercase
+  [/\bWebFetch tool\b/g, "webfetch tool"],
+  [/\bWebSearch tool\b/g, "websearch tool"],
+  [/\bRead tool\b/g, "read tool"],
+  [/\bWrite tool\b/g, "write tool"],
+  [/\bEdit tool\b/g, "edit tool"],
+  [/\bBash tool\b/g, "bash tool"],
+  [/\bGlob tool\b/g, "glob tool"],
+  [/\bGrep tool\b/g, "grep tool"],
+  [/\(Bash\)/g, "(bash tool)"],
+  [/\bvia Bash\b/g, "via the bash tool"],
+  [/\bvia Read\b/g, "via the read tool"],
+  [/\bvia Write\b/g, "via the write tool"],
+  [
+    /Skill\/Task tools/g,
+    "skill/task tools",
+  ],
+  [
+    /Skill\/Task tool parameters/g,
+    "skill/task tool parameters",
+  ],
+  [
+    /`Task` tool/g,
+    "`task` tool",
+  ],
+  [
+    /\bTask tool\b/g,
+    "`task` tool",
+  ],
+  [
+    /\bTask tool calls?\b/g,
+    "`task` tool calls",
+  ],
+  [
+    /\bthe Task tool\b/g,
+    "the `task` tool",
+  ],
+  [
+    /one `Task` tool-use block/g,
+    "one `task` tool-use block",
+  ],
+  [
+    /`Task\(G2\)`/g,
+    "`task(G2)`",
+  ],
+  [
+    /`Task\(G3\)`/g,
+    "`task(G3)`",
+  ],
+  [
+    /`Task\(G4\)`/g,
+    "`task(G4)`",
+  ],
+  [
+    /Skill tool/g,
+    "skill tool",
+  ],
+  [
+    /`Skill` tool/g,
+    "`skill` tool",
+  ],
+  [
+    /via Task tool \(/g,
+    "via `task` tool (",
+  ],
+  [
+    /via Task tool\b/g,
+    "via the `task` tool",
+  ],
+  [
+    /Claude Code's `auto` permission mode/g,
+    "opencode's auto-approve permission mode",
+  ],
+  [
+    /`acceptEdits`/g,
+    "edit-auto-approve",
+  ],
+  [
+    /`bypassPermissions`/g,
+    "full-access",
+  ],
+  [
+    /`default`, edit-auto-approve, `auto`, `plan`, full-access/g,
+    "any opencode permission mode",
+  ],
+  [
+    /`default`, `acceptEdits`, `auto`, `plan`, `bypassPermissions`/g,
+    "any opencode permission mode",
+  ],
+  [
+    /\(auto \/ edit-auto-approve \/ full-access modes\)/g,
+    "(any permission mode)",
+  ],
+  [
+    /\bauto \/ edit-auto-approve \/ full-access\b/g,
+    "any permission mode",
+  ],
+  [
+    /Claude Code lifecycle events/g,
+    "opencode lifecycle events",
+  ],
+  [
+    /`SessionStart` \(matcher: `compact`\)/g,
+    "post-compaction system reminder",
+  ],
+  [
+    /`PreToolUse` \(matcher: `Bash`\)/g,
+    "per-agent `permission.bash` rules",
+  ],
+  [
+    /`PreToolUse` hook/g,
+    "per-agent bash permission rules",
+  ],
+  [
+    /auto-discovered by Claude Code/g,
+    "auto-discovered by opencode",
+  ],
+  [
+    /Claude Code's built-in plan mode/g,
+    "opencode's read-only plan pass",
+  ],
+  [
+    /Claude Code's plan mode/g,
+    "plan mode",
+  ],
+  [
+    /\bClaude Code\b/g,
+    "opencode",
+  ],
+  [
+    /CLAUDE\.md/g,
+    "AGENTS.md",
+  ],
+  [
+    /claude-md-template\.md/g,
+    "agents-md-template.md",
+  ],
+
+  // The mockup studio server ships with the skill, so its path is a plain
+  // project-relative path — no plugin-root variable to resolve.
+  [
+    /\$\{CLAUDE_PLUGIN_ROOT\}\/skills\/mockup-studio\//g,
+    ".opencode/skill/maister-mockup-studio/",
+  ],
+  [
+    /The plugin root is this plugin's own directory — the one holding\s*`\.claude-plugin\/plugin\.json` — and the variable naming it is set in the session\s*environment\. Use it as written; do not work the directory out and substitute a\s*path of your own\./g,
+    "That path is relative to the project root. Use it exactly as written; do not resolve it by hand or substitute a path of your own.",
+  ],
+  [
+    /The plugin root is this plugin's own directory — the one holding `\.claude-plugin\/plugin\.json` — and the variable naming it is set in the session environment\. Use it as written rather than substituting a path of your own\./g,
+    "That path is relative to the project root. Use it exactly as written rather than substituting a path of your own.",
+  ],
+
+  // --- playwright MCP tools get the server name as prefix in opencode ------
+  [/\bbrowser_(navigate|click|type|fill_form|snapshot|evaluate|take_screenshot|console_messages)\b/g, "playwright_browser_$1"],
+
+  // --- namespaced identifiers: `maister:x` -> `maister-x` -------------------
+  // opencode has no plugin namespace, so every skill, agent and command lives
+  // in one flat space and needs the flat prefix instead. The lookbehind keeps
+  // `.maisster/`-style project paths out of it.
+  [/(?<![\w.-])maister:([a-z][a-z-]*)/g, `${PREFIX}$1`],
+  // the `[orchestrator-name]` placeholder is a template, not a real identifier
+  [/(?<![\w.-])maister:\[/g, `${PREFIX}`],
+  // a wildcard reference to the whole namespace
+  [/`\/maister:\*`/g, "`/maister-*`"],
+]
+
+// Support-file paths, resolved after the generic rewrites.
+//
+// Two forms appear upstream: `../<skill>/references/x.md` (explicit sibling) and
+// a bare `references/x.md` that names another skill's file. opencode has no
+// plugin-relative base directory, so every reference becomes a path from the
+// project root. Same-skill references keep their relative form — opencode
+// announces the skill's base directory when it loads one.
+function rewriteSupportPaths(text, currentSkill) {
+  // explicit `../<skill>/...` and `[plugin]/skills/<skill>/...` forms
+  text = text.replace(/(?:\.\.\/|\[plugin\]\/skills\/|\.\/skills\/)([a-z-]+)\//g, (_, s) =>
+    SRC_SKILLS.includes(s) ? `.opencode/skill/${PREFIX}${s}/` : `../${s}/`,
+  )
+
+  // bare `references/x.md` etc. that belong to a different skill
+  for (const [rel, owner] of SUPPORT_OWNER) {
+    if (owner === currentSkill) continue
+    text = text.replaceAll(new RegExp(`(?<![\\w/.-])${rel.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "g"), `.opencode/skill/${PREFIX}${owner}/${rel}`)
+  }
+
+  // `skills/<skill>/SKILL.md` — the upstream plugin's own layout
+  text = text.replace(/(?<![\w./-])skills\/([a-z-]+)\/[Ss][Kk][Ii][Ll][Ll]\.md/g, (_, s) =>
+    SRC_SKILLS.includes(s) ? `.opencode/skill/${PREFIX}${s}/SKILL.md` : `skills/${s}/SKILL.md`,
+  )
+
+  return text
+}
+
+function applyRewrites(text, currentSkill) {
+  for (const [pattern, replacement] of REWRITES) {
+    text = text.replace(pattern, replacement)
+  }
+  return rewriteSupportPaths(text, currentSkill)
+}
+
+// ---------------------------------------------------------------------------
+// frontmatter
+// ---------------------------------------------------------------------------
+
+function splitFrontmatter(raw) {
+  const m = raw.match(/^---\n([\s\S]*?)\n---\n?/)
+  if (!m) return { frontmatter: null, body: raw }
+  return { frontmatter: m[1], body: raw.slice(m[0].length) }
+}
+
+function parseFrontmatter(fm) {
+  const out = {}
+  for (const line of fm.split("\n")) {
+    const m = line.match(/^([A-Za-z_-]+):\s*(.*)$/)
+    if (m) out[m[1]] = m[2]
+  }
+  return out
+}
+
+function yamlValue(value) {
+  if (value === "") return '""'
+  if (/^[A-Za-z0-9_./@ -]+$/.test(value) && !/^(true|false|null|yes|no|on|off)$/i.test(value)) return value
+  return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
+}
+
+// ---------------------------------------------------------------------------
+// generated headers
+// ---------------------------------------------------------------------------
+
+const NOTE = `> **opencode port** — generated from the maister Claude Code plugin v${VERSION} by
+> \`tools/build-maister-opencode.mjs\`. Do not edit this file; edit \`maister/\` and rebuild.
+> Tool mapping: the \`question\` tool replaces \`AskUserQuestion\`, \`task\` replaces \`Task\`,
+> \`todowrite\` replaces \`TaskCreate\`/\`TaskUpdate\`/\`TaskList\`, the \`explore\` and \`general\`
+> subagents replace \`Explore\` and \`general-purpose\`, \`AGENTS.md\` replaces \`CLAUDE.md\`, and
+> \`playwright_browser_*\` are MCP tools served by the (disabled by default) \`playwright\` server.`
+
+const COMMAND_NOTE = `**opencode port** — generated from the maister Claude Code plugin v${VERSION} by \`tools/build-maister-opencode.mjs\`. Claude Code tool names are mapped as: \`question\` replaces \`AskUserQuestion\`, \`task\` replaces \`Task\`, \`todowrite\` replaces \`TaskCreate\`/\`TaskUpdate\`, \`explore\`/\`general\` replace the built-in \`Explore\`/\`general-purpose\` subagents, and \`AGENTS.md\` replaces \`CLAUDE.md\`.`
+
+// ---------------------------------------------------------------------------
+// generate: skills
+// ---------------------------------------------------------------------------
+
+const AGENT_COLOR = {
+  blue: "info",
+  cyan: "info",
+  green: "success",
+  orange: "warning",
+  yellow: "warning",
+  pink: "secondary",
+  purple: "accent",
+  red: "error",
+}
+
+// Agents the upstream PreToolUse hook whitelisted for full bash access.
+const FULL_BASH_AGENTS = new Set(["test-suite-runner", "e2e-test-verifier", "user-docs-generator", "docs-operator"])
+
+const DESTRUCTIVE_BASH = {
+  "git stash*": "deny",
+  "git reset --hard*": "deny",
+  "git checkout -- *": "deny",
+  "git checkout .*": "deny",
+  "git clean*": "deny",
+  "git push --force*": "deny",
+  "git push -f*": "deny",
+  "rm -rf*": "deny",
+}
+
+function generateSkills() {
+  const dir = join(OUT, "skill")
+  rmSync(dir, { recursive: true, force: true })
+  let n = 0
+
+  for (const skill of SRC_SKILLS) {
+    const srcDir = join(SRC, "skills", skill)
+    const dstDir = join(dir, PREFIX + skill)
+    const srcMd = join(srcDir, "SKILL.md")
+    if (!existsSync(srcMd)) continue
+
+    const { frontmatter, body } = splitFrontmatter(readFileSync(srcMd, "utf8"))
+    const meta = frontmatter ? parseFrontmatter(frontmatter) : {}
+
+    let description = meta.description ?? ""
+    if (meta["argument-hint"]) description += ` Usage: ${meta["argument-hint"]}.`
+    if (meta["user-invocable"] === "false") {
+      description += " Internal skill — not intended for direct user invocation; parent skills call it."
+    }
+    description = applyRewrites(description, skill).replace(/\s+/g, " ").trim()
+
+    // copy support files verbatim, applying the same rewrites to markdown
+    mkdirSync(dstDir, { recursive: true })
+    for (const file of walk(srcDir)) {
+      if (file === srcMd) continue
+      const rel = relative(srcDir, file)
+      const dst = join(dstDir, rel)
+      mkdirSync(dirname(dst), { recursive: true })
+      if (file.endsWith(".md")) writeFileSync(dst, applyRewrites(readFileSync(file, "utf8"), skill))
+      else copyFileSync(file, dst)
+    }
+
+    const newBody = injectNote(applyRewrites(body, skill), NOTE)
+    writeFileSync(join(dstDir, "SKILL.md"), `---\nname: ${PREFIX}${skill}\ndescription: ${yamlValue(description)}\n---\n\n${newBody}`)
+    n++
+  }
+  return n
+}
+
+// ---------------------------------------------------------------------------
+// generate: agents
+// ---------------------------------------------------------------------------
+
+function generateAgents() {
+  const dir = join(OUT, "agent")
+  rmSync(dir, { recursive: true, force: true })
+  mkdirSync(dir, { recursive: true })
+  let n = 0
+
+  for (const agent of SRC_AGENTS) {
+    const { frontmatter, body } = splitFrontmatter(readFileSync(join(SRC, "agents", agent + ".md"), "utf8"))
+    const meta = frontmatter ? parseFrontmatter(frontmatter) : {}
+
+    const lines = [
+      "---",
+      `description: ${yamlValue(applyRewrites(meta.description ?? "", agent).replace(/\s+/g, " ").trim())}`,
+      "mode: subagent",
+    ]
+    if (meta.color && AGENT_COLOR[meta.color]) lines.push(`color: ${AGENT_COLOR[meta.color]}`)
+
+    // Claude Code whitelisted a few agents for full bash in a PreToolUse hook.
+    // opencode expresses the same policy declaratively per agent.
+    if (FULL_BASH_AGENTS.has(agent)) {
+      lines.push("permission:", "  bash: allow")
+    } else {
+      lines.push("permission:", "  bash:")
+      for (const [pattern, action] of Object.entries(DESTRUCTIVE_BASH)) lines.push(`    "${pattern}": ${action}`)
+      lines.push('    "*": allow')
+    }
+    lines.push("---")
+
+    let prompt = applyRewrites(body, null)
+
+    // `skills:` frontmatter is Claude Code only; opencode has no equivalent, so
+    // the preloaded skill becomes an explicit first instruction.
+    const preloaded = (frontmatter ?? "").match(/^skills:\s*\n((?:\s+-\s+.+\n?)+)/m)
+    if (preloaded) {
+      const names = [...preloaded[1].matchAll(/-\s+(.+)/g)].map((m) => m[1].trim())
+      const rel = names.map((s) => `.opencode/skill/${PREFIX}${s}/SKILL.md`)
+      prompt = `**Preloaded skill**: read ${rel.map((r) => `\`${r}\``).join(" and ")} first and follow it for every operation below.\n\n${prompt}`
+    }
+
+    writeFileSync(join(dir, PREFIX + agent + ".md"), `${lines.join("\n")}\n\n${prompt.trim()}\n`)
+    n++
+  }
+  return n
+}
+
+// ---------------------------------------------------------------------------
+// generate: commands
+// ---------------------------------------------------------------------------
+
+function generateCommands() {
+  const dir = join(OUT, "command")
+  rmSync(dir, { recursive: true, force: true })
+  mkdirSync(dir, { recursive: true })
+  let n = 0
+
+  for (const command of SRC_COMMANDS) {
+    const { frontmatter, body } = splitFrontmatter(readFileSync(join(SRC, "commands", command + ".md"), "utf8"))
+    const meta = frontmatter ? parseFrontmatter(frontmatter) : {}
+
+    let template = applyRewrites(body, null)
+
+    // Claude Code injects the invoked command's name via a <command-name> tag;
+    // opencode has no such tag, so substitute the name directly.
+    const tag = new RegExp("`?<command-name>`?", "g")
+    template = template.replace(tag, `\`${PREFIX}${command}\``).replace(/THIS command only/g, `the \`${PREFIX}${command}\` command only`)
+
+    // `$ARGUMENTS` is opencode's argument placeholder; Claude Code passed the
+    // invocation string implicitly.
+    if (!template.includes("$ARGUMENTS")) {
+      template = `Invocation arguments: \`$ARGUMENTS\`\n\n${template}`
+    }
+
+    const description = applyRewrites(meta.description ?? "", command).replace(/\s+/g, " ").trim()
+    writeFileSync(
+      join(dir, PREFIX + command + ".md"),
+      `---\ndescription: ${yamlValue(description)}\nagent: build\n---\n\n${COMMAND_NOTE}\n\n${template.trim()}\n`,
+    )
+    n++
+  }
+  return n
+}
+
+// ---------------------------------------------------------------------------
+
+function injectNote(body, note) {
+  if (!body.startsWith("#")) return `${note}\n\n${body}`
+  const nl = body.indexOf("\n")
+  const title = body.slice(0, nl)
+  return `${title}\n\n${note}\n${body.slice(nl + 1)}`
+}
+
+// ---------------------------------------------------------------------------
+
+if (!existsSync(SRC)) {
+  console.error(`error: maister source not found at ${relative(ROOT, SRC)}\n       run: git clone https://github.com/SkillPanel/maister.git maister`)
+  process.exit(1)
+}
+
+mkdirSync(OUT, { recursive: true })
+const skills = generateSkills()
+const agents = generateAgents()
+const commands = generateCommands()
+
+console.log(`maister v${VERSION} -> opencode`)
+console.log(`  ${skills} skills    -> .opencode/skill/${PREFIX}*/`)
+console.log(`  ${agents} agents    -> .opencode/agent/${PREFIX}*.md`)
+console.log(`  ${commands} commands -> .opencode/command/${PREFIX}*.md`)
+console.log(`\nRestart opencode to load them.`)

+ 135 - 0
tools/har/record.mjs

@@ -0,0 +1,135 @@
+#!/usr/bin/env node
+// Record a HAR of an interactive login, for learning a vendor API shape.
+//
+//   node tools/har/record.mjs <url> [outputHarPath]
+//
+// Opens a real browser window. You log in and browse by hand. The script polls
+// for a sentinel file and saves the HAR when it appears:
+//
+//   touch <sentinel>          # when you are done browsing
+//
+// The raw HAR contains your session cookie and bearer token in clear text, so
+// the script writes it 0600 and immediately produces a redacted twin that is
+// safe to read, diff and quote from. Both live outside the repository — never
+// commit either one.
+//
+// What to look at while browsing: the zone/domain list request, and the request
+// that fetches one zone's records. That pair is enough to learn the read API.
+// Do NOT save any DNS change you make while recording: the gateway applies it
+// publicly within seconds.
+
+import { chromium } from "playwright"
+import { writeFileSync, existsSync, chmodSync, readFileSync } from "node:fs"
+import { join } from "node:path"
+
+const URL_TO_OPEN = process.argv[2] ?? "https://cloud.schlundtech.com/portfolio/domains/"
+const OUT = process.argv[3] ?? "/var/folders/cz/75zr419d1fq1ptvqp1bwgmw80000gp/T/opencode/har/schlundtech.har"
+const SENTINEL = `${OUT}.done`
+
+const SENSITIVE_HEADERS = new Set([
+  "cookie",
+  "set-cookie",
+  "authorization",
+  "proxy-authorization",
+  "x-api-key",
+  "x-auth-token",
+  "x-csrf-token",
+  "x-xsrf-token",
+])
+const SENSITIVE_KEYS = /^(?:.*_)?(?:password|passwd|secret|token|access_token|refresh_token|session|sessionid|csrf|xsrf|apikey|api_key|auth)$/i
+// Anything that looks like a long opaque bearer/JWT blob.
+const JWT = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\b/g
+
+const redactValue = (v) => (typeof v === "string" ? v.replace(JWT, "<redacted-jwt>") : v)
+
+function scrubEntry(entry) {
+  for (const h of entry.request?.headers ?? []) {
+    if (SENSITIVE_HEADERS.has(h.name.toLowerCase())) h.value = "<redacted>"
+  }
+  for (const h of entry.response?.headers ?? []) {
+    if (SENSITIVE_HEADERS.has(h.name.toLowerCase())) h.value = "<redacted>"
+  }
+  const url = entry.request?.url
+  if (url) {
+    entry.request.url = url.replace(/([?&](?:token|access_token|session|sessionid|auth|key)=)[^&]*/gi, "$1<redacted>")
+  }
+  for (const side of [entry.request, entry.response]) {
+    const text = side?.postData?.text
+    if (typeof text === "string") {
+      side.postData.text = text.replace(JWT, "<redacted-jwt>")
+      try {
+        const json = JSON.parse(text)
+        const walk = (node) => {
+          if (Array.isArray(node)) return node.forEach(walk)
+          if (node && typeof node === "object") {
+            for (const [k, val] of Object.entries(node)) {
+              if (SENSITIVE_KEYS.test(k)) node[k] = "<redacted>"
+              else walk(val)
+            }
+          }
+        }
+        walk(json)
+        side.postData.text = JSON.stringify(json, null, 2)
+      } catch {
+        /* not JSON — the JWT scrub above already ran */
+      }
+    }
+    if (Array.isArray(side?.postData?.params)) {
+      for (const p of side.postData.params) if (SENSITIVE_KEYS.test(p.name)) p.value = "<redacted>"
+    }
+  }
+}
+
+console.log(`opening  ${URL_TO_OPEN}`)
+console.log(`har      ${OUT}`)
+console.log(`done     touch ${SENTINEL}`)
+console.log("browsing…")
+
+// Prefer the real Chrome the user already has; fall back to Playwright's
+// bundled build if it is missing (or out of date relative to this Playwright).
+async function launch() {
+  for (const opts of [{ channel: "chrome" }, {}]) {
+    try {
+      return await chromium.launch({ headless: false, ...opts })
+    } catch (err) {
+      if (opts.channel) console.error(`  ${opts.channel}: ${String(err).split("\n")[0]}`)
+    }
+  }
+  throw new Error("no chromium available — run: npx playwright install chromium")
+}
+
+const browser = await launch()
+const context = await browser.newContext({
+  recordHar: { path: OUT, content: "embed" },
+  ignoreHTTPSErrors: false,
+})
+const page = await context.newPage()
+await page.goto(URL_TO_OPEN, { waitUntil: "domcontentloaded" })
+
+const deadline = Date.now() + 30 * 60 * 1000
+while (!existsSync(SENTINEL)) {
+  if (Date.now() > deadline) {
+    console.error("timeout after 30 min — closing")
+    break
+  }
+  await new Promise((r) => setTimeout(r, 1000))
+}
+
+await context.close()
+await browser.close()
+
+if (!existsSync(OUT)) {
+  console.error("no HAR written")
+  process.exit(1)
+}
+chmodSync(OUT, 0o600)
+
+const har = JSON.parse(readFileSync(OUT, "utf8"))
+har.log.entries.forEach(scrubEntry)
+const redacted = OUT.replace(/\.har$/, ".redacted.har")
+writeFileSync(redacted, JSON.stringify(har, null, 2))
+chmodSync(redacted, 0o600)
+
+console.log(`\nsaved   ${OUT} (raw, 0600)`)
+console.log(`saved   ${redacted} (redacted — this is the one to read)`)
+console.log(`entries ${har.log.entries.length}`)

+ 147 - 0
tools/verify-maister-opencode.mjs

@@ -0,0 +1,147 @@
+#!/usr/bin/env node
+// Validate the generated opencode port. Run after tools/build-maister-opencode.mjs.
+//
+// Catches the two ways this port can rot: a maister release that introduces a new
+// Claude-Code-only construct, and a rewrite rule that mangles ordinary prose.
+
+import { readFileSync, readdirSync, statSync, existsSync } from "node:fs"
+import { join, dirname, relative } from "node:path"
+import { fileURLToPath } from "node:url"
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..")
+const OUT = join(ROOT, ".opencode")
+
+const failures = []
+const check = (ok, message) => {
+  if (!ok) failures.push(message)
+}
+
+function walk(dir, out = []) {
+  if (!existsSync(dir)) return out
+  for (const entry of readdirSync(dir)) {
+    const full = join(dir, entry)
+    if (statSync(full).isDirectory()) walk(full, out)
+    else out.push(full)
+  }
+  return out
+}
+
+const files = [
+  ...walk(join(OUT, "skill")).filter((f) => f.endsWith(".md")),
+  ...walk(join(OUT, "agent")).filter((f) => f.endsWith(".md")),
+  ...walk(join(OUT, "command")).filter((f) => f.endsWith(".md")),
+]
+
+check(files.length > 0, "no generated markdown found — run the build first")
+
+// --- 1. no Claude-Code-only constructs survive -----------------------------
+const FORBIDDEN = [
+  ["AskUserQuestion", "Claude Code's user-question tool"],
+  ["TaskCreate", "Claude Code's incremental task tool"],
+  ["TaskUpdate", "Claude Code's incremental task tool"],
+  ["TaskList", "Claude Code's task-list tool"],
+  ["EnterPlanMode", "Claude Code's plan-mode tool"],
+  ["ExitPlanMode", "Claude Code's plan-mode tool"],
+  ["CLAUDE.md", "Claude Code's project instructions file"],
+  ["CLAUDE_PLUGIN_ROOT", "Claude Code's plugin-root variable"],
+  ["general-purpose", "Claude Code's general subagent"],
+  ["maister:", "the Claude Code plugin namespace"],
+]
+for (const file of files) {
+  const text = readFileSync(file, "utf8")
+  for (const [needle, what] of FORBIDDEN) {
+    // the generated header documents the mapping on purpose
+    const lines = text.split("\n").filter((l) => !l.includes("opencode port") && !l.trim().startsWith("> "))
+    if (lines.some((l) => l.includes(needle))) {
+      check(false, `${relative(ROOT, file)}: still mentions ${needle} (${what})`)
+    }
+  }
+}
+
+// --- 2. no mangled prose ---------------------------------------------------
+const SUSPECT = [
+  [/\bquestion tool tool\b/, "duplicated 'tool'"],
+  [/`explore` (the|a|an|for|to|and|this|it)\b/, "'Explore' verb rewritten as a subagent reference"],
+  [/\bthe the\b/, "duplicated article"],
+  [/\btool tool\b/, "duplicated 'tool'"],
+  [/opencode's auto-approve permission mode, or\b/, "unfinished permission-mode rewrite"],
+]
+for (const file of files) {
+  const lines = readFileSync(file, "utf8").split("\n").filter((l) => !l.includes("opencode port") && !l.trim().startsWith("> "))
+  for (const [pattern, what] of SUSPECT) {
+    if (lines.some((l) => pattern.test(l))) check(false, `${relative(ROOT, file)}: ${what}`)
+  }
+}
+
+// --- 3. every referenced skill and agent exists ----------------------------
+const skillNames = new Set(readdirSync(join(OUT, "skill")))
+const agentNames = new Set(
+  readdirSync(join(OUT, "agent"))
+    .filter((f) => f.endsWith(".md"))
+    .map((f) => f.replace(/\.md$/, "")),
+)
+const commandNames = new Set(
+  readdirSync(join(OUT, "command"))
+    .filter((f) => f.endsWith(".md"))
+    .map((f) => f.replace(/\.md$/, "")),
+)
+
+for (const file of files) {
+  const text = readFileSync(file, "utf8")
+  // every project-relative path into the generated tree must exist on disk
+  for (const m of text.matchAll(/`(\.opencode\/skill\/[A-Za-z0-9._/-]+)`/g)) {
+    check(existsSync(join(ROOT, m[1])), `${relative(ROOT, file)}: path does not exist: ${m[1]}`)
+  }
+  for (const m of text.matchAll(/(?<![\w./-])`(maister-[a-z0-9-]+)`/g)) {
+    const name = m[1]
+    // a name may be a skill, an agent or a command
+    check(
+      skillNames.has(name) || agentNames.has(name) || commandNames.has(name),
+      `${relative(ROOT, file)}: references unknown identifier ${name}`,
+    )
+  }
+}
+
+// --- 4. frontmatter sanity -------------------------------------------------
+for (const file of walk(join(OUT, "skill")).filter((f) => f.endsWith(".md") && f.split("/").pop() === "SKILL.md")) {
+  const text = readFileSync(file, "utf8")
+  const fm = text.match(/^---\n([\s\S]*?)\n---\n/)
+  check(!!fm, `${relative(ROOT, file)}: missing frontmatter`)
+  if (!fm) continue
+  check(/^name: [a-z0-9-]+$/m.test(fm[1]), `${relative(ROOT, file)}: skill name must be lowercase-hyphenated`)
+  check(/^description: .+/m.test(fm[1]), `${relative(ROOT, file)}: skill has no description`)
+  const dir = file.split("/").pop() === "SKILL.md" ? file.slice(0, file.lastIndexOf("/")) : ""
+  const name = fm[1].match(/^name: (.+)$/m)?.[1]
+  check(dir.endsWith(name), `${relative(ROOT, file)}: name "${name}" does not match its directory`)
+}
+
+for (const file of walk(join(OUT, "agent")).filter((f) => f.endsWith(".md"))) {
+  const text = readFileSync(file, "utf8")
+  const fm = text.match(/^---\n([\s\S]*?)\n---\n/)
+  check(!!fm, `${relative(ROOT, file)}: missing frontmatter`)
+  if (!fm) continue
+  check(/^mode: subagent$/m.test(fm[1]), `${relative(ROOT, file)}: agent is not mode: subagent`)
+  check(!/^name:/m.test(fm[1]), `${relative(ROOT, file)}: agent frontmatter must not set name (the filename does)`)
+  check(!/^model:/m.test(fm[1]), `${relative(ROOT, file)}: agent frontmatter must not set model`)
+  check(
+    !/^color: (?!(primary|secondary|accent|success|warning|error|info|#[0-9a-fA-F]{6})$)/m.test(fm[1]),
+    `${relative(ROOT, file)}: agent colour is not an opencode theme colour`,
+  )
+}
+
+for (const file of walk(join(OUT, "command")).filter((f) => f.endsWith(".md"))) {
+  const text = readFileSync(file, "utf8")
+  check(text.includes("$ARGUMENTS"), `${relative(ROOT, file)}: command never mentions $ARGUMENTS`)
+}
+
+// --- 5. the hand-written pieces exist -------------------------------------
+check(existsSync(join(OUT, "plugin", "maister.ts")), "missing .opencode/plugin/maister.ts")
+check(existsSync(join(ROOT, "opencode.json")), "missing opencode.json")
+
+// --- report ---------------------------------------------------------------
+if (failures.length) {
+  console.error(`FAIL — ${failures.length} problem(s):`)
+  for (const f of failures) console.error(`  - ${f}`)
+  process.exit(1)
+}
+console.log(`OK — ${skillNames.size} skills, ${agentNames.size} agents, ${commandNames.size} commands validated`)