源码快照 @ 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
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:
@@ -0,0 +1,36 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Scope
|
||||
|
||||
This file applies to `apps/cli/*`.
|
||||
|
||||
`apps/cli` owns the bundled `tutti` terminal entrypoint.
|
||||
|
||||
The CLI should stay thin:
|
||||
|
||||
- discover the local daemon endpoint
|
||||
- authenticate with the endpoint bearer token
|
||||
- parse terminal arguments
|
||||
- render daemon command output
|
||||
|
||||
Business rules, workspace resolution, permission filtering, command metadata, and command execution belong in `services/tuttid`.
|
||||
|
||||
## Setup Commands
|
||||
|
||||
- Run CLI tests: `go test ./...`
|
||||
- Build the CLI: `go build ./...`
|
||||
- Check generated defaults from the repository root: `pnpm check:defaults-generated`
|
||||
|
||||
## Action Rules
|
||||
|
||||
- keep daemon HTTP contract changes in `services/tuttid/api/openapi/tuttid.v1.yaml` first
|
||||
- keep the hand-written daemon transport model covered by contract tests against the OpenAPI spec
|
||||
- derive local state paths from generated defaults instead of adding new hardcoded `$HOME` paths
|
||||
- do not expose workspace flags or workspace help copy in personal edition commands
|
||||
- do not duplicate domain validation in the CLI; return daemon errors with clear terminal wording
|
||||
|
||||
## Related Docs
|
||||
|
||||
- [docs/architecture/project-structure.md](../../docs/architecture/project-structure.md)
|
||||
- [docs/architecture/desktop-transport.md](../../docs/architecture/desktop-transport.md)
|
||||
- [docs/conventions/local-state-storage.md](../../docs/conventions/local-state-storage.md)
|
||||
@@ -0,0 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"github.com/tutti-os/tutti/apps/cli/internal/app"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(app.RunWithProgram(context.Background(), os.Args[0], os.Args[1:], os.Stdout, os.Stderr))
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module github.com/tutti-os/tutti/apps/cli
|
||||
|
||||
go 1.24.3
|
||||
|
||||
toolchain go1.24.5
|
||||
@@ -0,0 +1,375 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/tutti-os/tutti/apps/cli/internal/daemon"
|
||||
)
|
||||
|
||||
func parseCommandInput(command daemon.Capability, args []string) (map[string]any, error) {
|
||||
input, err := parseCommandArguments(command, args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return typedCommandInput(command.InputSchema, input), nil
|
||||
}
|
||||
|
||||
func parseCommandArguments(command daemon.Capability, args []string) (map[string]any, error) {
|
||||
if input, ok, err := parsePositionalCommandInput(command, args); ok || err != nil {
|
||||
return input, err
|
||||
}
|
||||
return parseFlagCommandInput(command, args)
|
||||
}
|
||||
|
||||
// typedCommandInput restores the JSON types the advertised input schema
|
||||
// declares. Terminal arguments always arrive as text, and the daemon validates
|
||||
// invocation input against that schema before a command binds it, so a numeric
|
||||
// or boolean flag has to leave the CLI as a JSON number or boolean rather than
|
||||
// a quoted string.
|
||||
//
|
||||
// Values that do not parse are forwarded untouched: rejecting them here would
|
||||
// duplicate daemon-owned validation, and the daemon already reports the
|
||||
// authoritative error for the declared type.
|
||||
func typedCommandInput(schema map[string]any, input map[string]any) map[string]any {
|
||||
if len(input) == 0 {
|
||||
return input
|
||||
}
|
||||
properties, ok := schema["properties"].(map[string]any)
|
||||
if !ok {
|
||||
return input
|
||||
}
|
||||
for name, value := range input {
|
||||
text, isText := value.(string)
|
||||
if !isText {
|
||||
continue
|
||||
}
|
||||
property, declared := properties[name]
|
||||
if !declared {
|
||||
continue
|
||||
}
|
||||
if typed, ok := schemaTypedValue(schemaPropertyType(property), text); ok {
|
||||
input[name] = typed
|
||||
}
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func schemaTypedValue(propertyType string, value string) (any, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
switch propertyType {
|
||||
case "integer":
|
||||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return parsed, true
|
||||
case "number":
|
||||
parsed, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) {
|
||||
return nil, false
|
||||
}
|
||||
return parsed, true
|
||||
case "boolean":
|
||||
// Mirrors the daemon input binder, which has always accepted these
|
||||
// spellings for boolean flags.
|
||||
switch strings.ToLower(value) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true, true
|
||||
case "0", "false", "no", "off":
|
||||
return false, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func parseFlagCommandInput(command daemon.Capability, args []string) (map[string]any, error) {
|
||||
booleanFlags := commandBooleanFlags(command.InputSchema)
|
||||
arrayFlags := commandArrayFlags(command.InputSchema)
|
||||
input := map[string]any{}
|
||||
for index := 0; index < len(args); index++ {
|
||||
arg := args[index]
|
||||
if !strings.HasPrefix(arg, "--") {
|
||||
return nil, fmt.Errorf("unexpected argument %q", arg)
|
||||
}
|
||||
nameValue := strings.TrimPrefix(arg, "--")
|
||||
name, value, found := strings.Cut(nameValue, "=")
|
||||
if !found {
|
||||
if index+1 >= len(args) || strings.HasPrefix(args[index+1], "--") {
|
||||
if !booleanFlags[name] {
|
||||
return nil, fmt.Errorf("missing value for --%s", name)
|
||||
}
|
||||
input[name] = true
|
||||
continue
|
||||
} else {
|
||||
index++
|
||||
value = args[index]
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return nil, fmt.Errorf("invalid flag %q", arg)
|
||||
}
|
||||
if existing, ok := input[name]; ok {
|
||||
switch typed := existing.(type) {
|
||||
case []string:
|
||||
input[name] = append(typed, value)
|
||||
case string:
|
||||
input[name] = []string{typed, value}
|
||||
default:
|
||||
input[name] = value
|
||||
}
|
||||
continue
|
||||
}
|
||||
if arrayFlags[name] {
|
||||
input[name] = []string{value}
|
||||
continue
|
||||
}
|
||||
input[name] = value
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func commandBooleanFlags(schema map[string]any) map[string]bool {
|
||||
flags := map[string]bool{}
|
||||
properties, ok := schema["properties"].(map[string]any)
|
||||
if !ok {
|
||||
return flags
|
||||
}
|
||||
for name, property := range properties {
|
||||
if schemaPropertyType(property) == "boolean" {
|
||||
flags[name] = true
|
||||
}
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
func commandArrayFlags(schema map[string]any) map[string]bool {
|
||||
flags := map[string]bool{}
|
||||
properties, ok := schema["properties"].(map[string]any)
|
||||
if !ok {
|
||||
return flags
|
||||
}
|
||||
for name, property := range properties {
|
||||
if schemaPropertyType(property) == "array" {
|
||||
flags[name] = true
|
||||
}
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
func parsePositionalCommandInput(command daemon.Capability, args []string) (map[string]any, bool, error) {
|
||||
switch command.ID {
|
||||
case "agent-context.agent.open":
|
||||
if len(args) != 1 || strings.HasPrefix(args[0], "--") {
|
||||
return nil, false, nil
|
||||
}
|
||||
return map[string]any{"session-id": args[0]}, true, nil
|
||||
case "agent-context.agent.send":
|
||||
if len(args) < 2 || strings.HasPrefix(args[0], "--") {
|
||||
return nil, false, nil
|
||||
}
|
||||
if flagIndex := firstKnownFlagIndex(command.InputSchema, args[1:]); flagIndex >= 0 {
|
||||
input, err := parseFlagCommandInput(command, args[1+flagIndex:])
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
if flagIndex > 0 {
|
||||
input["prompt"] = strings.Join(args[1:1+flagIndex], " ")
|
||||
}
|
||||
input["session-id"] = args[0]
|
||||
return input, true, nil
|
||||
}
|
||||
return map[string]any{
|
||||
"session-id": args[0],
|
||||
"prompt": strings.Join(args[1:], " "),
|
||||
}, true, nil
|
||||
default:
|
||||
return nil, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func firstKnownFlagIndex(schema map[string]any, args []string) int {
|
||||
properties, ok := schema["properties"].(map[string]any)
|
||||
if !ok {
|
||||
return -1
|
||||
}
|
||||
for index, arg := range args {
|
||||
if !strings.HasPrefix(arg, "--") {
|
||||
continue
|
||||
}
|
||||
nameValue := strings.TrimPrefix(arg, "--")
|
||||
name, _, _ := strings.Cut(nameValue, "=")
|
||||
if _, ok := properties[name]; ok {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
type commandFlag struct {
|
||||
Name string
|
||||
Type string
|
||||
Description string
|
||||
Required bool
|
||||
Values []string
|
||||
Default string
|
||||
HasDefault bool
|
||||
}
|
||||
|
||||
func commandFlags(schema map[string]any) []commandFlag {
|
||||
properties, ok := schema["properties"].(map[string]any)
|
||||
if !ok || len(properties) == 0 {
|
||||
return nil
|
||||
}
|
||||
requiredNames := schemaRequiredNames(schema)
|
||||
required := map[string]bool{}
|
||||
for _, name := range requiredNames {
|
||||
required[name] = true
|
||||
}
|
||||
|
||||
flags := make([]commandFlag, 0, len(properties))
|
||||
for _, name := range requiredNames {
|
||||
property, ok := properties[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
flags = append(flags, commandFlag{
|
||||
Name: name,
|
||||
Type: schemaPropertyType(property),
|
||||
Description: schemaPropertyDescription(property),
|
||||
Required: true,
|
||||
Values: schemaPropertyEnumValues(property),
|
||||
Default: schemaPropertyDefault(property),
|
||||
HasDefault: schemaPropertyHasDefault(property),
|
||||
})
|
||||
}
|
||||
|
||||
optionalNames := make([]string, 0, len(properties))
|
||||
for name := range properties {
|
||||
if !required[name] {
|
||||
optionalNames = append(optionalNames, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(optionalNames)
|
||||
for _, name := range optionalNames {
|
||||
flags = append(flags, commandFlag{
|
||||
Name: name,
|
||||
Type: schemaPropertyType(properties[name]),
|
||||
Description: schemaPropertyDescription(properties[name]),
|
||||
Values: schemaPropertyEnumValues(properties[name]),
|
||||
Default: schemaPropertyDefault(properties[name]),
|
||||
HasDefault: schemaPropertyHasDefault(properties[name]),
|
||||
})
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
func schemaRequiredNames(schema map[string]any) []string {
|
||||
value, ok := schema["required"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case []string:
|
||||
return typed
|
||||
case []any:
|
||||
names := make([]string, 0, len(typed))
|
||||
for _, entry := range typed {
|
||||
name, ok := entry.(string)
|
||||
if ok {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func schemaPropertyType(property any) string {
|
||||
propertyMap, ok := property.(map[string]any)
|
||||
if !ok {
|
||||
return "value"
|
||||
}
|
||||
typeName, ok := propertyMap["type"].(string)
|
||||
if !ok || strings.TrimSpace(typeName) == "" {
|
||||
return "value"
|
||||
}
|
||||
return typeName
|
||||
}
|
||||
|
||||
func schemaPropertyDescription(property any) string {
|
||||
propertyMap, ok := property.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
description, ok := propertyMap["description"].(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(description)
|
||||
}
|
||||
|
||||
func schemaPropertyEnumValues(property any) []string {
|
||||
propertyMap, ok := property.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
value, ok := propertyMap["enum"].([]any)
|
||||
if ok {
|
||||
values := make([]string, 0, len(value))
|
||||
for _, item := range value {
|
||||
values = append(values, formatSchemaValue(item))
|
||||
}
|
||||
return values
|
||||
}
|
||||
stringValues, ok := propertyMap["enum"].([]string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
values := make([]string, 0, len(stringValues))
|
||||
values = append(values, stringValues...)
|
||||
return values
|
||||
}
|
||||
|
||||
func schemaPropertyDefault(property any) string {
|
||||
propertyMap, ok := property.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
value, ok := propertyMap["default"]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return formatSchemaValue(value)
|
||||
}
|
||||
|
||||
func schemaPropertyHasDefault(property any) bool {
|
||||
propertyMap, ok := property.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
_, ok = propertyMap["default"]
|
||||
return ok
|
||||
}
|
||||
|
||||
func formatSchemaValue(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case json.Number:
|
||||
return typed.String()
|
||||
case float64:
|
||||
if typed == float64(int64(typed)) {
|
||||
return fmt.Sprintf("%.0f", typed)
|
||||
}
|
||||
return fmt.Sprint(typed)
|
||||
default:
|
||||
return fmt.Sprint(value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/tutti-os/tutti/apps/cli/internal/daemon"
|
||||
)
|
||||
|
||||
const (
|
||||
reasonCommandNotFound = "command_not_found"
|
||||
reasonCommandOutputMissing = "command_output_missing"
|
||||
reasonDaemonRequestFailed = "daemon_request_failed"
|
||||
reasonDaemonUnavailable = "daemon_unavailable"
|
||||
reasonInvalidInput = "invalid_input"
|
||||
)
|
||||
|
||||
type cliErrorEnvelope struct {
|
||||
Error cliErrorDetails `json:"error"`
|
||||
}
|
||||
|
||||
type cliErrorDetails struct {
|
||||
ReasonCode string `json:"reasonCode"`
|
||||
Message string `json:"message"`
|
||||
Retryable bool `json:"retryable,omitempty"`
|
||||
CorrelationID string `json:"correlationId,omitempty"`
|
||||
}
|
||||
|
||||
func jsonRequested(args []string) bool {
|
||||
for _, arg := range args {
|
||||
if arg == "--json" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func writeCLIError(
|
||||
stdout io.Writer,
|
||||
stderr io.Writer,
|
||||
jsonOutput bool,
|
||||
prefix string,
|
||||
reasonCode string,
|
||||
err error,
|
||||
exitCode int,
|
||||
) int {
|
||||
message := strings.TrimSpace(errorMessage(err))
|
||||
if jsonOutput {
|
||||
details := cliErrorDetails{
|
||||
ReasonCode: strings.TrimSpace(reasonCode),
|
||||
Message: message,
|
||||
}
|
||||
if daemonDetails, ok := daemon.RequestErrorDetails(err); ok {
|
||||
if daemonDetails.ReasonCode != "" {
|
||||
details.ReasonCode = daemonDetails.ReasonCode
|
||||
}
|
||||
if daemonDetails.Message != "" {
|
||||
details.Message = daemonDetails.Message
|
||||
}
|
||||
details.Retryable = daemonDetails.Retryable
|
||||
details.CorrelationID = daemonDetails.CorrelationID
|
||||
}
|
||||
if details.ReasonCode == "" {
|
||||
details.ReasonCode = reasonInvalidInput
|
||||
}
|
||||
if details.Message == "" {
|
||||
details.Message = details.ReasonCode
|
||||
}
|
||||
if code := writeJSON(stdout, stderr, cliErrorEnvelope{Error: details}); code != 0 {
|
||||
return code
|
||||
}
|
||||
return exitCode
|
||||
}
|
||||
if prefix == "" {
|
||||
fmt.Fprintln(stderr, message)
|
||||
} else {
|
||||
fmt.Fprintf(stderr, "%s: %s\n", strings.TrimSpace(prefix), message)
|
||||
}
|
||||
return exitCode
|
||||
}
|
||||
|
||||
func daemonErrorExitCode(err error) int {
|
||||
details, ok := daemon.RequestErrorDetails(err)
|
||||
if ok && details.StatusCode == 400 {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func errorMessage(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/tutti-os/tutti/apps/cli/internal/daemon"
|
||||
)
|
||||
|
||||
const managedModelInputLimitBytes = 64 * 1024
|
||||
|
||||
const (
|
||||
managedModelExchangeCommandID = "managed-model.grant.exchange"
|
||||
managedModelModelsCommandID = "managed-model.models"
|
||||
managedModelCredentialCommandID = "managed-model.credential"
|
||||
managedModelRevokeCommandID = "managed-model.revoke"
|
||||
)
|
||||
|
||||
var managedModelInputReader = func() io.Reader { return os.Stdin }
|
||||
|
||||
type managedModelExchangeInput struct {
|
||||
ContextToken string `json:"contextToken"`
|
||||
GrantCode string `json:"grantCode"`
|
||||
Nonce string `json:"nonce"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
type managedModelGrantRefInput struct {
|
||||
GrantRef string `json:"grantRef"`
|
||||
}
|
||||
|
||||
type managedModelCredentialInput struct {
|
||||
Capability string `json:"capability"`
|
||||
GrantRef string `json:"grantRef"`
|
||||
Model string `json:"model"`
|
||||
Provider string `json:"provider"`
|
||||
}
|
||||
|
||||
func runManagedModel(ctx context.Context, commandName string, opts options, args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
commandID, input, err := parseManagedModelInput(args)
|
||||
if err != nil {
|
||||
return writeCLIError(stdout, stderr, opts.json, commandName+" managed-model", reasonInvalidInput, err, 2)
|
||||
}
|
||||
client, err := discoverClient()
|
||||
if err != nil {
|
||||
return writeCLIError(stdout, stderr, opts.json, commandName+" managed-model", reasonDaemonUnavailable, err, 1)
|
||||
}
|
||||
response, err := client.Invoke(ctx, commandID, daemon.InvokeRequest{
|
||||
Input: input,
|
||||
OutputMode: "json",
|
||||
Context: cliInvokeContextFromEnv(),
|
||||
})
|
||||
if err != nil {
|
||||
return writeCLIError(stdout, stderr, opts.json, commandName+" managed-model", reasonDaemonRequestFailed, err, daemonErrorExitCode(err))
|
||||
}
|
||||
if response.Output == nil {
|
||||
return writeCLIError(
|
||||
stdout, stderr, opts.json, commandName+" managed-model", reasonCommandOutputMissing,
|
||||
fmt.Errorf("command returned no output"), 1,
|
||||
)
|
||||
}
|
||||
return writeDynamicJSON(stdout, stderr, *response.Output)
|
||||
}
|
||||
|
||||
func parseManagedModelInput(args []string) (string, map[string]any, error) {
|
||||
commandID, payloadArgs, err := managedModelCommand(args)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if len(payloadArgs) != 2 || payloadArgs[0] != "--input-json" || payloadArgs[1] != "-" {
|
||||
return "", nil, fmt.Errorf("usage: managed-model %s --input-json -", strings.Join(args[:len(args)-len(payloadArgs)], " "))
|
||||
}
|
||||
switch commandID {
|
||||
case managedModelExchangeCommandID:
|
||||
var input managedModelExchangeInput
|
||||
if err := decodeManagedModelInput(&input); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
normalized, err := requiredManagedModelFields(map[string]any{
|
||||
"contextToken": input.ContextToken,
|
||||
"grantCode": input.GrantCode,
|
||||
"nonce": input.Nonce,
|
||||
"state": input.State,
|
||||
})
|
||||
return commandID, normalized, err
|
||||
case managedModelModelsCommandID, managedModelRevokeCommandID:
|
||||
var input managedModelGrantRefInput
|
||||
if err := decodeManagedModelInput(&input); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
normalized, err := requiredManagedModelFields(map[string]any{"grantRef": input.GrantRef})
|
||||
return commandID, normalized, err
|
||||
case managedModelCredentialCommandID:
|
||||
var input managedModelCredentialInput
|
||||
if err := decodeManagedModelInput(&input); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
normalized, err := requiredManagedModelFields(map[string]any{
|
||||
"capability": input.Capability,
|
||||
"grantRef": input.GrantRef,
|
||||
"model": input.Model,
|
||||
"provider": input.Provider,
|
||||
})
|
||||
return commandID, normalized, err
|
||||
default:
|
||||
return "", nil, fmt.Errorf("unsupported managed-model command")
|
||||
}
|
||||
}
|
||||
|
||||
func managedModelCommand(args []string) (string, []string, error) {
|
||||
if len(args) >= 2 && args[0] == "grant" && args[1] == "exchange" {
|
||||
return managedModelExchangeCommandID, args[2:], nil
|
||||
}
|
||||
if len(args) >= 1 && args[0] == "models" {
|
||||
return managedModelModelsCommandID, args[1:], nil
|
||||
}
|
||||
if len(args) >= 1 && args[0] == "credential" {
|
||||
return managedModelCredentialCommandID, args[1:], nil
|
||||
}
|
||||
if len(args) >= 1 && args[0] == "revoke" {
|
||||
return managedModelRevokeCommandID, args[1:], nil
|
||||
}
|
||||
return "", nil, fmt.Errorf("expected grant exchange, models, credential, or revoke")
|
||||
}
|
||||
|
||||
func decodeManagedModelInput(target any) error {
|
||||
reader := io.LimitReader(managedModelInputReader(), managedModelInputLimitBytes+1)
|
||||
content, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read input: %w", err)
|
||||
}
|
||||
if len(content) > managedModelInputLimitBytes {
|
||||
return fmt.Errorf("input exceeds %d bytes", managedModelInputLimitBytes)
|
||||
}
|
||||
decoder := json.NewDecoder(strings.NewReader(string(content)))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return fmt.Errorf("invalid input JSON: %w", err)
|
||||
}
|
||||
if decoder.Decode(&struct{}{}) != io.EOF {
|
||||
return fmt.Errorf("input must contain exactly one JSON object")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requiredManagedModelFields(input map[string]any) (map[string]any, error) {
|
||||
for key, value := range input {
|
||||
text, ok := value.(string)
|
||||
if !ok || strings.TrimSpace(text) == "" {
|
||||
return nil, fmt.Errorf("%s is required", key)
|
||||
}
|
||||
input[key] = strings.TrimSpace(text)
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseManagedModelExchangeInput(t *testing.T) {
|
||||
previous := managedModelInputReader
|
||||
managedModelInputReader = func() io.Reader {
|
||||
return strings.NewReader(`{"contextToken":"context-1","grantCode":"code-1","nonce":"nonce-1","state":"state-1"}`)
|
||||
}
|
||||
t.Cleanup(func() { managedModelInputReader = previous })
|
||||
|
||||
commandID, input, err := parseManagedModelInput([]string{"grant", "exchange", "--input-json", "-"})
|
||||
if err != nil {
|
||||
t.Fatalf("parseManagedModelInput: %v", err)
|
||||
}
|
||||
if commandID != managedModelExchangeCommandID {
|
||||
t.Fatalf("commandID = %q", commandID)
|
||||
}
|
||||
if input["grantCode"] != "code-1" || input["contextToken"] != "context-1" {
|
||||
t.Fatalf("input = %#v", input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedModelProtocolFixtureParsesExchangeInput(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "..", "..", "packages", "appcli", "core", "testdata", "managed-model", "protocol.v1.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fixture struct {
|
||||
Commands map[string]struct {
|
||||
Input json.RawMessage `json:"input"`
|
||||
} `json:"commands"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &fixture); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previous := managedModelInputReader
|
||||
managedModelInputReader = func() io.Reader {
|
||||
return strings.NewReader(string(fixture.Commands[managedModelExchangeCommandID].Input))
|
||||
}
|
||||
t.Cleanup(func() { managedModelInputReader = previous })
|
||||
commandID, input, err := parseManagedModelInput([]string{"grant", "exchange", "--input-json", "-"})
|
||||
if err != nil || commandID != managedModelExchangeCommandID || input["grantCode"] != "grant-test" {
|
||||
t.Fatalf("parse fixture commandID=%q input=%#v err=%v", commandID, input, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManagedModelInputRejectsUnknownFields(t *testing.T) {
|
||||
previous := managedModelInputReader
|
||||
managedModelInputReader = func() io.Reader {
|
||||
return strings.NewReader(`{"grantRef":"grant-1","extra":"no"}`)
|
||||
}
|
||||
t.Cleanup(func() { managedModelInputReader = previous })
|
||||
|
||||
_, _, err := parseManagedModelInput([]string{"models", "--input-json", "-"})
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown field") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManagedModelCredentialRejectsUnsupportedModelPlanID(t *testing.T) {
|
||||
previous := managedModelInputReader
|
||||
managedModelInputReader = func() io.Reader {
|
||||
return strings.NewReader(`{"capability":"chat","grantRef":"grant-1","model":"gpt","modelPlanId":"plan-1","provider":"openai"}`)
|
||||
}
|
||||
t.Cleanup(func() { managedModelInputReader = previous })
|
||||
|
||||
_, _, err := parseManagedModelInput([]string{"credential", "--input-json", "-"})
|
||||
if err == nil || !strings.Contains(err.Error(), `unknown field "modelPlanId"`) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,837 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/tutti-os/tutti/apps/cli/internal/daemon"
|
||||
"github.com/tutti-os/tutti/apps/cli/internal/defaults"
|
||||
)
|
||||
|
||||
const prefixHelpGroupPreviewLimit = 5
|
||||
const dynamicStdinInputLimitBytes = 1024 * 1024
|
||||
|
||||
var dynamicInputReader = func() io.Reader { return os.Stdin }
|
||||
|
||||
type options struct {
|
||||
json bool
|
||||
}
|
||||
|
||||
func RunWithProgram(ctx context.Context, program string, args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
commandName := displayCommandName(program)
|
||||
opts, rest, err := parseOptions(args)
|
||||
if err != nil {
|
||||
return writeCLIError(stdout, stderr, jsonRequested(args), "", reasonInvalidInput, err, 2)
|
||||
}
|
||||
if len(rest) == 0 || rest[0] == "help" || rest[0] == "--help" || rest[0] == "-h" {
|
||||
return runHelp(ctx, commandName, stdout)
|
||||
}
|
||||
|
||||
switch rest[0] {
|
||||
case "status":
|
||||
if len(rest) != 1 {
|
||||
return writeCLIError(
|
||||
stdout, stderr, opts.json, "", reasonInvalidInput,
|
||||
fmt.Errorf("usage: %s status [--json]", commandName), 2,
|
||||
)
|
||||
}
|
||||
return runStatus(ctx, commandName, opts, stdout, stderr)
|
||||
case "managed-model":
|
||||
return runManagedModel(ctx, commandName, opts, rest[1:], stdout, stderr)
|
||||
default:
|
||||
return runDynamic(ctx, commandName, opts, rest, stdout, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func displayCommandName(program string) string {
|
||||
name := filepath.Base(strings.TrimSpace(program))
|
||||
if name == "." || name == "" {
|
||||
name = "tutti"
|
||||
}
|
||||
if strings.EqualFold(name, "tutti") && isDevelopmentCLI(program) {
|
||||
return "tutti-dev"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func isDevelopmentCLI(program string) bool {
|
||||
cleanProgram := filepath.Clean(program)
|
||||
devBuildSegment := "build" + string(filepath.Separator) + "dev" + string(filepath.Separator)
|
||||
if strings.HasPrefix(cleanProgram, devBuildSegment) ||
|
||||
strings.Contains(cleanProgram, string(filepath.Separator)+devBuildSegment) {
|
||||
return true
|
||||
}
|
||||
return defaults.ResolveDefaultsFromEnv().Runtime.Env == "development"
|
||||
}
|
||||
|
||||
func parseOptions(args []string) (options, []string, error) {
|
||||
var opts options
|
||||
rest := make([]string, 0, len(args))
|
||||
for _, arg := range args {
|
||||
switch arg {
|
||||
case "--json":
|
||||
opts.json = true
|
||||
default:
|
||||
rest = append(rest, arg)
|
||||
}
|
||||
}
|
||||
return opts, rest, nil
|
||||
}
|
||||
|
||||
func runStatus(ctx context.Context, commandName string, opts options, stdout io.Writer, stderr io.Writer) int {
|
||||
client, err := discoverClient()
|
||||
if err != nil {
|
||||
return writeCLIError(stdout, stderr, opts.json, commandName+" status", reasonDaemonUnavailable, err, 1)
|
||||
}
|
||||
health, err := client.GetHealth(ctx)
|
||||
if err != nil {
|
||||
return writeCLIError(stdout, stderr, opts.json, commandName+" status", reasonDaemonRequestFailed, err, daemonErrorExitCode(err))
|
||||
}
|
||||
|
||||
if opts.json {
|
||||
return writeJSON(stdout, stderr, health)
|
||||
}
|
||||
|
||||
fmt.Fprintf(stdout, "service: %s\nstatus: %s\n", health.Service, health.Status)
|
||||
return 0
|
||||
}
|
||||
|
||||
func runDynamic(ctx context.Context, commandName string, opts options, args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
invocationPrefix := strings.TrimSpace(commandName + " " + strings.Join(args, " "))
|
||||
client, err := discoverClient()
|
||||
if err != nil {
|
||||
return writeCLIError(stdout, stderr, opts.json, invocationPrefix, reasonDaemonUnavailable, err, 1)
|
||||
}
|
||||
invokeContext := cliInvokeContextFromEnv()
|
||||
capabilities, err := client.ListCapabilitiesForWorkspaceWithOptions(ctx, invokeContext.WorkspaceID, daemon.CapabilityListOptions{
|
||||
IncludeIntegration: includeIntegrationCapabilitiesFromEnv(),
|
||||
AgentSessionID: invokeContext.AgentSessionID,
|
||||
})
|
||||
if err != nil {
|
||||
return writeCLIError(stdout, stderr, opts.json, invocationPrefix, reasonDaemonRequestFailed, err, daemonErrorExitCode(err))
|
||||
}
|
||||
command, commandArgs, ok := matchCapability(capabilities.Commands, args)
|
||||
if !ok && legacyAgentCompatibilityInvocation(args) && !includeIntegrationCapabilitiesFromEnv() {
|
||||
compatibilities, listErr := client.ListCapabilitiesForWorkspaceWithOptions(ctx, invokeContext.WorkspaceID, daemon.CapabilityListOptions{
|
||||
IncludeIntegration: true,
|
||||
AgentSessionID: invokeContext.AgentSessionID,
|
||||
})
|
||||
if listErr == nil {
|
||||
command, commandArgs, ok = matchCapability(compatibilities.Commands, args)
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
if prefix, help := commandHelpPrefix(args); help {
|
||||
if printCommandPrefixHelp(stdout, commandName, prefix, capabilities.Commands) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
if !opts.json && printCommandPrefixHelp(stdout, commandName, args, capabilities.Commands) {
|
||||
return 2
|
||||
}
|
||||
return writeCLIError(
|
||||
stdout, stderr, opts.json, "", reasonCommandNotFound,
|
||||
fmt.Errorf("unknown command: %s", strings.Join(args, " ")), 2,
|
||||
)
|
||||
}
|
||||
if isCommandHelpRequest(commandArgs) {
|
||||
printDynamicCommandHelp(stdout, commandName, command)
|
||||
return 0
|
||||
}
|
||||
waitOptions, commandArgs, err := parseWaitOptions(command, commandArgs)
|
||||
if err != nil {
|
||||
prefix := strings.TrimSpace(commandName + " " + strings.Join(command.Path, " "))
|
||||
return writeCLIError(stdout, stderr, opts.json, prefix, reasonInvalidInput, err, 2)
|
||||
}
|
||||
input, err := parseCommandInput(command, commandArgs)
|
||||
if err != nil {
|
||||
prefix := strings.TrimSpace(commandName + " " + strings.Join(command.Path, " "))
|
||||
return writeCLIError(stdout, stderr, opts.json, prefix, reasonInvalidInput, err, 2)
|
||||
}
|
||||
input, err = hydrateDynamicStdinInput(command, input)
|
||||
if err != nil {
|
||||
prefix := strings.TrimSpace(commandName + " " + strings.Join(command.Path, " "))
|
||||
return writeCLIError(stdout, stderr, opts.json, prefix, reasonInvalidInput, err, 2)
|
||||
}
|
||||
outputMode := command.Output.DefaultMode
|
||||
if opts.json {
|
||||
outputMode = "json"
|
||||
}
|
||||
response, err := invokeDynamicCommand(ctx, client, command, daemon.InvokeRequest{
|
||||
Input: input,
|
||||
OutputMode: outputMode,
|
||||
Context: invokeContext,
|
||||
}, waitOptions)
|
||||
if err != nil {
|
||||
prefix := strings.TrimSpace(commandName + " " + strings.Join(command.Path, " "))
|
||||
return writeCLIError(stdout, stderr, opts.json, prefix, reasonDaemonRequestFailed, err, daemonErrorExitCode(err))
|
||||
}
|
||||
if response.Output == nil {
|
||||
return 0
|
||||
}
|
||||
if opts.json {
|
||||
return writeDynamicJSON(stdout, stderr, *response.Output)
|
||||
}
|
||||
return writeCommandOutput(stdout, stderr, *response.Output)
|
||||
}
|
||||
|
||||
func hydrateDynamicStdinInput(command daemon.Capability, input map[string]any) (map[string]any, error) {
|
||||
value, _ := input["arguments-json"].(string)
|
||||
if strings.TrimSpace(value) != "-" {
|
||||
return input, nil
|
||||
}
|
||||
properties, _ := command.InputSchema["properties"].(map[string]any)
|
||||
if properties["arguments-json"] == nil {
|
||||
return nil, fmt.Errorf("--arguments-json - is not supported by this command")
|
||||
}
|
||||
content, err := io.ReadAll(io.LimitReader(dynamicInputReader(), dynamicStdinInputLimitBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read standard input: %w", err)
|
||||
}
|
||||
if len(content) > dynamicStdinInputLimitBytes {
|
||||
return nil, fmt.Errorf("standard input exceeds %d bytes", dynamicStdinInputLimitBytes)
|
||||
}
|
||||
if strings.TrimSpace(string(content)) == "" {
|
||||
return nil, fmt.Errorf("standard input must contain one JSON object")
|
||||
}
|
||||
next := make(map[string]any, len(input))
|
||||
for key, value := range input {
|
||||
next[key] = value
|
||||
}
|
||||
next["arguments-json"] = string(content)
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func legacyAgentCompatibilityInvocation(args []string) bool {
|
||||
if len(args) < 2 {
|
||||
return false
|
||||
}
|
||||
path := strings.Join(args[:2], " ")
|
||||
return path == "agent providers" || path == "agent cancel" || path == "agent session-summary" ||
|
||||
path == "codex start" || path == "claude start"
|
||||
}
|
||||
|
||||
func runHelp(ctx context.Context, commandName string, stdout io.Writer) int {
|
||||
var commands []daemon.Capability
|
||||
client, err := discoverClient()
|
||||
if err == nil {
|
||||
invokeContext := cliInvokeContextFromEnv()
|
||||
capabilities, listErr := client.ListCapabilitiesForWorkspaceWithOptions(ctx, invokeContext.WorkspaceID, daemon.CapabilityListOptions{
|
||||
IncludeIntegration: includeIntegrationCapabilitiesFromEnv(),
|
||||
AgentSessionID: invokeContext.AgentSessionID,
|
||||
})
|
||||
if listErr == nil {
|
||||
commands = capabilities.Commands
|
||||
}
|
||||
}
|
||||
printHelp(stdout, commandName, commands)
|
||||
return 0
|
||||
}
|
||||
|
||||
func includeIntegrationCapabilitiesFromEnv() bool {
|
||||
return strings.TrimSpace(os.Getenv("TUTTI_APP_ID")) != "" &&
|
||||
strings.TrimSpace(os.Getenv("TUTTI_CLI")) != ""
|
||||
}
|
||||
|
||||
func cliInvokeContextFromEnv() daemon.InvokeContext {
|
||||
return daemon.InvokeContext{
|
||||
AppID: strings.TrimSpace(os.Getenv("TUTTI_APP_ID")),
|
||||
Source: "cli",
|
||||
WorkspaceID: strings.TrimSpace(os.Getenv("TUTTI_WORKSPACE_ID")),
|
||||
ParentCommandID: strings.TrimSpace(os.Getenv("TUTTI_APP_CLI_PARENT_COMMAND_ID")),
|
||||
AgentSessionID: strings.TrimSpace(os.Getenv("TUTTI_AGENT_SESSION_ID")),
|
||||
AgentCWD: strings.TrimSpace(os.Getenv("TUTTI_AGENT_CWD")),
|
||||
AgentRailPlacementJSON: strings.TrimSpace(
|
||||
os.Getenv("TUTTI_AGENT_RAIL_PLACEMENT"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func discoverClient() (*daemon.Client, error) {
|
||||
endpoint, err := daemon.DiscoverEndpoint()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, err := daemon.NewClient(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func matchCapability(commands []daemon.Capability, args []string) (daemon.Capability, []string, bool) {
|
||||
var matchedCommand daemon.Capability
|
||||
var matchedArgs []string
|
||||
matchedLength := -1
|
||||
for _, command := range commands {
|
||||
if len(args) < len(command.Path) {
|
||||
continue
|
||||
}
|
||||
matched := true
|
||||
for index, segment := range command.Path {
|
||||
if args[index] != segment {
|
||||
matched = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched && len(command.Path) > matchedLength {
|
||||
matchedCommand = command
|
||||
matchedArgs = args[len(command.Path):]
|
||||
matchedLength = len(command.Path)
|
||||
}
|
||||
}
|
||||
if matchedLength >= 0 {
|
||||
return matchedCommand, matchedArgs, true
|
||||
}
|
||||
return daemon.Capability{}, nil, false
|
||||
}
|
||||
|
||||
func isCommandHelpRequest(args []string) bool {
|
||||
return len(args) == 1 && (args[0] == "--help" || args[0] == "-h")
|
||||
}
|
||||
|
||||
func commandHelpPrefix(args []string) ([]string, bool) {
|
||||
if len(args) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
last := args[len(args)-1]
|
||||
if last != "--help" && last != "-h" {
|
||||
return nil, false
|
||||
}
|
||||
prefix := args[:len(args)-1]
|
||||
if len(prefix) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return prefix, true
|
||||
}
|
||||
|
||||
func writeCommandOutput(stdout io.Writer, stderr io.Writer, output daemon.CommandOutput) int {
|
||||
switch output.Kind {
|
||||
case "plain", "markdown":
|
||||
writeCommandWarnings(stderr, output.Warnings)
|
||||
fmt.Fprintln(stdout, output.Text)
|
||||
return 0
|
||||
case "table":
|
||||
writeCommandWarnings(stderr, output.Warnings)
|
||||
writeTable(stdout, output.Columns, output.Rows)
|
||||
return 0
|
||||
case "json":
|
||||
return writeDynamicJSON(stdout, stderr, output)
|
||||
default:
|
||||
return writeJSON(stdout, stderr, output)
|
||||
}
|
||||
}
|
||||
|
||||
func writeDynamicJSON(stdout io.Writer, stderr io.Writer, output daemon.CommandOutput) int {
|
||||
if len(output.Warnings) > 0 {
|
||||
envelope := map[string]any{
|
||||
"warnings": output.Warnings,
|
||||
}
|
||||
if len(output.Value) > 0 {
|
||||
envelope["value"] = output.Value
|
||||
} else if output.Rows != nil {
|
||||
envelope["rows"] = output.Rows
|
||||
} else {
|
||||
envelope["output"] = output
|
||||
}
|
||||
return writeJSON(stdout, stderr, envelope)
|
||||
}
|
||||
if len(output.Value) > 0 {
|
||||
return writeJSON(stdout, stderr, output.Value)
|
||||
}
|
||||
if output.Rows != nil {
|
||||
return writeJSON(stdout, stderr, output.Rows)
|
||||
}
|
||||
return writeJSON(stdout, stderr, output)
|
||||
}
|
||||
|
||||
func writeCommandWarnings(stderr io.Writer, warnings []daemon.CommandWarning) {
|
||||
for _, warning := range warnings {
|
||||
message := strings.TrimSpace(warning.Message)
|
||||
if message == "" {
|
||||
message = strings.TrimSpace(warning.Code)
|
||||
}
|
||||
if message != "" {
|
||||
fmt.Fprintf(stderr, "warning: %s\n", message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(stdout io.Writer, stderr io.Writer, value any) int {
|
||||
encoded, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "encode output: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintln(stdout, string(encoded))
|
||||
return 0
|
||||
}
|
||||
|
||||
func writeTable(stdout io.Writer, columns []daemon.TableColumn, rows []map[string]any) {
|
||||
if len(columns) == 0 {
|
||||
for _, row := range rows {
|
||||
fmt.Fprintln(stdout, row)
|
||||
}
|
||||
return
|
||||
}
|
||||
widths := make([]int, len(columns))
|
||||
for index, column := range columns {
|
||||
widths[index] = len(column.Label)
|
||||
for _, row := range rows {
|
||||
width := len(fmt.Sprint(row[column.Key]))
|
||||
if width > widths[index] {
|
||||
widths[index] = width
|
||||
}
|
||||
}
|
||||
}
|
||||
for index, column := range columns {
|
||||
if index > 0 {
|
||||
fmt.Fprint(stdout, " ")
|
||||
}
|
||||
fmt.Fprintf(stdout, "%-*s", widths[index], column.Label)
|
||||
}
|
||||
fmt.Fprintln(stdout)
|
||||
for index := range columns {
|
||||
if index > 0 {
|
||||
fmt.Fprint(stdout, " ")
|
||||
}
|
||||
fmt.Fprint(stdout, strings.Repeat("-", widths[index]))
|
||||
}
|
||||
fmt.Fprintln(stdout)
|
||||
for _, row := range rows {
|
||||
for index, column := range columns {
|
||||
if index > 0 {
|
||||
fmt.Fprint(stdout, " ")
|
||||
}
|
||||
fmt.Fprintf(stdout, "%-*s", widths[index], fmt.Sprint(row[column.Key]))
|
||||
}
|
||||
fmt.Fprintln(stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func printHelp(stdout io.Writer, commandName string, commands []daemon.Capability) {
|
||||
fmt.Fprintf(stdout, "Usage: %s [--json] <command>\n", commandName)
|
||||
fmt.Fprintln(stdout)
|
||||
fmt.Fprintln(stdout, "Commands:")
|
||||
rows := []helpCommandRow{{Name: "status", Summary: "Show local tuttid status"}}
|
||||
rows = append(rows, rootHelpRows(commands)...)
|
||||
printHelpRows(stdout, rows)
|
||||
printIntegrationOnlyNotice(stdout, commands)
|
||||
}
|
||||
|
||||
func printCommandPrefixHelp(stdout io.Writer, commandName string, prefix []string, commands []daemon.Capability) bool {
|
||||
matches := matchingPrefixCommands(commands, prefix)
|
||||
if len(matches) == 0 {
|
||||
return false
|
||||
}
|
||||
fmt.Fprintf(stdout, "Usage: %s %s <command>\n", commandName, strings.Join(prefix, " "))
|
||||
fmt.Fprintln(stdout)
|
||||
fmt.Fprintln(stdout, "Commands:")
|
||||
printHelpRows(stdout, prefixHelpRows(prefix, matches))
|
||||
printIntegrationOnlyNotice(stdout, matches)
|
||||
fmt.Fprintln(stdout)
|
||||
fmt.Fprintf(stdout, "Use \"%s %s <command> --help\" for command details.\n", commandName, strings.Join(prefix, " "))
|
||||
printDocumentationHints(stdout, matches)
|
||||
return true
|
||||
}
|
||||
|
||||
func printDynamicCommandHelp(stdout io.Writer, commandName string, command daemon.Capability) {
|
||||
flags := waitCommandFlags(command, commandFlags(command.InputSchema))
|
||||
fmt.Fprintf(stdout, "Usage: %s %s", commandName, strings.Join(command.Path, " "))
|
||||
if command.Output.JSON || command.Output.DefaultMode == "json" {
|
||||
fmt.Fprint(stdout, " [--json]")
|
||||
}
|
||||
for _, flag := range flags {
|
||||
if flag.Required {
|
||||
fmt.Fprintf(stdout, " --%s <value>", flag.Name)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(stdout, " [--%s <value>]", flag.Name)
|
||||
}
|
||||
fmt.Fprintln(stdout)
|
||||
fmt.Fprintln(stdout)
|
||||
if strings.TrimSpace(command.Summary) != "" {
|
||||
fmt.Fprintln(stdout, command.Summary)
|
||||
}
|
||||
if strings.TrimSpace(command.Description) != "" && command.Description != command.Summary {
|
||||
if strings.TrimSpace(command.Summary) != "" {
|
||||
fmt.Fprintln(stdout)
|
||||
}
|
||||
fmt.Fprintln(stdout, command.Description)
|
||||
}
|
||||
printIntegrationOnlyNotice(stdout, []daemon.Capability{command})
|
||||
if len(flags) == 0 {
|
||||
printDocumentationHints(stdout, []daemon.Capability{command})
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(stdout)
|
||||
fmt.Fprintln(stdout, "Flags:")
|
||||
width := 0
|
||||
for _, flag := range flags {
|
||||
if len(flag.Name) > width {
|
||||
width = len(flag.Name)
|
||||
}
|
||||
}
|
||||
for _, flag := range flags {
|
||||
required := ""
|
||||
if flag.Required {
|
||||
required = " required"
|
||||
}
|
||||
description := ""
|
||||
if flag.Description != "" {
|
||||
description = " " + flag.Description
|
||||
}
|
||||
fmt.Fprintf(stdout, " --%-*s %s%s%s\n", width, flag.Name, flag.Type, required, description)
|
||||
if len(flag.Values) > 0 {
|
||||
fmt.Fprintf(stdout, " Values: %s\n", strings.Join(flag.Values, ", "))
|
||||
}
|
||||
if flag.HasDefault {
|
||||
fmt.Fprintf(stdout, " Default: %s\n", flag.Default)
|
||||
}
|
||||
}
|
||||
printDocumentationHints(stdout, []daemon.Capability{command})
|
||||
}
|
||||
|
||||
type helpCommandRow struct {
|
||||
Name string
|
||||
Summary string
|
||||
IntegrationOnly bool
|
||||
IncludesIntegrationOnly bool
|
||||
Children []helpCommandRow
|
||||
RemainingChildren int
|
||||
}
|
||||
|
||||
func rootHelpRows(commands []daemon.Capability) []helpCommandRow {
|
||||
type group struct {
|
||||
count int
|
||||
integrationCount int
|
||||
summary string
|
||||
description string
|
||||
}
|
||||
groups := map[string]group{}
|
||||
for _, command := range commands {
|
||||
if len(command.Path) == 0 {
|
||||
continue
|
||||
}
|
||||
name := command.Path[0]
|
||||
if name == "status" {
|
||||
continue
|
||||
}
|
||||
current := groups[name]
|
||||
current.count++
|
||||
if isIntegrationCapability(command) {
|
||||
current.integrationCount++
|
||||
}
|
||||
if len(command.Path) == 1 && current.summary == "" {
|
||||
current.summary = command.Summary
|
||||
}
|
||||
if current.description == "" {
|
||||
current.description = commandScopeDescription(command)
|
||||
}
|
||||
groups[name] = current
|
||||
}
|
||||
names := make([]string, 0, len(groups))
|
||||
for name := range groups {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
rows := make([]helpCommandRow, 0, len(names))
|
||||
for _, name := range names {
|
||||
item := groups[name]
|
||||
summary := item.summary
|
||||
if summary == "" {
|
||||
summary = fmt.Sprintf("%d commands", item.count)
|
||||
}
|
||||
if item.description != "" && item.count > 0 {
|
||||
summary = fmt.Sprintf("%s %d commands", item.description, item.count)
|
||||
}
|
||||
rows = append(rows, helpCommandRow{
|
||||
Name: name,
|
||||
Summary: summary,
|
||||
IntegrationOnly: item.count > 0 && item.integrationCount == item.count,
|
||||
IncludesIntegrationOnly: item.integrationCount > 0 && item.integrationCount < item.count,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func commandScopeDescription(command daemon.Capability) string {
|
||||
description := strings.TrimSpace(command.Source.CLIDescription)
|
||||
if description != "" {
|
||||
return description
|
||||
}
|
||||
return strings.TrimSpace(command.Source.AppDescription)
|
||||
}
|
||||
|
||||
func matchingPrefixCommands(commands []daemon.Capability, prefix []string) []daemon.Capability {
|
||||
result := make([]daemon.Capability, 0)
|
||||
for _, command := range commands {
|
||||
if len(command.Path) <= len(prefix) {
|
||||
continue
|
||||
}
|
||||
matched := true
|
||||
for index, segment := range prefix {
|
||||
if command.Path[index] != segment {
|
||||
matched = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
result = append(result, command)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(result, func(left, right int) bool {
|
||||
return strings.Join(result[left].Path, " ") < strings.Join(result[right].Path, " ")
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
func prefixHelpRows(prefix []string, commands []daemon.Capability) []helpCommandRow {
|
||||
type group struct {
|
||||
count int
|
||||
integrationCount int
|
||||
summary string
|
||||
}
|
||||
groups := map[string]group{}
|
||||
for _, command := range commands {
|
||||
if len(command.Path) <= len(prefix) {
|
||||
continue
|
||||
}
|
||||
remaining := command.Path[len(prefix):]
|
||||
name := remaining[0]
|
||||
current := groups[name]
|
||||
current.count++
|
||||
if isIntegrationCapability(command) {
|
||||
current.integrationCount++
|
||||
}
|
||||
if len(remaining) == 1 && current.summary == "" {
|
||||
current.summary = command.Summary
|
||||
}
|
||||
groups[name] = current
|
||||
}
|
||||
names := make([]string, 0, len(groups))
|
||||
for name := range groups {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
rows := make([]helpCommandRow, 0, len(names))
|
||||
for _, name := range names {
|
||||
item := groups[name]
|
||||
summary := item.summary
|
||||
if summary == "" {
|
||||
summary = fmt.Sprintf("%d commands", item.count)
|
||||
}
|
||||
if item.count == 1 {
|
||||
if required := requiredFlagSummaryForPrefixCommand(prefix, name, commands); required != "" {
|
||||
summary = summary + " required: " + required
|
||||
}
|
||||
}
|
||||
rows = append(rows, helpCommandRow{
|
||||
Name: name,
|
||||
Summary: summary,
|
||||
IntegrationOnly: item.count > 0 && item.integrationCount == item.count,
|
||||
IncludesIntegrationOnly: item.integrationCount > 0 && item.integrationCount < item.count,
|
||||
Children: prefixHelpChildRows(prefix, name, commands, prefixHelpGroupPreviewLimit),
|
||||
RemainingChildren: prefixHelpRemainingChildCount(prefix, name, commands, prefixHelpGroupPreviewLimit),
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func prefixHelpChildRows(prefix []string, name string, commands []daemon.Capability, limit int) []helpCommandRow {
|
||||
if limit <= 0 {
|
||||
return nil
|
||||
}
|
||||
childPrefix := append(append([]string(nil), prefix...), name)
|
||||
leaves := make([]daemon.Capability, 0)
|
||||
for _, command := range commands {
|
||||
if len(command.Path) != len(childPrefix)+1 {
|
||||
continue
|
||||
}
|
||||
if !pathHasPrefix(command.Path, childPrefix) {
|
||||
continue
|
||||
}
|
||||
leaves = append(leaves, command)
|
||||
}
|
||||
sort.SliceStable(leaves, func(left, right int) bool {
|
||||
return strings.Join(leaves[left].Path, " ") < strings.Join(leaves[right].Path, " ")
|
||||
})
|
||||
if len(leaves) > limit {
|
||||
leaves = leaves[:limit]
|
||||
}
|
||||
rows := make([]helpCommandRow, 0, len(leaves))
|
||||
for _, command := range leaves {
|
||||
childName := command.Path[len(childPrefix)]
|
||||
summary := strings.TrimSpace(command.Summary)
|
||||
if required := requiredFlagSummary(command); required != "" {
|
||||
summary = summary + " required: " + required
|
||||
}
|
||||
rows = append(rows, helpCommandRow{
|
||||
Name: childName,
|
||||
Summary: summary,
|
||||
IntegrationOnly: isIntegrationCapability(command),
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func prefixHelpRemainingChildCount(prefix []string, name string, commands []daemon.Capability, limit int) int {
|
||||
if limit <= 0 {
|
||||
return 0
|
||||
}
|
||||
childPrefix := append(append([]string(nil), prefix...), name)
|
||||
count := 0
|
||||
for _, command := range commands {
|
||||
if len(command.Path) == len(childPrefix)+1 && pathHasPrefix(command.Path, childPrefix) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count <= limit {
|
||||
return 0
|
||||
}
|
||||
return count - limit
|
||||
}
|
||||
|
||||
func pathHasPrefix(path []string, prefix []string) bool {
|
||||
if len(path) < len(prefix) {
|
||||
return false
|
||||
}
|
||||
for index, segment := range prefix {
|
||||
if path[index] != segment {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func requiredFlagSummaryForPrefixCommand(prefix []string, name string, commands []daemon.Capability) string {
|
||||
for _, command := range commands {
|
||||
if len(command.Path) != len(prefix)+1 {
|
||||
continue
|
||||
}
|
||||
if command.Path[len(prefix)] != name {
|
||||
continue
|
||||
}
|
||||
return requiredFlagSummary(command)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func requiredFlagSummary(command daemon.Capability) string {
|
||||
flags := commandFlags(command.InputSchema)
|
||||
required := make([]string, 0)
|
||||
for _, flag := range flags {
|
||||
if flag.Required {
|
||||
required = append(required, "--"+flag.Name+" <value>")
|
||||
}
|
||||
}
|
||||
return strings.Join(required, " ")
|
||||
}
|
||||
|
||||
func printHelpRows(stdout io.Writer, rows []helpCommandRow) {
|
||||
width := 0
|
||||
for _, row := range rows {
|
||||
if len(row.Name) > width {
|
||||
width = len(row.Name)
|
||||
}
|
||||
}
|
||||
for _, row := range rows {
|
||||
summary := row.Summary
|
||||
switch {
|
||||
case row.IntegrationOnly:
|
||||
summary += " [integration-only]"
|
||||
case row.IncludesIntegrationOnly:
|
||||
summary += " [includes integration-only]"
|
||||
}
|
||||
fmt.Fprintf(stdout, " %-*s %s\n", width, row.Name, summary)
|
||||
printHelpChildRows(stdout, row)
|
||||
}
|
||||
}
|
||||
|
||||
func printHelpChildRows(stdout io.Writer, row helpCommandRow) {
|
||||
if len(row.Children) == 0 && row.RemainingChildren == 0 {
|
||||
return
|
||||
}
|
||||
childWidth := 0
|
||||
for _, child := range row.Children {
|
||||
if len(child.Name) > childWidth {
|
||||
childWidth = len(child.Name)
|
||||
}
|
||||
}
|
||||
for _, child := range row.Children {
|
||||
summary := child.Summary
|
||||
if child.IntegrationOnly {
|
||||
summary += " [integration-only]"
|
||||
}
|
||||
fmt.Fprintf(stdout, " %-*s %s\n", childWidth, child.Name, summary)
|
||||
}
|
||||
if row.RemainingChildren > 0 {
|
||||
fmt.Fprintf(stdout, " ... %d more commands\n", row.RemainingChildren)
|
||||
}
|
||||
}
|
||||
|
||||
func printIntegrationOnlyNotice(stdout io.Writer, commands []daemon.Capability) {
|
||||
if !hasIntegrationCapability(commands) {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(stdout)
|
||||
fmt.Fprintln(stdout, "Integration-only commands are app-runtime plumbing. Do not expose or forward them as ordinary user or Agent actions; prefer public app commands.")
|
||||
}
|
||||
|
||||
func hasIntegrationCapability(commands []daemon.Capability) bool {
|
||||
for _, command := range commands {
|
||||
if isIntegrationCapability(command) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isIntegrationCapability(command daemon.Capability) bool {
|
||||
return strings.TrimSpace(command.Visibility) == "integration"
|
||||
}
|
||||
|
||||
func printDocumentationHints(stdout io.Writer, commands []daemon.Capability) {
|
||||
hints := documentationHints(commands)
|
||||
if len(hints) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(stdout)
|
||||
fmt.Fprintln(stdout, "More documentation:")
|
||||
for _, hint := range hints {
|
||||
fmt.Fprintf(stdout, " %s\n", hint.File)
|
||||
}
|
||||
}
|
||||
|
||||
type documentationHint struct {
|
||||
File string
|
||||
}
|
||||
|
||||
func documentationHints(commands []daemon.Capability) []documentationHint {
|
||||
seen := map[string]struct{}{}
|
||||
hints := make([]documentationHint, 0)
|
||||
for _, command := range commands {
|
||||
file := strings.TrimSpace(command.Source.DocumentationPath)
|
||||
if file == "" {
|
||||
file = strings.TrimSpace(command.Source.DocumentationFile)
|
||||
}
|
||||
if file == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[file]; ok {
|
||||
continue
|
||||
}
|
||||
seen[file] = struct{}{}
|
||||
hints = append(hints, documentationHint{File: file})
|
||||
}
|
||||
sort.SliceStable(hints, func(left, right int) bool {
|
||||
return hints[left].File < hints[right].File
|
||||
})
|
||||
return hints
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,199 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const tuttiModePlanCapabilitiesFixture = `{"commands":[
|
||||
{"id":"tutti-mode-plan.plan.propose","path":["plan","propose"],"summary":"Propose a Tutti Mode plan","inputSchema":{"type":"object","required":["file","request-id"],"properties":{"file":{"type":"string","description":"Markdown proposal file."},"request-id":{"type":"string"}}},"output":{"defaultMode":"json","json":true},"source":{"kind":"builtin"}},
|
||||
{"id":"tutti-mode-plan.plan.revise","path":["plan","revise"],"summary":"Revise a Tutti Mode plan","inputSchema":{"type":"object","required":["workflow-id","file","request-id"],"properties":{"workflow-id":{"type":"string"},"file":{"type":"string"},"request-id":{"type":"string"}}},"output":{"defaultMode":"json","json":true},"source":{"kind":"builtin"}},
|
||||
{"id":"tutti-mode-plan.plan.get","path":["plan","get"],"summary":"Get a Tutti Mode plan","inputSchema":{"type":"object","required":["workflow-id"],"properties":{"workflow-id":{"type":"string"}}},"output":{"defaultMode":"json","json":true},"source":{"kind":"builtin"}},
|
||||
{"id":"tutti-mode-plan.plan.issue.schedule","path":["plan","issue","schedule"],"summary":"Schedule exact Tutti Mode Issue tasks","inputSchema":{"type":"object","required":["issue-id","checkpoint-id","expected-graph-revision","task-ids-json","request-id"],"properties":{"issue-id":{"type":"string"},"checkpoint-id":{"type":"string"},"expected-graph-revision":{"type":"integer"},"task-ids-json":{"type":"string"},"request-id":{"type":"string"}}},"output":{"defaultMode":"json","json":true},"source":{"kind":"builtin"}}
|
||||
]}`
|
||||
|
||||
func TestRunTuttiModePlanCommandsUseDynamicDaemonCapabilityProtocol(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
commandID string
|
||||
args []string
|
||||
wantInput map[string]any
|
||||
}{
|
||||
{
|
||||
name: "propose",
|
||||
commandID: "tutti-mode-plan.plan.propose",
|
||||
args: []string{"plan", "propose", "--file", "/tmp/proposal.md", "--request-id", "proposal-request-1"},
|
||||
wantInput: map[string]any{"file": "/tmp/proposal.md", "request-id": "proposal-request-1"},
|
||||
},
|
||||
{
|
||||
name: "revise",
|
||||
commandID: "tutti-mode-plan.plan.revise",
|
||||
args: []string{"plan", "revise", "--workflow-id", "WF-1", "--file", "/tmp/revision.md", "--request-id", "revision-request-1"},
|
||||
wantInput: map[string]any{"workflow-id": "WF-1", "file": "/tmp/revision.md", "request-id": "revision-request-1"},
|
||||
},
|
||||
{
|
||||
name: "get",
|
||||
commandID: "tutti-mode-plan.plan.get",
|
||||
args: []string{"plan", "get", "--workflow-id", "WF-1"},
|
||||
wantInput: map[string]any{"workflow-id": "WF-1"},
|
||||
},
|
||||
{
|
||||
// The checkpoint-fenced commands carry an integer revision fence.
|
||||
// The daemon validates invocation input against the advertised
|
||||
// schema before binding it, so the fence has to reach the wire as
|
||||
// a JSON number instead of a quoted terminal argument.
|
||||
name: "issue schedule",
|
||||
commandID: "tutti-mode-plan.plan.issue.schedule",
|
||||
args: []string{
|
||||
"plan", "issue", "schedule",
|
||||
"--issue-id", "ISSUE-1",
|
||||
"--checkpoint-id", "CP-1",
|
||||
"--expected-graph-revision", "1",
|
||||
"--task-ids-json", `["task-1"]`,
|
||||
"--request-id", "schedule-request-1",
|
||||
},
|
||||
wantInput: map[string]any{
|
||||
"issue-id": "ISSUE-1",
|
||||
"checkpoint-id": "CP-1",
|
||||
"expected-graph-revision": 1,
|
||||
"task-ids-json": `["task-1"]`,
|
||||
"request-id": "schedule-request-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "issue schedule with inline flag values",
|
||||
commandID: "tutti-mode-plan.plan.issue.schedule",
|
||||
args: []string{
|
||||
"plan", "issue", "schedule",
|
||||
"--issue-id=ISSUE-1",
|
||||
"--checkpoint-id=CP-1",
|
||||
"--expected-graph-revision=12",
|
||||
`--task-ids-json=["task-1"]`,
|
||||
"--request-id=schedule-request-2",
|
||||
},
|
||||
wantInput: map[string]any{
|
||||
"issue-id": "ISSUE-1",
|
||||
"checkpoint-id": "CP-1",
|
||||
"expected-graph-revision": 12,
|
||||
"task-ids-json": `["task-1"]`,
|
||||
"request-id": "schedule-request-2",
|
||||
},
|
||||
},
|
||||
{
|
||||
// A value the declared type cannot hold stays untouched so the
|
||||
// daemon owns the rejection wording instead of the CLI.
|
||||
name: "issue schedule with non-integer revision",
|
||||
commandID: "tutti-mode-plan.plan.issue.schedule",
|
||||
args: []string{
|
||||
"plan", "issue", "schedule",
|
||||
"--issue-id", "ISSUE-1",
|
||||
"--checkpoint-id", "CP-1",
|
||||
"--expected-graph-revision", "1.5",
|
||||
"--task-ids-json", `["task-1"]`,
|
||||
"--request-id", "schedule-request-3",
|
||||
},
|
||||
wantInput: map[string]any{
|
||||
"issue-id": "ISSUE-1",
|
||||
"checkpoint-id": "CP-1",
|
||||
"expected-graph-revision": "1.5",
|
||||
"task-ids-json": `["task-1"]`,
|
||||
"request-id": "schedule-request-3",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var invokedBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/v1/cli/capabilities":
|
||||
_, _ = w.Write([]byte(tuttiModePlanCapabilitiesFixture))
|
||||
case "/v1/cli/commands/" + tt.commandID + "/invoke":
|
||||
if err := json.NewDecoder(r.Body).Decode(&invokedBody); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"ok":true,"output":{"kind":"json","value":{"workflowId":"WF-1","checkpoint":{"id":"CP-1","status":"pending"}}}}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
writeEndpoint(t, server.URL, "token-1")
|
||||
t.Setenv("TUTTI_WORKSPACE_ID", "workspace-1")
|
||||
t.Setenv("TUTTI_AGENT_SESSION_ID", "session-1")
|
||||
t.Setenv("TUTTI_APP_CLI_PARENT_COMMAND_ID", "tool-call-1")
|
||||
t.Setenv("TUTTI_AGENT_CWD", "/workspace/project/worktree")
|
||||
t.Setenv("TUTTI_AGENT_RAIL_PLACEMENT", `{"version":1,"kind":"project","projectPath":"/workspace/project","sectionKey":"project:/workspace/project"}`)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
if code := runDefaultProgram(t, tt.args, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("code = %d, stderr = %s", code, stderr.String())
|
||||
}
|
||||
|
||||
input, ok := invokedBody["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("input = %#v", invokedBody["input"])
|
||||
}
|
||||
if encoded, _ := json.Marshal(input); string(encoded) != mustJSON(t, tt.wantInput) {
|
||||
t.Fatalf("input = %s, want %s", encoded, mustJSON(t, tt.wantInput))
|
||||
}
|
||||
contextValue, ok := invokedBody["context"].(map[string]any)
|
||||
if !ok || contextValue["source"] != "cli" || contextValue["workspaceID"] != "workspace-1" || contextValue["agentSessionId"] != "session-1" || contextValue["parentCommandId"] != "tool-call-1" || contextValue["agentCwd"] != "/workspace/project/worktree" || contextValue["agentRailPlacementJSON"] != `{"version":1,"kind":"project","projectPath":"/workspace/project","sectionKey":"project:/workspace/project"}` {
|
||||
t.Fatalf("context = %#v", invokedBody["context"])
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"workflowId": "WF-1"`) {
|
||||
t.Fatalf("stdout = %q", stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTuttiModePlanHelpAdvertisesNoWaitAndNoDecisionMutation(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.URL.Path == "/v1/cli/capabilities" {
|
||||
_, _ = w.Write([]byte(tuttiModePlanCapabilitiesFixture))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
writeEndpoint(t, server.URL, "token-1")
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
if code := runDefaultProgram(t, []string{"plan", "--help"}, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("code = %d, stderr = %s", code, stderr.String())
|
||||
}
|
||||
|
||||
output := stdout.String()
|
||||
for _, expected := range []string{"propose", "revise", "get"} {
|
||||
if !strings.Contains(output, expected) {
|
||||
t.Fatalf("help is missing %q:\n%s", expected, output)
|
||||
}
|
||||
}
|
||||
// The review decision reaches the agent as a new user message; the CLI
|
||||
// must neither block on it (wait) nor make it (decide).
|
||||
for _, forbidden := range []string{"wait", "decide"} {
|
||||
if strings.Contains(output, forbidden) {
|
||||
t.Fatalf("agent-facing CLI must not advertise %q:\n%s", forbidden, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, value any) string {
|
||||
t.Helper()
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal value: %v", err)
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tutti-os/tutti/apps/cli/internal/daemon"
|
||||
)
|
||||
|
||||
const appHandlerRequestTimeoutMargin = 30 * time.Second
|
||||
|
||||
type waitOptions struct {
|
||||
timeout time.Duration
|
||||
set bool
|
||||
}
|
||||
|
||||
func isWaitCommand(command daemon.Capability) bool {
|
||||
return command.Execution != nil && command.Execution.Mode == "wait"
|
||||
}
|
||||
|
||||
func waitCommandFlags(command daemon.Capability, flags []commandFlag) []commandFlag {
|
||||
if !isWaitCommand(command) {
|
||||
return flags
|
||||
}
|
||||
for _, flag := range flags {
|
||||
if flag.Name == "timeout-ms" {
|
||||
return flags
|
||||
}
|
||||
}
|
||||
return append(flags, commandFlag{
|
||||
Name: "timeout-ms", Type: "integer",
|
||||
Description: "Maximum total wait in milliseconds; omit to wait until the command reaches a stop point.",
|
||||
})
|
||||
}
|
||||
|
||||
func parseWaitOptions(command daemon.Capability, args []string) (waitOptions, []string, error) {
|
||||
if !isWaitCommand(command) {
|
||||
return waitOptions{}, args, nil
|
||||
}
|
||||
filtered := make([]string, 0, len(args))
|
||||
var options waitOptions
|
||||
for index := 0; index < len(args); index++ {
|
||||
arg := args[index]
|
||||
if arg != "--timeout-ms" && !strings.HasPrefix(arg, "--timeout-ms=") {
|
||||
filtered = append(filtered, arg)
|
||||
continue
|
||||
}
|
||||
if options.set {
|
||||
return waitOptions{}, nil, errors.New("--timeout-ms may only be provided once")
|
||||
}
|
||||
value := ""
|
||||
if _, inline, found := strings.Cut(arg, "="); found {
|
||||
value = inline
|
||||
} else {
|
||||
if index+1 >= len(args) || strings.HasPrefix(args[index+1], "--") {
|
||||
return waitOptions{}, nil, errors.New("missing value for --timeout-ms")
|
||||
}
|
||||
index++
|
||||
value = args[index]
|
||||
}
|
||||
milliseconds, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||
if err != nil || milliseconds <= 0 || milliseconds > int64(^uint64(0)>>1)/int64(time.Millisecond) {
|
||||
return waitOptions{}, nil, fmt.Errorf("invalid --timeout-ms value %q: expected a positive integer", value)
|
||||
}
|
||||
options = waitOptions{timeout: time.Duration(milliseconds) * time.Millisecond, set: true}
|
||||
}
|
||||
return options, filtered, nil
|
||||
}
|
||||
|
||||
func invokeDynamicCommand(
|
||||
ctx context.Context,
|
||||
client *daemon.Client,
|
||||
command daemon.Capability,
|
||||
request daemon.InvokeRequest,
|
||||
options waitOptions,
|
||||
) (daemon.InvokeResponse, error) {
|
||||
if !isWaitCommand(command) {
|
||||
return invokeOnce(ctx, client, command, request)
|
||||
}
|
||||
|
||||
waitCtx := ctx
|
||||
var cancel context.CancelFunc
|
||||
if options.set {
|
||||
waitCtx, cancel = context.WithTimeout(ctx, options.timeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
var lastOutput *daemon.CommandOutput
|
||||
for {
|
||||
response, err := invokeOnce(waitCtx, client, command, request)
|
||||
if err != nil {
|
||||
if options.set && errors.Is(waitCtx.Err(), context.DeadlineExceeded) {
|
||||
return waitTimedOutResponse(lastOutput), nil
|
||||
}
|
||||
return daemon.InvokeResponse{}, err
|
||||
}
|
||||
if response.Output == nil || response.Output.Continuation == nil {
|
||||
return response, nil
|
||||
}
|
||||
lastOutput = response.Output
|
||||
delay := time.Duration(response.Output.Continuation.RetryAfterMs) * time.Millisecond
|
||||
timer := time.NewTimer(delay)
|
||||
select {
|
||||
case <-waitCtx.Done():
|
||||
timer.Stop()
|
||||
if options.set && errors.Is(waitCtx.Err(), context.DeadlineExceeded) {
|
||||
return waitTimedOutResponse(lastOutput), nil
|
||||
}
|
||||
return daemon.InvokeResponse{}, waitCtx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func invokeOnce(ctx context.Context, client *daemon.Client, command daemon.Capability, request daemon.InvokeRequest) (daemon.InvokeResponse, error) {
|
||||
if command.HandlerTimeoutMs <= 0 {
|
||||
return client.Invoke(ctx, command.ID, request)
|
||||
}
|
||||
timeout := time.Duration(command.HandlerTimeoutMs)*time.Millisecond + appHandlerRequestTimeoutMargin
|
||||
return client.InvokeWithTimeout(ctx, command.ID, request, timeout)
|
||||
}
|
||||
|
||||
func waitTimedOutResponse(lastOutput *daemon.CommandOutput) daemon.InvokeResponse {
|
||||
value := map[string]any{
|
||||
"reason": "wait_timeout",
|
||||
"timedOut": true,
|
||||
"executionContinues": true,
|
||||
}
|
||||
if lastOutput != nil {
|
||||
switch {
|
||||
case len(lastOutput.Value) > 0:
|
||||
value["lastResult"] = lastOutput.Value
|
||||
case lastOutput.Rows != nil:
|
||||
value["lastResult"] = lastOutput.Rows
|
||||
case strings.TrimSpace(lastOutput.Text) != "":
|
||||
value["lastResult"] = lastOutput.Text
|
||||
}
|
||||
}
|
||||
return daemon.InvokeResponse{
|
||||
OK: true,
|
||||
Output: &daemon.CommandOutput{Kind: "json", Value: value},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
const (
|
||||
// Built-in commands use this broad fallback budget. App commands publish
|
||||
// their handler timeout through capability metadata and override it per
|
||||
// invocation, including the transport margin owned by the terminal CLI.
|
||||
defaultClientTimeout = 360 * time.Second
|
||||
healthPath = "/v1/health"
|
||||
cliCapabilitiesPath = "/v1/cli/capabilities"
|
||||
cliCommandInvokePattern = "/v1/cli/commands/{commandID}/invoke"
|
||||
)
|
||||
|
||||
type HealthStatus struct {
|
||||
Service string `json:"service"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type CapabilityList struct {
|
||||
Commands []Capability `json:"commands"`
|
||||
}
|
||||
|
||||
type Capability struct {
|
||||
ID string `json:"id"`
|
||||
Path []string `json:"path"`
|
||||
Summary string `json:"summary"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Visibility string `json:"visibility,omitempty"`
|
||||
InputSchema map[string]any `json:"inputSchema,omitempty"`
|
||||
Output CapabilityOutput `json:"output"`
|
||||
Execution *CommandExecution `json:"execution,omitempty"`
|
||||
HandlerTimeoutMs int `json:"handlerTimeoutMs,omitempty"`
|
||||
Source CapabilitySource `json:"source"`
|
||||
}
|
||||
|
||||
type CommandExecution struct {
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
type CapabilityListOptions struct {
|
||||
IncludeHidden bool
|
||||
IncludeIntegration bool
|
||||
AgentSessionID string
|
||||
}
|
||||
|
||||
type CapabilitySource struct {
|
||||
Kind string `json:"kind"`
|
||||
AppID string `json:"appId,omitempty"`
|
||||
AppName string `json:"appName,omitempty"`
|
||||
CLIDescription string `json:"cliDescription,omitempty"`
|
||||
AppDescription string `json:"appDescription,omitempty"`
|
||||
DocumentationFile string `json:"documentationFile,omitempty"`
|
||||
DocumentationPath string `json:"documentationPath,omitempty"`
|
||||
}
|
||||
|
||||
type CapabilityOutput struct {
|
||||
DefaultMode string `json:"defaultMode"`
|
||||
JSON bool `json:"json"`
|
||||
Table *struct {
|
||||
Columns []TableColumn `json:"columns"`
|
||||
} `json:"table"`
|
||||
}
|
||||
|
||||
type TableColumn struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
type InvokeRequest struct {
|
||||
Input map[string]any `json:"input,omitempty"`
|
||||
OutputMode string `json:"outputMode,omitempty"`
|
||||
Context InvokeContext `json:"context"`
|
||||
}
|
||||
|
||||
type InvokeContext struct {
|
||||
AppID string `json:"appId,omitempty"`
|
||||
Source string `json:"source"`
|
||||
WorkspaceID string `json:"workspaceID,omitempty"`
|
||||
ParentCommandID string `json:"parentCommandId,omitempty"`
|
||||
AgentSessionID string `json:"agentSessionId,omitempty"`
|
||||
AgentCWD string `json:"agentCwd,omitempty"`
|
||||
AgentRailPlacementJSON string `json:"agentRailPlacementJSON,omitempty"`
|
||||
}
|
||||
|
||||
type InvokeResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Output *CommandOutput `json:"output,omitempty"`
|
||||
}
|
||||
|
||||
type CommandOutput struct {
|
||||
Kind string `json:"kind"`
|
||||
Columns []TableColumn `json:"columns,omitempty"`
|
||||
Rows []map[string]any `json:"rows,omitempty"`
|
||||
Value map[string]any `json:"value,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Warnings []CommandWarning `json:"warnings,omitempty"`
|
||||
Continuation *CommandContinuation `json:"continuation,omitempty"`
|
||||
}
|
||||
|
||||
type CommandContinuation struct {
|
||||
State string `json:"state"`
|
||||
RetryAfterMs int `json:"retryAfterMs"`
|
||||
}
|
||||
|
||||
type CommandWarning struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type apiErrorEnvelope struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Reason string `json:"reason"`
|
||||
Retryable bool `json:"retryable"`
|
||||
DeveloperMessage string `json:"developerMessage"`
|
||||
CorrelationID string `json:"correlationId"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
func NewClient(endpoint Endpoint) (*Client, error) {
|
||||
baseURL, err := endpoint.BaseURL()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
token: endpoint.Token,
|
||||
httpClient: &http.Client{
|
||||
Timeout: defaultClientTimeout,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (client *Client) GetHealth(ctx context.Context) (HealthStatus, error) {
|
||||
var result HealthStatus
|
||||
if err := client.DoJSON(ctx, http.MethodGet, healthPath, nil, &result); err != nil {
|
||||
return HealthStatus{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (client *Client) ListCapabilitiesForWorkspaceWithOptions(ctx context.Context, workspaceID string, options CapabilityListOptions) (CapabilityList, error) {
|
||||
var result CapabilityList
|
||||
path := cliCapabilitiesPath
|
||||
query := url.Values{}
|
||||
if strings.TrimSpace(workspaceID) != "" {
|
||||
query.Set("workspaceID", strings.TrimSpace(workspaceID))
|
||||
}
|
||||
if strings.TrimSpace(options.AgentSessionID) != "" {
|
||||
query.Set("agentSessionID", strings.TrimSpace(options.AgentSessionID))
|
||||
}
|
||||
if options.IncludeHidden {
|
||||
query.Set("includeHidden", "true")
|
||||
}
|
||||
if options.IncludeIntegration {
|
||||
query.Set("includeIntegration", "true")
|
||||
}
|
||||
if len(query) > 0 {
|
||||
path += "?" + query.Encode()
|
||||
}
|
||||
if err := client.DoJSON(ctx, http.MethodGet, path, nil, &result); err != nil {
|
||||
return CapabilityList{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (client *Client) Invoke(ctx context.Context, commandID string, request InvokeRequest) (InvokeResponse, error) {
|
||||
return client.InvokeWithTimeout(ctx, commandID, request, defaultClientTimeout)
|
||||
}
|
||||
|
||||
func (client *Client) InvokeWithTimeout(ctx context.Context, commandID string, request InvokeRequest, timeout time.Duration) (InvokeResponse, error) {
|
||||
var result InvokeResponse
|
||||
path := strings.Replace(cliCommandInvokePattern, "{commandID}", urlPathEscape(commandID), 1)
|
||||
httpClient := client.httpClient
|
||||
if timeout > 0 {
|
||||
cloned := *client.httpClient
|
||||
cloned.Timeout = timeout
|
||||
httpClient = &cloned
|
||||
}
|
||||
if err := client.doJSON(ctx, httpClient, http.MethodPost, path, request, &result); err != nil {
|
||||
return InvokeResponse{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (client *Client) DoJSON(ctx context.Context, method string, path string, body any, result any) error {
|
||||
return client.doJSON(ctx, client.httpClient, method, path, body, result)
|
||||
}
|
||||
|
||||
func (client *Client) doJSON(ctx context.Context, httpClient *http.Client, method string, path string, body any, result any) error {
|
||||
var requestBody io.Reader
|
||||
if body != nil {
|
||||
encoded, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return newRequestError(ErrorDetails{ReasonCode: "daemon_request_invalid", Message: fmt.Sprintf("encode request body: %v", err)})
|
||||
}
|
||||
requestBody = bytes.NewReader(encoded)
|
||||
}
|
||||
|
||||
url := client.baseURL + path
|
||||
request, err := http.NewRequestWithContext(ctx, method, url, requestBody)
|
||||
if err != nil {
|
||||
return newRequestError(ErrorDetails{ReasonCode: "daemon_request_invalid", Message: fmt.Sprintf("create daemon request: %v", err)})
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("Authorization", "Bearer "+client.token)
|
||||
if body != nil {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return daemonRequestError(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
content, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return newRequestError(ErrorDetails{ReasonCode: "daemon_response_read_failed", Message: fmt.Sprintf("read daemon response: %v", err)})
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return daemonResponseError(response.StatusCode, response.Status, content)
|
||||
}
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(content, result); err != nil {
|
||||
return newRequestError(ErrorDetails{ReasonCode: "daemon_response_invalid", Message: fmt.Sprintf("decode daemon response: %v", err)})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func daemonResponseError(statusCode int, status string, content []byte) error {
|
||||
var envelope apiErrorEnvelope
|
||||
if err := json.Unmarshal(content, &envelope); err == nil {
|
||||
reasonCode := strings.TrimSpace(envelope.Error.Reason)
|
||||
if reasonCode == "" {
|
||||
reasonCode = strings.TrimSpace(envelope.Error.Code)
|
||||
}
|
||||
if reasonCode != "" {
|
||||
message := strings.TrimSpace(envelope.Error.DeveloperMessage)
|
||||
if message == "" {
|
||||
message = reasonCode
|
||||
}
|
||||
return newRequestError(ErrorDetails{
|
||||
ReasonCode: reasonCode, Message: message, Retryable: envelope.Error.Retryable,
|
||||
CorrelationID: strings.TrimSpace(envelope.Error.CorrelationID), StatusCode: statusCode,
|
||||
})
|
||||
}
|
||||
}
|
||||
if statusCode == http.StatusUnauthorized {
|
||||
return newRequestError(ErrorDetails{ReasonCode: "unauthorized", Message: "daemon authentication failed", StatusCode: statusCode})
|
||||
}
|
||||
message := strings.TrimSpace(string(content))
|
||||
if message == "" {
|
||||
message = strings.TrimSpace(status)
|
||||
}
|
||||
return newRequestError(ErrorDetails{
|
||||
ReasonCode: "daemon_request_failed", Message: "daemon request failed: " + message, StatusCode: statusCode,
|
||||
})
|
||||
}
|
||||
|
||||
func daemonRequestError(err error) error {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return newRequestError(ErrorDetails{ReasonCode: "daemon_request_canceled", Message: "daemon request canceled"})
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) || os.IsTimeout(err) {
|
||||
return newRequestError(ErrorDetails{ReasonCode: "daemon_request_timed_out", Message: "daemon request timed out", Retryable: true})
|
||||
}
|
||||
if runningInAgentEnvironment() {
|
||||
return newRequestError(ErrorDetails{
|
||||
ReasonCode: "daemon_unavailable", Retryable: true,
|
||||
Message: "daemon is not reachable from this agent execution environment; rerun the command in an execution environment with localhost/IPC access",
|
||||
})
|
||||
}
|
||||
return newRequestError(ErrorDetails{ReasonCode: "daemon_unavailable", Message: "daemon is not reachable", Retryable: true})
|
||||
}
|
||||
|
||||
func runningInAgentEnvironment() bool {
|
||||
return strings.TrimSpace(os.Getenv("TUTTI_AGENT_SESSION_ID")) != "" ||
|
||||
strings.TrimSpace(os.Getenv("TUTTI_AGENT_ROUTING")) != ""
|
||||
}
|
||||
|
||||
func urlPathEscape(value string) string {
|
||||
replacer := strings.NewReplacer("%", "%25", "/", "%2F", "?", "%3F", "#", "%23")
|
||||
return replacer.Replace(value)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewClientUsesStartupFriendlyTimeout(t *testing.T) {
|
||||
client, err := NewClient(Endpoint{Addr: "127.0.0.1:1", Token: "token-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
|
||||
if client.httpClient.Timeout < 30*time.Second {
|
||||
t.Fatalf("timeout = %s, want at least 30s", client.httpClient.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvokeWithTimeoutOverridesDefaultClientBudget(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true,"output":{"kind":"json","value":{"ok":true}}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &Client{
|
||||
baseURL: server.URL,
|
||||
token: "token-1",
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Millisecond,
|
||||
},
|
||||
}
|
||||
response, err := client.InvokeWithTimeout(context.Background(), "wait.command", InvokeRequest{}, 100*time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatalf("InvokeWithTimeout() error = %v", err)
|
||||
}
|
||||
if response.Output == nil || response.Output.Value["ok"] != true {
|
||||
t.Fatalf("response = %#v", response)
|
||||
}
|
||||
if client.httpClient.Timeout != 10*time.Millisecond {
|
||||
t.Fatalf("shared client timeout mutated to %s", client.httpClient.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoJSONReportsTimeoutSeparatelyFromUnreachable(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"service":"tuttid","status":"ok"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &Client{
|
||||
baseURL: server.URL,
|
||||
token: "token-1",
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Millisecond,
|
||||
},
|
||||
}
|
||||
|
||||
var result HealthStatus
|
||||
err := client.DoJSON(context.Background(), http.MethodGet, healthPath, nil, &result)
|
||||
if err == nil {
|
||||
t.Fatal("expected timeout error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "daemon request timed out") {
|
||||
t.Fatalf("error = %q, want timeout message", err.Error())
|
||||
}
|
||||
if strings.Contains(err.Error(), "daemon is not reachable") {
|
||||
t.Fatalf("error = %q, should not report daemon as unreachable", err.Error())
|
||||
}
|
||||
details, ok := RequestErrorDetails(err)
|
||||
if !ok || details.ReasonCode != "daemon_request_timed_out" || !details.Retryable {
|
||||
t.Fatalf("details = %#v ok=%t", details, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoJSONPreservesStructuredDaemonError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":"workspace_not_found","reason":"workspace_agent_session_not_found","retryable":true,"developerMessage":"agent session was not found","correlationId":"corr-1"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &Client{baseURL: server.URL, token: "token-1", httpClient: server.Client()}
|
||||
err := client.DoJSON(context.Background(), http.MethodGet, healthPath, nil, &HealthStatus{})
|
||||
details, ok := RequestErrorDetails(err)
|
||||
if !ok {
|
||||
t.Fatalf("RequestErrorDetails(%v) did not match", err)
|
||||
}
|
||||
if details.ReasonCode != "workspace_agent_session_not_found" || details.Message != "agent session was not found" ||
|
||||
!details.Retryable || details.CorrelationID != "corr-1" || details.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("details = %#v", details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonRequestErrorKeepsPlainUnreachableOutsideAgent(t *testing.T) {
|
||||
t.Setenv("TUTTI_AGENT_SESSION_ID", "")
|
||||
t.Setenv("TUTTI_AGENT_ROUTING", "")
|
||||
|
||||
err := daemonRequestError(errors.New("dial tcp failed"))
|
||||
|
||||
if got, want := err.Error(), "daemon is not reachable"; got != want {
|
||||
t.Fatalf("error = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonRequestErrorHintsLocalDaemonAccessInsideAgent(t *testing.T) {
|
||||
t.Setenv("TUTTI_AGENT_SESSION_ID", "session-1")
|
||||
t.Setenv("TUTTI_AGENT_ROUTING", "")
|
||||
|
||||
err := daemonRequestError(errors.New("dial tcp failed"))
|
||||
message := err.Error()
|
||||
|
||||
for _, expected := range []string{
|
||||
"daemon is not reachable from this agent execution environment",
|
||||
"execution environment with localhost/IPC access",
|
||||
"localhost/IPC",
|
||||
} {
|
||||
if !strings.Contains(message, expected) {
|
||||
t.Fatalf("error = %q, want to contain %q", message, expected)
|
||||
}
|
||||
}
|
||||
if strings.Contains(message, "sandbox_permissions=require_escalated") {
|
||||
t.Fatalf("error = %q, should not contain provider-specific Codex escalation syntax", message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/tutti-os/tutti/apps/cli/internal/defaults"
|
||||
)
|
||||
|
||||
type Endpoint struct {
|
||||
Addr string
|
||||
Token string
|
||||
}
|
||||
|
||||
type endpointFile struct {
|
||||
Version int `json:"version"`
|
||||
Addr string `json:"addr"`
|
||||
Auth endpointFileAuth `json:"auth"`
|
||||
}
|
||||
|
||||
type endpointFileAuth struct {
|
||||
Scheme string `json:"scheme"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
func DiscoverEndpoint() (Endpoint, error) {
|
||||
return ReadEndpointFile(ListenerInfoPath())
|
||||
}
|
||||
|
||||
func ReadEndpointFile(path string) (Endpoint, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return Endpoint{}, fmt.Errorf("daemon endpoint is not available; start the Tutti desktop app")
|
||||
}
|
||||
return Endpoint{}, fmt.Errorf("read daemon endpoint: %w", err)
|
||||
}
|
||||
|
||||
var payload endpointFile
|
||||
if err := json.Unmarshal(content, &payload); err != nil {
|
||||
return Endpoint{}, fmt.Errorf("daemon endpoint file is invalid JSON")
|
||||
}
|
||||
|
||||
addr := strings.TrimSpace(payload.Addr)
|
||||
if addr == "" {
|
||||
return Endpoint{}, fmt.Errorf("daemon endpoint file does not contain addr")
|
||||
}
|
||||
if payload.Version != 0 && payload.Version != 1 {
|
||||
return Endpoint{}, fmt.Errorf("daemon endpoint file version is not supported")
|
||||
}
|
||||
|
||||
scheme := strings.ToLower(strings.TrimSpace(payload.Auth.Scheme))
|
||||
token := strings.TrimSpace(payload.Auth.Token)
|
||||
if scheme != "bearer" || token == "" {
|
||||
return Endpoint{}, fmt.Errorf("daemon endpoint file does not contain bearer auth")
|
||||
}
|
||||
|
||||
return Endpoint{Addr: addr, Token: token}, nil
|
||||
}
|
||||
|
||||
func (endpoint Endpoint) BaseURL() (string, error) {
|
||||
addr := strings.TrimSpace(endpoint.Addr)
|
||||
if addr == "" {
|
||||
return "", fmt.Errorf("daemon address is empty")
|
||||
}
|
||||
if strings.HasPrefix(addr, "http://") || strings.HasPrefix(addr, "https://") {
|
||||
parsed, err := url.Parse(addr)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return "", fmt.Errorf("daemon address is invalid")
|
||||
}
|
||||
return strings.TrimRight(addr, "/"), nil
|
||||
}
|
||||
return "http://" + strings.TrimRight(addr, "/"), nil
|
||||
}
|
||||
|
||||
func ListenerInfoPath() string {
|
||||
return defaults.ResolveDefaultsFromEnv().State.TuttidListenerInfoPath
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadEndpointFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tuttid.listener.json")
|
||||
if err := os.WriteFile(path, []byte(`{"version":1,"addr":"127.0.0.1:4545","auth":{"scheme":"bearer","token":"token-1"}}`), 0o600); err != nil {
|
||||
t.Fatalf("write endpoint: %v", err)
|
||||
}
|
||||
|
||||
endpoint, err := ReadEndpointFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEndpointFile: %v", err)
|
||||
}
|
||||
if endpoint.Addr != "127.0.0.1:4545" {
|
||||
t.Fatalf("addr = %q", endpoint.Addr)
|
||||
}
|
||||
if endpoint.Token != "token-1" {
|
||||
t.Fatalf("token = %q", endpoint.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadEndpointFileRejectsMissingAuth(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tuttid.listener.json")
|
||||
if err := os.WriteFile(path, []byte(`{"addr":"127.0.0.1:4545"}`), 0o600); err != nil {
|
||||
t.Fatalf("write endpoint: %v", err)
|
||||
}
|
||||
|
||||
if _, err := ReadEndpointFile(path); err == nil {
|
||||
t.Fatal("expected missing auth to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenerInfoPathUsesStateRoot(t *testing.T) {
|
||||
stateDir := t.TempDir()
|
||||
t.Setenv("TUTTI_STATE_DIR", stateDir)
|
||||
t.Setenv("TUTTID_RUN_DIR", "")
|
||||
t.Setenv("TUTTID_LISTENER_INFO_PATH", "")
|
||||
|
||||
want := filepath.Join(stateDir, "run", "tuttid.listener.json")
|
||||
if got := ListenerInfoPath(); got != want {
|
||||
t.Fatalf("ListenerInfoPath() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ErrorDetails struct {
|
||||
ReasonCode string
|
||||
Message string
|
||||
Retryable bool
|
||||
CorrelationID string
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
type requestError struct {
|
||||
details ErrorDetails
|
||||
}
|
||||
|
||||
func (e *requestError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.details.Message
|
||||
}
|
||||
|
||||
func RequestErrorDetails(err error) (ErrorDetails, bool) {
|
||||
var requestErr *requestError
|
||||
if !errors.As(err, &requestErr) || requestErr == nil {
|
||||
return ErrorDetails{}, false
|
||||
}
|
||||
return requestErr.details, true
|
||||
}
|
||||
|
||||
func newRequestError(details ErrorDetails) error {
|
||||
details.ReasonCode = strings.TrimSpace(details.ReasonCode)
|
||||
details.Message = strings.TrimSpace(details.Message)
|
||||
if details.ReasonCode == "" {
|
||||
details.ReasonCode = "daemon_request_failed"
|
||||
}
|
||||
if details.Message == "" {
|
||||
details.Message = details.ReasonCode
|
||||
}
|
||||
return &requestError{details: details}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package defaults
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type generatedDefaultsSpec struct {
|
||||
State generatedStateDefaults
|
||||
}
|
||||
|
||||
type generatedStateDefaults struct {
|
||||
ProductionDirName string
|
||||
DevelopmentDirName string
|
||||
RunDirName string
|
||||
ListenerInfoFileName string
|
||||
}
|
||||
|
||||
type ResolvedDefaults struct {
|
||||
Runtime RuntimeDefaults
|
||||
State StateDefaults
|
||||
}
|
||||
|
||||
type RuntimeDefaults struct {
|
||||
Env string
|
||||
}
|
||||
|
||||
type StateDefaults struct {
|
||||
RootDir string
|
||||
RunDir string
|
||||
TuttidListenerInfoPath string
|
||||
}
|
||||
|
||||
func ResolveDefaultsFromEnv() ResolvedDefaults {
|
||||
env := resolveTuttiEnv()
|
||||
stateRootDir := resolveStateRootDir(env)
|
||||
runDir := resolveRunDir(stateRootDir)
|
||||
|
||||
return ResolvedDefaults{
|
||||
Runtime: RuntimeDefaults{
|
||||
Env: env,
|
||||
},
|
||||
State: StateDefaults{
|
||||
RootDir: stateRootDir,
|
||||
RunDir: runDir,
|
||||
TuttidListenerInfoPath: resolveListenerInfoPath(runDir),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func resolveTuttiEnv() string {
|
||||
value := strings.ToLower(resolveStringOverride("TUTTI_ENV", ""))
|
||||
switch value {
|
||||
case "dev", "development", "local":
|
||||
return "development"
|
||||
default:
|
||||
return "production"
|
||||
}
|
||||
}
|
||||
|
||||
func resolveStateRootDir(env string) string {
|
||||
override := resolveStringOverride("TUTTI_STATE_DIR", "")
|
||||
if override != "" {
|
||||
return override
|
||||
}
|
||||
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil || strings.TrimSpace(homeDir) == "" {
|
||||
if env == "development" {
|
||||
return generatedDefaults.State.DevelopmentDirName
|
||||
}
|
||||
return generatedDefaults.State.ProductionDirName
|
||||
}
|
||||
|
||||
dirName := generatedDefaults.State.ProductionDirName
|
||||
if env == "development" {
|
||||
dirName = generatedDefaults.State.DevelopmentDirName
|
||||
}
|
||||
|
||||
return filepath.Join(homeDir, dirName)
|
||||
}
|
||||
|
||||
func resolveRunDir(stateRootDir string) string {
|
||||
override := resolveStringOverride("TUTTID_RUN_DIR", "")
|
||||
if override != "" {
|
||||
return override
|
||||
}
|
||||
|
||||
return filepath.Join(stateRootDir, generatedDefaults.State.RunDirName)
|
||||
}
|
||||
|
||||
func resolveListenerInfoPath(runDir string) string {
|
||||
override := resolveStringOverride("TUTTID_LISTENER_INFO_PATH", "")
|
||||
if override != "" {
|
||||
return override
|
||||
}
|
||||
|
||||
return filepath.Join(runDir, generatedDefaults.State.ListenerInfoFileName)
|
||||
}
|
||||
|
||||
func resolveStringOverride(name string, fallback string) string {
|
||||
override := strings.TrimSpace(os.Getenv(name))
|
||||
if override != "" {
|
||||
return override
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package defaults
|
||||
|
||||
// Code generated by tools/scripts/generate-defaults.mjs. DO NOT EDIT.
|
||||
|
||||
var generatedDefaults = generatedDefaultsSpec{
|
||||
State: generatedStateDefaults{
|
||||
ProductionDirName: ".tutti",
|
||||
DevelopmentDirName: ".tutti-dev",
|
||||
RunDirName: "run",
|
||||
ListenerInfoFileName: "tuttid.listener.json",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package defaults
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveDefaultsFromEnvUsesGeneratedStateDefaults(t *testing.T) {
|
||||
t.Setenv("HOME", "/tmp/tutti-cli-home")
|
||||
t.Setenv("TUTTI_ENV", "development")
|
||||
t.Setenv("TUTTI_STATE_DIR", "")
|
||||
t.Setenv("TUTTID_RUN_DIR", "")
|
||||
t.Setenv("TUTTID_LISTENER_INFO_PATH", "")
|
||||
|
||||
got := ResolveDefaultsFromEnv()
|
||||
|
||||
if got.Runtime.Env != "development" {
|
||||
t.Fatalf("env = %q, want development", got.Runtime.Env)
|
||||
}
|
||||
wantRoot := filepath.Join("/tmp/tutti-cli-home", ".tutti-dev")
|
||||
if got.State.RootDir != wantRoot {
|
||||
t.Fatalf("root dir = %q, want %q", got.State.RootDir, wantRoot)
|
||||
}
|
||||
wantListenerInfo := filepath.Join(wantRoot, "run", "tuttid.listener.json")
|
||||
if got.State.TuttidListenerInfoPath != wantListenerInfo {
|
||||
t.Fatalf("listener info path = %q, want %q", got.State.TuttidListenerInfoPath, wantListenerInfo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDefaultsFromEnvHonorsListenerInfoOverride(t *testing.T) {
|
||||
t.Setenv("TUTTID_LISTENER_INFO_PATH", "/tmp/tuttid.listener.json")
|
||||
|
||||
got := ResolveDefaultsFromEnv()
|
||||
|
||||
if got.State.TuttidListenerInfoPath != "/tmp/tuttid.listener.json" {
|
||||
t.Fatalf("listener info path = %q", got.State.TuttidListenerInfoPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDefaultsFromEnvAppliesOverrides(t *testing.T) {
|
||||
t.Setenv("TUTTI_ENV", "development")
|
||||
t.Setenv("TUTTI_STATE_DIR", "/tmp/tutti-state")
|
||||
t.Setenv("TUTTID_LISTENER_INFO_PATH", "/tmp/tuttid.listener.json")
|
||||
|
||||
got := ResolveDefaultsFromEnv()
|
||||
|
||||
if got.Runtime.Env != "development" {
|
||||
t.Fatalf("env = %q, want development", got.Runtime.Env)
|
||||
}
|
||||
if got.State.RootDir != "/tmp/tutti-state" {
|
||||
t.Fatalf("root dir = %q", got.State.RootDir)
|
||||
}
|
||||
if got.State.TuttidListenerInfoPath != "/tmp/tuttid.listener.json" {
|
||||
t.Fatalf("listener info path = %q", got.State.TuttidListenerInfoPath)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user