源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s

This commit is contained in:
agent-radar
2026-09-01 20:17:27 +08:00
commit 2ed3415fb7
8018 changed files with 1936476 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
# App CLI Core
`packages/appcli/core` owns the reusable Go implementation of the App CLI
protocol contract used by workspace apps.
The package is intentionally limited to protocol-level behavior:
- `tutti.app.cli.v1` manifest reading and validation
- command capability construction from a manifest and host-provided app metadata
- manifest input normalization
- `tutti.app.cli.invoke.v1` HTTP invoke envelopes
- handler response decoding and output contract validation
- wait-command execution metadata and validated pending continuations
- scope conflict and reserved-scope state calculation
Host products keep their own workspace lookup, durable state, app package
metadata, runtime startup, and user-facing status DTO adapters outside this
module. Tutti daemon integration lives in `services/tuttid/service/cli/appcli`.
The protocol strings still use the frozen Tutti App CLI contract for backwards
compatibility with existing app manifests and handlers. That does not make this
module a Tutti daemon business layer; consumers such as TSH can reuse the
protocol core while supplying their own host adapters.
## Wait commands
An app command that semantically waits for a run, session, or other durable
execution declares `execution.mode` as `wait`. Its handler returns an ordinary
final output when the execution reaches a stop point. While the execution is
still active, the handler returns a validated `continuation` with state
`pending` and a bounded `retryAfterMs` delay.
The terminal CLI consumes continuations automatically. One visible command
therefore remains active across multiple handler invocations; callers do not
poll and do not pass a follow flag. The CLI-owned `--timeout-ms` option is an
optional total wait deadline. When it expires, the command returns a successful
JSON observation with `reason: "wait_timeout"`, `timedOut: true`, and
`executionContinues: true`; it never cancels the underlying execution.
Handler `timeoutMs` remains a per-invocation transport budget. It is not a
workflow or total wait timeout.
+73
View File
@@ -0,0 +1,73 @@
package core
import (
"strings"
"time"
)
func BuildCommands(manifest Manifest, options CommandBuildOptions) []Command {
commands := make([]Command, 0, len(manifest.Commands))
for _, command := range manifest.Commands {
commands = append(commands, Command{
Capability: Capability{
ID: CommandID(options.AppID, manifest.Scope, command.Path),
Path: append([]string{manifest.Scope}, command.Path...),
Summary: strings.TrimSpace(command.Summary),
Description: strings.TrimSpace(command.Description),
Visibility: NormalizeVisibility(command.Visibility),
InputSchema: CloneSchema(command.InputSchema),
Output: CapabilityOutput{
DefaultMode: command.Output.DefaultMode,
JSON: command.Output.JSON,
Table: tableOutput(command.Output.Table),
},
Execution: commandExecution(command.Execution),
HandlerTimeoutMs: NormalizedTimeoutMs(command.Handler.TimeoutMs),
Source: CapabilitySource{
Kind: CapabilitySourceApp,
AppID: strings.TrimSpace(options.AppID),
AppName: strings.TrimSpace(options.AppName),
IconURL: strings.TrimSpace(options.IconURL),
CLIDescription: strings.TrimSpace(manifest.Description),
AppDescription: strings.TrimSpace(options.AppDescription),
DocumentationFile: strings.TrimSpace(options.DocumentationFile),
DocumentationPath: strings.TrimSpace(options.DocumentationPath),
},
},
Manifest: command,
Timeout: time.Duration(NormalizedTimeoutMs(command.Handler.TimeoutMs)) * time.Millisecond,
})
}
return commands
}
func commandExecution(execution *ManifestCommandExecution) *CommandExecution {
if execution == nil {
return nil
}
return &CommandExecution{Mode: execution.Mode}
}
func CommandID(appID string, scope string, path []string) string {
parts := []string{"app", strings.TrimSpace(appID), strings.TrimSpace(scope)}
parts = append(parts, path...)
return strings.Join(parts, ".")
}
func tableOutput(output *ManifestTableOutput) *TableOutput {
if output == nil {
return nil
}
return &TableOutput{Columns: append([]TableColumn(nil), output.Columns...)}
}
func CloneSchema(schema map[string]any) map[string]any {
if len(schema) == 0 {
return nil
}
cloned := make(map[string]any, len(schema))
for key, value := range schema {
cloned[key] = value
}
return cloned
}
+61
View File
@@ -0,0 +1,61 @@
package core
import (
"errors"
"strings"
)
var (
ErrInvalidInput = errors.New("invalid app cli input")
ErrServiceUnavailable = errors.New("app cli service unavailable")
ErrHandlerBadResponse = errors.New("app cli handler bad response")
ErrHandlerFailed = errors.New("app cli handler failed")
)
type InvokeError struct {
Kind error
Reason string
Err error
}
func (e *InvokeError) Error() string {
if e == nil {
return ""
}
message := strings.TrimSpace(e.Reason)
if e.Err != nil {
if message != "" {
message += ": "
}
message += e.Err.Error()
}
if message == "" && e.Kind != nil {
message = e.Kind.Error()
}
return message
}
func (e *InvokeError) Unwrap() error {
if e == nil {
return nil
}
if e.Kind == nil {
return e.Err
}
if e.Err == nil {
return e.Kind
}
return errors.Join(e.Kind, e.Err)
}
func invokeError(kind error, reason string, err error) error {
return &InvokeError{Kind: kind, Reason: strings.TrimSpace(reason), Err: err}
}
func InvokeErrorReason(err error) string {
var invokeErr *InvokeError
if errors.As(err, &invokeErr) {
return invokeErr.Reason
}
return ""
}
+5
View File
@@ -0,0 +1,5 @@
module github.com/tutti-os/tutti/packages/appcli/core
go 1.24.3
toolchain go1.24.5
+118
View File
@@ -0,0 +1,118 @@
package core
import (
"encoding/json"
"errors"
"fmt"
"sort"
"strconv"
"strings"
)
func NormalizeInput(schema map[string]any, input map[string]any) (map[string]any, error) {
normalized, _, err := NormalizeInputWithWarnings(schema, input)
return normalized, err
}
func NormalizeInputWithWarnings(schema map[string]any, input map[string]any) (map[string]any, []InputWarning, error) {
if len(schema) == 0 {
return map[string]any{}, unknownInputWarnings(input, nil), nil
}
properties, _ := schema["properties"].(map[string]any)
required := map[string]bool{}
for _, name := range RequiredNames(schema) {
required[name] = true
}
result := make(map[string]any, len(input))
for key, value := range input {
property, ok := properties[key]
if !ok {
continue
}
propertyMap, _ := property.(map[string]any)
normalized, err := normalizeValue(schemaType(propertyMap), value)
if err != nil {
return nil, nil, fmt.Errorf("%w: invalid input %q", ErrInvalidInput, key)
}
result[key] = normalized
}
for name := range required {
if _, ok := result[name]; !ok {
return nil, nil, fmt.Errorf("%w: required input %q is missing", ErrInvalidInput, name)
}
}
return result, unknownInputWarnings(input, properties), nil
}
func unknownInputWarnings(input map[string]any, properties map[string]any) []InputWarning {
if len(input) == 0 {
return nil
}
unknown := make([]string, 0)
for key := range input {
if _, ok := properties[key]; !ok {
unknown = append(unknown, key)
}
}
sort.Strings(unknown)
warnings := make([]InputWarning, 0, len(unknown))
for _, key := range unknown {
warnings = append(warnings, InputWarning{
Code: "unknown_input_ignored",
Message: fmt.Sprintf("Ignoring unknown input %q; this CLI may be newer than the app handler.", key),
})
}
return warnings
}
func normalizeValue(typeName string, value any) (any, error) {
switch typeName {
case "string":
text, ok := value.(string)
if !ok {
return nil, errors.New("not a string")
}
return text, nil
case "boolean":
switch typed := value.(type) {
case bool:
return typed, nil
case string:
parsed, err := strconv.ParseBool(strings.TrimSpace(typed))
if err != nil {
return nil, err
}
return parsed, nil
default:
return nil, errors.New("not a boolean")
}
case "integer":
switch typed := value.(type) {
case int:
return typed, nil
case int64:
return typed, nil
case float64:
if typed != float64(int64(typed)) {
return nil, errors.New("not an integer")
}
return int64(typed), nil
case json.Number:
parsed, err := typed.Int64()
if err != nil {
return nil, err
}
return parsed, nil
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
if err != nil {
return nil, err
}
return parsed, nil
default:
return nil, errors.New("not an integer")
}
default:
return nil, errors.New("unsupported type")
}
}
+67
View File
@@ -0,0 +1,67 @@
package core
import (
"errors"
"strings"
"testing"
)
func TestNormalizeInputIgnoresUnknownAndCoercesScalars(t *testing.T) {
input, err := NormalizeInput(map[string]any{
"type": "object",
"properties": map[string]any{
"count": map[string]any{"type": "integer"},
"dry-run": map[string]any{"type": "boolean"},
"name": map[string]any{"type": "string"},
},
"required": []any{"count"},
}, map[string]any{
"count": "2",
"dry-run": "true",
"name": "daily",
"unknown": "x",
})
if err != nil {
t.Fatalf("NormalizeInput() error = %v", err)
}
if input["count"] != int64(2) || input["dry-run"] != true || input["name"] != "daily" {
t.Fatalf("input = %#v", input)
}
if _, ok := input["unknown"]; ok {
t.Fatalf("unknown input was forwarded: %#v", input)
}
}
func TestNormalizeInputWithWarningsReportsUnknownInput(t *testing.T) {
input, warnings, err := NormalizeInputWithWarnings(map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{"type": "string"},
},
}, map[string]any{
"name": "daily",
"schedule": "0 9 * * *",
})
if err != nil {
t.Fatalf("NormalizeInputWithWarnings() error = %v", err)
}
if input["name"] != "daily" {
t.Fatalf("input = %#v", input)
}
if len(warnings) != 1 ||
warnings[0].Code != "unknown_input_ignored" ||
!strings.Contains(warnings[0].Message, `"schedule"`) {
t.Fatalf("warnings = %#v", warnings)
}
}
func TestNormalizeInputRequiresRequiredProperties(t *testing.T) {
_, err := NormalizeInput(map[string]any{
"type": "object",
"properties": map[string]any{"name": map[string]any{"type": "string"}},
"required": []any{"name"},
}, map[string]any{})
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("NormalizeInput() error = %v, want ErrInvalidInput", err)
}
}
+175
View File
@@ -0,0 +1,175 @@
package core
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
)
const InvokeSchemaVersion = "tutti.app.cli.invoke.v1"
type InvokeContext struct {
Source string
ParentCommandID string
}
type HTTPInvokeRequest struct {
BaseURL string
Command Command
AppID string
Scope string
WorkspaceID string
Input map[string]any
OutputMode OutputMode
Context InvokeContext
HTTPClient *http.Client
}
type InvokeEnvelope struct {
SchemaVersion string `json:"schemaVersion"`
CommandID string `json:"commandId"`
AppID string `json:"appId"`
Scope string `json:"scope"`
Path []string `json:"path"`
WorkspaceID string `json:"workspaceId"`
Input map[string]any `json:"input"`
OutputMode OutputMode `json:"outputMode"`
Context InvokeEnvelopeContext `json:"context"`
}
type InvokeEnvelopeContext struct {
Source string `json:"source"`
ParentCommandID *string `json:"parentCommandId"`
}
func BuildInvokeEnvelope(request HTTPInvokeRequest) InvokeEnvelope {
return InvokeEnvelope{
SchemaVersion: InvokeSchemaVersion,
CommandID: request.Command.Capability.ID,
AppID: strings.TrimSpace(request.AppID),
Scope: strings.TrimSpace(request.Scope),
Path: append([]string(nil), request.Command.Manifest.Path...),
WorkspaceID: strings.TrimSpace(request.WorkspaceID),
Input: request.Input,
OutputMode: request.OutputMode,
Context: InvokeEnvelopeContext{
Source: firstNonEmpty(request.Context.Source, "cli"),
ParentCommandID: nullableString(request.Context.ParentCommandID),
},
}
}
func InvokeHTTP(ctx context.Context, request HTTPInvokeRequest) (CommandOutput, error) {
endpoint, err := url.JoinPath(strings.TrimRight(strings.TrimSpace(request.BaseURL), "/"), request.Command.Manifest.Handler.Path)
if err != nil {
return CommandOutput{}, invokeError(ErrHandlerBadResponse, "app_cli_handler_bad_response", err)
}
body, err := json.Marshal(BuildInvokeEnvelope(request))
if err != nil {
return CommandOutput{}, invokeError(ErrHandlerBadResponse, "app_cli_handler_bad_response", err)
}
timeout := request.Command.Timeout
if timeout <= 0 {
timeout = time.Duration(DefaultTimeoutMs) * time.Millisecond
}
requestCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
httpRequest, err := http.NewRequestWithContext(requestCtx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return CommandOutput{}, invokeError(ErrHandlerBadResponse, "app_cli_handler_bad_response", err)
}
httpRequest.Header.Set("Accept", "application/json")
httpRequest.Header.Set("Content-Type", "application/json")
response, err := httpClient(request.HTTPClient).Do(httpRequest)
if err != nil {
if errors.Is(requestCtx.Err(), context.DeadlineExceeded) {
return CommandOutput{}, invokeError(ErrServiceUnavailable, "app_cli_handler_timeout", err)
}
if isConnectionUnavailable(err) {
return CommandOutput{}, invokeError(ErrServiceUnavailable, "app_cli_runtime_unavailable", err)
}
return CommandOutput{}, invokeError(ErrHandlerBadResponse, "app_cli_handler_bad_response", err)
}
defer response.Body.Close()
content, err := io.ReadAll(io.LimitReader(response.Body, 1024*1024))
if err != nil {
return CommandOutput{}, invokeError(ErrHandlerBadResponse, "app_cli_handler_bad_response", err)
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
if validAppErrorBody(content) {
return CommandOutput{}, invokeError(ErrHandlerFailed, "app_cli_handler_failed", errors.New(appErrorMessage(content)))
}
return CommandOutput{}, invokeError(ErrHandlerBadResponse, "app_cli_handler_bad_response", fmt.Errorf("app cli handler returned %s", response.Status))
}
output, err := DecodeCommandOutput(content)
if err != nil {
return CommandOutput{}, invokeError(ErrHandlerBadResponse, "app_cli_handler_bad_response", err)
}
return output, nil
}
func httpClient(client *http.Client) *http.Client {
if client != nil {
return client
}
return &http.Client{Timeout: MaxTimeoutMs*time.Millisecond + 30*time.Second}
}
func nullableString(value string) *string {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
return &value
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func isConnectionUnavailable(err error) bool {
var netErr net.Error
if errors.As(err, &netErr) {
return true
}
return strings.Contains(strings.ToLower(err.Error()), "connection refused")
}
func validAppErrorBody(content []byte) bool {
var body struct {
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(content, &body); err != nil {
return false
}
return body.Error != nil && strings.TrimSpace(body.Error.Code) != "" && strings.TrimSpace(body.Error.Message) != ""
}
func appErrorMessage(content []byte) string {
var body struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(content, &body); err == nil && strings.TrimSpace(body.Error.Message) != "" {
return strings.TrimSpace(body.Error.Message)
}
return "app cli handler failed"
}
+56
View File
@@ -0,0 +1,56 @@
package core
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestInvokeHTTPPostsEnvelope(t *testing.T) {
var envelope map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/tutti/cli/run" {
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
}
if err := json.NewDecoder(r.Body).Decode(&envelope); err != nil {
t.Fatalf("decode envelope: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"kind":"json","value":{"ok":true}}`))
}))
defer server.Close()
output, err := InvokeHTTP(context.Background(), HTTPInvokeRequest{
BaseURL: server.URL,
AppID: "automation-app",
Scope: "automation",
Command: Command{
Capability: Capability{ID: "app.automation-app.automation.run"},
Manifest: ManifestCommand{
Path: []string{"run"},
Handler: ManifestCommandHandler{Kind: "http", Method: "POST", Path: "/tutti/cli/run"},
},
Timeout: time.Second,
},
WorkspaceID: "ws-1",
Input: map[string]any{"name": "daily"},
OutputMode: OutputModeJSON,
Context: InvokeContext{Source: "cli", ParentCommandID: "parent"},
})
if err != nil {
t.Fatalf("InvokeHTTP() error = %v", err)
}
if output.Kind != OutputModeJSON || output.Value["ok"] != true {
t.Fatalf("output = %#v", output)
}
if envelope["schemaVersion"] != InvokeSchemaVersion || envelope["workspaceId"] != "ws-1" {
t.Fatalf("envelope = %#v", envelope)
}
contextValue := envelope["context"].(map[string]any)
if contextValue["source"] != "cli" || contextValue["parentCommandId"] != "parent" {
t.Fatalf("envelope context = %#v", contextValue)
}
}
+423
View File
@@ -0,0 +1,423 @@
package core
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
const (
ManifestSchemaVersion = "tutti.app.cli.v1"
maxManifestBytes = 256 * 1024
DefaultTimeoutMs = 30000
MinTimeoutMs = 1000
MaxTimeoutMs = 600000
)
const (
CommandVisibilityPublic CommandVisibility = "public"
CommandVisibilityIntegration CommandVisibility = "integration"
)
var segmentPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$`)
type Manifest struct {
SchemaVersion string `json:"schemaVersion"`
Scope string `json:"scope"`
Description string `json:"description,omitempty"`
Documentation *ManifestDocumentation `json:"documentation,omitempty"`
Commands []ManifestCommand `json:"commands"`
}
type ManifestDocumentation struct {
File string `json:"file"`
}
type ManifestCommand struct {
Path []string `json:"path"`
Summary string `json:"summary"`
Description string `json:"description,omitempty"`
Visibility CommandVisibility `json:"visibility,omitempty"`
Execution *ManifestCommandExecution `json:"execution,omitempty"`
InputSchema map[string]any `json:"inputSchema,omitempty"`
Output ManifestCommandOutput `json:"output"`
Handler ManifestCommandHandler `json:"handler"`
}
type ManifestCommandExecution struct {
Mode CommandExecutionMode `json:"mode"`
}
type ManifestCommandOutput struct {
DefaultMode OutputMode `json:"defaultMode"`
JSON bool `json:"json"`
Table *ManifestTableOutput `json:"table,omitempty"`
}
type ManifestTableOutput struct {
Columns []TableColumn `json:"columns"`
}
type ManifestCommandHandler struct {
Kind string `json:"kind"`
Method string `json:"method"`
Path string `json:"path"`
TimeoutMs int `json:"timeoutMs,omitempty"`
}
func ReadManifest(path string) (Manifest, error) {
file, err := os.Open(path)
if err != nil {
return Manifest{}, fmt.Errorf("read cli manifest: %w", err)
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return Manifest{}, fmt.Errorf("stat cli manifest: %w", err)
}
if info.Size() > maxManifestBytes {
return Manifest{}, fmt.Errorf("cli manifest exceeds maximum size %d", maxManifestBytes)
}
decoder := json.NewDecoder(file)
decoder.UseNumber()
var manifest Manifest
if err := decoder.Decode(&manifest); err != nil {
return Manifest{}, fmt.Errorf("parse cli manifest json: %w", err)
}
if err := ValidateManifest(manifest); err != nil {
return Manifest{}, err
}
return manifest, nil
}
func ValidateManifest(manifest Manifest) error {
if !isSupportedManifestSchemaVersion(strings.TrimSpace(manifest.SchemaVersion)) {
return fmt.Errorf("unsupported cli manifest schema version %q", manifest.SchemaVersion)
}
scope := strings.TrimSpace(manifest.Scope)
if err := validateSegment(scope, "scope"); err != nil {
return err
}
if manifest.Documentation != nil {
if err := validatePackageRelativePath(manifest.Documentation.File, "documentation.file"); err != nil {
return err
}
}
if len(manifest.Commands) == 0 {
return errors.New("cli manifest commands is required")
}
seenPaths := map[string]struct{}{}
for index, command := range manifest.Commands {
location := fmt.Sprintf("commands[%d]", index)
if len(command.Path) == 0 {
return fmt.Errorf("cli manifest %s.path is required", location)
}
if command.Path[0] == scope {
return fmt.Errorf("cli manifest %s.path must not repeat scope", location)
}
for segmentIndex, segment := range command.Path {
if err := validateSegment(strings.TrimSpace(segment), fmt.Sprintf("%s.path[%d]", location, segmentIndex)); err != nil {
return err
}
}
pathKey := strings.Join(command.Path, ".")
if _, ok := seenPaths[pathKey]; ok {
return fmt.Errorf("cli manifest command path %q is duplicated", strings.Join(command.Path, " "))
}
seenPaths[pathKey] = struct{}{}
if strings.TrimSpace(command.Summary) == "" {
return fmt.Errorf("cli manifest %s.summary is required", location)
}
if err := validateVisibility(command.Visibility, location+".visibility"); err != nil {
return err
}
if err := validateExecution(command.Execution, command.InputSchema, command.Output, location+".execution"); err != nil {
return err
}
if err := validateInputSchema(command.InputSchema, location+".inputSchema"); err != nil {
return err
}
if err := validateOutput(command.Output, location+".output"); err != nil {
return err
}
if err := validateHandler(command.Handler, location+".handler"); err != nil {
return err
}
}
return nil
}
func validateExecution(execution *ManifestCommandExecution, inputSchema map[string]any, output ManifestCommandOutput, location string) error {
if execution == nil {
return nil
}
if execution.Mode != CommandExecutionModeWait {
return fmt.Errorf("cli manifest %s.mode must be wait", location)
}
if output.DefaultMode != OutputModeJSON || !output.JSON {
return fmt.Errorf("cli manifest %s mode wait requires json default output", location)
}
properties, _ := inputSchema["properties"].(map[string]any)
if _, declared := properties["timeout-ms"]; declared {
return fmt.Errorf("cli manifest %s mode wait reserves --timeout-ms for the total CLI wait timeout", location)
}
return nil
}
func NormalizeVisibility(visibility CommandVisibility) CommandVisibility {
switch CommandVisibility(strings.TrimSpace(string(visibility))) {
case "", CommandVisibilityPublic:
return CommandVisibilityPublic
case CommandVisibilityIntegration:
return CommandVisibilityIntegration
default:
return visibility
}
}
func validateVisibility(visibility CommandVisibility, location string) error {
switch NormalizeVisibility(visibility) {
case CommandVisibilityPublic, CommandVisibilityIntegration:
return nil
default:
return fmt.Errorf("cli manifest %s must be public or integration", location)
}
}
func isSupportedManifestSchemaVersion(schemaVersion string) bool {
return schemaVersion == ManifestSchemaVersion
}
func validateSegment(segment string, location string) error {
if segment == "" {
return fmt.Errorf("cli manifest %s is required", location)
}
if strings.HasPrefix(segment, "--") || strings.Contains(segment, " ") || !segmentPattern.MatchString(segment) {
return fmt.Errorf("cli manifest %s must contain lowercase letters, numbers, and hyphen only", location)
}
return nil
}
func validatePackageRelativePath(value string, location string) error {
value = strings.TrimSpace(value)
if value == "" {
return fmt.Errorf("cli manifest %s is required", location)
}
if filepath.IsAbs(value) || strings.HasPrefix(value, `\`) {
return fmt.Errorf("cli manifest %s must be a relative package path", location)
}
for _, part := range strings.FieldsFunc(value, func(char rune) bool {
return char == '/' || char == '\\'
}) {
if part == ".." {
return fmt.Errorf("cli manifest %s must not contain parent path segments", location)
}
}
return nil
}
func validateInputSchema(schema map[string]any, location string) error {
if len(schema) == 0 {
return nil
}
if schemaType(schema) != "object" {
return fmt.Errorf("cli manifest %s.type must be object", location)
}
properties, ok := schema["properties"].(map[string]any)
if !ok {
return fmt.Errorf("cli manifest %s.properties is required", location)
}
for name, property := range properties {
if err := validateSegment(strings.TrimSpace(name), location+".properties"); err != nil {
return err
}
propertyMap, ok := property.(map[string]any)
if !ok {
return fmt.Errorf("cli manifest %s.properties.%s must be an object", location, name)
}
propertyType := schemaType(propertyMap)
switch propertyType {
case "string", "boolean", "integer":
default:
return fmt.Errorf("cli manifest %s.properties.%s.type must be string, boolean, or integer", location, name)
}
for key := range propertyMap {
if key != "type" && key != "description" && key != "enum" && key != "default" {
return fmt.Errorf("cli manifest %s.properties.%s has unsupported key %q", location, name, key)
}
}
if enum, ok := propertyMap["enum"]; ok {
if err := validateInputSchemaEnum(propertyType, enum, fmt.Sprintf("%s.properties.%s.enum", location, name)); err != nil {
return err
}
}
if defaultValue, ok := propertyMap["default"]; ok {
if err := validateInputSchemaValue(propertyType, defaultValue, fmt.Sprintf("%s.properties.%s.default", location, name)); err != nil {
return err
}
}
}
for _, required := range RequiredNames(schema) {
if _, ok := properties[required]; !ok {
return fmt.Errorf("cli manifest %s.required contains unknown property %q", location, required)
}
}
for key := range schema {
if key != "type" && key != "properties" && key != "required" {
return fmt.Errorf("cli manifest %s has unsupported key %q", location, key)
}
}
return nil
}
func validateInputSchemaEnum(typeName string, value any, location string) error {
items, ok := value.([]any)
if !ok {
return fmt.Errorf("cli manifest %s must be an array", location)
}
for index, item := range items {
if err := validateInputSchemaValue(typeName, item, fmt.Sprintf("%s[%d]", location, index)); err != nil {
return err
}
}
return nil
}
func validateInputSchemaValue(typeName string, value any, location string) error {
switch typeName {
case "string":
if _, ok := value.(string); !ok {
return fmt.Errorf("cli manifest %s must be a string", location)
}
case "boolean":
if _, ok := value.(bool); !ok {
return fmt.Errorf("cli manifest %s must be a boolean", location)
}
case "integer":
switch typed := value.(type) {
case int, int64:
return nil
case float64:
if typed == float64(int64(typed)) {
return nil
}
case json.Number:
if _, err := typed.Int64(); err == nil {
return nil
}
}
return fmt.Errorf("cli manifest %s must be an integer", location)
}
return nil
}
func validateOutput(output ManifestCommandOutput, location string) error {
switch output.DefaultMode {
case OutputModeJSON:
if !output.JSON {
return fmt.Errorf("cli manifest %s.json must be true when defaultMode is json", location)
}
case OutputModeTable:
if output.Table == nil || len(output.Table.Columns) == 0 {
return fmt.Errorf("cli manifest %s.table.columns is required when defaultMode is table", location)
}
case "":
return fmt.Errorf("cli manifest %s.defaultMode is required", location)
default:
return fmt.Errorf("cli manifest %s.defaultMode must be json or table", location)
}
if output.Table != nil {
seen := map[string]struct{}{}
for _, column := range output.Table.Columns {
key := strings.TrimSpace(column.Key)
if err := validateSegment(key, location+".table.columns.key"); err != nil {
return err
}
if strings.TrimSpace(column.Label) == "" {
return fmt.Errorf("cli manifest %s.table.columns.label is required", location)
}
if _, ok := seen[key]; ok {
return fmt.Errorf("cli manifest %s.table.columns key %q is duplicated", location, key)
}
seen[key] = struct{}{}
}
}
return nil
}
func validateHandler(handler ManifestCommandHandler, location string) error {
if strings.TrimSpace(handler.Kind) != "http" {
return fmt.Errorf("cli manifest %s.kind must be http", location)
}
if strings.TrimSpace(handler.Method) != "POST" {
return fmt.Errorf("cli manifest %s.method must be POST", location)
}
handlerPath := strings.TrimSpace(handler.Path)
if !strings.HasPrefix(handlerPath, "/tutti/cli/") {
return fmt.Errorf("cli manifest %s.path must start with /tutti/cli/", location)
}
timeoutMs := NormalizedTimeoutMs(handler.TimeoutMs)
if timeoutMs < MinTimeoutMs || timeoutMs > MaxTimeoutMs {
return fmt.Errorf("cli manifest %s.timeoutMs must be between %d and %d", location, MinTimeoutMs, MaxTimeoutMs)
}
return nil
}
func NormalizedTimeoutMs(value int) int {
if value == 0 {
return DefaultTimeoutMs
}
return value
}
func schemaType(schema map[string]any) string {
value, _ := schema["type"].(string)
return strings.TrimSpace(value)
}
func RequiredNames(schema map[string]any) []string {
value, ok := schema["required"]
if !ok {
return nil
}
switch typed := value.(type) {
case []string:
return append([]string(nil), typed...)
case []any:
names := make([]string, 0, len(typed))
for _, item := range typed {
if name, ok := item.(string); ok {
names = append(names, name)
}
}
return names
default:
return nil
}
}
func PackageRelativePath(packageDir string, manifestPath string) (string, error) {
manifestPath = strings.TrimSpace(manifestPath)
if manifestPath == "" {
return "", errors.New("cli manifest path is required")
}
if filepath.IsAbs(manifestPath) || strings.HasPrefix(manifestPath, `\`) {
return "", errors.New("cli manifest path must be relative")
}
for _, part := range strings.FieldsFunc(manifestPath, func(char rune) bool {
return char == '/' || char == '\\'
}) {
if part == ".." {
return "", errors.New("cli manifest path must not contain parent path segments")
}
}
return filepath.Join(packageDir, filepath.FromSlash(manifestPath)), nil
}
+204
View File
@@ -0,0 +1,204 @@
package core
import (
"strings"
"testing"
)
func TestValidateManifestRejectsReservedHandlerPath(t *testing.T) {
err := ValidateManifest(Manifest{
SchemaVersion: ManifestSchemaVersion,
Scope: "automation",
Commands: []ManifestCommand{{
Path: []string{"run"},
Summary: "Run automation",
Output: ManifestCommandOutput{DefaultMode: OutputModeJSON, JSON: true},
Handler: ManifestCommandHandler{Kind: "http", Method: "POST", Path: "/run"},
}},
})
if err == nil {
t.Fatal("ValidateManifest() error = nil, want handler path error")
}
}
func TestValidateManifestRejectsUnknownVisibility(t *testing.T) {
err := ValidateManifest(Manifest{
SchemaVersion: ManifestSchemaVersion,
Scope: "automation",
Commands: []ManifestCommand{{
Path: []string{"run"},
Summary: "Run automation",
Visibility: "private",
Output: ManifestCommandOutput{DefaultMode: OutputModeJSON, JSON: true},
Handler: ManifestCommandHandler{Kind: "http", Method: "POST", Path: "/tutti/cli/run"},
}},
})
if err == nil {
t.Fatal("ValidateManifest() error = nil, want visibility error")
}
}
func TestValidateManifestAcceptsEnumAndDefaultInputAnnotations(t *testing.T) {
err := ValidateManifest(Manifest{
SchemaVersion: ManifestSchemaVersion,
Scope: "automation",
Commands: []ManifestCommand{{
Path: []string{"create"},
Summary: "Create automation",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"schedule-type": map[string]any{
"type": "string",
"description": "Schedule type.",
"enum": []any{"manual", "interval", "daily", "weekly", "cron"},
"default": "manual",
},
"enabled": map[string]any{
"type": "boolean",
"default": true,
},
"interval-minutes": map[string]any{
"type": "integer",
"default": 60,
},
},
},
Output: ManifestCommandOutput{DefaultMode: OutputModeJSON, JSON: true},
Handler: ManifestCommandHandler{Kind: "http", Method: "POST", Path: "/tutti/cli/create"},
}},
})
if err != nil {
t.Fatalf("ValidateManifest() error = %v", err)
}
}
func TestValidateManifestRejectsEnumValuesWithWrongType(t *testing.T) {
err := ValidateManifest(Manifest{
SchemaVersion: ManifestSchemaVersion,
Scope: "automation",
Commands: []ManifestCommand{{
Path: []string{"create"},
Summary: "Create automation",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"schedule-type": map[string]any{
"type": "string",
"enum": []any{"manual", 1},
},
},
},
Output: ManifestCommandOutput{DefaultMode: OutputModeJSON, JSON: true},
Handler: ManifestCommandHandler{Kind: "http", Method: "POST", Path: "/tutti/cli/create"},
}},
})
if err == nil {
t.Fatal("ValidateManifest() error = nil, want enum type error")
}
}
func TestBuildCommandsBuildsAppCapability(t *testing.T) {
manifest := Manifest{
SchemaVersion: ManifestSchemaVersion,
Scope: "automation",
Description: "Automation commands",
Commands: []ManifestCommand{{
Path: []string{"run"},
Summary: "Run automation",
Description: "Runs a job",
Visibility: CommandVisibilityIntegration,
InputSchema: map[string]any{"type": "object", "properties": map[string]any{"name": map[string]any{"type": "string"}}},
Output: ManifestCommandOutput{DefaultMode: OutputModeJSON, JSON: true},
Handler: ManifestCommandHandler{Kind: "http", Method: "POST", Path: "/tutti/cli/run"},
}},
}
commands := BuildCommands(manifest, CommandBuildOptions{
AppID: "automation-app",
AppName: "Automation",
IconURL: "data:image/png;base64,abc",
AppDescription: "Test app",
DocumentationFile: "COMMANDS.md",
DocumentationPath: "/tmp/COMMANDS.md",
})
if len(commands) != 1 {
t.Fatalf("commands = %#v", commands)
}
capability := commands[0].Capability
if capability.ID != "app.automation-app.automation.run" || capability.Path[0] != "automation" {
t.Fatalf("capability = %#v", capability)
}
if capability.Source.AppID != "automation-app" || capability.Source.CLIDescription != "Automation commands" {
t.Fatalf("capability source = %#v", capability.Source)
}
if capability.Visibility != CommandVisibilityIntegration {
t.Fatalf("capability visibility = %q", capability.Visibility)
}
}
func TestBuildCommandsDefaultsVisibilityToPublic(t *testing.T) {
commands := BuildCommands(Manifest{
SchemaVersion: ManifestSchemaVersion,
Scope: "automation",
Commands: []ManifestCommand{{
Path: []string{"run"},
Summary: "Run automation",
Output: ManifestCommandOutput{DefaultMode: OutputModeJSON, JSON: true},
Handler: ManifestCommandHandler{Kind: "http", Method: "POST", Path: "/tutti/cli/run"},
}},
}, CommandBuildOptions{AppID: "automation-app"})
if len(commands) != 1 || commands[0].Capability.Visibility != CommandVisibilityPublic {
t.Fatalf("commands = %#v", commands)
}
}
func TestValidateManifestAcceptsWaitExecutionAndBuildsCapabilityMetadata(t *testing.T) {
manifest := Manifest{
SchemaVersion: ManifestSchemaVersion,
Scope: "automation",
Commands: []ManifestCommand{{
Path: []string{"runs", "wait"},
Summary: "Wait for a run",
Execution: &ManifestCommandExecution{Mode: CommandExecutionModeWait},
Output: ManifestCommandOutput{DefaultMode: OutputModeJSON, JSON: true},
Handler: ManifestCommandHandler{
Kind: "http", Method: "POST", Path: "/tutti/cli/runs/wait", TimeoutMs: 45000,
},
}},
}
if err := ValidateManifest(manifest); err != nil {
t.Fatalf("ValidateManifest() error = %v", err)
}
commands := BuildCommands(manifest, CommandBuildOptions{AppID: "automation-app"})
if len(commands) != 1 || commands[0].Capability.Execution == nil ||
commands[0].Capability.Execution.Mode != CommandExecutionModeWait ||
commands[0].Capability.HandlerTimeoutMs != 45000 {
t.Fatalf("commands = %#v", commands)
}
}
func TestValidateManifestRejectsWaitExecutionTimeoutInput(t *testing.T) {
err := ValidateManifest(Manifest{
SchemaVersion: ManifestSchemaVersion,
Scope: "automation",
Commands: []ManifestCommand{{
Path: []string{"runs", "wait"},
Summary: "Wait for a run",
Execution: &ManifestCommandExecution{Mode: CommandExecutionModeWait},
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"timeout-ms": map[string]any{"type": "integer"},
},
},
Output: ManifestCommandOutput{DefaultMode: OutputModeJSON, JSON: true},
Handler: ManifestCommandHandler{Kind: "http", Method: "POST", Path: "/tutti/cli/runs/wait"},
}},
})
if err == nil || !strings.Contains(err.Error(), "reserves --timeout-ms") {
t.Fatalf("ValidateManifest() error = %v", err)
}
}
+107
View File
@@ -0,0 +1,107 @@
package core
import (
"bytes"
"encoding/json"
"errors"
"fmt"
)
func DecodeCommandOutput(content []byte) (CommandOutput, error) {
var raw struct {
Kind OutputMode `json:"kind"`
Columns []TableColumn `json:"columns"`
Rows []map[string]any `json:"rows"`
Value map[string]any `json:"value"`
Text string `json:"text"`
Continuation *struct {
State CommandContinuationState `json:"state"`
RetryAfterMs int `json:"retryAfterMs"`
} `json:"continuation"`
}
decoder := json.NewDecoder(bytes.NewReader(content))
decoder.UseNumber()
if err := decoder.Decode(&raw); err != nil {
return CommandOutput{}, err
}
if raw.Kind == "" {
return CommandOutput{}, errors.New("cli command output kind is required")
}
output := CommandOutput{
Kind: raw.Kind,
Columns: raw.Columns,
Rows: raw.Rows,
Value: raw.Value,
Text: raw.Text,
}
if raw.Continuation != nil {
output.Continuation = &CommandContinuation{
State: raw.Continuation.State, RetryAfterMs: raw.Continuation.RetryAfterMs,
}
}
return output, nil
}
func ValidateCommandOutput(contract CapabilityOutput, output CommandOutput) (CommandOutput, error) {
switch output.Kind {
case OutputModeJSON:
if !contract.JSON {
return CommandOutput{}, invokeError(ErrHandlerBadResponse, "app_cli_handler_bad_response", errors.New("json output is not declared"))
}
case OutputModeTable:
if contract.Table == nil {
return CommandOutput{}, invokeError(ErrHandlerBadResponse, "app_cli_handler_bad_response", errors.New("table output is not declared"))
}
columns, err := normalizeOutputColumns(contract.Table.Columns, output.Columns)
if err != nil {
return CommandOutput{}, invokeError(ErrHandlerBadResponse, "app_cli_handler_bad_response", err)
}
output.Columns = columns
default:
return CommandOutput{}, invokeError(ErrHandlerBadResponse, "app_cli_handler_bad_response", fmt.Errorf("unsupported output kind %q", output.Kind))
}
return output, nil
}
func ValidateCommandContinuation(execution *CommandExecution, output CommandOutput) error {
continuation := output.Continuation
if continuation == nil {
return nil
}
if execution == nil || execution.Mode != CommandExecutionModeWait {
return invokeError(ErrHandlerBadResponse, "app_cli_handler_bad_response", errors.New("continuation is only allowed for wait commands"))
}
if output.Kind != OutputModeJSON {
return invokeError(ErrHandlerBadResponse, "app_cli_handler_bad_response", errors.New("wait continuation requires json output"))
}
if continuation.State != CommandContinuationStatePending {
return invokeError(ErrHandlerBadResponse, "app_cli_handler_bad_response", fmt.Errorf("unsupported continuation state %q", continuation.State))
}
if continuation.RetryAfterMs < MinContinuationRetryAfterMs || continuation.RetryAfterMs > MaxContinuationRetryAfterMs {
return invokeError(
ErrHandlerBadResponse,
"app_cli_handler_bad_response",
fmt.Errorf("continuation retryAfterMs must be between %d and %d", MinContinuationRetryAfterMs, MaxContinuationRetryAfterMs),
)
}
return nil
}
func normalizeOutputColumns(contract []TableColumn, actual []TableColumn) ([]TableColumn, error) {
if len(actual) == 0 {
return append([]TableColumn(nil), contract...), nil
}
contractByKey := map[string]TableColumn{}
for _, column := range contract {
contractByKey[column.Key] = column
}
result := make([]TableColumn, 0, len(actual))
for _, column := range actual {
expected, ok := contractByKey[column.Key]
if !ok || expected.Label != column.Label {
return nil, fmt.Errorf("table output column %q is not declared", column.Key)
}
result = append(result, column)
}
return result, nil
}
+66
View File
@@ -0,0 +1,66 @@
package core
import (
"errors"
"testing"
)
func TestValidateCommandOutputFillsDeclaredTableColumns(t *testing.T) {
output, err := ValidateCommandOutput(CapabilityOutput{
DefaultMode: OutputModeTable,
JSON: true,
Table: &TableOutput{Columns: []TableColumn{{Key: "id", Label: "ID"}}},
}, CommandOutput{
Kind: OutputModeTable,
Rows: []map[string]any{{"id": "job-1"}},
})
if err != nil {
t.Fatalf("ValidateCommandOutput() error = %v", err)
}
if len(output.Columns) != 1 || output.Columns[0].Key != "id" {
t.Fatalf("output = %#v", output)
}
}
func TestValidateCommandOutputRejectsUndeclaredJSON(t *testing.T) {
_, err := ValidateCommandOutput(CapabilityOutput{
DefaultMode: OutputModeTable,
Table: &TableOutput{Columns: []TableColumn{{Key: "id", Label: "ID"}}},
}, CommandOutput{Kind: OutputModeJSON, Value: map[string]any{"ok": true}})
if !errors.Is(err, ErrHandlerBadResponse) {
t.Fatalf("ValidateCommandOutput() error = %v, want ErrHandlerBadResponse", err)
}
}
func TestDecodeAndValidateWaitContinuation(t *testing.T) {
output, err := DecodeCommandOutput([]byte(`{
"kind":"json",
"value":{"status":"running"},
"continuation":{"state":"pending","retryAfterMs":500}
}`))
if err != nil {
t.Fatalf("DecodeCommandOutput() error = %v", err)
}
if err := ValidateCommandContinuation(&CommandExecution{Mode: CommandExecutionModeWait}, output); err != nil {
t.Fatalf("ValidateCommandContinuation() error = %v", err)
}
if output.Continuation == nil || output.Continuation.RetryAfterMs != 500 {
t.Fatalf("output = %#v", output)
}
}
func TestValidateCommandContinuationRejectsHotLoopAndNonWaitCommands(t *testing.T) {
output := CommandOutput{
Kind: OutputModeJSON,
Continuation: &CommandContinuation{
State: CommandContinuationStatePending, RetryAfterMs: MinContinuationRetryAfterMs - 1,
},
}
if err := ValidateCommandContinuation(&CommandExecution{Mode: CommandExecutionModeWait}, output); !errors.Is(err, ErrHandlerBadResponse) {
t.Fatalf("hot-loop error = %v", err)
}
output.Continuation.RetryAfterMs = MinContinuationRetryAfterMs
if err := ValidateCommandContinuation(nil, output); !errors.Is(err, ErrHandlerBadResponse) {
t.Fatalf("non-wait error = %v", err)
}
}
+299
View File
@@ -0,0 +1,299 @@
package core
import (
"fmt"
"sort"
"strings"
)
type Status string
const (
StatusActive Status = "active"
StatusWarning Status = "warning"
StatusError Status = "error"
)
type Issue struct {
Code string
Message string
}
type State struct {
Status Status
Scope string
Active bool
Issues []Issue
}
type ScopeIssueMessages struct {
Reserved func(scope string) Issue
Conflict func(scope string, winnerAppID string) Issue
}
type ScopeSetOptions struct {
ReservedScopes map[string]struct{}
IssueMessages ScopeIssueMessages
}
type RegisteredApp struct {
AppID string
AppName string
Scope string
BaseURL string
Commands []Command
}
type CapabilityListOptions struct {
IncludeIntegration bool
}
type CommandRef struct {
AppID string
CommandID string
}
type ScopeSet struct {
reservedScopes map[string]struct{}
issueMessages ScopeIssueMessages
entries map[string]*scopeEntry
states map[string]State
commandID map[string]CommandRef
}
type scopeEntry struct {
RegisteredApp
active bool
issues []Issue
}
func NewScopeSet(options ScopeSetOptions) *ScopeSet {
reservedScopes := map[string]struct{}{}
for scope := range options.ReservedScopes {
reservedScopes[strings.TrimSpace(scope)] = struct{}{}
}
return &ScopeSet{
reservedScopes: reservedScopes,
issueMessages: options.IssueMessages,
entries: map[string]*scopeEntry{},
states: map[string]State{},
commandID: map[string]CommandRef{},
}
}
func (s *ScopeSet) Upsert(app RegisteredApp) State {
if s == nil {
return State{}
}
s.ensure()
app.AppID = strings.TrimSpace(app.AppID)
app.Scope = strings.TrimSpace(app.Scope)
if app.AppID == "" {
return State{}
}
s.entries[app.AppID] = &scopeEntry{RegisteredApp: app}
s.recompute()
return s.State(app.AppID)
}
func (s *ScopeSet) SetError(appID string, scope string, issue Issue) State {
if s == nil {
return State{}
}
s.ensure()
appID = strings.TrimSpace(appID)
if appID == "" {
return State{}
}
delete(s.entries, appID)
s.recompute()
state := State{
Status: StatusError,
Scope: strings.TrimSpace(scope),
Active: false,
Issues: []Issue{issue},
}
s.states[appID] = state
return cloneState(state)
}
func (s *ScopeSet) Remove(appID string) {
if s == nil {
return
}
s.ensure()
delete(s.entries, strings.TrimSpace(appID))
delete(s.states, strings.TrimSpace(appID))
s.recompute()
}
func (s *ScopeSet) Capabilities(options ...CapabilityListOptions) []Capability {
if s == nil || len(s.entries) == 0 {
return []Capability{}
}
includeIntegration := false
if len(options) > 0 {
includeIntegration = options[0].IncludeIntegration
}
appIDs := make([]string, 0, len(s.entries))
for appID := range s.entries {
appIDs = append(appIDs, appID)
}
sort.Strings(appIDs)
result := make([]Capability, 0)
for _, appID := range appIDs {
entry := s.entries[appID]
if entry == nil || !entry.active {
continue
}
for _, command := range entry.Commands {
if !includeIntegration && NormalizeVisibility(command.Capability.Visibility) == CommandVisibilityIntegration {
continue
}
result = append(result, command.Capability)
}
}
return result
}
func (s *ScopeSet) Command(commandID string) (RegisteredApp, Command, bool) {
if s == nil {
return RegisteredApp{}, Command{}, false
}
ref, ok := s.commandID[strings.TrimSpace(commandID)]
if !ok {
return RegisteredApp{}, Command{}, false
}
entry := s.entries[ref.AppID]
if entry == nil || !entry.active {
return RegisteredApp{}, Command{}, false
}
for _, command := range entry.Commands {
if command.Capability.ID == ref.CommandID {
return entry.RegisteredApp, command, true
}
}
return RegisteredApp{}, Command{}, false
}
func (s *ScopeSet) UpdateBaseURL(appID string, baseURL string) {
if s == nil {
return
}
if entry := s.entries[strings.TrimSpace(appID)]; entry != nil {
entry.BaseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
}
}
func (s *ScopeSet) State(appID string) State {
if s == nil {
return State{}
}
state, ok := s.states[strings.TrimSpace(appID)]
if !ok {
return State{}
}
return cloneState(state)
}
func (s *ScopeSet) Empty() bool {
return s == nil || len(s.entries) == 0 && len(s.states) == 0
}
func (s *ScopeSet) ensure() {
if s.entries == nil {
s.entries = map[string]*scopeEntry{}
}
if s.states == nil {
s.states = map[string]State{}
}
if s.commandID == nil {
s.commandID = map[string]CommandRef{}
}
if s.reservedScopes == nil {
s.reservedScopes = map[string]struct{}{}
}
}
func (s *ScopeSet) recompute() {
s.ensure()
for commandID := range s.commandID {
delete(s.commandID, commandID)
}
for appID, state := range s.states {
if state.Status != StatusError {
delete(s.states, appID)
}
}
if len(s.entries) == 0 {
return
}
byScope := map[string][]*scopeEntry{}
for _, entry := range s.entries {
if entry == nil {
continue
}
entry.active = false
entry.issues = nil
byScope[entry.Scope] = append(byScope[entry.Scope], entry)
}
for scope, scopedEntries := range byScope {
sort.Slice(scopedEntries, func(left, right int) bool {
return scopedEntries[left].AppID < scopedEntries[right].AppID
})
if _, reserved := s.reservedScopes[scope]; reserved {
for _, entry := range scopedEntries {
entry.issues = []Issue{s.reservedIssue(scope)}
s.states[entry.AppID] = stateFromEntry(entry, StatusWarning)
}
continue
}
winner := scopedEntries[0]
winner.active = true
s.states[winner.AppID] = stateFromEntry(winner, StatusActive)
for _, command := range winner.Commands {
s.commandID[command.Capability.ID] = CommandRef{
AppID: winner.AppID,
CommandID: command.Capability.ID,
}
}
for _, loser := range scopedEntries[1:] {
loser.issues = []Issue{s.conflictIssue(scope, winner.AppID)}
s.states[loser.AppID] = stateFromEntry(loser, StatusWarning)
}
}
}
func (s *ScopeSet) reservedIssue(scope string) Issue {
if s.issueMessages.Reserved != nil {
return s.issueMessages.Reserved(scope)
}
return Issue{
Code: "app_cli_scope_reserved",
Message: fmt.Sprintf("CLI scope %q is reserved.", scope),
}
}
func (s *ScopeSet) conflictIssue(scope string, winnerAppID string) Issue {
if s.issueMessages.Conflict != nil {
return s.issueMessages.Conflict(scope, winnerAppID)
}
return Issue{
Code: "app_cli_scope_conflict",
Message: fmt.Sprintf("CLI scope %q is already provided by app %q.", scope, winnerAppID),
}
}
func stateFromEntry(entry *scopeEntry, status Status) State {
return State{
Status: status,
Scope: entry.Scope,
Active: status == StatusActive,
Issues: append([]Issue(nil), entry.issues...),
}
}
func cloneState(state State) State {
state.Issues = append([]Issue(nil), state.Issues...)
return state
}
+72
View File
@@ -0,0 +1,72 @@
package core
import "testing"
func TestScopeSetConflictKeepsDeterministicWinner(t *testing.T) {
scopeSet := NewScopeSet(ScopeSetOptions{})
scopeSet.Upsert(RegisteredApp{
AppID: "z-app",
Scope: "automation",
Commands: []Command{{
Capability: Capability{ID: "app.z-app.automation.run"},
}},
})
scopeSet.Upsert(RegisteredApp{
AppID: "a-app",
Scope: "automation",
Commands: []Command{{
Capability: Capability{ID: "app.a-app.automation.run"},
}},
})
capabilities := scopeSet.Capabilities()
if len(capabilities) != 1 || capabilities[0].ID != "app.a-app.automation.run" {
t.Fatalf("capabilities = %#v", capabilities)
}
if state := scopeSet.State("z-app"); state.Status != StatusWarning || len(state.Issues) != 1 {
t.Fatalf("loser state = %#v", state)
}
}
func TestScopeSetReservedScopeWarningsDoNotExposeCommands(t *testing.T) {
scopeSet := NewScopeSet(ScopeSetOptions{ReservedScopes: map[string]struct{}{"agent": {}}})
state := scopeSet.Upsert(RegisteredApp{
AppID: "agent-app",
Scope: "agent",
Commands: []Command{{
Capability: Capability{ID: "app.agent-app.agent.run"},
}},
})
if state.Status != StatusWarning || state.Active {
t.Fatalf("state = %#v", state)
}
if capabilities := scopeSet.Capabilities(); len(capabilities) != 0 {
t.Fatalf("capabilities = %#v", capabilities)
}
}
func TestScopeSetHidesIntegrationCommandsFromDefaultCapabilities(t *testing.T) {
scopeSet := NewScopeSet(ScopeSetOptions{})
scopeSet.Upsert(RegisteredApp{
AppID: "automation-app",
Scope: "automation",
Commands: []Command{
{Capability: Capability{ID: "app.automation.automation.list", Visibility: CommandVisibilityPublic}},
{Capability: Capability{ID: "app.automation.automation.internal-sync", Visibility: CommandVisibilityIntegration}},
},
})
capabilities := scopeSet.Capabilities()
if len(capabilities) != 1 || capabilities[0].ID != "app.automation.automation.list" {
t.Fatalf("capabilities = %#v", capabilities)
}
capabilities = scopeSet.Capabilities(CapabilityListOptions{IncludeIntegration: true})
if len(capabilities) != 2 {
t.Fatalf("capabilities with integration = %#v", capabilities)
}
if _, command, ok := scopeSet.Command("app.automation.automation.internal-sync"); !ok || command.Capability.ID == "" {
t.Fatalf("integration command lookup failed: %#v", command)
}
}
@@ -0,0 +1,59 @@
{
"version": 1,
"commands": {
"managed-model.grant.exchange": {
"argv": ["managed-model", "grant", "exchange", "--input-json", "-"],
"input": {
"contextToken": "context-test",
"grantCode": "grant-test",
"nonce": "nonce-test",
"state": "state-test"
},
"output": {
"grantRef": "grant-ref-test",
"providers": ["openai"],
"models": [
{ "id": "gpt-test", "name": "GPT Test", "provider": "openai" }
],
"expiresAt": "2026-07-12T00:10:00Z"
}
},
"managed-model.models": {
"argv": ["managed-model", "models", "--input-json", "-"],
"input": { "grantRef": "grant-ref-test" },
"output": {
"models": [
{ "id": "gpt-test", "name": "GPT Test", "provider": "openai" }
],
"expiresAt": "2026-07-12T00:10:00Z"
}
},
"managed-model.credential": {
"argv": ["managed-model", "credential", "--input-json", "-"],
"input": {
"grantRef": "grant-ref-test",
"provider": "openai",
"model": "gpt-test",
"capability": "agent"
},
"output": {
"credential": {
"provider": "openai",
"apiKey": "fixture-only-key",
"models": [
{ "id": "gpt-test", "name": "GPT Test", "provider": "openai" }
]
},
"models": [
{ "id": "gpt-test", "name": "GPT Test", "provider": "openai" }
],
"expiresAt": "2026-07-12T00:05:00Z"
}
},
"managed-model.revoke": {
"argv": ["managed-model", "revoke", "--input-json", "-"],
"input": { "grantRef": "grant-ref-test" },
"output": { "ok": true }
}
}
}
+108
View File
@@ -0,0 +1,108 @@
package core
import "time"
type OutputMode string
const (
OutputModeTable OutputMode = "table"
OutputModeJSON OutputMode = "json"
)
type CommandVisibility string
type CommandExecutionMode string
const CommandExecutionModeWait CommandExecutionMode = "wait"
type CommandExecution struct {
Mode CommandExecutionMode
}
type TableColumn struct {
Key string
Label string
}
type TableOutput struct {
Columns []TableColumn
}
type CapabilityOutput struct {
DefaultMode OutputMode
JSON bool
Table *TableOutput
}
type CapabilitySourceKind string
const (
CapabilitySourceApp CapabilitySourceKind = "app"
)
type CapabilitySource struct {
Kind CapabilitySourceKind
AppID string
AppName string
IconURL string
CLIDescription string
AppDescription string
DocumentationFile string
DocumentationPath string
}
type Capability struct {
ID string
Path []string
Summary string
Description string
Visibility CommandVisibility
InputSchema map[string]any
Output CapabilityOutput
Execution *CommandExecution
HandlerTimeoutMs int
Source CapabilitySource
}
type CommandContinuationState string
const CommandContinuationStatePending CommandContinuationState = "pending"
const (
MinContinuationRetryAfterMs = 250
MaxContinuationRetryAfterMs = 60000
)
type CommandContinuation struct {
State CommandContinuationState
RetryAfterMs int
}
type CommandOutput struct {
Kind OutputMode
Columns []TableColumn
Rows []map[string]any
Value map[string]any
Text string
Continuation *CommandContinuation
}
type InputWarning struct {
Code string `json:"code"`
Message string `json:"message"`
}
type Command struct {
Capability Capability
Manifest ManifestCommand
Timeout time.Duration
}
type CommandBuildOptions struct {
AppID string
AppName string
IconURL string
AppDescription string
DocumentationFile string
DocumentationPath string
}