From 52a64d42de751f95f061e90bb842257cef93f223 Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Mon, 29 Jun 2026 09:57:39 -0700 Subject: [PATCH] Add generated input_schema to command manifest and validate help --output Generates a JSON Schema per command from manifest args and flags, enabling tool callers and MCP integrations to validate structured input before building CLI invocations. Also validates --output format on help requests early so invalid formats produce proper structured errors. --- cmd/help_input_schema.go | 156 ++++++++++++++++++ cmd/help_json.go | 68 +++++++- cmd/help_json_test.go | 148 +++++++++++++++++ cmd/json_contract_test.go | 5 +- cmd/root.go | 10 +- cmd/root_test.go | 24 +++ .../json_contract/success_outputs.json | 129 +++++++++++++++ .../json_contract/success_schemas.json | 26 +++ docs/automation.md | 12 +- docs/json-schema/v1/README.md | 9 + docs/json-schema/v1/commands.json | 26 +++ docs/json-schema/v1/manifest.schema.json | 110 +++++++++++- 12 files changed, 715 insertions(+), 8 deletions(-) create mode 100644 cmd/help_input_schema.go diff --git a/cmd/help_input_schema.go b/cmd/help_input_schema.go new file mode 100644 index 0000000..339b050 --- /dev/null +++ b/cmd/help_input_schema.go @@ -0,0 +1,156 @@ +package cmd + +import ( + "strconv" + "strings" +) + +func commandInputSchemaFor(args []jsonCommandArg, flags []jsonCommandFlag) jsonCommandInputSchema { + schema := jsonCommandInputSchema{ + Type: "object", + AdditionalProperties: false, + Required: []string{}, + Properties: map[string]jsonCommandInputProperty{}, + } + + for _, arg := range args { + name := commandInputPropertyName(arg.Name) + property := jsonCommandInputProperty{ + Type: commandInputPropertyType(arg.ValueKind, ""), + Description: arg.Description, + XCLIKind: "arg", + XCLIName: arg.Name, + XValueKind: arg.ValueKind, + XStreamDash: arg.StreamDash, + } + if arg.Variadic { + property.Type = "array" + property.Items = &jsonCommandInputProperty{Type: commandInputPropertyType(arg.ValueKind, "")} + if arg.Required { + property.MinItems = 1 + } + } + if format := commandInputPropertyFormat(arg.ValueKind); format != "" && !arg.Variadic { + property.Format = format + } + schema.Properties[name] = property + if arg.Required { + schema.Required = append(schema.Required, name) + } + } + + for _, flag := range flags { + if !commandInputSchemaIncludesFlag(flag) { + continue + } + name := commandInputPropertyName(flag.Name) + propertyType := commandInputPropertyType(flag.ValueKind, flag.Type) + property := jsonCommandInputProperty{ + Type: propertyType, + Description: flag.Usage, + Enum: sortedCopyStringSlice(flag.EnumValues), + XCLIKind: "flag", + XCLIName: flag.Name, + XValueKind: flag.ValueKind, + XSensitive: flag.Sensitive, + XConflicts: commandInputPropertyNames(flag.Conflicts), + XInherited: flag.Inherited, + XShorthand: flag.Shorthand, + XMayPrompt: flag.MayPrompt, + } + if format := commandInputPropertyFormat(flag.ValueKind); format != "" { + property.Format = format + } + if flag.Sensitive { + property.WriteOnly = true + } + if value, ok := commandInputFlagDefault(flag, propertyType); ok { + property.Default = value + } + schema.Properties[name] = property + if flag.Required { + schema.Required = append(schema.Required, name) + } + } + + return schema +} + +func commandInputSchemaIncludesFlag(flag jsonCommandFlag) bool { + switch flag.Name { + case "help", outputFlag: + return false + default: + return true + } +} + +func commandInputPropertyNames(names []string) []string { + result := make([]string, 0, len(names)) + for _, name := range names { + result = append(result, commandInputPropertyName(name)) + } + return sortedCopyStringSlice(result) +} + +func commandInputPropertyName(name string) string { + return strings.ReplaceAll(name, "-", "_") +} + +func commandInputPropertyType(valueKind string, flagType string) string { + switch valueKind { + case "boolean": + return "boolean" + case "bytes", "integer": + return "integer" + case "enum", "string", "dropbox_path", "local_path", "dropbox_member_id", + "dropbox_app_key", "local_file", "secret", "rfc3339_timestamp", + "url", "email", "account_id", "auth_type", "command_path", "revision": + return "string" + } + + switch flagType { + case "bool": + return "boolean" + case "int", "int64", "uint64": + return "integer" + default: + return "string" + } +} + +func commandInputPropertyFormat(valueKind string) string { + switch valueKind { + case "email": + return "email" + case "rfc3339_timestamp": + return "date-time" + case "url": + return "uri" + default: + return "" + } +} + +func commandInputFlagDefault(flag jsonCommandFlag, propertyType string) (any, bool) { + if flag.Default == "" { + return nil, false + } + + switch propertyType { + case "boolean": + value, err := strconv.ParseBool(flag.Default) + if err != nil { + return nil, false + } + return value, true + case "integer": + value, err := strconv.ParseInt(flag.Default, 10, 64) + if err != nil { + return nil, false + } + return value, true + default: + return flag.Default, true + } +} diff --git a/cmd/help_json.go b/cmd/help_json.go index 452f033..4d3d7eb 100644 --- a/cmd/help_json.go +++ b/cmd/help_json.go @@ -49,6 +49,7 @@ type jsonCommandManifest struct { ResultKinds []string `json:"result_kinds"` WarningCodes []string `json:"warning_codes"` MayPrompt bool `json:"may_prompt"` + InputSchema jsonCommandInputSchema `json:"input_schema"` } type jsonCommandFlag struct { @@ -94,6 +95,34 @@ type jsonCommandStdinStdout struct { Stderr string `json:"stderr"` } +type jsonCommandInputSchema struct { + Type string `json:"type"` + AdditionalProperties bool `json:"additionalProperties"` + Required []string `json:"required"` + Properties map[string]jsonCommandInputProperty `json:"properties"` +} + +type jsonCommandInputProperty struct { + Type string `json:"type"` + Description string `json:"description,omitempty"` + Items *jsonCommandInputProperty `json:"items,omitempty"` + Enum []string `json:"enum,omitempty"` + Default any `json:"default,omitempty"` + Format string `json:"format,omitempty"` + MinItems int `json:"minItems,omitempty"` + WriteOnly bool `json:"writeOnly,omitempty"` + + XCLIKind string `json:"x-cli-kind,omitempty"` + XCLIName string `json:"x-cli-name,omitempty"` + XValueKind string `json:"x-value-kind,omitempty"` + XStreamDash bool `json:"x-stream-dash,omitempty"` + XSensitive bool `json:"x-sensitive,omitempty"` + XConflicts []string `json:"x-conflicts,omitempty"` + XInherited bool `json:"x-inherited,omitempty"` + XShorthand string `json:"x-shorthand,omitempty"` + XMayPrompt bool `json:"x-may-prompt,omitempty"` +} + func installJSONHelp(root *cobra.Command) { defaultHelp := root.HelpFunc() root.SetHelpFunc(func(cmd *cobra.Command, args []string) { @@ -185,6 +214,41 @@ func rawArgsRequestJSONHelp(args []string) bool { return outputJSONRequested(args) && (rawArgsHaveHelpFlag(args) || rawArgsFirstCommand(args) == "help") } +func rawArgsHelpOutputFormatError(args []string) error { + if !rawArgsHaveHelpFlag(args) { + return nil + } + value, ok := rawArgsOutputValue(args) + if !ok { + return nil + } + _, err := parseOutputFormat(value) + return err +} + +func rawArgsOutputValue(args []string) (string, bool) { + value := "" + found := false + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--": + return value, found + case arg == "--output": + if i+1 >= len(args) { + return value, found + } + value = args[i+1] + found = true + i++ + case strings.HasPrefix(arg, "--output="): + value = strings.TrimPrefix(arg, "--output=") + found = true + } + } + return value, found +} + func rawArgsHaveHelpFlag(args []string) bool { for _, arg := range args { switch arg { @@ -312,6 +376,7 @@ func jsonCommandManifestFor(cmd *cobra.Command) jsonCommandManifest { path := jsonManifestCommandPath(cmd) meta := commandManifestMetadataFor(path) supportsStructuredOutput := commandSupportsStructuredOutput(cmd) + flags := jsonCommandFlags(cmd, meta.Flags) return jsonCommandManifest{ Path: path, @@ -319,7 +384,7 @@ func jsonCommandManifestFor(cmd *cobra.Command) jsonCommandManifest { Short: cmd.Short, Aliases: sortedCopyStringSlice(cmd.Aliases), Runnable: cmd.Runnable(), - Flags: jsonCommandFlags(cmd, meta.Flags), + Flags: flags, SupportsStructuredOutput: supportsStructuredOutput, AuthModes: commandManifestAuthModes(cmd), DestructiveLevel: commandManifestDestructiveLevel(cmd), @@ -334,6 +399,7 @@ func jsonCommandManifestFor(cmd *cobra.Command) jsonCommandManifest { ResultKinds: sortedCopyStringSlice(meta.ResultKinds), WarningCodes: sortedCopyStringSlice(meta.WarningCodes), MayPrompt: meta.MayPrompt, + InputSchema: commandInputSchemaFor(meta.Args, flags), } } diff --git a/cmd/help_json_test.go b/cmd/help_json_test.go index f85a14e..000f19e 100644 --- a/cmd/help_json_test.go +++ b/cmd/help_json_test.go @@ -281,6 +281,31 @@ func TestJSONHelpManifestV1MachineFields(t *testing.T) { if len(put.Examples) == 0 { t.Fatal("put examples = empty, want examples") } + + if put.InputSchema.Type != "object" || put.InputSchema.AdditionalProperties { + t.Fatalf("put input_schema = %+v, want strict object", put.InputSchema) + } + assertStringSliceEqual(t, "put input_schema required", put.InputSchema.Required, []string{"source"}) + assertJSONHelpInputProperty(t, put.InputSchema, "source", "string", "arg", "source", "local_path") + if !put.InputSchema.Properties["source"].XStreamDash { + t.Fatal("put source x-stream-dash = false, want true") + } + assertJSONHelpInputProperty(t, put.InputSchema, "target", "string", "arg", "target", "dropbox_path") + ifExists := assertJSONHelpInputProperty(t, put.InputSchema, "if_exists", "string", "flag", "if-exists", "enum") + assertStringSliceEqual(t, "put input_schema if_exists enum", ifExists.Enum, []string{"fail", "overwrite", "skip"}) + if ifExists.Default != "overwrite" { + t.Fatalf("put if_exists default = %#v, want overwrite", ifExists.Default) + } + recursive := assertJSONHelpInputProperty(t, put.InputSchema, "recursive", "boolean", "flag", "recursive", "boolean") + if recursive.Default != false { + t.Fatalf("put recursive default = %#v, want false", recursive.Default) + } + if _, ok := put.InputSchema.Properties["help"]; ok { + t.Fatal("put input_schema contains help flag") + } + if _, ok := put.InputSchema.Properties["output"]; ok { + t.Fatal("put input_schema contains output flag") + } } func TestJSONHelpManifestV1SelectedCommandMetadata(t *testing.T) { @@ -297,6 +322,17 @@ func TestJSONHelpManifestV1SelectedCommandMetadata(t *testing.T) { t.Fatal("cp source variadic = false, want true") } assertStringSliceEqual(t, "cp --if-exists enum", jsonHelpFlagByName(t, cp.Flags, "if-exists").EnumValues, []string{"fail", "skip"}) + assertStringSliceEqual(t, "cp input_schema required", cp.InputSchema.Required, []string{"source", "target"}) + source := assertJSONHelpInputProperty(t, cp.InputSchema, "source", "array", "arg", "source", "dropbox_path") + if source.Items == nil || source.Items.Type != "string" { + t.Fatalf("cp source items = %+v, want string items", source.Items) + } + if source.MinItems != 1 { + t.Fatalf("cp source minItems = %d, want 1", source.MinItems) + } + assertJSONHelpInputProperty(t, cp.InputSchema, "target", "string", "arg", "target", "dropbox_path") + cpIfExists := assertJSONHelpInputProperty(t, cp.InputSchema, "if_exists", "string", "flag", "if-exists", "enum") + assertStringSliceEqual(t, "cp input_schema if_exists enum", cpIfExists.Enum, []string{"fail", "skip"}) create := jsonCommandManifestFor(shareLinkCreateCmd) assertStringSliceEqual(t, "share-link create audience enum", jsonHelpFlagByName(t, create.Flags, "audience").EnumValues, []string{"public", "team", "members", "no-one"}) @@ -311,6 +347,22 @@ func TestJSONHelpManifestV1SelectedCommandMetadata(t *testing.T) { update := jsonCommandManifestFor(shareLinkUpdateCmd) assertStringSliceEqual(t, "share-link update expires conflict", jsonHelpFlagByName(t, update.Flags, "expires").Conflicts, []string{"remove-expiration"}) assertStringSliceEqual(t, "share-link update remove-password conflict", jsonHelpFlagByName(t, update.Flags, "remove-password").Conflicts, []string{"password", "password-file", "password-prompt"}) + audience := assertJSONHelpInputProperty(t, update.InputSchema, "audience", "string", "flag", "audience", "enum") + assertStringSliceEqual(t, "share-link update input_schema audience enum", audience.Enum, []string{"members", "no-one", "public", "team"}) + expires := assertJSONHelpInputProperty(t, update.InputSchema, "expires", "string", "flag", "expires", "rfc3339_timestamp") + if expires.Format != "date-time" { + t.Fatalf("share-link update expires format = %q, want date-time", expires.Format) + } + removeExpiration := assertJSONHelpInputProperty(t, update.InputSchema, "remove_expiration", "boolean", "flag", "remove-expiration", "boolean") + assertStringSliceEqual(t, "share-link update remove_expiration conflicts", removeExpiration.XConflicts, []string{"expires"}) + password := assertJSONHelpInputProperty(t, update.InputSchema, "password", "string", "flag", "password", "secret") + if !password.XSensitive || !password.WriteOnly { + t.Fatalf("share-link update password sensitivity = %+v, want sensitive writeOnly", password) + } + passwordPrompt := assertJSONHelpInputProperty(t, update.InputSchema, "password_prompt", "boolean", "flag", "password-prompt", "boolean") + if !passwordPrompt.XMayPrompt { + t.Fatal("share-link update password_prompt x-may-prompt = false, want true") + } deprecatedListLink := jsonCommandManifestFor(shareListLinksCmd) if !strings.Contains(deprecatedListLink.Use, "[path]") { @@ -347,6 +399,24 @@ func TestJSONHelpManifestRegistryAudit(t *testing.T) { } manifest := jsonCommandManifestFor(command) + if manifest.InputSchema.Type != "object" { + t.Errorf("%s input_schema type = %q, want object", path, manifest.InputSchema.Type) + } + if manifest.InputSchema.AdditionalProperties { + t.Errorf("%s input_schema additionalProperties = true, want false", path) + } + if manifest.InputSchema.Required == nil { + t.Errorf("%s input_schema required = nil, want empty array", path) + } + if manifest.InputSchema.Properties == nil { + t.Errorf("%s input_schema properties = nil, want empty object", path) + } + if _, ok := manifest.InputSchema.Properties["help"]; ok { + t.Errorf("%s input_schema contains help flag", path) + } + if _, ok := manifest.InputSchema.Properties["output"]; ok { + t.Errorf("%s input_schema contains output flag", path) + } flagNames := make(map[string]bool) for _, flag := range manifest.Flags { flagNames[flag.Name] = true @@ -541,6 +611,72 @@ func TestJSONHelpRawArgsDetection(t *testing.T) { } } +func TestRawArgsHelpOutputFormatError(t *testing.T) { + tests := []struct { + name string + args []string + wantErr bool + }{ + { + name: "text help", + args: []string{"put", "--help"}, + }, + { + name: "json help", + args: []string{"put", "--help", "--output=json"}, + }, + { + name: "explicit text help", + args: []string{"put", "--help", "--output", "text"}, + }, + { + name: "invalid help output", + args: []string{"put", "--help", "--output=yaml"}, + wantErr: true, + }, + { + name: "invalid root help output", + args: []string{"--help", "--output", "yaml"}, + wantErr: true, + }, + { + name: "normal command invalid output is not help", + args: []string{"put", "--output=yaml", "file.txt"}, + }, + { + name: "last valid output wins", + args: []string{"put", "--output=yaml", "--output=json", "--help"}, + }, + { + name: "last invalid output wins", + args: []string{"put", "--output=json", "--output=yaml", "--help"}, + wantErr: true, + }, + { + name: "output after double dash ignored", + args: []string{"put", "--help", "--", "--output=yaml"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := rawArgsHelpOutputFormatError(tt.args) + if tt.wantErr { + if err == nil { + t.Fatalf("rawArgsHelpOutputFormatError(%v) = nil, want error", tt.args) + } + if !strings.Contains(err.Error(), `unsupported output format "yaml"`) { + t.Fatalf("error = %q, want unsupported output format", err.Error()) + } + return + } + if err != nil { + t.Fatalf("rawArgsHelpOutputFormatError(%v) = %v, want nil", tt.args, err) + } + }) + } +} + func TestJSONHelpSuppressesCobraDeprecationWarning(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer @@ -882,6 +1018,18 @@ func jsonHelpArgByName(t *testing.T, args []jsonCommandArg, name string) jsonCom return jsonCommandArg{} } +func assertJSONHelpInputProperty(t *testing.T, schema jsonCommandInputSchema, name string, propertyType string, cliKind string, cliName string, valueKind string) jsonCommandInputProperty { + t.Helper() + property, ok := schema.Properties[name] + if !ok { + t.Fatalf("input_schema property %q not found in %+v", name, schema.Properties) + } + if property.Type != propertyType || property.XCLIKind != cliKind || property.XCLIName != cliName || property.XValueKind != valueKind { + t.Fatalf("input_schema property %s = %+v, want type=%s x-cli-kind=%s x-cli-name=%s x-value-kind=%s", name, property, propertyType, cliKind, cliName, valueKind) + } + return property +} + func sortStrings(values []string) { sort.Strings(values) } diff --git a/cmd/json_contract_test.go b/cmd/json_contract_test.go index 0a5d9ad..6faee0a 100644 --- a/cmd/json_contract_test.go +++ b/cmd/json_contract_test.go @@ -234,6 +234,7 @@ func TestPublicJSONManifestSchemaFile(t *testing.T) { "dropbox_scopes", "examples", "flags", + "input_schema", "manifest_version", "may_prompt", "path", @@ -250,7 +251,7 @@ func TestPublicJSONManifestSchemaFile(t *testing.T) { } assertStringSliceEqual(t, "manifest schema required", schema.Required, want) assertStringSliceEqual(t, "manifest schema properties", mapKeys(schema.Properties), want) - for _, def := range []string{"arg", "example", "flag", "schema_refs", "stdin_stdout"} { + for _, def := range []string{"arg", "example", "flag", "input_property", "input_schema", "schema_refs", "stdin_stdout"} { if _, ok := schema.Defs[def]; !ok { t.Fatalf("manifest schema missing $defs.%s", def) } @@ -871,6 +872,8 @@ func jsonContractDefinitions() map[string][]string { "command_arg": jsonFieldNames[jsonCommandArg](), "command_example": jsonFieldNames[jsonCommandExample](), "command_flag": jsonFieldNames[jsonCommandFlag](), + "command_input_property": jsonFieldNames[jsonCommandInputProperty](), + "command_input_schema": jsonFieldNames[jsonCommandInputSchema](), "command_manifest": jsonFieldNames[jsonCommandManifest](), "command_schema_refs": jsonFieldNames[jsonCommandSchemaRefs](), "command_stdin_stdout": jsonFieldNames[jsonCommandStdinStdout](), diff --git a/cmd/root.go b/cmd/root.go index be3a2c0..0fc747d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -238,9 +238,15 @@ manage your team and more. It is easy, scriptable and works on all platforms!`, // Execute adds all child commands to the root command sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { - jsonErrorOutput := outputJSONRequested(os.Args[1:]) + args := os.Args[1:] + jsonErrorOutput := outputJSONRequested(args) + if err := rawArgsHelpOutputFormatError(args); err != nil { + renderCommandErrorWithJSON(RootCmd, err, jsonErrorOutput) + os.Exit(exitCodeForError(err)) + } + restoreDeprecatedCommands := func() {} - if rawArgsRequestJSONHelp(os.Args[1:]) { + if rawArgsRequestJSONHelp(args) { restoreDeprecatedCommands = temporarilyClearDeprecatedCommands(RootCmd) } defer restoreDeprecatedCommands() diff --git a/cmd/root_test.go b/cmd/root_test.go index 91a428a..2ae27c0 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -70,6 +70,30 @@ func TestExecuteExitsWithMappedCodes(t *testing.T) { wantExitCode: exitCodeValidationError, wantStderrText: `unsupported output format "yaml"`, }, + { + name: "unsupported output format with root help", + args: []string{"--help", "--output=yaml"}, + wantExitCode: exitCodeValidationError, + wantStderrText: `unsupported output format "yaml"`, + }, + { + name: "unsupported output format with command help", + args: []string{"put", "--help", "--output=yaml"}, + wantExitCode: exitCodeValidationError, + wantStderrText: `unsupported output format "yaml"`, + }, + { + name: "unsupported output format with help command", + args: []string{"help", "put", "--output=yaml"}, + wantExitCode: exitCodeValidationError, + wantStderrText: `unsupported output format "yaml"`, + }, + { + name: "unsupported output format before help command", + args: []string{"--output=yaml", "help", "put"}, + wantExitCode: exitCodeValidationError, + wantStderrText: `unsupported output format "yaml"`, + }, { name: "last unsupported output format wins", args: []string{"--output=json", "--output=yaml", "ls", "/"}, diff --git a/cmd/testdata/json_contract/success_outputs.json b/cmd/testdata/json_contract/success_outputs.json index 1d7a7e2..2bcca43 100644 --- a/cmd/testdata/json_contract/success_outputs.json +++ b/cmd/testdata/json_contract/success_outputs.json @@ -234,6 +234,135 @@ "value_kind": "boolean" } ], + "input_schema": { + "type": "object", + "additionalProperties": false, + "required": [], + "properties": { + "as_member": { + "type": "string", + "description": "Member ID to perform action as", + "x-cli-kind": "flag", + "x-cli-name": "as-member", + "x-value-kind": "dropbox_member_id", + "x-inherited": true + }, + "include_deleted": { + "type": "boolean", + "description": "Include deleted files", + "default": false, + "x-cli-kind": "flag", + "x-cli-name": "include-deleted", + "x-value-kind": "boolean", + "x-shorthand": "d" + }, + "limit": { + "type": "integer", + "description": "Maximum number of entries to return", + "default": 0, + "x-cli-kind": "flag", + "x-cli-name": "limit", + "x-value-kind": "integer" + }, + "long": { + "type": "boolean", + "description": "Long listing", + "default": false, + "x-cli-kind": "flag", + "x-cli-name": "long", + "x-value-kind": "boolean", + "x-shorthand": "l" + }, + "only_deleted": { + "type": "boolean", + "description": "Only show deleted files", + "default": false, + "x-cli-kind": "flag", + "x-cli-name": "only-deleted", + "x-value-kind": "boolean", + "x-shorthand": "D" + }, + "path": { + "type": "string", + "description": "Dropbox folder or file path", + "x-cli-kind": "arg", + "x-cli-name": "path", + "x-value-kind": "dropbox_path" + }, + "recurse": { + "type": "boolean", + "description": "Alias for --recursive", + "default": false, + "x-cli-kind": "flag", + "x-cli-name": "recurse", + "x-value-kind": "boolean", + "x-shorthand": "R" + }, + "recursive": { + "type": "boolean", + "description": "Recursively list all subfolders", + "default": false, + "x-cli-kind": "flag", + "x-cli-name": "recursive", + "x-value-kind": "boolean" + }, + "reverse": { + "type": "boolean", + "description": "Reverse sort order", + "default": false, + "x-cli-kind": "flag", + "x-cli-name": "reverse", + "x-value-kind": "boolean", + "x-shorthand": "r" + }, + "sort": { + "type": "string", + "description": "Sort by: name, size, time, type", + "enum": [ + "name", + "size", + "time", + "type" + ], + "x-cli-kind": "flag", + "x-cli-name": "sort", + "x-value-kind": "enum" + }, + "time": { + "type": "string", + "description": "Time field: server, client", + "enum": [ + "client", + "server" + ], + "default": "server", + "x-cli-kind": "flag", + "x-cli-name": "time", + "x-value-kind": "enum" + }, + "time_format": { + "type": "string", + "description": "Time format: short (2006-01-02 15:04), rfc3339", + "enum": [ + "rfc3339", + "short" + ], + "x-cli-kind": "flag", + "x-cli-name": "time-format", + "x-value-kind": "enum" + }, + "verbose": { + "type": "boolean", + "description": "Enable verbose logging", + "default": false, + "x-cli-kind": "flag", + "x-cli-name": "verbose", + "x-value-kind": "boolean", + "x-inherited": true, + "x-shorthand": "v" + } + } + }, "supports_structured_output": true, "auth_modes": [ "personal", diff --git a/cmd/testdata/json_contract/success_schemas.json b/cmd/testdata/json_contract/success_schemas.json index 8eaaa30..df805b7 100644 --- a/cmd/testdata/json_contract/success_schemas.json +++ b/cmd/testdata/json_contract/success_schemas.json @@ -64,6 +64,31 @@ "usage", "value_kind" ], + "command_input_property": [ + "default", + "description", + "enum", + "format", + "items", + "minItems", + "type", + "writeOnly", + "x-cli-kind", + "x-cli-name", + "x-conflicts", + "x-inherited", + "x-may-prompt", + "x-sensitive", + "x-shorthand", + "x-stream-dash", + "x-value-kind" + ], + "command_input_schema": [ + "additionalProperties", + "properties", + "required", + "type" + ], "command_manifest": [ "aliases", "args", @@ -72,6 +97,7 @@ "dropbox_scopes", "examples", "flags", + "input_schema", "manifest_version", "may_prompt", "path", diff --git a/docs/automation.md b/docs/automation.md index 800e95d..e14f9b2 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -87,9 +87,15 @@ dbxcli share-link create --help --output=json dbxcli --output=json help share-link create ``` -Use JSON help to discover command paths, structured args, flags, aliases, known -auth modes, known destructive levels, stdin/stdout behavior, schema refs, and -whether normal structured command output is supported. +Use JSON help to discover command paths, structured args, flags, generated +input schemas, aliases, known auth modes, known destructive levels, +stdin/stdout behavior, schema refs, and whether normal structured command +output is supported. + +Each manifest result includes `input_schema`, a JSON Schema object for the +command's CLI inputs. It uses JSON-friendly names such as `if_exists` while +preserving original CLI names in `x-cli-name`, so tools can validate structured +input and then build the correct argument/flag invocation. ## Safe scripting patterns diff --git a/docs/json-schema/v1/README.md b/docs/json-schema/v1/README.md index 366b1a9..ebddd7b 100644 --- a/docs/json-schema/v1/README.md +++ b/docs/json-schema/v1/README.md @@ -78,12 +78,21 @@ metadata: - structured `args` - enriched `flags`, including enum values, conflicts, prompt behavior, and sensitive inputs +- generated `input_schema` for command arguments and flags - `examples` - `schema_refs` - best-effort audited `dropbox_scopes` - `stdin_stdout` - `result_statuses`, `result_kinds`, and `warning_codes` +`input_schema` is a JSON Schema object generated from the command manifest's +structured positional arguments and flags. It is intended for tool callers, +MCP-style integrations, and automation planners that need to validate command +inputs before building a CLI invocation. It excludes `--help` and `--output`, +uses JSON-friendly property names such as `if_exists`, and preserves the +original CLI names in `x-cli-name`. Sensitive inputs are marked with +`writeOnly` and `x-sensitive`; flag conflicts are listed in `x-conflicts`. + `scope_accuracy` is currently `audited_best_effort` for commands with audited manifest metadata. Scope metadata is intended for planning and diagnostics; Dropbox API errors remain the source of truth at runtime. diff --git a/docs/json-schema/v1/commands.json b/docs/json-schema/v1/commands.json index 8eaaa30..df805b7 100644 --- a/docs/json-schema/v1/commands.json +++ b/docs/json-schema/v1/commands.json @@ -64,6 +64,31 @@ "usage", "value_kind" ], + "command_input_property": [ + "default", + "description", + "enum", + "format", + "items", + "minItems", + "type", + "writeOnly", + "x-cli-kind", + "x-cli-name", + "x-conflicts", + "x-inherited", + "x-may-prompt", + "x-sensitive", + "x-shorthand", + "x-stream-dash", + "x-value-kind" + ], + "command_input_schema": [ + "additionalProperties", + "properties", + "required", + "type" + ], "command_manifest": [ "aliases", "args", @@ -72,6 +97,7 @@ "dropbox_scopes", "examples", "flags", + "input_schema", "manifest_version", "may_prompt", "path", diff --git a/docs/json-schema/v1/manifest.schema.json b/docs/json-schema/v1/manifest.schema.json index f79d239..6d83565 100644 --- a/docs/json-schema/v1/manifest.schema.json +++ b/docs/json-schema/v1/manifest.schema.json @@ -24,7 +24,8 @@ "result_statuses", "result_kinds", "warning_codes", - "may_prompt" + "may_prompt", + "input_schema" ], "properties": { "path": { @@ -118,6 +119,9 @@ }, "may_prompt": { "type": "boolean" + }, + "input_schema": { + "$ref": "#/$defs/input_schema" } }, "$defs": { @@ -277,6 +281,110 @@ "type": "string" } } + }, + "input_schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "additionalProperties", + "required", + "properties" + ], + "properties": { + "type": { + "const": "object" + }, + "additionalProperties": { + "const": false + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/input_property" + } + } + } + }, + "input_property": { + "type": "object", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "array", + "boolean", + "integer", + "string" + ] + }, + "description": { + "type": "string" + }, + "items": { + "$ref": "#/$defs/input_property" + }, + "enum": { + "type": "array", + "items": { + "type": "string" + } + }, + "default": {}, + "format": { + "type": "string" + }, + "minItems": { + "type": "integer" + }, + "writeOnly": { + "type": "boolean" + }, + "x-cli-kind": { + "type": "string", + "enum": [ + "arg", + "flag" + ] + }, + "x-cli-name": { + "type": "string" + }, + "x-value-kind": { + "type": "string" + }, + "x-stream-dash": { + "type": "boolean" + }, + "x-sensitive": { + "type": "boolean" + }, + "x-conflicts": { + "type": "array", + "items": { + "type": "string" + } + }, + "x-inherited": { + "type": "boolean" + }, + "x-shorthand": { + "type": "string" + }, + "x-may-prompt": { + "type": "boolean" + } + } } } }