diff --git a/cmd/help_json.go b/cmd/help_json.go index c47280e..452f033 100644 --- a/cmd/help_json.go +++ b/cmd/help_json.go @@ -29,23 +29,69 @@ type jsonHelpInput struct { } type jsonCommandManifest struct { - Path string `json:"path"` - Use string `json:"use"` - Short string `json:"short"` - Aliases []string `json:"aliases"` - Runnable bool `json:"runnable"` - Flags []jsonCommandFlag `json:"flags"` - SupportsStructuredOutput bool `json:"supports_structured_output"` - AuthModes []string `json:"auth_modes"` - DestructiveLevel string `json:"destructive_level"` + Path string `json:"path"` + Use string `json:"use"` + Short string `json:"short"` + Aliases []string `json:"aliases"` + Runnable bool `json:"runnable"` + Flags []jsonCommandFlag `json:"flags"` + SupportsStructuredOutput bool `json:"supports_structured_output"` + AuthModes []string `json:"auth_modes"` + DestructiveLevel string `json:"destructive_level"` + ManifestVersion string `json:"manifest_version"` + Args []jsonCommandArg `json:"args"` + Examples []jsonCommandExample `json:"examples"` + SchemaRefs jsonCommandSchemaRefs `json:"schema_refs"` + DropboxScopes []string `json:"dropbox_scopes"` + ScopeAccuracy string `json:"scope_accuracy"` + StdinStdout jsonCommandStdinStdout `json:"stdin_stdout"` + ResultStatuses []string `json:"result_statuses"` + ResultKinds []string `json:"result_kinds"` + WarningCodes []string `json:"warning_codes"` + MayPrompt bool `json:"may_prompt"` } type jsonCommandFlag struct { - Name string `json:"name"` - Type string `json:"type"` - Default string `json:"default"` - Usage string `json:"usage"` - Inherited bool `json:"inherited"` + Name string `json:"name"` + Type string `json:"type"` + Default string `json:"default"` + Usage string `json:"usage"` + Inherited bool `json:"inherited"` + Shorthand string `json:"shorthand"` + EnumValues []string `json:"enum_values"` + Conflicts []string `json:"conflicts"` + Required bool `json:"required"` + Sensitive bool `json:"sensitive"` + MayPrompt bool `json:"may_prompt"` + ValueKind string `json:"value_kind"` +} + +type jsonCommandArg struct { + Name string `json:"name"` + Required bool `json:"required"` + Variadic bool `json:"variadic"` + Placement string `json:"placement"` + ValueKind string `json:"value_kind"` + Description string `json:"description"` + StreamDash bool `json:"stream_dash"` +} + +type jsonCommandExample struct { + Description string `json:"description"` + Command string `json:"command"` +} + +type jsonCommandSchemaRefs struct { + SuccessSchema string `json:"success_schema"` + ErrorSchema string `json:"error_schema"` + CommandContract string `json:"command_contract,omitempty"` +} + +type jsonCommandStdinStdout struct { + ReadsStdin bool `json:"reads_stdin"` + WritesBinaryStdout bool `json:"writes_binary_stdout"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` } func installJSONHelp(root *cobra.Command) { @@ -263,17 +309,31 @@ func publicCommandSubtree(cmd *cobra.Command) []*cobra.Command { func jsonCommandManifestFor(cmd *cobra.Command) jsonCommandManifest { cmd.InitDefaultHelpFlag() cmd.InitDefaultVersionFlag() + path := jsonManifestCommandPath(cmd) + meta := commandManifestMetadataFor(path) + supportsStructuredOutput := commandSupportsStructuredOutput(cmd) return jsonCommandManifest{ - Path: jsonManifestCommandPath(cmd), + Path: path, Use: cmd.UseLine(), Short: cmd.Short, Aliases: sortedCopyStringSlice(cmd.Aliases), Runnable: cmd.Runnable(), - Flags: jsonCommandFlags(cmd), - SupportsStructuredOutput: commandSupportsStructuredOutput(cmd), + Flags: jsonCommandFlags(cmd, meta.Flags), + SupportsStructuredOutput: supportsStructuredOutput, AuthModes: commandManifestAuthModes(cmd), DestructiveLevel: commandManifestDestructiveLevel(cmd), + ManifestVersion: commandManifestVersion, + Args: normalizeJSONCommandArgs(meta.Args), + Examples: normalizeJSONCommandExamples(meta.Examples), + SchemaRefs: commandManifestSchemaRefs(path, supportsStructuredOutput), + DropboxScopes: sortedCopyStringSlice(meta.DropboxScopes), + ScopeAccuracy: commandManifestScopeAccuracy(meta), + StdinStdout: commandManifestStdinStdout(meta), + ResultStatuses: sortedCopyStringSlice(meta.ResultStatuses), + ResultKinds: sortedCopyStringSlice(meta.ResultKinds), + WarningCodes: sortedCopyStringSlice(meta.WarningCodes), + MayPrompt: meta.MayPrompt, } } @@ -287,7 +347,7 @@ func jsonManifestCommandPath(cmd *cobra.Command) string { return jsonCommandPath(cmd) } -func jsonCommandFlags(cmd *cobra.Command) []jsonCommandFlag { +func jsonCommandFlags(cmd *cobra.Command, metadata map[string]jsonCommandFlagMetadata) []jsonCommandFlag { flagsByName := make(map[string]jsonCommandFlag) addFlags := func(flags *pflag.FlagSet, inherited bool) { if flags == nil { @@ -304,12 +364,20 @@ func jsonCommandFlags(cmd *cobra.Command) []jsonCommandFlag { if flag.Value != nil { flagType = flag.Value.Type() } + flagMeta := metadata[flag.Name] flagsByName[flag.Name] = jsonCommandFlag{ - Name: flag.Name, - Type: flagType, - Default: flag.DefValue, - Usage: flag.Usage, - Inherited: inherited, + Name: flag.Name, + Type: flagType, + Default: flag.DefValue, + Usage: flag.Usage, + Inherited: inherited, + Shorthand: flag.Shorthand, + EnumValues: sortedCopyStringSlice(flagMeta.EnumValues), + Conflicts: sortedCopyStringSlice(flagMeta.Conflicts), + Required: flagMeta.Required, + Sensitive: flagMeta.Sensitive, + MayPrompt: flagMeta.MayPrompt, + ValueKind: commandManifestFlagValueKind(flag, flagMeta), } }) } @@ -330,6 +398,11 @@ func jsonCommandFlags(cmd *cobra.Command) []jsonCommandFlag { return result } +// CommandManifestFor returns the JSON help manifest for a command. +func CommandManifestFor(cmd *cobra.Command) jsonCommandManifest { + return jsonCommandManifestFor(cmd) +} + func setCommandAuthModes(cmd *cobra.Command, modes ...string) { setCommandAnnotationList(cmd, commandAuthModesAnnotation, modes) } diff --git a/cmd/help_json_test.go b/cmd/help_json_test.go index 99cda96..f85a14e 100644 --- a/cmd/help_json_test.go +++ b/cmd/help_json_test.go @@ -3,6 +3,7 @@ package cmd import ( "bytes" "encoding/json" + "os" "path/filepath" "reflect" "sort" @@ -256,6 +257,146 @@ func TestJSONHelpManifestFields(t *testing.T) { } } +func TestJSONHelpManifestV1MachineFields(t *testing.T) { + put := jsonCommandManifestFor(putCmd) + if put.ManifestVersion != commandManifestVersion { + t.Fatalf("manifest_version = %q, want %q", put.ManifestVersion, commandManifestVersion) + } + assertJSONHelpArg(t, put.Args, "source", true, "local_path", true) + assertJSONHelpArg(t, put.Args, "target", false, "dropbox_path", false) + assertStringSliceEqual(t, "put --if-exists enum", jsonHelpFlagByName(t, put.Flags, "if-exists").EnumValues, []string{"overwrite", "skip", "fail"}) + if !put.StdinStdout.ReadsStdin || put.StdinStdout.WritesBinaryStdout { + t.Fatalf("put stdin_stdout = %+v, want stdin only", put.StdinStdout) + } + assertStringSliceEqual(t, "put warning codes", put.WarningCodes, []string{jsonWarningCodeSkippedSymlink}) + assertStringSliceEqual(t, "put result statuses", put.ResultStatuses, []string{"created", "existing", "skipped", "uploaded"}) + assertStringSliceEqual(t, "put result kinds", put.ResultKinds, []string{"file", "folder"}) + assertStringSliceEqual(t, "put scopes", put.DropboxScopes, []string{"files.content.write", "files.metadata.read"}) + if put.ScopeAccuracy != commandManifestScopeAccuracyBestEffort { + t.Fatalf("scope_accuracy = %q, want %q", put.ScopeAccuracy, commandManifestScopeAccuracyBestEffort) + } + if put.SchemaRefs.CommandContract != "docs/json-schema/v1/commands.json#/commands/put" { + t.Fatalf("put command_contract = %q", put.SchemaRefs.CommandContract) + } + if len(put.Examples) == 0 { + t.Fatal("put examples = empty, want examples") + } +} + +func TestJSONHelpManifestV1SelectedCommandMetadata(t *testing.T) { + get := jsonCommandManifestFor(getCmd) + assertJSONHelpArg(t, get.Args, "source", true, "dropbox_path", false) + assertJSONHelpArg(t, get.Args, "target", false, "local_path", true) + if !get.StdinStdout.WritesBinaryStdout { + t.Fatalf("get stdin_stdout = %+v, want binary stdout support", get.StdinStdout) + } + + cp := jsonCommandManifestFor(cpCmd) + assertJSONHelpArg(t, cp.Args, "source", true, "dropbox_path", false) + if !jsonHelpArgByName(t, cp.Args, "source").Variadic { + t.Fatal("cp source variadic = false, want true") + } + assertStringSliceEqual(t, "cp --if-exists enum", jsonHelpFlagByName(t, cp.Flags, "if-exists").EnumValues, []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"}) + assertStringSliceEqual(t, "share-link create allow conflict", jsonHelpFlagByName(t, create.Flags, "allow-download").Conflicts, []string{"disallow-download"}) + if !jsonHelpFlagByName(t, create.Flags, "password").Sensitive { + t.Fatal("share-link create password sensitive = false, want true") + } + if !jsonHelpFlagByName(t, create.Flags, "password-prompt").MayPrompt { + t.Fatal("share-link create password-prompt may_prompt = false, want true") + } + + 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"}) + + deprecatedListLink := jsonCommandManifestFor(shareListLinksCmd) + if !strings.Contains(deprecatedListLink.Use, "[path]") { + t.Fatalf("share list link use = %q, want optional path", deprecatedListLink.Use) + } + assertJSONHelpArg(t, deprecatedListLink.Args, "path", false, "dropbox_path", false) + + login := jsonCommandManifestFor(loginCmd) + if !login.MayPrompt { + t.Fatal("login may_prompt = false, want true") + } + if jsonHelpFlagByName(t, login.Flags, "app-key").ValueKind != "dropbox_app_key" { + t.Fatalf("login app-key value_kind = %q", jsonHelpFlagByName(t, login.Flags, "app-key").ValueKind) + } + + completion := jsonCommandManifestFor(newCompletionCmd()) + if completion.SupportsStructuredOutput { + t.Fatal("completion supports_structured_output = true, want false") + } + if len(completion.AuthModes) != 0 || len(completion.DropboxScopes) != 0 { + t.Fatalf("completion auth/scopes = %v/%v, want none", completion.AuthModes, completion.DropboxScopes) + } +} + +func TestJSONHelpManifestRegistryAudit(t *testing.T) { + RootCmd.InitDefaultHelpCmd() + + for _, command := range publicCommandSubtree(RootCmd) { + path := jsonCommandManifestFor(command).Path + if command.Runnable() { + if !commandManifestRegistry[path].Known { + t.Errorf("runnable command %q has no command manifest registry entry", path) + } + } + + manifest := jsonCommandManifestFor(command) + flagNames := make(map[string]bool) + for _, flag := range manifest.Flags { + flagNames[flag.Name] = true + } + if registry := commandManifestRegistry[path]; registry.Known { + for name, flagMeta := range registry.Flags { + if !flagNames[name] { + t.Errorf("%s has registry metadata for unknown flag --%s", path, name) + } + for _, conflict := range flagMeta.Conflicts { + if !flagNames[conflict] { + t.Errorf("%s registry metadata for --%s conflicts with unknown flag --%s", path, name, conflict) + } + } + } + } + for _, flag := range manifest.Flags { + for _, conflict := range flag.Conflicts { + if !flagNames[conflict] { + t.Errorf("%s --%s conflicts with unknown flag --%s", path, flag.Name, conflict) + } + } + } + if manifest.SchemaRefs.SuccessSchema != commandManifestSuccessSchema { + t.Errorf("%s success schema = %q", path, manifest.SchemaRefs.SuccessSchema) + } + if _, err := os.Stat(filepath.Join("..", manifest.SchemaRefs.SuccessSchema)); err != nil { + t.Errorf("%s success schema %q is not readable: %v", path, manifest.SchemaRefs.SuccessSchema, err) + } + if manifest.SchemaRefs.ErrorSchema != commandManifestErrorSchema { + t.Errorf("%s error schema = %q", path, manifest.SchemaRefs.ErrorSchema) + } + if _, err := os.Stat(filepath.Join("..", manifest.SchemaRefs.ErrorSchema)); err != nil { + t.Errorf("%s error schema %q is not readable: %v", path, manifest.SchemaRefs.ErrorSchema, err) + } + if manifest.SupportsStructuredOutput && manifest.SchemaRefs.CommandContract == "" { + t.Errorf("%s supports structured output but has no command contract ref", path) + } + if manifest.SchemaRefs.CommandContract != "" { + wantPrefix := commandManifestContractFile + "#/commands/" + if !strings.HasPrefix(manifest.SchemaRefs.CommandContract, wantPrefix) { + t.Errorf("%s command contract = %q, want prefix %q", path, manifest.SchemaRefs.CommandContract, wantPrefix) + } + if _, err := os.Stat(filepath.Join("..", commandManifestContractFile)); err != nil { + t.Errorf("%s command contract file %q is not readable: %v", path, commandManifestContractFile, err) + } + } + } +} + func TestJSONHelpIsDeterministic(t *testing.T) { first, _, err := executeJSONHelpTestRoot(t, []string{"--help", "--output=json"}) if err != nil { @@ -722,6 +863,25 @@ func jsonHelpFlagByName(t *testing.T, flags []jsonCommandFlag, name string) json return jsonCommandFlag{} } +func assertJSONHelpArg(t *testing.T, args []jsonCommandArg, name string, required bool, valueKind string, streamDash bool) { + t.Helper() + arg := jsonHelpArgByName(t, args, name) + if arg.Required != required || arg.ValueKind != valueKind || arg.StreamDash != streamDash { + t.Fatalf("arg %s = %+v, want required=%t value_kind=%s stream_dash=%t", name, arg, required, valueKind, streamDash) + } +} + +func jsonHelpArgByName(t *testing.T, args []jsonCommandArg, name string) jsonCommandArg { + t.Helper() + for _, arg := range args { + if arg.Name == name { + return arg + } + } + t.Fatalf("arg %q not found", name) + return jsonCommandArg{} +} + func sortStrings(values []string) { sort.Strings(values) } diff --git a/cmd/help_manifest.go b/cmd/help_manifest.go new file mode 100644 index 0000000..faff373 --- /dev/null +++ b/cmd/help_manifest.go @@ -0,0 +1,474 @@ +package cmd + +import "github.com/spf13/pflag" + +const ( + commandManifestVersion = "1" + commandManifestScopeAccuracyBestEffort = "audited_best_effort" + + commandManifestSuccessSchema = "docs/json-schema/v1/success.schema.json" + commandManifestErrorSchema = "docs/json-schema/v1/error.schema.json" + commandManifestContractFile = "docs/json-schema/v1/commands.json" +) + +type jsonCommandManifestMetadata struct { + Args []jsonCommandArg + Examples []jsonCommandExample + Flags map[string]jsonCommandFlagMetadata + DropboxScopes []string + StdinStdout jsonCommandStdinStdout + ResultStatuses []string + ResultKinds []string + WarningCodes []string + MayPrompt bool + Known bool +} + +type jsonCommandFlagMetadata struct { + EnumValues []string + Conflicts []string + Required bool + Sensitive bool + MayPrompt bool + ValueKind string +} + +type jsonCommandContractMetadata struct { + Statuses []string + Kinds []string + Warnings []string +} + +var globalCommandFlagMetadata = map[string]jsonCommandFlagMetadata{ + "as-member": {ValueKind: "dropbox_member_id"}, + "help": {ValueKind: "boolean"}, + "output": {EnumValues: []string{"text", "json"}, ValueKind: "enum"}, + "verbose": {ValueKind: "boolean"}, +} + +var commonListFlagMetadata = map[string]jsonCommandFlagMetadata{ + "limit": {ValueKind: "integer"}, + "long": {ValueKind: "boolean"}, + "reverse": {ValueKind: "boolean"}, + "sort": {EnumValues: []string{"name", "size", "time", "type"}, ValueKind: "enum"}, + "time": {EnumValues: []string{"server", "client"}, ValueKind: "enum"}, + "time-format": {EnumValues: []string{"short", "rfc3339"}, ValueKind: "enum"}, +} + +var revsFlagMetadata = map[string]jsonCommandFlagMetadata{ + "limit": {ValueKind: "integer"}, + "long": {ValueKind: "boolean"}, + "time": {EnumValues: []string{"server", "client"}, ValueKind: "enum"}, + "time-format": {EnumValues: []string{"short", "rfc3339"}, ValueKind: "enum"}, +} + +var sharedLinkPasswordFlagMetadata = map[string]jsonCommandFlagMetadata{ + "password": {Conflicts: []string{"password-file", "password-prompt"}, Sensitive: true, ValueKind: "secret"}, + "password-file": {Conflicts: []string{"password", "password-prompt"}, ValueKind: "local_file"}, + "password-prompt": {Conflicts: []string{"password", "password-file"}, MayPrompt: true, ValueKind: "boolean"}, +} + +var sharedLinkSettingsFlagMetadata = map[string]jsonCommandFlagMetadata{ + "allow-download": {Conflicts: []string{"disallow-download"}, ValueKind: "boolean"}, + "audience": {EnumValues: []string{"public", "team", "members", "no-one"}, ValueKind: "enum"}, + "disallow-download": {Conflicts: []string{"allow-download"}, ValueKind: "boolean"}, + "expires": {Conflicts: []string{"remove-expiration"}, ValueKind: "rfc3339_timestamp"}, + "remove-expiration": {Conflicts: []string{"expires"}, ValueKind: "boolean"}, +} + +var commandManifestRegistry = map[string]jsonCommandManifestMetadata{ + "account": { + Args: []jsonCommandArg{commandArg("account-id", false, false, "account_id", "Dropbox account ID to look up")}, + Examples: []jsonCommandExample{{Description: "Display the current account", Command: "dbxcli account"}}, + DropboxScopes: []string{"account_info.read"}, + Known: true, + }, + "completion": { + Examples: []jsonCommandExample{{Description: "Generate Bash completion", Command: "dbxcli completion bash"}}, + Known: true, + }, + "completion bash": { + Flags: map[string]jsonCommandFlagMetadata{"no-descriptions": {ValueKind: "boolean"}}, + Known: true, + }, + "completion fish": { + Flags: map[string]jsonCommandFlagMetadata{"no-descriptions": {ValueKind: "boolean"}}, + Known: true, + }, + "completion powershell": { + Flags: map[string]jsonCommandFlagMetadata{"no-descriptions": {ValueKind: "boolean"}}, + Known: true, + }, + "completion zsh": { + Flags: map[string]jsonCommandFlagMetadata{"no-descriptions": {ValueKind: "boolean"}}, + Known: true, + }, + "cp": { + Args: []jsonCommandArg{ + commandArg("source", true, true, "dropbox_path", "One or more Dropbox source paths"), + commandArg("target", true, false, "dropbox_path", "Dropbox destination path"), + }, + Examples: []jsonCommandExample{{Description: "Copy a Dropbox file", Command: "dbxcli cp /from.txt /to.txt"}}, + Flags: map[string]jsonCommandFlagMetadata{"if-exists": {EnumValues: []string{"fail", "skip"}, ValueKind: "enum"}}, + DropboxScopes: []string{"files.content.write", "files.metadata.read"}, + Known: true, + }, + "du": { + Examples: []jsonCommandExample{{Description: "Display space usage", Command: "dbxcli du"}}, + DropboxScopes: []string{"account_info.read"}, + Known: true, + }, + "get": { + Args: []jsonCommandArg{ + commandArg("source", true, false, "dropbox_path", "Dropbox file or folder path"), + streamCommandArg("target", false, false, "local_path", "Local destination path, or - for stdout"), + }, + Examples: []jsonCommandExample{{Description: "Download a file", Command: "dbxcli get /remote.txt ./remote.txt"}}, + Flags: map[string]jsonCommandFlagMetadata{"recursive": {ValueKind: "boolean"}}, + DropboxScopes: []string{"files.content.read", "files.metadata.read"}, + StdinStdout: jsonCommandStdinStdout{WritesBinaryStdout: true}, + Known: true, + }, + "help": { + Args: []jsonCommandArg{commandArg("command", false, true, "command_path", "Command path to describe")}, + Examples: []jsonCommandExample{{Description: "Describe a command as JSON", Command: "dbxcli --output=json help put"}}, + Known: true, + }, + "login": { + Args: []jsonCommandArg{commandArg("token-type", false, false, "auth_type", "Credential type: personal, team-access, or team-manage")}, + Examples: []jsonCommandExample{{Description: "Log in with the personal Dropbox app key", Command: "dbxcli login"}}, + Flags: map[string]jsonCommandFlagMetadata{"app-key": {ValueKind: "dropbox_app_key"}}, + MayPrompt: true, + Known: true, + }, + "logout": { + Examples: []jsonCommandExample{{Description: "Log out and remove saved credentials", Command: "dbxcli logout"}}, + Known: true, + }, + "ls": { + Args: []jsonCommandArg{commandArg("path", false, false, "dropbox_path", "Dropbox folder or file path")}, + Examples: []jsonCommandExample{{Description: "List the root folder", Command: "dbxcli ls /"}}, + Flags: mergeCommandFlagMetadata(commonListFlagMetadata, map[string]jsonCommandFlagMetadata{ + "include-deleted": {ValueKind: "boolean"}, + "only-deleted": {ValueKind: "boolean"}, + "recursive": {ValueKind: "boolean"}, + "recurse": {ValueKind: "boolean"}, + }), + DropboxScopes: []string{"files.metadata.read"}, + Known: true, + }, + "mkdir": { + Args: []jsonCommandArg{commandArg("directory", true, false, "dropbox_path", "Dropbox directory path to create")}, + Examples: []jsonCommandExample{{Description: "Create a Dropbox folder", Command: "dbxcli mkdir /Reports"}}, + Flags: map[string]jsonCommandFlagMetadata{"parents": {ValueKind: "boolean"}}, + DropboxScopes: []string{"files.content.write"}, + Known: true, + }, + "mv": { + Args: []jsonCommandArg{ + commandArg("source", true, true, "dropbox_path", "One or more Dropbox source paths"), + commandArg("target", true, false, "dropbox_path", "Dropbox destination path"), + }, + Examples: []jsonCommandExample{{Description: "Move a Dropbox file", Command: "dbxcli mv /from.txt /to.txt"}}, + Flags: map[string]jsonCommandFlagMetadata{"if-exists": {EnumValues: []string{"fail", "skip"}, ValueKind: "enum"}}, + DropboxScopes: []string{"files.content.write", "files.metadata.read"}, + Known: true, + }, + "put": { + Args: []jsonCommandArg{ + streamCommandArg("source", true, false, "local_path", "Local source path, or - for stdin"), + commandArg("target", false, false, "dropbox_path", "Dropbox destination path"), + }, + Examples: []jsonCommandExample{ + {Description: "Upload a file", Command: "dbxcli put file.txt /destination/file.txt"}, + {Description: "Upload from stdin", Command: "printf 'hello' | dbxcli put - /hello.txt"}, + }, + Flags: map[string]jsonCommandFlagMetadata{ + "chunksize": {ValueKind: "bytes"}, + "debug": {ValueKind: "boolean"}, + "if-exists": {EnumValues: []string{"overwrite", "skip", "fail"}, ValueKind: "enum"}, + "recursive": {ValueKind: "boolean"}, + "workers": {ValueKind: "integer"}, + }, + DropboxScopes: []string{"files.content.write", "files.metadata.read"}, + StdinStdout: jsonCommandStdinStdout{ReadsStdin: true}, + Known: true, + }, + "restore": { + Args: []jsonCommandArg{ + commandArg("target-path", true, false, "dropbox_path", "Dropbox file path to restore"), + commandArg("revision", true, false, "revision", "Dropbox file revision to restore"), + }, + Examples: []jsonCommandExample{{Description: "Restore a file revision", Command: "dbxcli restore /Reports/old.pdf 015f"}}, + DropboxScopes: []string{"files.content.write", "files.metadata.read"}, + Known: true, + }, + "revs": { + Args: []jsonCommandArg{commandArg("file", true, false, "dropbox_path", "Dropbox file path")}, + Examples: []jsonCommandExample{{Description: "List file revisions", Command: "dbxcli revs /Reports/old.pdf"}}, + Flags: revsFlagMetadata, + DropboxScopes: []string{"files.metadata.read"}, + Known: true, + }, + "rm": { + Args: []jsonCommandArg{commandArg("file", true, true, "dropbox_path", "Dropbox path to remove")}, + Examples: []jsonCommandExample{{Description: "Remove a Dropbox path", Command: "dbxcli rm /old.txt"}}, + Flags: map[string]jsonCommandFlagMetadata{ + "force": {ValueKind: "boolean"}, + "permanent": {ValueKind: "boolean"}, + "recursive": {ValueKind: "boolean"}, + }, + DropboxScopes: []string{"files.content.write", "files.metadata.read"}, + Known: true, + }, + "search": { + Args: []jsonCommandArg{ + commandArg("query", true, false, "string", "Search query"), + commandArg("path-scope", false, false, "dropbox_path", "Dropbox path scope"), + }, + Examples: []jsonCommandExample{{Description: "Search by filename", Command: "dbxcli search report /Reports"}}, + Flags: mergeCommandFlagMetadata(commonListFlagMetadata, map[string]jsonCommandFlagMetadata{ + "content": {ValueKind: "boolean"}, + "order-by": {EnumValues: []string{"relevance", "modified"}, ValueKind: "enum"}, + }), + DropboxScopes: []string{"files.metadata.read", "files.content.read"}, + Known: true, + }, + "share list folder": { + Examples: []jsonCommandExample{{Description: "List shared folders", Command: "dbxcli share list folder"}}, + DropboxScopes: []string{"sharing.read"}, + Known: true, + }, + "share list link": { + Args: []jsonCommandArg{commandArg("path", false, false, "dropbox_path", "Dropbox path filter")}, + Examples: []jsonCommandExample{{Description: "List shared links", Command: "dbxcli share list link"}}, + DropboxScopes: []string{"sharing.read"}, + Known: true, + }, + "share-link create": { + Args: []jsonCommandArg{commandArg("path", true, false, "dropbox_path", "Dropbox path to share")}, + Examples: []jsonCommandExample{{Description: "Create a shared link", Command: "dbxcli share-link create /Reports/report.pdf"}}, + Flags: mergeCommandFlagMetadata(mergeCommandFlagMetadata(sharedLinkSettingsFlagMetadata, sharedLinkPasswordFlagMetadata), map[string]jsonCommandFlagMetadata{"access": {EnumValues: []string{"viewer", "editor", "max"}, ValueKind: "enum"}}), + DropboxScopes: []string{"sharing.write", "sharing.read"}, + Known: true, + }, + "share-link download": { + Args: []jsonCommandArg{ + commandArg("url", true, false, "url", "Shared link URL"), + streamCommandArg("target", false, false, "local_path", "Local destination path, or - for stdout"), + }, + Examples: []jsonCommandExample{{Description: "Download a shared link", Command: "dbxcli share-link download https://www.dropbox.com/s/example/file.txt"}}, + Flags: mergeCommandFlagMetadata(sharedLinkPasswordFlagMetadata, map[string]jsonCommandFlagMetadata{ + "path": {ValueKind: "dropbox_path"}, + "recursive": {ValueKind: "boolean"}, + }), + DropboxScopes: []string{"sharing.read", "files.content.read"}, + StdinStdout: jsonCommandStdinStdout{WritesBinaryStdout: true}, + Known: true, + }, + "share-link info": { + Args: []jsonCommandArg{commandArg("url", true, false, "url", "Shared link URL")}, + Examples: []jsonCommandExample{{Description: "Display shared link metadata", Command: "dbxcli share-link info https://www.dropbox.com/s/example/file.txt"}}, + Flags: mergeCommandFlagMetadata(sharedLinkPasswordFlagMetadata, map[string]jsonCommandFlagMetadata{"path": {ValueKind: "dropbox_path"}}), + DropboxScopes: []string{"sharing.read"}, + Known: true, + }, + "share-link list": { + Args: []jsonCommandArg{commandArg("path", false, false, "dropbox_path", "Dropbox path filter")}, + Examples: []jsonCommandExample{{Description: "List shared links", Command: "dbxcli share-link list /Reports/report.pdf"}}, + DropboxScopes: []string{"sharing.read"}, + Known: true, + }, + "share-link revoke": { + Args: []jsonCommandArg{commandArg("url", false, false, "url", "Shared link URL; omit when using --path")}, + Examples: []jsonCommandExample{{Description: "Revoke a shared link", Command: "dbxcli share-link revoke https://www.dropbox.com/s/example/file.txt"}}, + Flags: map[string]jsonCommandFlagMetadata{"path": {ValueKind: "dropbox_path"}}, + DropboxScopes: []string{"sharing.write", "sharing.read"}, + Known: true, + }, + "share-link update": { + Args: []jsonCommandArg{commandArg("url", true, false, "url", "Shared link URL")}, + Examples: []jsonCommandExample{{Description: "Set a shared link audience", Command: "dbxcli share-link update https://www.dropbox.com/s/example/file.txt --audience team"}}, + Flags: mergeCommandFlagMetadata(mergeCommandFlagMetadata(sharedLinkSettingsFlagMetadata, sharedLinkPasswordFlagMetadata), map[string]jsonCommandFlagMetadata{ + "password": {Conflicts: []string{"password-file", "password-prompt", "remove-password"}, Sensitive: true, ValueKind: "secret"}, + "password-file": {Conflicts: []string{"password", "password-prompt", "remove-password"}, ValueKind: "local_file"}, + "password-prompt": {Conflicts: []string{"password", "password-file", "remove-password"}, MayPrompt: true, ValueKind: "boolean"}, + "remove-password": {Conflicts: []string{"password", "password-file", "password-prompt"}, ValueKind: "boolean"}, + }), + DropboxScopes: []string{"sharing.write", "sharing.read"}, + Known: true, + }, + "team add-member": { + Args: []jsonCommandArg{ + commandArg("email", true, false, "email", "Member email address"), + commandArg("first-name", true, false, "string", "Member first name"), + commandArg("last-name", true, false, "string", "Member last name"), + }, + Examples: []jsonCommandExample{{Description: "Add a team member", Command: "dbxcli team add-member ada@example.com Ada Lovelace"}}, + DropboxScopes: []string{"members.write"}, + Known: true, + }, + "team info": { + Examples: []jsonCommandExample{{Description: "Display team information", Command: "dbxcli team info"}}, + DropboxScopes: []string{"team_info.read"}, + Known: true, + }, + "team list-groups": { + Examples: []jsonCommandExample{{Description: "List team groups", Command: "dbxcli team list-groups"}}, + DropboxScopes: []string{"groups.read"}, + Known: true, + }, + "team list-members": { + Examples: []jsonCommandExample{{Description: "List team members", Command: "dbxcli team list-members"}}, + DropboxScopes: []string{"members.read"}, + Known: true, + }, + "team remove-member": { + Args: []jsonCommandArg{commandArg("email", true, false, "email", "Member email address")}, + Examples: []jsonCommandExample{{Description: "Remove a team member", Command: "dbxcli team remove-member ada@example.com"}}, + DropboxScopes: []string{"members.write"}, + Known: true, + }, + "version": { + Examples: []jsonCommandExample{{Description: "Print version information", Command: "dbxcli version"}}, + Known: true, + }, +} + +var commandContractRegistry = map[string]jsonCommandContractMetadata{ + "account": {Statuses: []string{"found"}, Kinds: []string{"account"}}, + "cp": {Statuses: []string{"copied", "skipped"}, Kinds: []string{"deleted", "file", "folder"}}, + "du": {Statuses: []string{"reported"}, Kinds: []string{"space_usage"}}, + "get": {Statuses: []string{"created", "downloaded", "existing"}, Kinds: []string{"file", "folder"}}, + "help": {Statuses: []string{"described"}, Kinds: []string{"command"}}, + "logout": {Statuses: []string{"already_logged_out", "logged_out"}, Kinds: []string{"auth"}, Warnings: []string{jsonWarningCodeTokenRevokeFailed}}, + "ls": {Statuses: []string{"listed"}, Kinds: []string{"deleted", "file", "folder"}}, + "mkdir": {Statuses: []string{"created", "existing"}, Kinds: []string{"folder"}}, + "mv": {Statuses: []string{"moved", "skipped"}, Kinds: []string{"deleted", "file", "folder"}}, + "put": {Statuses: []string{"created", "existing", "skipped", "uploaded"}, Kinds: []string{"file", "folder"}, Warnings: []string{jsonWarningCodeSkippedSymlink}}, + "restore": {Statuses: []string{"restored"}, Kinds: []string{"file"}}, + "revs": {Statuses: []string{"revision"}, Kinds: []string{"file"}}, + "rm": {Statuses: []string{"deleted", "permanently_deleted"}, Kinds: []string{"deleted", "file", "folder"}}, + "search": {Statuses: []string{"found"}, Kinds: []string{"deleted", "file", "folder"}}, + "share list folder": {Statuses: []string{"listed"}, Kinds: []string{"shared_folder"}}, + "share list link": {Statuses: []string{"listed"}, Kinds: []string{"file", "folder", "link"}, Warnings: []string{jsonWarningCodeDeprecatedCommand}}, + "share-link create": {Statuses: []string{"created", "existing"}, Kinds: []string{"file", "folder", "link"}}, + "share-link download": {Statuses: []string{"downloaded"}, Kinds: []string{"file", "folder", "link"}}, + "share-link info": {Statuses: []string{"found"}, Kinds: []string{"file", "folder", "link"}}, + "share-link list": {Statuses: []string{"listed"}, Kinds: []string{"file", "folder", "link"}}, + "share-link revoke": {Statuses: []string{"revoked"}, Kinds: []string{"file", "folder", "link", "shared_link"}}, + "share-link update": {Statuses: []string{"updated"}, Kinds: []string{"file", "folder", "link"}}, + "team add-member": {Statuses: []string{"added", "completed", "started"}, Kinds: []string{"team_member"}}, + "team info": {Statuses: []string{"found"}, Kinds: []string{"team"}}, + "team list-groups": {Statuses: []string{"listed"}, Kinds: []string{"team_group"}}, + "team list-members": {Statuses: []string{"listed"}, Kinds: []string{"team_member"}}, + "team remove-member": {Statuses: []string{"completed", "removed", "started"}, Kinds: []string{"team_member"}}, + "version": {Statuses: []string{"reported"}, Kinds: []string{"version"}}, +} + +func commandArg(name string, required bool, variadic bool, valueKind string, description string) jsonCommandArg { + return jsonCommandArg{ + Name: name, + Required: required, + Variadic: variadic, + Placement: "positional", + ValueKind: valueKind, + Description: description, + } +} + +func streamCommandArg(name string, required bool, variadic bool, valueKind string, description string) jsonCommandArg { + arg := commandArg(name, required, variadic, valueKind, description) + arg.StreamDash = true + return arg +} + +func commandManifestMetadataFor(path string) jsonCommandManifestMetadata { + meta := commandManifestRegistry[path] + meta.Flags = mergeCommandFlagMetadata(globalCommandFlagMetadata, meta.Flags) + if contract, ok := commandContractRegistry[path]; ok { + meta.ResultStatuses = sortedCopyStringSlice(contract.Statuses) + meta.ResultKinds = sortedCopyStringSlice(contract.Kinds) + meta.WarningCodes = sortedCopyStringSlice(contract.Warnings) + } + return meta +} + +func mergeCommandFlagMetadata(base, override map[string]jsonCommandFlagMetadata) map[string]jsonCommandFlagMetadata { + result := make(map[string]jsonCommandFlagMetadata, len(base)+len(override)) + for name, metadata := range base { + result[name] = metadata + } + for name, metadata := range override { + result[name] = metadata + } + return result +} + +func commandManifestSchemaRefs(path string, supportsStructuredOutput bool) jsonCommandSchemaRefs { + refs := jsonCommandSchemaRefs{ + SuccessSchema: commandManifestSuccessSchema, + ErrorSchema: commandManifestErrorSchema, + } + if supportsStructuredOutput || path == "help" { + if _, ok := commandContractRegistry[path]; ok { + refs.CommandContract = commandManifestContractFile + "#/commands/" + path + } + } + return refs +} + +func commandManifestScopeAccuracy(meta jsonCommandManifestMetadata) string { + if !meta.Known { + return "" + } + return commandManifestScopeAccuracyBestEffort +} + +func commandManifestStdinStdout(meta jsonCommandManifestMetadata) jsonCommandStdinStdout { + stdinStdout := meta.StdinStdout + if stdinStdout.Stdout == "" { + stdinStdout.Stdout = "command_results" + } + if stdinStdout.WritesBinaryStdout { + stdinStdout.Stdout = "binary" + } + if stdinStdout.Stderr == "" { + stdinStdout.Stderr = "status_progress_warnings_diagnostics" + } + return stdinStdout +} + +func commandManifestFlagValueKind(flag *pflag.Flag, metadata jsonCommandFlagMetadata) string { + if metadata.ValueKind != "" { + return metadata.ValueKind + } + if len(metadata.EnumValues) > 0 { + return "enum" + } + if flag == nil || flag.Value == nil { + return "" + } + switch flag.Value.Type() { + case "bool": + return "boolean" + case "int", "int64", "uint64": + return "integer" + default: + return flag.Value.Type() + } +} + +func normalizeJSONCommandArgs(args []jsonCommandArg) []jsonCommandArg { + if args == nil { + return []jsonCommandArg{} + } + return args +} + +func normalizeJSONCommandExamples(examples []jsonCommandExample) []jsonCommandExample { + if examples == nil { + return []jsonCommandExample{} + } + return examples +} diff --git a/cmd/json_contract_test.go b/cmd/json_contract_test.go index 7e1dbd5..0a5d9ad 100644 --- a/cmd/json_contract_test.go +++ b/cmd/json_contract_test.go @@ -215,6 +215,48 @@ func TestPublicJSONSchemaFiles(t *testing.T) { } } +func TestPublicJSONManifestSchemaFile(t *testing.T) { + schema := loadPublicJSONSchema(t, "../docs/json-schema/v1/manifest.schema.json") + if schema.Schema == "" { + t.Fatal("manifest schema has no $schema") + } + if schema.ID == "" { + t.Fatal("manifest schema has no $id") + } + if schema.Type != "object" { + t.Fatalf("manifest schema type = %q, want object", schema.Type) + } + want := []string{ + "aliases", + "args", + "auth_modes", + "destructive_level", + "dropbox_scopes", + "examples", + "flags", + "manifest_version", + "may_prompt", + "path", + "result_kinds", + "result_statuses", + "runnable", + "schema_refs", + "scope_accuracy", + "short", + "stdin_stdout", + "supports_structured_output", + "use", + "warning_codes", + } + 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"} { + if _, ok := schema.Defs[def]; !ok { + t.Fatalf("manifest schema missing $defs.%s", def) + } + } +} + func TestPublicJSONCommandCatalogMatchesGoldenContract(t *testing.T) { got := loadJSONContractFile(t, "../docs/json-schema/v1/commands.json", "public command catalog") want := loadJSONGoldenContract(t) @@ -608,23 +650,7 @@ func jsonGoldenSuccessOutputExamples() map[string]jsonOperationOutput { newJSONOperationResult(getStatusDownloaded, getKindFile, getResultInput{Source: "/Reports/old.pdf", Target: "old.pdf"}, file), }, nil), "help": newJSONOperationOutput(jsonHelpInput{Help: true, Path: "ls"}, []jsonOperationResult{ - newJSONOperationResult(jsonHelpStatusDescribed, jsonHelpKindCommand, nil, jsonCommandManifest{ - Path: "ls", - Use: "dbxcli ls [flags] []", - Short: "List files and folders", - Aliases: []string{}, - Runnable: true, - Flags: []jsonCommandFlag{{ - Name: "output", - Type: "string", - Default: "text", - Usage: "Output format: text, json", - Inherited: true, - }}, - SupportsStructuredOutput: true, - AuthModes: []string{"personal", "team-access"}, - DestructiveLevel: destructiveLevelNone, - }), + newJSONOperationResult(jsonHelpStatusDescribed, jsonHelpKindCommand, nil, jsonCommandManifestFor(lsCmd)), }, nil), "ls": newJSONOperationOutput(lsInput{Path: "/Reports", Recursive: false, IncludeDeleted: true, OnlyDeleted: false, Long: true, Sort: "name", Reverse: false, Time: "server", TimeFormat: "2006-01-02"}, []jsonOperationResult{ newJSONOperationResult(lsJSONStatusListed, file.Type, nil, file), @@ -842,8 +868,12 @@ func jsonContractDefinitions() map[string][]string { "du_allocation": jsonFieldNames[duAllocation](), "du_output": jsonFieldNames[duOutput](), "empty": {}, + "command_arg": jsonFieldNames[jsonCommandArg](), + "command_example": jsonFieldNames[jsonCommandExample](), "command_flag": jsonFieldNames[jsonCommandFlag](), "command_manifest": jsonFieldNames[jsonCommandManifest](), + "command_schema_refs": jsonFieldNames[jsonCommandSchemaRefs](), + "command_stdin_stdout": jsonFieldNames[jsonCommandStdinStdout](), "get_input": jsonFieldNames[getCommandInput](), "get_result_input": jsonFieldNames[getResultInput](), "help_input": jsonFieldNames[jsonHelpInput](), diff --git a/cmd/share-list-links.go b/cmd/share-list-links.go index 33122c8..2ac6158 100644 --- a/cmd/share-list-links.go +++ b/cmd/share-list-links.go @@ -152,7 +152,7 @@ When path is supplied, dbxcli lists direct shared links for that Dropbox path on } var shareListLinksCmd = &cobra.Command{ - Use: "link", + Use: "link [path]", Short: "List shared links", Deprecated: "use `dbxcli share-link list` instead", RunE: shareListLinks, diff --git a/cmd/testdata/json_contract/success_outputs.json b/cmd/testdata/json_contract/success_outputs.json index 2f6b2e6..1d7a7e2 100644 --- a/cmd/testdata/json_contract/success_outputs.json +++ b/cmd/testdata/json_contract/success_outputs.json @@ -3,7 +3,291 @@ "cp": {"ok":true,"schema_version":"1","command":"cp","input":{},"results":[{"input":{"from_path":"/Reports/old.pdf","to_path":"/Reports/copy.pdf"},"result":{"type":"file","path_display":"/Reports/copy.pdf","path_lower":"/reports/copy.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"},"status":"copied","kind":"file"}],"warnings":[]}, "du": {"ok":true,"schema_version":"1","command":"du","input":{},"results":[{"kind":"space_usage","input":{},"result":{"used":2048,"allocation":{"type":"team","allocated":1000000,"used":2048,"user_within_team_space_allocated":500000,"user_within_team_space_used_cached":1024,"user_within_team_space_limit_type":"fixed"}},"status":"reported"}],"warnings":[]}, "get": {"ok":true,"schema_version":"1","command":"get","input":{"source":"/Reports/old.pdf","target":"old.pdf","recursive":false,"stdout":false},"results":[{"status":"downloaded","kind":"file","input":{"source":"/Reports/old.pdf","target":"old.pdf"},"result":{"type":"file","path_display":"/Reports/old.pdf","path_lower":"/reports/old.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"}}],"warnings":[]}, - "help": {"ok":true,"schema_version":"1","command":"help","input":{"help":true,"path":"ls"},"results":[{"status":"described","kind":"command","input":{},"result":{"path":"ls","use":"dbxcli ls [flags] []","short":"List files and folders","aliases":[],"runnable":true,"flags":[{"name":"output","type":"string","default":"text","usage":"Output format: text, json","inherited":true}],"supports_structured_output":true,"auth_modes":["personal","team-access"],"destructive_level":"none"}}],"warnings":[]}, + "help": { + "ok": true, + "schema_version": "1", + "command": "help", + "input": { + "help": true, + "path": "ls" + }, + "results": [ + { + "status": "described", + "kind": "command", + "input": {}, + "result": { + "path": "ls", + "use": "dbxcli ls [flags] []", + "short": "List files and folders", + "aliases": [], + "runnable": true, + "flags": [ + { + "name": "as-member", + "type": "string", + "default": "", + "usage": "Member ID to perform action as", + "inherited": true, + "shorthand": "", + "enum_values": [], + "conflicts": [], + "required": false, + "sensitive": false, + "may_prompt": false, + "value_kind": "dropbox_member_id" + }, + { + "name": "help", + "type": "bool", + "default": "false", + "usage": "help for ls", + "inherited": false, + "shorthand": "h", + "enum_values": [], + "conflicts": [], + "required": false, + "sensitive": false, + "may_prompt": false, + "value_kind": "boolean" + }, + { + "name": "include-deleted", + "type": "bool", + "default": "false", + "usage": "Include deleted files", + "inherited": false, + "shorthand": "d", + "enum_values": [], + "conflicts": [], + "required": false, + "sensitive": false, + "may_prompt": false, + "value_kind": "boolean" + }, + { + "name": "limit", + "type": "uint64", + "default": "0", + "usage": "Maximum number of entries to return", + "inherited": false, + "shorthand": "", + "enum_values": [], + "conflicts": [], + "required": false, + "sensitive": false, + "may_prompt": false, + "value_kind": "integer" + }, + { + "name": "long", + "type": "bool", + "default": "false", + "usage": "Long listing", + "inherited": false, + "shorthand": "l", + "enum_values": [], + "conflicts": [], + "required": false, + "sensitive": false, + "may_prompt": false, + "value_kind": "boolean" + }, + { + "name": "only-deleted", + "type": "bool", + "default": "false", + "usage": "Only show deleted files", + "inherited": false, + "shorthand": "D", + "enum_values": [], + "conflicts": [], + "required": false, + "sensitive": false, + "may_prompt": false, + "value_kind": "boolean" + }, + { + "name": "output", + "type": "string", + "default": "text", + "usage": "Output format: text, json", + "inherited": true, + "shorthand": "", + "enum_values": [ + "json", + "text" + ], + "conflicts": [], + "required": false, + "sensitive": false, + "may_prompt": false, + "value_kind": "enum" + }, + { + "name": "recurse", + "type": "bool", + "default": "false", + "usage": "Alias for --recursive", + "inherited": false, + "shorthand": "R", + "enum_values": [], + "conflicts": [], + "required": false, + "sensitive": false, + "may_prompt": false, + "value_kind": "boolean" + }, + { + "name": "recursive", + "type": "bool", + "default": "false", + "usage": "Recursively list all subfolders", + "inherited": false, + "shorthand": "", + "enum_values": [], + "conflicts": [], + "required": false, + "sensitive": false, + "may_prompt": false, + "value_kind": "boolean" + }, + { + "name": "reverse", + "type": "bool", + "default": "false", + "usage": "Reverse sort order", + "inherited": false, + "shorthand": "r", + "enum_values": [], + "conflicts": [], + "required": false, + "sensitive": false, + "may_prompt": false, + "value_kind": "boolean" + }, + { + "name": "sort", + "type": "string", + "default": "", + "usage": "Sort by: name, size, time, type", + "inherited": false, + "shorthand": "", + "enum_values": [ + "name", + "size", + "time", + "type" + ], + "conflicts": [], + "required": false, + "sensitive": false, + "may_prompt": false, + "value_kind": "enum" + }, + { + "name": "time", + "type": "string", + "default": "server", + "usage": "Time field: server, client", + "inherited": false, + "shorthand": "", + "enum_values": [ + "client", + "server" + ], + "conflicts": [], + "required": false, + "sensitive": false, + "may_prompt": false, + "value_kind": "enum" + }, + { + "name": "time-format", + "type": "string", + "default": "", + "usage": "Time format: short (2006-01-02 15:04), rfc3339", + "inherited": false, + "shorthand": "", + "enum_values": [ + "rfc3339", + "short" + ], + "conflicts": [], + "required": false, + "sensitive": false, + "may_prompt": false, + "value_kind": "enum" + }, + { + "name": "verbose", + "type": "bool", + "default": "false", + "usage": "Enable verbose logging", + "inherited": true, + "shorthand": "v", + "enum_values": [], + "conflicts": [], + "required": false, + "sensitive": false, + "may_prompt": false, + "value_kind": "boolean" + } + ], + "supports_structured_output": true, + "auth_modes": [ + "personal", + "team-access" + ], + "destructive_level": "none", + "manifest_version": "1", + "args": [ + { + "name": "path", + "required": false, + "variadic": false, + "placement": "positional", + "value_kind": "dropbox_path", + "description": "Dropbox folder or file path", + "stream_dash": false + } + ], + "examples": [ + { + "description": "List the root folder", + "command": "dbxcli ls /" + } + ], + "schema_refs": { + "success_schema": "docs/json-schema/v1/success.schema.json", + "error_schema": "docs/json-schema/v1/error.schema.json", + "command_contract": "docs/json-schema/v1/commands.json#/commands/ls" + }, + "dropbox_scopes": [ + "files.metadata.read" + ], + "scope_accuracy": "audited_best_effort", + "stdin_stdout": { + "reads_stdin": false, + "writes_binary_stdout": false, + "stdout": "command_results", + "stderr": "status_progress_warnings_diagnostics" + }, + "result_statuses": [ + "listed" + ], + "result_kinds": [ + "deleted", + "file", + "folder" + ], + "warning_codes": [], + "may_prompt": false + } + } + ], + "warnings": [] + }, "ls": {"ok":true,"schema_version":"1","command":"ls","input":{"path":"/Reports","recursive":false,"include_deleted":true,"only_deleted":false,"long":true,"sort":"name","reverse":false,"time":"server","time_format":"2006-01-02"},"results":[{"status":"listed","kind":"file","result":{"type":"file","path_display":"/Reports/old.pdf","path_lower":"/reports/old.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"},"input":{}}],"warnings":[]}, "logout": {"ok":true,"schema_version":"1","command":"logout","input":{},"results":[{"status":"logged_out","kind":"auth","input":{},"result":{"removed_saved_credentials":true,"remote_token_revoked":true}}],"warnings":[]}, "mkdir": {"ok":true,"schema_version":"1","command":"mkdir","input":{"path":"/Reports/new","parents":true},"results":[{"status":"created","kind":"folder","input":{"path":"/Reports/new","parents":true},"result":{"type":"folder","path_display":"/Reports/new","path_lower":"/reports/new","id":"id:folder"}}],"warnings":[]}, diff --git a/cmd/testdata/json_contract/success_schemas.json b/cmd/testdata/json_contract/success_schemas.json index ae43cce..8eaaa30 100644 --- a/cmd/testdata/json_contract/success_schemas.json +++ b/cmd/testdata/json_contract/success_schemas.json @@ -37,23 +37,65 @@ "member_id", "name" ], + "command_arg": [ + "description", + "name", + "placement", + "required", + "stream_dash", + "value_kind", + "variadic" + ], + "command_example": [ + "command", + "description" + ], "command_flag": [ + "conflicts", "default", + "enum_values", "inherited", + "may_prompt", "name", + "required", + "sensitive", + "shorthand", "type", - "usage" + "usage", + "value_kind" ], "command_manifest": [ "aliases", + "args", "auth_modes", "destructive_level", + "dropbox_scopes", + "examples", "flags", + "manifest_version", + "may_prompt", "path", + "result_kinds", + "result_statuses", "runnable", + "schema_refs", + "scope_accuracy", "short", + "stdin_stdout", "supports_structured_output", - "use" + "use", + "warning_codes" + ], + "command_schema_refs": [ + "command_contract", + "error_schema", + "success_schema" + ], + "command_stdin_stdout": [ + "reads_stdin", + "stderr", + "stdout", + "writes_binary_stdout" ], "du_allocation": [ "allocated", diff --git a/docs/automation.md b/docs/automation.md index 6f8e455..800e95d 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -23,6 +23,12 @@ Use JSON help to discover whether a command supports structured command output: dbxcli put --help --output=json ``` +JSON help is also the Command Manifest v1 surface for tools and agents. Each +command manifest includes structured positional arguments, flag enum values and +conflicts, prompt/sensitive-input metadata, examples, auth modes, best-effort +Dropbox scopes, stdin/stdout behavior, schema refs, result statuses/kinds, and +known warning codes. + Successful JSON responses use a stable envelope: ```json @@ -81,8 +87,9 @@ dbxcli share-link create --help --output=json dbxcli --output=json help share-link create ``` -Use JSON help to discover command paths, flags, aliases, known auth modes, known -destructive levels, and whether normal structured command output is supported. +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. ## Safe scripting patterns diff --git a/docs/commands/dbxcli.md b/docs/commands/dbxcli.md index a4ed30f..9886a3f 100644 --- a/docs/commands/dbxcli.md +++ b/docs/commands/dbxcli.md @@ -22,7 +22,10 @@ manage your team and more. It is easy, scriptable and works on all platforms! * Structured JSON output: no * JSON help manifest: yes +* Manifest version: `1` * Auth modes: none +* Dropbox scopes: none +* Flag metadata: `--output` (values: `json`, `text`) ### SEE ALSO diff --git a/docs/commands/dbxcli_account.md b/docs/commands/dbxcli_account.md index 00df951..f383ccb 100644 --- a/docs/commands/dbxcli_account.md +++ b/docs/commands/dbxcli_account.md @@ -33,7 +33,14 @@ dbxcli account [flags] [] * Structured JSON output: yes * JSON help manifest: yes +* Manifest version: `1` * Auth modes: `personal`, `team-access` +* Dropbox scopes: `account_info.read` +* Arguments: `account-id` (optional, account_id) +* Flag metadata: `--output` (values: `json`, `text`) +* Result statuses: `found` +* Result kinds: `account` +* JSON contract: `docs/json-schema/v1/commands.json#/commands/account` ### SEE ALSO diff --git a/docs/commands/dbxcli_completion.md b/docs/commands/dbxcli_completion.md index 199373a..38aea4f 100644 --- a/docs/commands/dbxcli_completion.md +++ b/docs/commands/dbxcli_completion.md @@ -32,7 +32,10 @@ dbxcli completion [bash|zsh|fish|powershell] [flags] * Structured JSON output: no * JSON help manifest: yes +* Manifest version: `1` * Auth modes: none +* Dropbox scopes: none +* Flag metadata: `--output` (values: `json`, `text`) ### SEE ALSO diff --git a/docs/commands/dbxcli_completion_bash.md b/docs/commands/dbxcli_completion_bash.md index 51109df..eca9111 100644 --- a/docs/commands/dbxcli_completion_bash.md +++ b/docs/commands/dbxcli_completion_bash.md @@ -51,7 +51,10 @@ dbxcli completion bash * Structured JSON output: no * JSON help manifest: yes +* Manifest version: `1` * Auth modes: none +* Dropbox scopes: none +* Flag metadata: `--output` (values: `json`, `text`) ### SEE ALSO diff --git a/docs/commands/dbxcli_completion_fish.md b/docs/commands/dbxcli_completion_fish.md index bf72bf9..a23874f 100644 --- a/docs/commands/dbxcli_completion_fish.md +++ b/docs/commands/dbxcli_completion_fish.md @@ -42,7 +42,10 @@ dbxcli completion fish [flags] * Structured JSON output: no * JSON help manifest: yes +* Manifest version: `1` * Auth modes: none +* Dropbox scopes: none +* Flag metadata: `--output` (values: `json`, `text`) ### SEE ALSO diff --git a/docs/commands/dbxcli_completion_powershell.md b/docs/commands/dbxcli_completion_powershell.md index ae2d494..511b07c 100644 --- a/docs/commands/dbxcli_completion_powershell.md +++ b/docs/commands/dbxcli_completion_powershell.md @@ -39,7 +39,10 @@ dbxcli completion powershell [flags] * Structured JSON output: no * JSON help manifest: yes +* Manifest version: `1` * Auth modes: none +* Dropbox scopes: none +* Flag metadata: `--output` (values: `json`, `text`) ### SEE ALSO diff --git a/docs/commands/dbxcli_completion_zsh.md b/docs/commands/dbxcli_completion_zsh.md index 55a91f1..cf72c52 100644 --- a/docs/commands/dbxcli_completion_zsh.md +++ b/docs/commands/dbxcli_completion_zsh.md @@ -53,7 +53,10 @@ dbxcli completion zsh [flags] * Structured JSON output: no * JSON help manifest: yes +* Manifest version: `1` * Auth modes: none +* Dropbox scopes: none +* Flag metadata: `--output` (values: `json`, `text`) ### SEE ALSO diff --git a/docs/commands/dbxcli_cp.md b/docs/commands/dbxcli_cp.md index 0561034..8587f53 100644 --- a/docs/commands/dbxcli_cp.md +++ b/docs/commands/dbxcli_cp.md @@ -27,7 +27,14 @@ dbxcli cp [flags] [more sources] * Structured JSON output: yes * JSON help manifest: yes +* Manifest version: `1` * Auth modes: `personal`, `team-access` +* Dropbox scopes: `files.content.write`, `files.metadata.read` +* Arguments: `source` (required, dropbox_path, variadic), `target` (required, dropbox_path) +* Flag metadata: `--if-exists` (values: `fail`, `skip`), `--output` (values: `json`, `text`) +* Result statuses: `copied`, `skipped` +* Result kinds: `deleted`, `file`, `folder` +* JSON contract: `docs/json-schema/v1/commands.json#/commands/cp` ### SEE ALSO diff --git a/docs/commands/dbxcli_du.md b/docs/commands/dbxcli_du.md index 3a4b95c..bfa7c2c 100644 --- a/docs/commands/dbxcli_du.md +++ b/docs/commands/dbxcli_du.md @@ -26,7 +26,13 @@ dbxcli du [flags] * Structured JSON output: yes * JSON help manifest: yes +* Manifest version: `1` * Auth modes: `personal`, `team-access` +* Dropbox scopes: `account_info.read` +* Flag metadata: `--output` (values: `json`, `text`) +* Result statuses: `reported` +* Result kinds: `space_usage` +* JSON contract: `docs/json-schema/v1/commands.json#/commands/du` ### SEE ALSO diff --git a/docs/commands/dbxcli_get.md b/docs/commands/dbxcli_get.md index b30fc69..e6edfd8 100644 --- a/docs/commands/dbxcli_get.md +++ b/docs/commands/dbxcli_get.md @@ -44,8 +44,15 @@ dbxcli get [flags] [] * Structured JSON output: yes * JSON help manifest: yes +* Manifest version: `1` * Auth modes: `personal`, `team-access` +* Dropbox scopes: `files.content.read`, `files.metadata.read` +* Arguments: `source` (required, dropbox_path), `target` (optional, local_path, `-` stream operand) +* Flag metadata: `--output` (values: `json`, `text`) * Stdin/stdout behavior: Use `-` as the local target to write downloaded file bytes to stdout; diagnostics go to stderr. +* Result statuses: `created`, `downloaded`, `existing` +* Result kinds: `file`, `folder` +* JSON contract: `docs/json-schema/v1/commands.json#/commands/get` ### SEE ALSO diff --git a/docs/commands/dbxcli_login.md b/docs/commands/dbxcli_login.md index c848587..bb23904 100644 --- a/docs/commands/dbxcli_login.md +++ b/docs/commands/dbxcli_login.md @@ -34,7 +34,11 @@ dbxcli login [personal|team-access|team-manage] [flags] * Structured JSON output: no * JSON help manifest: yes +* Manifest version: `1` * Auth modes: none +* Dropbox scopes: none +* Arguments: `token-type` (optional, auth_type) +* Flag metadata: `--output` (values: `json`, `text`) ### SEE ALSO diff --git a/docs/commands/dbxcli_logout.md b/docs/commands/dbxcli_logout.md index b6e2424..8015237 100644 --- a/docs/commands/dbxcli_logout.md +++ b/docs/commands/dbxcli_logout.md @@ -35,7 +35,14 @@ dbxcli logout [flags] * Structured JSON output: yes * JSON help manifest: yes +* Manifest version: `1` * Auth modes: none +* Dropbox scopes: none +* Flag metadata: `--output` (values: `json`, `text`) +* Result statuses: `already_logged_out`, `logged_out` +* Result kinds: `auth` +* Warning codes: `token_revoke_failed` +* JSON contract: `docs/json-schema/v1/commands.json#/commands/logout` ### SEE ALSO diff --git a/docs/commands/dbxcli_ls.md b/docs/commands/dbxcli_ls.md index ad1ba64..801e546 100644 --- a/docs/commands/dbxcli_ls.md +++ b/docs/commands/dbxcli_ls.md @@ -45,7 +45,14 @@ dbxcli ls [flags] [] * Structured JSON output: yes * JSON help manifest: yes +* Manifest version: `1` * Auth modes: `personal`, `team-access` +* Dropbox scopes: `files.metadata.read` +* Arguments: `path` (optional, dropbox_path) +* Flag metadata: `--output` (values: `json`, `text`), `--sort` (values: `name`, `size`, `time`, `type`), `--time` (values: `client`, `server`), `--time-format` (values: `rfc3339`, `short`) +* Result statuses: `listed` +* Result kinds: `deleted`, `file`, `folder` +* JSON contract: `docs/json-schema/v1/commands.json#/commands/ls` ### SEE ALSO diff --git a/docs/commands/dbxcli_mkdir.md b/docs/commands/dbxcli_mkdir.md index 36d31f2..8d690dd 100644 --- a/docs/commands/dbxcli_mkdir.md +++ b/docs/commands/dbxcli_mkdir.md @@ -27,7 +27,14 @@ dbxcli mkdir [flags] * Structured JSON output: yes * JSON help manifest: yes +* Manifest version: `1` * Auth modes: `personal`, `team-access` +* Dropbox scopes: `files.content.write` +* Arguments: `directory` (required, dropbox_path) +* Flag metadata: `--output` (values: `json`, `text`) +* Result statuses: `created`, `existing` +* Result kinds: `folder` +* JSON contract: `docs/json-schema/v1/commands.json#/commands/mkdir` ### SEE ALSO diff --git a/docs/commands/dbxcli_mv.md b/docs/commands/dbxcli_mv.md index f3154ac..e751f97 100644 --- a/docs/commands/dbxcli_mv.md +++ b/docs/commands/dbxcli_mv.md @@ -27,7 +27,14 @@ dbxcli mv [flags] [more sources] * Structured JSON output: yes * JSON help manifest: yes +* Manifest version: `1` * Auth modes: `personal`, `team-access` +* Dropbox scopes: `files.content.write`, `files.metadata.read` +* Arguments: `source` (required, dropbox_path, variadic), `target` (required, dropbox_path) +* Flag metadata: `--if-exists` (values: `fail`, `skip`), `--output` (values: `json`, `text`) +* Result statuses: `moved`, `skipped` +* Result kinds: `deleted`, `file`, `folder` +* JSON contract: `docs/json-schema/v1/commands.json#/commands/mv` ### SEE ALSO diff --git a/docs/commands/dbxcli_put.md b/docs/commands/dbxcli_put.md index 0fcc158..1c723f2 100644 --- a/docs/commands/dbxcli_put.md +++ b/docs/commands/dbxcli_put.md @@ -54,8 +54,16 @@ dbxcli put [flags] [] * Structured JSON output: yes * JSON help manifest: yes +* Manifest version: `1` * Auth modes: `personal`, `team-access` +* Dropbox scopes: `files.content.write`, `files.metadata.read` +* Arguments: `source` (required, local_path, `-` stream operand), `target` (optional, dropbox_path) +* Flag metadata: `--if-exists` (values: `fail`, `overwrite`, `skip`), `--output` (values: `json`, `text`) * Stdin/stdout behavior: Use `-` as the local source to upload from stdin; stdin is spooled to a temporary file before upload. +* Result statuses: `created`, `existing`, `skipped`, `uploaded` +* Result kinds: `file`, `folder` +* Warning codes: `skipped_symlink` +* JSON contract: `docs/json-schema/v1/commands.json#/commands/put` ### SEE ALSO diff --git a/docs/commands/dbxcli_restore.md b/docs/commands/dbxcli_restore.md index 96352a6..018df61 100644 --- a/docs/commands/dbxcli_restore.md +++ b/docs/commands/dbxcli_restore.md @@ -40,7 +40,14 @@ dbxcli restore [flags] * Structured JSON output: yes * JSON help manifest: yes +* Manifest version: `1` * Auth modes: `personal`, `team-access` +* Dropbox scopes: `files.content.write`, `files.metadata.read` +* Arguments: `target-path` (required, dropbox_path), `revision` (required, revision) +* Flag metadata: `--output` (values: `json`, `text`) +* Result statuses: `restored` +* Result kinds: `file` +* JSON contract: `docs/json-schema/v1/commands.json#/commands/restore` ### SEE ALSO diff --git a/docs/commands/dbxcli_revs.md b/docs/commands/dbxcli_revs.md index f5b1cf2..5f8d8fd 100644 --- a/docs/commands/dbxcli_revs.md +++ b/docs/commands/dbxcli_revs.md @@ -30,7 +30,14 @@ dbxcli revs [flags] * Structured JSON output: yes * JSON help manifest: yes +* Manifest version: `1` * Auth modes: `personal`, `team-access` +* Dropbox scopes: `files.metadata.read` +* Arguments: `file` (required, dropbox_path) +* Flag metadata: `--output` (values: `json`, `text`), `--time` (values: `client`, `server`), `--time-format` (values: `rfc3339`, `short`) +* Result statuses: `revision` +* Result kinds: `file` +* JSON contract: `docs/json-schema/v1/commands.json#/commands/revs` ### SEE ALSO diff --git a/docs/commands/dbxcli_rm.md b/docs/commands/dbxcli_rm.md index 323a987..bcd833d 100644 --- a/docs/commands/dbxcli_rm.md +++ b/docs/commands/dbxcli_rm.md @@ -29,8 +29,15 @@ dbxcli rm [flags] * Structured JSON output: yes * JSON help manifest: yes +* Manifest version: `1` * Auth modes: `personal`, `team-access` +* Dropbox scopes: `files.content.write`, `files.metadata.read` +* Arguments: `file` (required, dropbox_path, variadic) +* Flag metadata: `--output` (values: `json`, `text`) * Destructive behavior: `delete` +* Result statuses: `deleted`, `permanently_deleted` +* Result kinds: `deleted`, `file`, `folder` +* JSON contract: `docs/json-schema/v1/commands.json#/commands/rm` ### SEE ALSO diff --git a/docs/commands/dbxcli_search.md b/docs/commands/dbxcli_search.md index d2a37d7..5634905 100644 --- a/docs/commands/dbxcli_search.md +++ b/docs/commands/dbxcli_search.md @@ -34,7 +34,14 @@ dbxcli search [flags] [path-scope] * Structured JSON output: yes * JSON help manifest: yes +* Manifest version: `1` * Auth modes: `personal`, `team-access` +* Dropbox scopes: `files.content.read`, `files.metadata.read` +* Arguments: `query` (required, string), `path-scope` (optional, dropbox_path) +* Flag metadata: `--order-by` (values: `modified`, `relevance`), `--output` (values: `json`, `text`), `--sort` (values: `name`, `size`, `time`, `type`), `--time` (values: `client`, `server`), `--time-format` (values: `rfc3339`, `short`) +* Result statuses: `found` +* Result kinds: `deleted`, `file`, `folder` +* JSON contract: `docs/json-schema/v1/commands.json#/commands/search` ### SEE ALSO diff --git a/docs/commands/dbxcli_share-link.md b/docs/commands/dbxcli_share-link.md index 9945feb..7a7b562 100644 --- a/docs/commands/dbxcli_share-link.md +++ b/docs/commands/dbxcli_share-link.md @@ -26,7 +26,10 @@ Create, list, inspect, download, update, and revoke Dropbox shared links. * Structured JSON output: no * JSON help manifest: yes +* Manifest version: `1` * Auth modes: none +* Dropbox scopes: none +* Flag metadata: `--output` (values: `json`, `text`) ### SEE ALSO diff --git a/docs/commands/dbxcli_share-link_create.md b/docs/commands/dbxcli_share-link_create.md index 7089e89..c98f98f 100644 --- a/docs/commands/dbxcli_share-link_create.md +++ b/docs/commands/dbxcli_share-link_create.md @@ -51,7 +51,14 @@ dbxcli share-link create [flags] * Structured JSON output: yes * JSON help manifest: yes +* Manifest version: `1` * Auth modes: `personal`, `team-access` +* Dropbox scopes: `sharing.read`, `sharing.write` +* Arguments: `path` (required, dropbox_path) +* Flag metadata: `--access` (values: `editor`, `max`, `viewer`), `--allow-download` (conflicts: `disallow-download`), `--audience` (values: `members`, `no-one`, `public`, `team`), `--disallow-download` (conflicts: `allow-download`), `--expires` (conflicts: `remove-expiration`), `--output` (values: `json`, `text`), `--password` (conflicts: `password-file`, `password-prompt`; sensitive), `--password-file` (conflicts: `password`, `password-prompt`), `--password-prompt` (conflicts: `password`, `password-file`; may prompt), `--remove-expiration` (conflicts: `expires`) +* Result statuses: `created`, `existing` +* Result kinds: `file`, `folder`, `link` +* JSON contract: `docs/json-schema/v1/commands.json#/commands/share-link create` ### SEE ALSO diff --git a/docs/commands/dbxcli_share-link_download.md b/docs/commands/dbxcli_share-link_download.md index 1c91ce4..fa992cb 100644 --- a/docs/commands/dbxcli_share-link_download.md +++ b/docs/commands/dbxcli_share-link_download.md @@ -50,8 +50,15 @@ dbxcli share-link download [target] [flags] * Structured JSON output: yes * JSON help manifest: yes +* Manifest version: `1` * Auth modes: `personal`, `team-access` +* Dropbox scopes: `files.content.read`, `sharing.read` +* Arguments: `url` (required, url), `target` (optional, local_path, `-` stream operand) +* Flag metadata: `--output` (values: `json`, `text`), `--password` (conflicts: `password-file`, `password-prompt`; sensitive), `--password-file` (conflicts: `password`, `password-prompt`), `--password-prompt` (conflicts: `password`, `password-file`; may prompt) * Stdin/stdout behavior: Use `-` as the target for file shared links to write bytes to stdout; folder shared links require `--recursive` and cannot be written to stdout. +* Result statuses: `downloaded` +* Result kinds: `file`, `folder`, `link` +* JSON contract: `docs/json-schema/v1/commands.json#/commands/share-link download` ### SEE ALSO diff --git a/docs/commands/dbxcli_share-link_info.md b/docs/commands/dbxcli_share-link_info.md index 5a016f0..d789168 100644 --- a/docs/commands/dbxcli_share-link_info.md +++ b/docs/commands/dbxcli_share-link_info.md @@ -42,7 +42,14 @@ dbxcli share-link info [flags] * Structured JSON output: yes * JSON help manifest: yes +* Manifest version: `1` * Auth modes: `personal`, `team-access` +* Dropbox scopes: `sharing.read` +* Arguments: `url` (required, url) +* Flag metadata: `--output` (values: `json`, `text`), `--password` (conflicts: `password-file`, `password-prompt`; sensitive), `--password-file` (conflicts: `password`, `password-prompt`), `--password-prompt` (conflicts: `password`, `password-file`; may prompt) +* Result statuses: `found` +* Result kinds: `file`, `folder`, `link` +* JSON contract: `docs/json-schema/v1/commands.json#/commands/share-link info` ### SEE ALSO diff --git a/docs/commands/dbxcli_share-link_list.md b/docs/commands/dbxcli_share-link_list.md index 5c4ec02..34ff97e 100644 --- a/docs/commands/dbxcli_share-link_list.md +++ b/docs/commands/dbxcli_share-link_list.md @@ -38,7 +38,14 @@ dbxcli share-link list [path] [flags] * Structured JSON output: yes * JSON help manifest: yes +* Manifest version: `1` * Auth modes: `personal`, `team-access` +* Dropbox scopes: `sharing.read` +* Arguments: `path` (optional, dropbox_path) +* Flag metadata: `--output` (values: `json`, `text`) +* Result statuses: `listed` +* Result kinds: `file`, `folder`, `link` +* JSON contract: `docs/json-schema/v1/commands.json#/commands/share-link list` ### SEE ALSO diff --git a/docs/commands/dbxcli_share-link_revoke.md b/docs/commands/dbxcli_share-link_revoke.md index c4d3e33..5c67739 100644 --- a/docs/commands/dbxcli_share-link_revoke.md +++ b/docs/commands/dbxcli_share-link_revoke.md @@ -38,7 +38,14 @@ dbxcli share-link revoke [url] [flags] * Structured JSON output: yes * JSON help manifest: yes +* Manifest version: `1` * Auth modes: `personal`, `team-access` +* Dropbox scopes: `sharing.read`, `sharing.write` +* Arguments: `url` (optional, url) +* Flag metadata: `--output` (values: `json`, `text`) +* Result statuses: `revoked` +* Result kinds: `file`, `folder`, `link`, `shared_link` +* JSON contract: `docs/json-schema/v1/commands.json#/commands/share-link revoke` ### SEE ALSO diff --git a/docs/commands/dbxcli_share-link_update.md b/docs/commands/dbxcli_share-link_update.md index 3cddf74..ba508fd 100644 --- a/docs/commands/dbxcli_share-link_update.md +++ b/docs/commands/dbxcli_share-link_update.md @@ -50,7 +50,14 @@ dbxcli share-link update [flags] * Structured JSON output: yes * JSON help manifest: yes +* Manifest version: `1` * Auth modes: `personal`, `team-access` +* Dropbox scopes: `sharing.read`, `sharing.write` +* Arguments: `url` (required, url) +* Flag metadata: `--allow-download` (conflicts: `disallow-download`), `--audience` (values: `members`, `no-one`, `public`, `team`), `--disallow-download` (conflicts: `allow-download`), `--expires` (conflicts: `remove-expiration`), `--output` (values: `json`, `text`), `--password` (conflicts: `password-file`, `password-prompt`, `remove-password`; sensitive), `--password-file` (conflicts: `password`, `password-prompt`, `remove-password`), `--password-prompt` (conflicts: `password`, `password-file`, `remove-password`; may prompt), `--remove-expiration` (conflicts: `expires`), `--remove-password` (conflicts: `password`, `password-file`, `password-prompt`) +* Result statuses: `updated` +* Result kinds: `file`, `folder`, `link` +* JSON contract: `docs/json-schema/v1/commands.json#/commands/share-link update` ### SEE ALSO diff --git a/docs/commands/dbxcli_share.md b/docs/commands/dbxcli_share.md index 4f0ea0d..6ff1c1a 100644 --- a/docs/commands/dbxcli_share.md +++ b/docs/commands/dbxcli_share.md @@ -22,7 +22,10 @@ Sharing commands * Structured JSON output: no * JSON help manifest: yes +* Manifest version: `1` * Auth modes: none +* Dropbox scopes: none +* Flag metadata: `--output` (values: `json`, `text`) ### SEE ALSO diff --git a/docs/commands/dbxcli_share_list.md b/docs/commands/dbxcli_share_list.md index 74edabb..bf059df 100644 --- a/docs/commands/dbxcli_share_list.md +++ b/docs/commands/dbxcli_share_list.md @@ -22,7 +22,10 @@ List shared things * Structured JSON output: no * JSON help manifest: yes +* Manifest version: `1` * Auth modes: none +* Dropbox scopes: none +* Flag metadata: `--output` (values: `json`, `text`) ### SEE ALSO diff --git a/docs/commands/dbxcli_share_list_folder.md b/docs/commands/dbxcli_share_list_folder.md index 9154bf6..716ae85 100644 --- a/docs/commands/dbxcli_share_list_folder.md +++ b/docs/commands/dbxcli_share_list_folder.md @@ -26,7 +26,13 @@ dbxcli share list folder [flags] * Structured JSON output: yes * JSON help manifest: yes +* Manifest version: `1` * Auth modes: `personal`, `team-access` +* Dropbox scopes: `sharing.read` +* Flag metadata: `--output` (values: `json`, `text`) +* Result statuses: `listed` +* Result kinds: `shared_folder` +* JSON contract: `docs/json-schema/v1/commands.json#/commands/share list folder` ### SEE ALSO diff --git a/docs/commands/dbxcli_team.md b/docs/commands/dbxcli_team.md index f442b54..d90f700 100644 --- a/docs/commands/dbxcli_team.md +++ b/docs/commands/dbxcli_team.md @@ -22,7 +22,10 @@ Team management commands * Structured JSON output: no * JSON help manifest: yes +* Manifest version: `1` * Auth modes: none +* Dropbox scopes: none +* Flag metadata: `--output` (values: `json`, `text`) ### SEE ALSO diff --git a/docs/commands/dbxcli_team_add-member.md b/docs/commands/dbxcli_team_add-member.md index c8502cd..c8bc8ed 100644 --- a/docs/commands/dbxcli_team_add-member.md +++ b/docs/commands/dbxcli_team_add-member.md @@ -26,8 +26,15 @@ dbxcli team add-member [flags] * Structured JSON output: yes * JSON help manifest: yes +* Manifest version: `1` * Auth modes: `team-manage` +* Dropbox scopes: `members.write` +* Arguments: `email` (required, email), `first-name` (required, string), `last-name` (required, string) +* Flag metadata: `--output` (values: `json`, `text`) * Destructive behavior: `admin` +* Result statuses: `added`, `completed`, `started` +* Result kinds: `team_member` +* JSON contract: `docs/json-schema/v1/commands.json#/commands/team add-member` ### SEE ALSO diff --git a/docs/commands/dbxcli_team_info.md b/docs/commands/dbxcli_team_info.md index 5006851..086624b 100644 --- a/docs/commands/dbxcli_team_info.md +++ b/docs/commands/dbxcli_team_info.md @@ -26,7 +26,13 @@ dbxcli team info [flags] * Structured JSON output: yes * JSON help manifest: yes +* Manifest version: `1` * Auth modes: `team-manage` +* Dropbox scopes: `team_info.read` +* Flag metadata: `--output` (values: `json`, `text`) +* Result statuses: `found` +* Result kinds: `team` +* JSON contract: `docs/json-schema/v1/commands.json#/commands/team info` ### SEE ALSO diff --git a/docs/commands/dbxcli_team_list-groups.md b/docs/commands/dbxcli_team_list-groups.md index d3a7191..5d62b44 100644 --- a/docs/commands/dbxcli_team_list-groups.md +++ b/docs/commands/dbxcli_team_list-groups.md @@ -26,7 +26,13 @@ dbxcli team list-groups [flags] * Structured JSON output: yes * JSON help manifest: yes +* Manifest version: `1` * Auth modes: `team-manage` +* Dropbox scopes: `groups.read` +* Flag metadata: `--output` (values: `json`, `text`) +* Result statuses: `listed` +* Result kinds: `team_group` +* JSON contract: `docs/json-schema/v1/commands.json#/commands/team list-groups` ### SEE ALSO diff --git a/docs/commands/dbxcli_team_list-members.md b/docs/commands/dbxcli_team_list-members.md index f157653..27a1525 100644 --- a/docs/commands/dbxcli_team_list-members.md +++ b/docs/commands/dbxcli_team_list-members.md @@ -26,7 +26,13 @@ dbxcli team list-members [flags] * Structured JSON output: yes * JSON help manifest: yes +* Manifest version: `1` * Auth modes: `team-manage` +* Dropbox scopes: `members.read` +* Flag metadata: `--output` (values: `json`, `text`) +* Result statuses: `listed` +* Result kinds: `team_member` +* JSON contract: `docs/json-schema/v1/commands.json#/commands/team list-members` ### SEE ALSO diff --git a/docs/commands/dbxcli_team_remove-member.md b/docs/commands/dbxcli_team_remove-member.md index 28cbd71..692e6ec 100644 --- a/docs/commands/dbxcli_team_remove-member.md +++ b/docs/commands/dbxcli_team_remove-member.md @@ -26,8 +26,15 @@ dbxcli team remove-member [flags] * Structured JSON output: yes * JSON help manifest: yes +* Manifest version: `1` * Auth modes: `team-manage` +* Dropbox scopes: `members.write` +* Arguments: `email` (required, email) +* Flag metadata: `--output` (values: `json`, `text`) * Destructive behavior: `admin` +* Result statuses: `completed`, `removed`, `started` +* Result kinds: `team_member` +* JSON contract: `docs/json-schema/v1/commands.json#/commands/team remove-member` ### SEE ALSO diff --git a/docs/commands/dbxcli_version.md b/docs/commands/dbxcli_version.md index 9aff3d7..ebc5dc2 100644 --- a/docs/commands/dbxcli_version.md +++ b/docs/commands/dbxcli_version.md @@ -26,7 +26,13 @@ dbxcli version [flags] * Structured JSON output: yes * JSON help manifest: yes +* Manifest version: `1` * Auth modes: none +* Dropbox scopes: none +* Flag metadata: `--output` (values: `json`, `text`) +* Result statuses: `reported` +* Result kinds: `version` +* JSON contract: `docs/json-schema/v1/commands.json#/commands/version` ### SEE ALSO diff --git a/docs/json-schema/v1/README.md b/docs/json-schema/v1/README.md index 77e04d9..366b1a9 100644 --- a/docs/json-schema/v1/README.md +++ b/docs/json-schema/v1/README.md @@ -6,6 +6,8 @@ These schemas describe the stable top-level JSON envelopes emitted by - `success.schema.json` validates successful command responses. - `error.schema.json` validates command error responses. +- `manifest.schema.json` validates each command manifest object emitted by + JSON help. - `commands.json` documents command-specific input/result payload names, result statuses, result kinds, and warning codes. @@ -56,6 +58,36 @@ text-only. The current JSON-enabled command paths are listed in `commands.json`. +## Command Manifest v1 + +JSON help is the canonical command manifest surface: + +```sh +dbxcli --help --output=json +dbxcli put --help --output=json +dbxcli --output=json help put +``` + +Each manifest result has `status: "described"`, `kind: "command"`, and a +`result` object that validates against `manifest.schema.json`. + +Manifest v1 keeps the original JSON help fields and adds machine-contract +metadata: + +- `manifest_version: "1"` +- structured `args` +- enriched `flags`, including enum values, conflicts, prompt behavior, and + sensitive inputs +- `examples` +- `schema_refs` +- best-effort audited `dropbox_scopes` +- `stdin_stdout` +- `result_statuses`, `result_kinds`, and `warning_codes` + +`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. + `account` results include auth context under `result.auth`: `result.auth.source` is `saved` or `env`; `result.auth.refreshable` is a boolean; and `result.auth.auth_file` is `default`, `custom`, or `none`. diff --git a/docs/json-schema/v1/commands.json b/docs/json-schema/v1/commands.json index ae43cce..8eaaa30 100644 --- a/docs/json-schema/v1/commands.json +++ b/docs/json-schema/v1/commands.json @@ -37,23 +37,65 @@ "member_id", "name" ], + "command_arg": [ + "description", + "name", + "placement", + "required", + "stream_dash", + "value_kind", + "variadic" + ], + "command_example": [ + "command", + "description" + ], "command_flag": [ + "conflicts", "default", + "enum_values", "inherited", + "may_prompt", "name", + "required", + "sensitive", + "shorthand", "type", - "usage" + "usage", + "value_kind" ], "command_manifest": [ "aliases", + "args", "auth_modes", "destructive_level", + "dropbox_scopes", + "examples", "flags", + "manifest_version", + "may_prompt", "path", + "result_kinds", + "result_statuses", "runnable", + "schema_refs", + "scope_accuracy", "short", + "stdin_stdout", "supports_structured_output", - "use" + "use", + "warning_codes" + ], + "command_schema_refs": [ + "command_contract", + "error_schema", + "success_schema" + ], + "command_stdin_stdout": [ + "reads_stdin", + "stderr", + "stdout", + "writes_binary_stdout" ], "du_allocation": [ "allocated", diff --git a/docs/json-schema/v1/manifest.schema.json b/docs/json-schema/v1/manifest.schema.json new file mode 100644 index 0000000..f79d239 --- /dev/null +++ b/docs/json-schema/v1/manifest.schema.json @@ -0,0 +1,282 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/dropbox/dbxcli/master/docs/json-schema/v1/manifest.schema.json", + "title": "dbxcli JSON command manifest v1", + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "use", + "short", + "aliases", + "runnable", + "flags", + "supports_structured_output", + "auth_modes", + "destructive_level", + "manifest_version", + "args", + "examples", + "schema_refs", + "dropbox_scopes", + "scope_accuracy", + "stdin_stdout", + "result_statuses", + "result_kinds", + "warning_codes", + "may_prompt" + ], + "properties": { + "path": { + "type": "string" + }, + "use": { + "type": "string" + }, + "short": { + "type": "string" + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "runnable": { + "type": "boolean" + }, + "flags": { + "type": "array", + "items": { + "$ref": "#/$defs/flag" + } + }, + "supports_structured_output": { + "type": "boolean" + }, + "auth_modes": { + "type": "array", + "items": { + "type": "string" + } + }, + "destructive_level": { + "type": "string", + "enum": [ + "none", + "delete", + "admin" + ] + }, + "manifest_version": { + "const": "1" + }, + "args": { + "type": "array", + "items": { + "$ref": "#/$defs/arg" + } + }, + "examples": { + "type": "array", + "items": { + "$ref": "#/$defs/example" + } + }, + "schema_refs": { + "$ref": "#/$defs/schema_refs" + }, + "dropbox_scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "scope_accuracy": { + "type": "string" + }, + "stdin_stdout": { + "$ref": "#/$defs/stdin_stdout" + }, + "result_statuses": { + "type": "array", + "items": { + "type": "string" + } + }, + "result_kinds": { + "type": "array", + "items": { + "type": "string" + } + }, + "warning_codes": { + "type": "array", + "items": { + "type": "string" + } + }, + "may_prompt": { + "type": "boolean" + } + }, + "$defs": { + "arg": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "required", + "variadic", + "placement", + "value_kind", + "description", + "stream_dash" + ], + "properties": { + "name": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "variadic": { + "type": "boolean" + }, + "placement": { + "type": "string" + }, + "value_kind": { + "type": "string" + }, + "description": { + "type": "string" + }, + "stream_dash": { + "type": "boolean" + } + } + }, + "example": { + "type": "object", + "additionalProperties": false, + "required": [ + "description", + "command" + ], + "properties": { + "description": { + "type": "string" + }, + "command": { + "type": "string" + } + } + }, + "flag": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "type", + "default", + "usage", + "inherited", + "shorthand", + "enum_values", + "conflicts", + "required", + "sensitive", + "may_prompt", + "value_kind" + ], + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "default": { + "type": "string" + }, + "usage": { + "type": "string" + }, + "inherited": { + "type": "boolean" + }, + "shorthand": { + "type": "string" + }, + "enum_values": { + "type": "array", + "items": { + "type": "string" + } + }, + "conflicts": { + "type": "array", + "items": { + "type": "string" + } + }, + "required": { + "type": "boolean" + }, + "sensitive": { + "type": "boolean" + }, + "may_prompt": { + "type": "boolean" + }, + "value_kind": { + "type": "string" + } + } + }, + "schema_refs": { + "type": "object", + "additionalProperties": false, + "required": [ + "success_schema", + "error_schema" + ], + "properties": { + "success_schema": { + "type": "string" + }, + "error_schema": { + "type": "string" + }, + "command_contract": { + "type": "string" + } + } + }, + "stdin_stdout": { + "type": "object", + "additionalProperties": false, + "required": [ + "reads_stdin", + "writes_binary_stdout", + "stdout", + "stderr" + ], + "properties": { + "reads_stdin": { + "type": "boolean" + }, + "writes_binary_stdout": { + "type": "boolean" + }, + "stdout": { + "type": "string" + }, + "stderr": { + "type": "string" + } + } + } + } +} diff --git a/tools/gen-docs/main.go b/tools/gen-docs/main.go index 7c0be85..93fce33 100644 --- a/tools/gen-docs/main.go +++ b/tools/gen-docs/main.go @@ -29,11 +29,7 @@ import ( const outputDir = "docs/commands" -const ( - structuredOutputSupportedAnnotation = "dbxcli.supportsStructuredOutput" - commandDestructiveLevelAnnotation = "dbxcli.destructiveLevel" - destructiveLevelNone = "none" -) +const destructiveLevelNone = "none" var stdinStdoutNotes = map[string]string{ "get": "Use `-` as the local target to write downloaded file bytes to stdout; diagnostics go to stderr.", @@ -140,11 +136,14 @@ func insertMetadataSection(contents []byte, section []byte) []byte { } func commandMetadataSection(command *cobra.Command) []byte { + manifest := cmd.CommandManifestFor(command) var buf bytes.Buffer buf.WriteString("### Command metadata\n\n") - buf.WriteString(fmt.Sprintf("* Structured JSON output: %s\n", yesNo(commandSupportsStructuredOutput(command)))) + buf.WriteString(fmt.Sprintf("* Structured JSON output: %s\n", yesNo(manifest.SupportsStructuredOutput))) buf.WriteString("* JSON help manifest: yes\n") - buf.WriteString("* Auth modes: " + markdownValueList(cmd.CommandManifestAuthModes(command)) + "\n") + buf.WriteString("* Manifest version: `" + manifest.ManifestVersion + "`\n") + buf.WriteString("* Auth modes: " + markdownValueList(manifest.AuthModes) + "\n") + buf.WriteString("* Dropbox scopes: " + markdownValueList(manifest.DropboxScopes) + "\n") if aliases := sortedAliases(command); len(aliases) > 0 { buf.WriteString("* Aliases: ") @@ -157,36 +156,78 @@ func commandMetadataSection(command *cobra.Command) []byte { buf.WriteString("\n") } + if len(manifest.Args) > 0 { + buf.WriteString("* Arguments: ") + for i, arg := range manifest.Args { + if i > 0 { + buf.WriteString(", ") + } + requirement := "optional" + if arg.Required { + requirement = "required" + } + variadic := "" + if arg.Variadic { + variadic = ", variadic" + } + streamDash := "" + if arg.StreamDash { + streamDash = ", `-` stream operand" + } + buf.WriteString(fmt.Sprintf("`%s` (%s, %s%s%s)", arg.Name, requirement, arg.ValueKind, variadic, streamDash)) + } + buf.WriteString("\n") + } + + if len(manifest.Flags) > 0 { + var describedFlags []string + for _, flag := range manifest.Flags { + var details []string + if len(flag.EnumValues) > 0 { + details = append(details, "values: "+markdownValueList(flag.EnumValues)) + } + if len(flag.Conflicts) > 0 { + details = append(details, "conflicts: "+markdownValueList(flag.Conflicts)) + } + if flag.Sensitive { + details = append(details, "sensitive") + } + if flag.MayPrompt { + details = append(details, "may prompt") + } + if len(details) > 0 { + describedFlags = append(describedFlags, "`--"+flag.Name+"` ("+strings.Join(details, "; ")+")") + } + } + if len(describedFlags) > 0 { + buf.WriteString("* Flag metadata: " + strings.Join(describedFlags, ", ") + "\n") + } + } + if note, ok := stdinStdoutNotes[relativeCommandPath(command)]; ok { buf.WriteString("* Stdin/stdout behavior: " + note + "\n") + } else if manifest.StdinStdout.ReadsStdin || manifest.StdinStdout.WritesBinaryStdout { + buf.WriteString(fmt.Sprintf("* Stdin/stdout behavior: reads_stdin=%t, writes_binary_stdout=%t\n", manifest.StdinStdout.ReadsStdin, manifest.StdinStdout.WritesBinaryStdout)) } - if level := commandDestructiveLevel(command); level != destructiveLevelNone { + if level := manifest.DestructiveLevel; level != destructiveLevelNone { buf.WriteString("* Destructive behavior: `" + level + "`\n") } - - buf.WriteString("\n") - return buf.Bytes() -} - -func commandSupportsStructuredOutput(command *cobra.Command) bool { - return command != nil && command.Annotations[structuredOutputSupportedAnnotation] == "true" -} - -func commandDestructiveLevel(command *cobra.Command) string { - if command == nil || command.Annotations == nil { - return destructiveLevelNone + if len(manifest.ResultStatuses) > 0 { + buf.WriteString("* Result statuses: " + markdownValueList(manifest.ResultStatuses) + "\n") + } + if len(manifest.ResultKinds) > 0 { + buf.WriteString("* Result kinds: " + markdownValueList(manifest.ResultKinds) + "\n") } - level := strings.TrimSpace(command.Annotations[commandDestructiveLevelAnnotation]) - if level == "" { - return destructiveLevelNone + if len(manifest.WarningCodes) > 0 { + buf.WriteString("* Warning codes: " + markdownValueList(manifest.WarningCodes) + "\n") } - parts := strings.Split(level, ",") - level = strings.TrimSpace(parts[0]) - if level == "" { - return destructiveLevelNone + if manifest.SchemaRefs.CommandContract != "" { + buf.WriteString("* JSON contract: `" + manifest.SchemaRefs.CommandContract + "`\n") } - return level + + buf.WriteString("\n") + return buf.Bytes() } func sortedAliases(command *cobra.Command) []string {