This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
The following commit(s) were added to refs/heads/master by this push:
new 03a51d7 feat(agent): let dsh reach a provider through the gateway
03a51d7 is described below
commit 03a51d74c3d439599ee533f7bac92e5c27fd9209
Author: Yang Luo <[email protected]>
AuthorDate: Sun Aug 23 22:13:39 2026 +0800
feat(agent): let dsh reach a provider through the gateway
---
agentconfig/layout.go | 9 ++
agentconfig/mcp_cordis.go | 297 ++++++++++++++++++++++++++++++++++++
agentprovider/dsh.go | 343 ++++++++++++++++++++++++++++++++++++++++++
internal/yamledit/yamledit.go | 244 ++++++++++++++++++++++++++++++
4 files changed, 893 insertions(+)
diff --git a/agentconfig/layout.go b/agentconfig/layout.go
index 9bfbb04..e1504f9 100644
--- a/agentconfig/layout.go
+++ b/agentconfig/layout.go
@@ -157,6 +157,15 @@ var layouts = map[string]layout{
store: &jsonStore{paths: [][]string{{"mcpServers"}}},
},
},
+ // dsh reads the shared ~/.agents skills too, but writes go to its own
+ // directory, which is the first user source.
+ "dsh": {
+ skills: &skillLayout{sources: []skillSource{
+ userSkills(".dsh", "skills"),
+ userSkills(".agents", "skills"),
+ }},
+ mcp: &mcpLayout{file: under(".dsh", "cordis.patch.yml"), store:
&cordisStore{}},
+ },
"openclaw": {
skills: &skillLayout{sources: []skillSource{
userSkills(".openclaw", "skills"),
diff --git a/agentconfig/mcp_cordis.go b/agentconfig/mcp_cordis.go
new file mode 100644
index 0000000..3c1ead6
--- /dev/null
+++ b/agentconfig/mcp_cordis.go
@@ -0,0 +1,297 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agentconfig
+
+import (
+ "fmt"
+ "os"
+ "regexp"
+ "strings"
+
+ "github.com/apache/casbin-gateway/internal/yamledit"
+ "gopkg.in/yaml.v3"
+)
+
+const (
+ // cordisMcpPlugin is the plugin one MCP server is mounted through.
+ cordisMcpPlugin = "@deepseek-ai/dsh-mcp-client"
+ // cordisRowPrefix names the rows Gateway adds, so a server is
recognisable
+ // in a file the owner also edits.
+ cordisRowPrefix = "mcp-"
+)
+
+// cordisServerName is what dsh accepts as a server namespace; a name outside
it
+// fails the whole plugin instance at load rather than only that server.
+var cordisServerName = regexp.MustCompile(`^[A-Za-z0-9_-]{1,32}$`)
+
+// cordisStore holds MCP servers in a Cordis patch list. dsh has no map of
+// servers: it mounts one plugin instance per server, and the user's patch file
+// is where instances are added to the composed tree.
+type cordisStore struct{}
+
+func (store *cordisStore) read(file string) (map[string]map[string]any, error)
{
+ _, rows, err := store.load(file)
+ if err != nil {
+ return nil, err
+ }
+
+ entries := map[string]map[string]any{}
+ for _, row := range rows {
+ name := yamledit.String(row, "config", "serverName")
+ if name == "" || entries[name] != nil {
+ continue
+ }
+ entries[name] = configEntry(yamledit.Get(row, "config"))
+ }
+ return entries, nil
+}
+
+func (store *cordisStore) write(file string, name string, entry
map[string]any) error {
+ if err := checkName(name); err != nil {
+ return err
+ }
+ if !cordisServerName.MatchString(name) {
+ return fmt.Errorf("DeepSeek Harness only accepts letters,
digits, %q and %q in a server name, so %q cannot be added", "-", "_", name)
+ }
+ if entry == nil {
+ return fmt.Errorf("the definition of MCP server %q is empty",
name)
+ }
+
+ document, rows, err := store.load(file)
+ if err != nil {
+ return err
+ }
+ config, err := yamledit.Node(cordisConfig(name, entry))
+ if err != nil {
+ return err
+ }
+
+ for _, row := range rows {
+ if yamledit.String(row, "config", "serverName") != name {
+ continue
+ }
+ if err := yamledit.Set(row, config, "config"); err != nil {
+ return err
+ }
+ return store.save(file, document)
+ }
+
+ row, err := yamledit.Node(map[string]any{"id": cordisRowPrefix + name,
"name": cordisMcpPlugin})
+ if err != nil {
+ return err
+ }
+ if err := yamledit.Set(row, config, "config"); err != nil {
+ return err
+ }
+ if err := store.insert(document, row); err != nil {
+ return err
+ }
+ return store.save(file, document)
+}
+
+func (store *cordisStore) remove(file string, name string) error {
+ if _, err := os.Stat(file); err != nil {
+ return fmt.Errorf("%s does not exist", file)
+ }
+
+ document, _, err := store.load(file)
+ if err != nil {
+ return err
+ }
+ patches, err := document.Sequence()
+ if err != nil {
+ return err
+ }
+
+ removed := false
+ kept := patches.Content[:0]
+ for _, patch := range patches.Content {
+ inserts := yamledit.Get(patch, "insert")
+ if inserts != nil && inserts.Kind == yaml.SequenceNode {
+ rows := inserts.Content[:0]
+ for _, row := range inserts.Content {
+ if isMcpRow(row) && yamledit.String(row,
"config", "serverName") == name {
+ removed = true
+ continue
+ }
+ rows = append(rows, row)
+ }
+ inserts.Content = rows
+ // A patch whose only purpose was the server it no
longer inserts is
+ // not a patch worth keeping.
+ if len(rows) == 0 && len(patch.Content) == 2 {
+ continue
+ }
+ }
+ kept = append(kept, patch)
+ }
+ patches.Content = kept
+
+ if !removed {
+ return fmt.Errorf("no MCP server named %q in %s", name, file)
+ }
+ return store.save(file, document)
+}
+
+// insert adds a row to the patch list, reusing the untargeted insert patch the
+// file already has so one server per patch does not accumulate.
+func (store *cordisStore) insert(document *yamledit.Document, row *yaml.Node)
error {
+ patches, err := document.Sequence()
+ if err != nil {
+ return err
+ }
+ for _, patch := range patches.Content {
+ inserts := yamledit.Get(patch, "insert")
+ if inserts == nil || inserts.Kind != yaml.SequenceNode ||
yamledit.Get(patch, "id") != nil {
+ continue
+ }
+ inserts.Style = 0
+ inserts.Content = append(inserts.Content, row)
+ return nil
+ }
+
+ patch, err := yamledit.Node(map[string]any{"insert": []any{}})
+ if err != nil {
+ return err
+ }
+ inserts := yamledit.Get(patch, "insert")
+ inserts.Style = 0
+ inserts.Content = append(inserts.Content, row)
+ patches.Style = 0
+ patches.Content = append(patches.Content, patch)
+ return nil
+}
+
+// load returns the document and every MCP row in it, in file order.
+func (store *cordisStore) load(file string) (*yamledit.Document, []*yaml.Node,
error) {
+ data, _, err := readFile(file)
+ if err != nil {
+ return nil, nil, err
+ }
+ document, err := yamledit.Parse(data)
+ if err != nil {
+ return nil, nil, fmt.Errorf("parse %s: %w", file, err)
+ }
+ patches, err := document.Sequence()
+ if err != nil {
+ return nil, nil, fmt.Errorf("parse %s: %w", file, err)
+ }
+
+ var rows []*yaml.Node
+ for _, patch := range patches.Content {
+ inserts := yamledit.Get(patch, "insert")
+ if inserts == nil || inserts.Kind != yaml.SequenceNode {
+ continue
+ }
+ for _, row := range inserts.Content {
+ if isMcpRow(row) {
+ rows = append(rows, row)
+ }
+ }
+ }
+ return document, rows, nil
+}
+
+func (store *cordisStore) save(file string, document *yamledit.Document) error
{
+ data, err := document.Bytes()
+ if err != nil {
+ return err
+ }
+ _, mode, err := readFile(file)
+ if err != nil {
+ return err
+ }
+ return writeFile(file, data, mode)
+}
+
+func isMcpRow(row *yaml.Node) bool {
+ return row.Kind == yaml.MappingNode && yamledit.String(row, "name") ==
cordisMcpPlugin
+}
+
+// cordisConfig is one server as the plugin's own config spells it: the name is
+// a field rather than a key, and the transport is named rather than implied.
+//
+// The values dsh types are spelled as strings, and a config it refuses stops
+// the whole agent from starting rather than only that server, so an entry
+// written for an agent that allows a number here is narrowed on the way in.
+func cordisConfig(name string, entry map[string]any) map[string]any {
+ config := map[string]any{}
+ for key, value := range entry {
+ config[key] = value
+ }
+
+ transport := cordisTransport(config)
+ delete(config, "type")
+ config["serverName"] = name
+ config["transport"] = transport
+ if arguments, ok := config["args"].([]any); ok {
+ config["args"] = stringList(arguments)
+ }
+ for _, key := range []string{"env", "headers"} {
+ if values, ok := config[key].(map[string]any); ok {
+ config[key] = stringMap(values)
+ }
+ }
+ return config
+}
+
+func stringList(values []any) []string {
+ result := make([]string, 0, len(values))
+ for _, value := range values {
+ result = append(result, fmt.Sprint(value))
+ }
+ return result
+}
+
+func stringMap(values map[string]any) map[string]string {
+ result := make(map[string]string, len(values))
+ for key, value := range values {
+ result[key] = fmt.Sprint(value)
+ }
+ return result
+}
+
+// cordisTransport maps the spellings other agents use onto the two dsh takes,
+// falling back to how the server is reached when the entry names neither.
+func cordisTransport(entry map[string]any) string {
+ switch declared := strings.ToLower(strings.TrimSpace(stringField(entry,
"transport", "type"))); declared {
+ case "stdio":
+ return "stdio"
+ case "":
+ if stringField(entry, "command") != "" {
+ return "stdio"
+ }
+ return "streamable-http"
+ default:
+ return "streamable-http"
+ }
+}
+
+// configEntry reads a server's config back. A field written as a Cordis
+// expression cannot be read as data, and is left out rather than failing the
+// listing of every server beside it.
+func configEntry(config *yaml.Node) map[string]any {
+ entry := map[string]any{}
+ if config == nil || config.Kind != yaml.MappingNode {
+ return entry
+ }
+ for index := 0; index+1 < len(config.Content); index += 2 {
+ var value any
+ if config.Content[index+1].Decode(&value) == nil {
+ entry[config.Content[index].Value] = value
+ }
+ }
+ return entry
+}
diff --git a/agentprovider/dsh.go b/agentprovider/dsh.go
new file mode 100644
index 0000000..f8e1cd0
--- /dev/null
+++ b/agentprovider/dsh.go
@@ -0,0 +1,343 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agentprovider
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "os/user"
+ "path/filepath"
+ "strings"
+
+ "github.com/apache/casbin-gateway/agenthome"
+ "github.com/apache/casbin-gateway/internal/yamledit"
+ "gopkg.in/yaml.v3"
+)
+
+const (
+ // dshRoute is the provider route Gateway owns in settings.yaml. Every
other
+ // route in the file, and every other section, is left alone.
+ dshRoute = "casbin-gateway"
+ // dshCredentialRef is the name settings.yaml refers the key by; the key
+ // itself lives in the credentials document, which is what dsh reads
first.
+ dshCredentialRef = "CASBIN_GATEWAY_API_KEY"
+ // dshProtocol is the wire format the route serves. The gateway answers
+ // OpenAI chat completions under the base URL dsh is pointed at.
+ dshProtocol = "openai-completions"
+)
+
+// The settings Gateway overwrites, remembered under these names.
+const (
+ dshProviderKey = "agent-default-model.provider"
+ dshModelKey = "agent-default-model.model"
+ dshEffortKey = "agent-default-model.reasoningEffort"
+ dshCredentialKey = "credentials." + dshCredentialRef
+)
+
+var (
+ dshRoutePath = []string{"llm-pi-ai", "providers", dshRoute}
+ dshProvidersPath = []string{"llm-pi-ai", "providers"}
+ dshRefPath = []string{"refs", dshCredentialRef}
+)
+
+// dshDefaultModelKeys are the fields of the default-model section Gateway
+// owns, by the name each is remembered under.
+var dshDefaultModelKeys = map[string]string{
+ dshProviderKey: "provider",
+ dshModelKey: "model",
+ dshEffortKey: "reasoningEffort",
+}
+
+// errDshNoKey rejects a provider that forwards the caller's own credentials. A
+// route dsh does not ship cannot hold a sign-in of its own, so the key written
+// here is the only credential such a route ever sends.
+var errDshNoKey = errors.New("DeepSeek Harness needs an API key, so it cannot
use a provider that forwards the credentials of the caller")
+
+// errDshNoModel rejects a route dsh could not serve: a provider it does not
+// ship must name the models it answers for.
+var errDshNoModel = errors.New("DeepSeek Harness needs a model name, so bind a
provider that lists at least one model")
+
+// dshRouteConfig is the provider route as settings.yaml spells it. The key is
+// named rather than written: dsh resolves the reference on every request.
+type dshRouteConfig struct {
+ DisplayName string `yaml:"displayName"`
+ ApiKeyEnv string `yaml:"apiKeyEnv"`
+ Api string `yaml:"api"`
+ BaseURL string `yaml:"baseURL"`
+ Models []dshRouteModel `yaml:"models"`
+}
+
+type dshRouteModel struct {
+ Id string `yaml:"id"`
+}
+
+type dshWriter struct{}
+
+func init() {
+ register(dshWriter{})
+}
+
+func (dshWriter) AgentId() string { return "dsh" }
+
+func (dshWriter) Protocol() string { return "openai" }
+
+func (w dshWriter) Plan(target Target, endpoint Endpoint) ([]File, error) {
+ if err := w.check(endpoint); err != nil {
+ return nil, err
+ }
+ settingsPath, credentialsPath, err := w.paths(target)
+ if err != nil {
+ return nil, err
+ }
+
+ settings, err := yamledit.Render(map[string]any{
+ "llm-pi-ai": map[string]any{"providers":
map[string]any{dshRoute: w.route(endpoint)}},
+ "agent-default-model": map[string]any{"provider": dshRoute,
"model": endpoint.Model},
+ })
+ if err != nil {
+ return nil, err
+ }
+ credentials, err := yamledit.Render(map[string]any{
+ "refs": map[string]any{dshCredentialRef:
maskSecret(endpoint.ApiKey)},
+ })
+ if err != nil {
+ return nil, err
+ }
+ return []File{
+ {Path: settingsPath, Format: "yaml", Preview: settings},
+ {Path: credentialsPath, Format: "yaml", Preview: credentials},
+ }, nil
+}
+
+func (w dshWriter) Apply(target Target, endpoint Endpoint) (map[string]string,
error) {
+ if err := w.check(endpoint); err != nil {
+ return nil, err
+ }
+ settingsPath, credentialsPath, err := w.paths(target)
+ if err != nil {
+ return nil, err
+ }
+
+ settings, root, err := w.load(settingsPath)
+ if err != nil {
+ return nil, err
+ }
+ previous := map[string]string{}
+ for key, field := range dshDefaultModelKeys {
+ if value := yamledit.String(root, "agent-default-model",
field); value != "" {
+ previous[key] = value
+ }
+ }
+
+ if err := yamledit.Set(root, w.route(endpoint), dshRoutePath...); err
!= nil {
+ return nil, err
+ }
+ if err := yamledit.Set(root, dshRoute, "agent-default-model",
"provider"); err != nil {
+ return nil, err
+ }
+ if err := yamledit.Set(root, endpoint.Model, "agent-default-model",
"model"); err != nil {
+ return nil, err
+ }
+ // The route declares no reasoning levels, so an effort left over from
+ // another provider would name one this model cannot take.
+ yamledit.Delete(root, "agent-default-model", "reasoningEffort")
+
+ credentials, refs, err := w.load(credentialsPath)
+ if err != nil {
+ return nil, err
+ }
+ if value := yamledit.String(refs, dshRefPath...); value != "" {
+ previous[dshCredentialKey] = value
+ }
+ // The document declares its own version; only a file Gateway is
creating
+ // needs one written.
+ if yamledit.Get(refs, "version") == nil {
+ if err := yamledit.Set(refs, 1, "version"); err != nil {
+ return nil, err
+ }
+ }
+ if err := yamledit.Set(refs, endpoint.ApiKey, dshRefPath...); err !=
nil {
+ return nil, err
+ }
+
+ if err := w.save(settingsPath, settings, credentialsPath, credentials);
err != nil {
+ return nil, err
+ }
+ return previous, nil
+}
+
+func (w dshWriter) Restore(target Target, previous map[string]string) error {
+ settingsPath, credentialsPath, err := w.paths(target)
+ if err != nil {
+ return err
+ }
+
+ settings, root, err := w.load(settingsPath)
+ if err != nil {
+ return err
+ }
+ yamledit.Delete(root, dshRoutePath...)
+ if yamledit.IsEmpty(yamledit.Get(root, dshProvidersPath...)) {
+ yamledit.Delete(root, dshProvidersPath...)
+ }
+ if yamledit.IsEmpty(yamledit.Get(root, "llm-pi-ai")) {
+ yamledit.Delete(root, "llm-pi-ai")
+ }
+ for key, field := range dshDefaultModelKeys {
+ if value, ok := previous[key]; ok {
+ if err := yamledit.Set(root, value,
"agent-default-model", field); err != nil {
+ return err
+ }
+ continue
+ }
+ yamledit.Delete(root, "agent-default-model", field)
+ }
+ if yamledit.IsEmpty(yamledit.Get(root, "agent-default-model")) {
+ yamledit.Delete(root, "agent-default-model")
+ }
+
+ credentials, refs, err := w.load(credentialsPath)
+ if err != nil {
+ return err
+ }
+ if value, ok := previous[dshCredentialKey]; ok {
+ if err := yamledit.Set(refs, value, dshRefPath...); err != nil {
+ return err
+ }
+ } else {
+ yamledit.Delete(refs, dshRefPath...)
+ }
+
+ return w.save(settingsPath, settings, credentialsPath, credentials)
+}
+
+func (w dshWriter) Current(target Target) (string, error) {
+ settingsPath, _, err := w.paths(target)
+ if err != nil {
+ return "", err
+ }
+ _, root, err := w.load(settingsPath)
+ if err != nil {
+ return "", err
+ }
+
+ selected := yamledit.String(root, "agent-default-model", "provider")
+ if selected == "" {
+ return "", nil
+ }
+ if baseUrl := yamledit.String(root, "llm-pi-ai", "providers", selected,
"baseURL"); baseUrl != "" {
+ return baseUrl, nil
+ }
+ // A route without a base URL is one dsh ships itself, which is still
worth
+ // naming: it is not the endpoint Gateway wrote.
+ return selected, nil
+}
+
+// check reports why dsh cannot be pointed at endpoint.
+func (dshWriter) check(endpoint Endpoint) error {
+ if endpoint.ApiKey == "" {
+ return errDshNoKey
+ }
+ if endpoint.Model == "" {
+ return errDshNoModel
+ }
+ return nil
+}
+
+// route is the llm-pi-ai profile Gateway writes. dsh ships no catalog for this
+// route, so the profile carries the whole provider: protocol, endpoint,
models.
+func (dshWriter) route(endpoint Endpoint) dshRouteConfig {
+ return dshRouteConfig{
+ DisplayName: "Casbin Gateway",
+ ApiKeyEnv: dshCredentialRef,
+ Api: dshProtocol,
+ BaseURL: endpoint.BaseUrl,
+ Models: []dshRouteModel{{Id: endpoint.Model}},
+ }
+}
+
+func (dshWriter) load(path string) (*yamledit.Document, *yaml.Node, error) {
+ data, _, _, err := readFile(path)
+ if err != nil {
+ return nil, nil, err
+ }
+ document, err := yamledit.Parse(data)
+ if err != nil {
+ return nil, nil, fmt.Errorf("parse %s: %w", path, err)
+ }
+ root, err := document.Mapping()
+ if err != nil {
+ return nil, nil, fmt.Errorf("parse %s: %w", path, err)
+ }
+ return document, root, nil
+}
+
+func (dshWriter) save(settingsPath string, settings *yamledit.Document,
+ credentialsPath string, credentials *yamledit.Document) error {
+ settingsData, err := settings.Bytes()
+ if err != nil {
+ return err
+ }
+ credentialsData, err := credentials.Bytes()
+ if err != nil {
+ return err
+ }
+
+ changes := &txn{}
+ if err := changes.write(settingsPath, settingsData); err != nil {
+ changes.abort()
+ return err
+ }
+ if err := changes.write(credentialsPath, credentialsData); err != nil {
+ changes.abort()
+ return err
+ }
+ return changes.commit()
+}
+
+func (w dshWriter) paths(target Target) (string, string, error) {
+ home, err := w.dshHome(target.Owner)
+ if err != nil {
+ return "", "", err
+ }
+ return filepath.Join(home, "settings.yaml"), filepath.Join(home,
".credentials.yaml"), nil
+}
+
+// dshHome is the harness home the owner's dsh reads. DSH_HOME is meaningful
+// only for the account Gateway runs as.
+func (dshWriter) dshHome(owner string) (string, error) {
+ if current, err := user.Current(); err == nil && sameAccountName(owner,
current.Username) {
+ if configured := strings.TrimSpace(os.Getenv("DSH_HOME"));
configured != "" {
+ if !filepath.IsAbs(configured) {
+ return "", errors.New("DSH_HOME must be an
absolute path")
+ }
+ return filepath.Clean(configured), nil
+ }
+ }
+ home, err := agenthome.Resolve(owner)
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(home, ".dsh"), nil
+}
+
+func sameAccountName(left, right string) bool {
+ bare := func(value string) string {
+ value = strings.ReplaceAll(strings.TrimSpace(value), "\\", "/")
+ return strings.ToLower(filepath.Base(value))
+ }
+ return left != "" && bare(left) == bare(right)
+}
diff --git a/internal/yamledit/yamledit.go b/internal/yamledit/yamledit.go
new file mode 100644
index 0000000..173a3e5
--- /dev/null
+++ b/internal/yamledit/yamledit.go
@@ -0,0 +1,244 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package yamledit edits a YAML file through its node tree, so a rewrite keeps
+// the comments, key order and formatting of everything it does not touch. An
+// agent's YAML configuration is a file its owner also edits by hand.
+package yamledit
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+
+ "gopkg.in/yaml.v3"
+)
+
+// indent is what the agents whose files this edits write themselves.
+const indent = 2
+
+// Document is one parsed YAML file.
+type Document struct {
+ root *yaml.Node
+}
+
+// Parse reads a document. Empty content is an empty document rather than an
+// error: a file the agent has not written yet is not a malformed one.
+func Parse(data []byte) (*Document, error) {
+ document := &Document{root: &yaml.Node{Kind: yaml.DocumentNode}}
+ if len(bytes.TrimSpace(data)) == 0 {
+ return document, nil
+ }
+ if err := yaml.Unmarshal(data, document.root); err != nil {
+ return nil, err
+ }
+ return document, nil
+}
+
+// Mapping is the document's root mapping, created when the document is empty.
+func (d *Document) Mapping() (*yaml.Node, error) {
+ return d.rootNode(yaml.MappingNode, "!!map", "a mapping")
+}
+
+// Sequence is the document's root sequence, created when the document is
empty.
+func (d *Document) Sequence() (*yaml.Node, error) {
+ return d.rootNode(yaml.SequenceNode, "!!seq", "a list")
+}
+
+// Bytes renders the document. An untouched empty document renders to nothing,
+// which is what it was.
+func (d *Document) Bytes() ([]byte, error) {
+ if len(d.root.Content) == 0 {
+ return nil, nil
+ }
+
+ var buffer bytes.Buffer
+ encoder := yaml.NewEncoder(&buffer)
+ encoder.SetIndent(indent)
+ if err := encoder.Encode(d.root); err != nil {
+ return nil, err
+ }
+ if err := encoder.Close(); err != nil {
+ return nil, err
+ }
+ return buffer.Bytes(), nil
+}
+
+// rootNode returns the root of the wanted kind. A root holding something else
+// is an error rather than a value to replace: that is the owner's own file.
+func (d *Document) rootNode(kind yaml.Kind, tag string, want string)
(*yaml.Node, error) {
+ if len(d.root.Content) == 0 {
+ node := &yaml.Node{Kind: kind, Tag: tag}
+ d.root.Content = []*yaml.Node{node}
+ return node, nil
+ }
+
+ node := d.root.Content[0]
+ if node.Kind == kind {
+ return node, nil
+ }
+ // A file holding nothing but comments parses to a null root, which is
as
+ // empty as an absent file and is filled in the same way.
+ if isNull(node) {
+ node.Kind, node.Tag, node.Value, node.Content = kind, tag, "",
nil
+ return node, nil
+ }
+ return nil, fmt.Errorf("the document is not %s", want)
+}
+
+// Get walks keys through nested mappings, nil when any of them is missing.
+func Get(mapping *yaml.Node, keys ...string) *yaml.Node {
+ current := mapping
+ for _, key := range keys {
+ if current == nil || current.Kind != yaml.MappingNode {
+ return nil
+ }
+ current = entry(current, key)
+ }
+ return current
+}
+
+// String is the scalar at keys, empty when it is missing or is not a scalar.
+func String(mapping *yaml.Node, keys ...string) string {
+ node := Get(mapping, keys...)
+ if node == nil || node.Kind != yaml.ScalarNode || isNull(node) {
+ return ""
+ }
+ return node.Value
+}
+
+// Set writes value at keys, creating the mappings on the way. The key node of
+// an entry that already exists is kept, so its comments survive the change.
+func Set(mapping *yaml.Node, value any, keys ...string) error {
+ if len(keys) == 0 {
+ return errors.New("no key to set")
+ }
+ node, err := Node(value)
+ if err != nil {
+ return err
+ }
+
+ parent := mapping
+ for _, key := range keys[:len(keys)-1] {
+ parent, err = ensureMapping(parent, key)
+ if err != nil {
+ return err
+ }
+ }
+ put(parent, keys[len(keys)-1], node)
+ return nil
+}
+
+// Delete removes the entry at keys and reports whether there was one.
+func Delete(mapping *yaml.Node, keys ...string) bool {
+ if len(keys) == 0 {
+ return false
+ }
+ parent := Get(mapping, keys[:len(keys)-1]...)
+ if parent == nil || parent.Kind != yaml.MappingNode {
+ return false
+ }
+
+ key := keys[len(keys)-1]
+ for index := 0; index+1 < len(parent.Content); index += 2 {
+ if parent.Content[index].Value == key {
+ parent.Content = append(parent.Content[:index],
parent.Content[index+2:]...)
+ return true
+ }
+ }
+ return false
+}
+
+// Node renders a Go value as the YAML nodes that spell it.
+func Node(value any) (*yaml.Node, error) {
+ if node, ok := value.(*yaml.Node); ok {
+ return node, nil
+ }
+ data, err := yaml.Marshal(value)
+ if err != nil {
+ return nil, err
+ }
+
+ var document yaml.Node
+ if err := yaml.Unmarshal(data, &document); err != nil {
+ return nil, err
+ }
+ if len(document.Content) == 0 {
+ return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null"}, nil
+ }
+ return document.Content[0], nil
+}
+
+// Render is one value as a whole YAML document, which is what a preview shows.
+func Render(value any) (string, error) {
+ var buffer bytes.Buffer
+ encoder := yaml.NewEncoder(&buffer)
+ encoder.SetIndent(indent)
+ if err := encoder.Encode(value); err != nil {
+ return "", err
+ }
+ if err := encoder.Close(); err != nil {
+ return "", err
+ }
+ return buffer.String(), nil
+}
+
+// IsEmpty reports a mapping or sequence holding nothing.
+func IsEmpty(node *yaml.Node) bool {
+ return node == nil || len(node.Content) == 0
+}
+
+func entry(mapping *yaml.Node, key string) *yaml.Node {
+ for index := 0; index+1 < len(mapping.Content); index += 2 {
+ if mapping.Content[index].Value == key {
+ return mapping.Content[index+1]
+ }
+ }
+ return nil
+}
+
+func put(mapping *yaml.Node, key string, node *yaml.Node) {
+ for index := 0; index+1 < len(mapping.Content); index += 2 {
+ if mapping.Content[index].Value == key {
+ mapping.Content[index+1] = node
+ return
+ }
+ }
+ mapping.Content = append(mapping.Content, keyNode(key), node)
+}
+
+func ensureMapping(mapping *yaml.Node, key string) (*yaml.Node, error) {
+ existing := entry(mapping, key)
+ if existing == nil {
+ created := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
+ mapping.Content = append(mapping.Content, keyNode(key), created)
+ return created, nil
+ }
+ if existing.Kind == yaml.MappingNode {
+ return existing, nil
+ }
+ if isNull(existing) {
+ existing.Kind, existing.Tag, existing.Value = yaml.MappingNode,
"!!map", ""
+ return existing, nil
+ }
+ return nil, fmt.Errorf("%q is not a mapping", key)
+}
+
+func keyNode(key string) *yaml.Node {
+ return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}
+}
+
+func isNull(node *yaml.Node) bool {
+ return node.Kind == yaml.ScalarNode && (node.Tag == "!!null" ||
node.Tag == "")
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]