Files
dingtalk-workspace-cli/internal/ir/catalog.go
T
2026-03-27 17:55:57 +08:00

366 lines
12 KiB
Go

// Copyright 2026 Alibaba Group
// 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 ir
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/url"
"sort"
"strings"
"unicode"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/discovery"
)
type Catalog struct {
Products []CanonicalProduct `json:"products"`
}
type LifecycleInfo struct {
DeprecatedBy int `json:"deprecated_by,omitempty"`
DeprecationDate string `json:"deprecation_date,omitempty"`
MigrationURL string `json:"migration_url,omitempty"`
DeprecatedCandidate bool `json:"deprecated_candidate,omitempty"`
}
type ProductCLIMetadata struct {
Command string `json:"command,omitempty"`
Group string `json:"group,omitempty"`
Hidden bool `json:"hidden,omitempty"`
Skip bool `json:"skip,omitempty"`
}
type CLIFlagHint struct {
Shorthand string `json:"shorthand,omitempty"`
Alias string `json:"alias,omitempty"`
}
type CanonicalProduct struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
Description string `json:"description,omitempty"`
ServerKey string `json:"server_key"`
Endpoint string `json:"endpoint"`
SchemaURI string `json:"schema_uri,omitempty"`
NegotiatedProtocolVersion string `json:"negotiated_protocol_version,omitempty"`
Source string `json:"source,omitempty"`
Degraded bool `json:"degraded"`
Lifecycle *LifecycleInfo `json:"lifecycle,omitempty"`
CLI *ProductCLIMetadata `json:"cli,omitempty"`
Tools []ToolDescriptor `json:"tools"`
}
type ToolDescriptor struct {
RPCName string `json:"rpc_name"`
CLIName string `json:"cli_name,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
InputSchema map[string]any `json:"input_schema,omitempty"`
OutputSchema map[string]any `json:"output_schema,omitempty"`
Sensitive bool `json:"sensitive"`
Hidden bool `json:"hidden,omitempty"`
FlagHints map[string]CLIFlagHint `json:"flag_hints,omitempty"`
SourceServerKey string `json:"source_server_key"`
CanonicalPath string `json:"canonical_path"`
}
func BuildCatalog(runtimeServers []discovery.RuntimeServer) Catalog {
sorted := append([]discovery.RuntimeServer(nil), runtimeServers...)
sort.Slice(sorted, func(i, j int) bool {
left := sorted[i].Server
right := sorted[j].Server
if left.DisplayName != right.DisplayName {
return left.DisplayName < right.DisplayName
}
if left.Endpoint != right.Endpoint {
return left.Endpoint < right.Endpoint
}
return left.Key < right.Key
})
usedIDs := make(map[string]struct{}, len(sorted))
products := make([]CanonicalProduct, 0, len(sorted))
for _, runtimeServer := range sorted {
productID := nextCanonicalProductID(
runtimeServer.Server.Key,
runtimeServer.Server.DisplayName,
runtimeServer.Server.Endpoint,
runtimeServer.Server.CLI.Command,
usedIDs,
)
sensitiveByTool := make(map[string]bool, len(runtimeServer.Server.CLI.Tools))
hasSensitiveOverride := make(map[string]bool, len(runtimeServer.Server.CLI.Tools))
toolCLIName := make(map[string]string, len(runtimeServer.Server.CLI.Tools))
toolHidden := make(map[string]bool, len(runtimeServer.Server.CLI.Tools))
toolTitleOverride := make(map[string]string, len(runtimeServer.Server.CLI.Tools))
toolDescriptionOverride := make(map[string]string, len(runtimeServer.Server.CLI.Tools))
toolFlagHints := make(map[string]map[string]CLIFlagHint, len(runtimeServer.Server.CLI.Tools))
for _, cliTool := range runtimeServer.Server.CLI.Tools {
name := strings.TrimSpace(cliTool.Name)
if name == "" {
continue
}
sensitiveByTool[name] = cliTool.IsSensitive
hasSensitiveOverride[name] = true
toolCLIName[name] = strings.TrimSpace(cliTool.CLIName)
toolHidden[name] = cliTool.Hidden
toolTitleOverride[name] = strings.TrimSpace(cliTool.Title)
toolDescriptionOverride[name] = strings.TrimSpace(cliTool.Description)
if len(cliTool.Flags) > 0 {
hints := make(map[string]CLIFlagHint, len(cliTool.Flags))
for key, hint := range cliTool.Flags {
trimmed := strings.TrimSpace(key)
if trimmed == "" {
continue
}
hints[trimmed] = CLIFlagHint{
Shorthand: strings.TrimSpace(hint.Shorthand),
Alias: strings.TrimSpace(hint.Alias),
}
}
if len(hints) > 0 {
toolFlagHints[name] = hints
}
}
}
tools := make([]ToolDescriptor, 0, len(runtimeServer.Tools))
for _, tool := range runtimeServer.Tools {
title := strings.TrimSpace(tool.Title)
if override := strings.TrimSpace(toolTitleOverride[tool.Name]); override != "" {
title = override
}
if title == "" {
title = tool.Name
}
description := strings.TrimSpace(tool.Description)
if override := strings.TrimSpace(toolDescriptionOverride[tool.Name]); override != "" {
description = override
}
if description == "" {
description = title
}
sensitive := tool.Sensitive
if hasSensitiveOverride[tool.Name] {
sensitive = sensitiveByTool[tool.Name]
}
cliName := strings.TrimSpace(toolCLIName[tool.Name])
if cliName == "" {
cliName = tool.Name
}
tools = append(tools, ToolDescriptor{
RPCName: tool.Name,
CLIName: cliName,
Title: title,
Description: description,
InputSchema: cloneMap(tool.InputSchema),
OutputSchema: cloneMap(tool.OutputSchema),
Sensitive: sensitive,
Hidden: toolHidden[tool.Name],
FlagHints: cloneFlagHints(toolFlagHints[tool.Name]),
SourceServerKey: runtimeServer.Server.Key,
CanonicalPath: fmt.Sprintf("%s.%s", productID, tool.Name),
})
}
sort.Slice(tools, func(i, j int) bool {
return tools[i].RPCName < tools[j].RPCName
})
lifecycle := LifecycleInfo{
DeprecatedBy: runtimeServer.Server.Lifecycle.DeprecatedBy,
DeprecationDate: strings.TrimSpace(runtimeServer.Server.Lifecycle.DeprecationDate),
MigrationURL: strings.TrimSpace(runtimeServer.Server.Lifecycle.MigrationURL),
DeprecatedCandidate: runtimeServer.Server.Lifecycle.DeprecatedCandidate,
}
var lifecyclePtr *LifecycleInfo
if lifecycle.DeprecatedBy > 0 || lifecycle.DeprecationDate != "" || lifecycle.MigrationURL != "" || lifecycle.DeprecatedCandidate {
lifecycleCopy := lifecycle
lifecyclePtr = &lifecycleCopy
}
cliMeta := ProductCLIMetadata{
Command: strings.TrimSpace(runtimeServer.Server.CLI.Command),
Group: strings.TrimSpace(runtimeServer.Server.CLI.Group),
Hidden: runtimeServer.Server.CLI.Hidden,
Skip: runtimeServer.Server.CLI.Skip,
}
var cliPtr *ProductCLIMetadata
if cliMeta.Command != "" || cliMeta.Group != "" || cliMeta.Hidden || cliMeta.Skip {
cliCopy := cliMeta
cliPtr = &cliCopy
}
description := runtimeServer.Server.Description
if cliDescription := strings.TrimSpace(runtimeServer.Server.CLI.Description); cliDescription != "" {
description = cliDescription
}
products = append(products, CanonicalProduct{
ID: productID,
DisplayName: runtimeServer.Server.DisplayName,
Description: description,
ServerKey: runtimeServer.Server.Key,
Endpoint: runtimeServer.Server.Endpoint,
SchemaURI: runtimeServer.Server.SchemaURI,
NegotiatedProtocolVersion: runtimeServer.NegotiatedProtocolVersion,
Source: runtimeServer.Source,
Degraded: runtimeServer.Degraded,
Lifecycle: lifecyclePtr,
CLI: cliPtr,
Tools: tools,
})
}
return Catalog{Products: products}
}
func (c Catalog) FindProduct(id string) (CanonicalProduct, bool) {
for _, product := range c.Products {
if product.ID == id {
return product, true
}
}
return CanonicalProduct{}, false
}
func (c Catalog) FindTool(path string) (CanonicalProduct, ToolDescriptor, bool) {
productID, toolName, ok := strings.Cut(path, ".")
if !ok || productID == "" || toolName == "" {
return CanonicalProduct{}, ToolDescriptor{}, false
}
product, ok := c.FindProduct(productID)
if !ok {
return CanonicalProduct{}, ToolDescriptor{}, false
}
tool, ok := product.FindTool(toolName)
if !ok {
return CanonicalProduct{}, ToolDescriptor{}, false
}
return product, tool, true
}
func (p CanonicalProduct) FindTool(name string) (ToolDescriptor, bool) {
for _, tool := range p.Tools {
if tool.RPCName == name {
return tool, true
}
}
return ToolDescriptor{}, false
}
func nextCanonicalProductID(key, displayName, endpoint, cliCommand string, usedIDs map[string]struct{}) string {
base := canonicalProductID(key, displayName, endpoint, cliCommand)
id := base
if _, exists := usedIDs[id]; exists {
id = fmt.Sprintf("%s-%s", base, shorten(key))
}
usedIDs[id] = struct{}{}
return id
}
func canonicalProductID(key, displayName, endpoint, cliCommand string) string {
for _, candidate := range []string{
slugify(cliCommand),
endpointSlug(endpoint),
slugify(displayName),
slugify(key),
} {
if candidate != "" {
return canonicalProductAlias(candidate)
}
}
return "srv-" + shorten(key)
}
func canonicalProductAlias(id string) string {
switch id {
case "table":
return "aitable"
default:
return id
}
}
func endpointSlug(raw string) string {
parsed, err := url.Parse(raw)
if err != nil {
return ""
}
path := strings.Trim(parsed.Path, "/")
if path == "" {
return ""
}
parts := strings.Split(path, "/")
return slugify(parts[len(parts)-1])
}
func slugify(value string) string {
var builder strings.Builder
lastDash := false
for _, r := range strings.ToLower(strings.TrimSpace(value)) {
switch {
case r >= 'a' && r <= 'z':
builder.WriteRune(r)
lastDash = false
case r >= '0' && r <= '9':
builder.WriteRune(r)
lastDash = false
case unicode.IsLetter(r) || unicode.IsDigit(r):
continue
default:
if !lastDash && builder.Len() > 0 {
builder.WriteByte('-')
lastDash = true
}
}
}
return strings.Trim(builder.String(), "-")
}
func shorten(value string) string {
if strings.TrimSpace(value) == "" {
sum := sha256.Sum256([]byte("dws"))
return hex.EncodeToString(sum[:])[:8]
}
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])[:8]
}
func cloneMap(value map[string]any) map[string]any {
if len(value) == 0 {
return nil
}
data, err := json.Marshal(value)
if err != nil {
return nil
}
var cloned map[string]any
if err := json.Unmarshal(data, &cloned); err != nil {
return nil
}
return cloned
}
func cloneFlagHints(value map[string]CLIFlagHint) map[string]CLIFlagHint {
if len(value) == 0 {
return nil
}
out := make(map[string]CLIFlagHint, len(value))
for key, hint := range value {
out[key] = hint
}
return out
}