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]

Reply via email to