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

This commit is contained in:
agent-radar
2026-09-01 20:17:27 +08:00
commit 2ed3415fb7
8018 changed files with 1936476 additions and 0 deletions
+36
View File
@@ -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)
+12
View File
@@ -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))
}
+5
View File
@@ -0,0 +1,5 @@
module github.com/tutti-os/tutti/apps/cli
go 1.24.3
toolchain go1.24.5
+375
View File
@@ -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)
}
}
+96
View File
@@ -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()
}
+159
View File
@@ -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)
}
}
+837
View File
@@ -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)
}
+147
View File
@@ -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},
}
}
+306
View File
@@ -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)
}
+134
View File
@@ -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)
}
}
+82
View File
@@ -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
}
+48
View File
@@ -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)
}
}
+45
View File
@@ -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}
}
+108
View File
@@ -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)
}
}