Repository: rclone/rclone
Pull request #6516 (2022-10-25 14:04:21)

s3: Add Liara LOS to provider list
<!--
Thank you very much for contributing code or documentation to rclone! Please
fill out the following questions to make it easier for us to review your
changes.

You do not need to check all the boxes below all at once, feel free to take
your time and add more commits. If you're done and ready for review, please
check the last box.
-->

#### What is the purpose of this change?

<!--
Describe the changes here
-->

#### Was the change discussed in an issue or in the forum before?

<!--
Link issues and relevant forum posts here.
-->

#### Checklist

- [x] I have read the [contribution guidelines](https://github.com/rclone/rclone/blob/master/CONTRIBUTING.md#submitting-a-new-feature-or-bug-fix).
- [x] I have added tests for all changes in this PR if appropriate.
- [x] I have added documentation for the changes if appropriate.
- [x] All commit messages are in [house style](https://github.com/rclone/rclone/blob/master/CONTRIBUTING.md#commit-messages).
- [x] I'm done, this Pull Request is ready for review :-)


vashian: @ncw Liara Object Storage (**LOS**) is a cloud object storage service and is  compatible with S3.
vashian: Using the new URL `liara.ir/landing/object-storage`
Now it's human-readable without any encoded character.

Also, drop the changes from **autogenerated options** part.
vashian: @ncw Unfortunately I haven't heard from you in a while. Could you please tell me what went wrong?
ncw: @vashian sorry, I'm waiting for an update from you to remove the auto generated content from s3.md - otherwise your PR looks fine.  So remove all the changes in s3.md between

```
{{< rem autogenerated options start" - DO NOT EDIT - instead edit fs.RegInfo in backend/s3/s3.go then run make backenddocs" >}}
```

and

```
{{< rem autogenerated options stop >}}
```

These will be put in as part of the release process - I prefer not to add them at this point.

Thank you
vashian: 

> @vashian sorry, I'm waiting for an update from you to remove the auto generated content from s3.md - otherwise your PR looks fine. So remove all the changes in s3.md between
> 
> ```
> {{< rem autogenerated options start" - DO NOT EDIT - instead edit fs.RegInfo in backend/s3/s3.go then run make backenddocs" >}}
> ```
> 
> and
> 
> ```
> {{< rem autogenerated options stop >}}
> ```
> 
> These will be put in as part of the release process - I prefer not to add them at this point.
> 
> Thank you

@ncw I appreciate it. Thank you for your time.

Patch:
diff --git a/README.md b/README.md
index fc8064ac7..9c47e6b77 100644
--- a/README.md
+++ b/README.md
@@ -50,6 +50,7 @@ Rclone *("rsync for cloud storage")* is a command-line program to sync files and
   * IBM COS S3 [:page_facing_up:](https://rclone.org/s3/#ibm-cos-s3)
   * IONOS Cloud [:page_facing_up:](https://rclone.org/s3/#ionos)
   * Koofr [:page_facing_up:](https://rclone.org/koofr/)
+  * Liara Object Storage [:page_facing_up:](https://rclone.org/s3/#liara-object-storage)
   * Mail.ru Cloud [:page_facing_up:](https://rclone.org/mailru/)
   * Memset Memstore [:page_facing_up:](https://rclone.org/swift/)
   * Mega [:page_facing_up:](https://rclone.org/mega/)
diff --git a/backend/ftp/ftp.go b/backend/ftp/ftp.go
index ede6d088a..01d686c8e 100644
--- a/backend/ftp/ftp.go
+++ b/backend/ftp/ftp.go
@@ -70,7 +70,7 @@ func init() {
 When using implicit FTP over TLS the client connects using TLS
 right from the start which breaks compatibility with
 non-TLS-aware servers. This is usually served over port 990 rather
-than port 21. Cannot be used in combination with explicit FTP.`,
+than port 21. Cannot be used in combination with explicit FTPS.`,
 			Default: false,
 		}, {
 			Name: "explicit_tls",
@@ -78,7 +78,7 @@ than port 21. Cannot be used in combination with explicit FTP.`,
 
 When using explicit FTP over TLS the client explicitly requests
 security from the server in order to upgrade a plain text connection
-to an encrypted one. Cannot be used in combination with implicit FTP.`,
+to an encrypted one. Cannot be used in combination with implicit FTPS.`,
 			Default: false,
 		}, {
 			Name: "concurrency",
@@ -657,8 +657,7 @@ func (f *Fs) dirFromStandardPath(dir string) string {
 // findItem finds a directory entry for the name in its parent directory
 func (f *Fs) findItem(ctx context.Context, remote string) (entry *ftp.Entry, err error) {
 	// defer fs.Trace(remote, "")("o=%v, err=%v", &o, &err)
-	fullPath := path.Join(f.root, remote)
-	if fullPath == "" || fullPath == "." || fullPath == "/" {
+	if remote == "" || remote == "." || remote == "/" {
 		// if root, assume exists and synthesize an entry
 		return &ftp.Entry{
 			Name: "",
@@ -666,13 +665,32 @@ func (f *Fs) findItem(ctx context.Context, remote string) (entry *ftp.Entry, err
 			Time: time.Now(),
 		}, nil
 	}
-	dir := path.Dir(fullPath)
-	base := path.Base(fullPath)
 
 	c, err := f.getFtpConnection(ctx)
 	if err != nil {
 		return nil, fmt.Errorf("findItem: %w", err)
 	}
+
+	// returns TRUE if MLST is supported which is required to call GetEntry
+	if c.IsTimePreciseInList() {
+		entry, err := c.GetEntry(f.opt.Enc.FromStandardPath(remote))
+		f.putFtpConnection(&c, err)
+		if err != nil {
+			err = translateErrorFile(err)
+			if err == fs.ErrorObjectNotFound {
+				return nil, nil
+			}
+			return nil, err
+		}
+		if entry != nil {
+			f.entryToStandard(entry)
+		}
+		return entry, nil
+	}
+
+	dir := path.Dir(remote)
+	base := path.Base(remote)
+
 	files, err := c.List(f.dirFromStandardPath(dir))
 	f.putFtpConnection(&c, err)
 	if err != nil {
@@ -691,7 +709,7 @@ func (f *Fs) findItem(ctx context.Context, remote string) (entry *ftp.Entry, err
 // it returns the error fs.ErrorObjectNotFound.
 func (f *Fs) NewObject(ctx context.Context, remote string) (o fs.Object, err error) {
 	// defer fs.Trace(remote, "")("o=%v, err=%v", &o, &err)
-	entry, err := f.findItem(ctx, remote)
+	entry, err := f.findItem(ctx, path.Join(f.root, remote))
 	if err != nil {
 		return nil, err
 	}
@@ -713,7 +731,7 @@ func (f *Fs) NewObject(ctx context.Context, remote string) (o fs.Object, err err
 
 // dirExists checks the directory pointed to by remote exists or not
 func (f *Fs) dirExists(ctx context.Context, remote string) (exists bool, err error) {
-	entry, err := f.findItem(ctx, remote)
+	entry, err := f.findItem(ctx, path.Join(f.root, remote))
 	if err != nil {
 		return false, fmt.Errorf("dirExists: %w", err)
 	}
@@ -857,32 +875,18 @@ func (f *Fs) PutStream(ctx context.Context, in io.Reader, src fs.ObjectInfo, opt
 // getInfo reads the FileInfo for a path
 func (f *Fs) getInfo(ctx context.Context, remote string) (fi *FileInfo, err error) {
 	// defer fs.Trace(remote, "")("fi=%v, err=%v", &fi, &err)
-	dir := path.Dir(remote)
-	base := path.Base(remote)
-
-	c, err := f.getFtpConnection(ctx)
+	file, err := f.findItem(ctx, remote)
 	if err != nil {
-		return nil, fmt.Errorf("getInfo: %w", err)
-	}
-	files, err := c.List(f.dirFromStandardPath(dir))
-	f.putFtpConnection(&c, err)
-	if err != nil {
-		return nil, translateErrorFile(err)
-	}
-
-	for i := range files {
-		file := files[i]
-		f.entryToStandard(file)
-		if file.Name == base {
-			info := &FileInfo{
-				Name:    remote,
-				Size:    file.Size,
-				ModTime: file.Time,
-				precise: f.fLstTime,
-				IsDir:   file.Type == ftp.EntryTypeFolder,
-			}
-			return info, nil
+		return nil, err
+	} else if file != nil {
+		info := &FileInfo{
+			Name:    remote,
+			Size:    file.Size,
+			ModTime: file.Time,
+			precise: f.fLstTime,
+			IsDir:   file.Type == ftp.EntryTypeFolder,
 		}
+		return info, nil
 	}
 	return nil, fs.ErrorObjectNotFound
 }
diff --git a/backend/s3/s3.go b/backend/s3/s3.go
index 9de4a7e6a..8b0b19ee3 100644
--- a/backend/s3/s3.go
+++ b/backend/s3/s3.go
@@ -66,7 +66,7 @@ import (
 func init() {
 	fs.Register(&fs.RegInfo{
 		Name:        "s3",
-		Description: "Amazon S3 Compliant Storage Providers including AWS, Alibaba, Ceph, China Mobile, Cloudflare, ArvanCloud, Digital Ocean, Dreamhost, Huawei OBS, IBM COS, IDrive e2, IONOS Cloud, Lyve Cloud, Minio, Netease, RackCorp, Scaleway, SeaweedFS, StackPath, Storj, Tencent COS, Qiniu and Wasabi",
+		Description: "Amazon S3 Compliant Storage Providers including AWS, Alibaba, Ceph, China Mobile, Cloudflare, ArvanCloud, Digital Ocean, Dreamhost, Huawei OBS, IBM COS, IDrive e2, IONOS Cloud, Liara, Lyve Cloud, Minio, Netease, RackCorp, Scaleway, SeaweedFS, StackPath, Storj, Tencent COS, Qiniu and Wasabi",
 		NewFs:       NewFs,
 		CommandHelp: commandHelp,
 		Config: func(ctx context.Context, name string, m configmap.Mapper, config fs.ConfigIn) (*fs.ConfigOut, error) {
@@ -124,6 +124,9 @@ func init() {
 			}, {
 				Value: "LyveCloud",
 				Help:  "Seagate Lyve Cloud",
+			}, {
+				Value: "Liara",
+				Help:  "Liara Object Storage",
 			}, {
 				Value: "Minio",
 				Help:  "Minio Object Storage",
@@ -437,7 +440,7 @@ func init() {
 		}, {
 			Name:     "region",
 			Help:     "Region to connect to.\n\nLeave blank if you are using an S3 clone and you don't have a region.",
-			Provider: "!AWS,Alibaba,ChinaMobile,Cloudflare,IONOS,ArvanCloud,Qiniu,RackCorp,Scaleway,Storj,TencentCOS,HuaweiOBS,IDrive",
+			Provider: "!AWS,Alibaba,ChinaMobile,Cloudflare,IONOS,ArvanCloud,Liara,Qiniu,RackCorp,Scaleway,Storj,TencentCOS,HuaweiOBS,IDrive",
 			Examples: []fs.OptionExample{{
 				Value: "",
 				Help:  "Use this if unsure.\nWill use v4 signatures and an empty region.",
@@ -762,6 +765,15 @@ func init() {
 				Value: "s3-eu-south-2.ionoscloud.com",
 				Help:  "Logrono, Spain",
 			}},
+		}, {
+			// Liara endpoints: https://liara.ir/landing/object-storage
+			Name:     "endpoint",
+			Help:     "Endpoint for Liara Object Storage API.",
+			Provider: "Liara",
+			Examples: []fs.OptionExample{{
+				Value: "storage.iran.liara.space",
+				Help:  "The default endpoint\nIran",
+			}},
 		}, {
 			// oss endpoints: https://help.aliyun.com/document_detail/31837.html
 			Name:     "endpoint",
@@ -1092,7 +1104,7 @@ func init() {
 		}, {
 			Name:     "endpoint",
 			Help:     "Endpoint for S3 API.\n\nRequired when using an S3 clone.",
-			Provider: "!AWS,IBMCOS,IDrive,IONOS,TencentCOS,HuaweiOBS,Alibaba,ChinaMobile,ArvanCloud,Scaleway,StackPath,Storj,RackCorp,Qiniu",
+			Provider: "!AWS,IBMCOS,IDrive,IONOS,TencentCOS,HuaweiOBS,Alibaba,ChinaMobile,Liara,ArvanCloud,Scaleway,StackPath,Storj,RackCorp,Qiniu",
 			Examples: []fs.OptionExample{{
 				Value:    "objects-us-east-1.dream.io",
 				Help:     "Dream Objects endpoint",
@@ -1177,6 +1189,10 @@ func init() {
 				Value:    "s3.ap-southeast-2.wasabisys.com",
 				Help:     "Wasabi AP Southeast 2 (Sydney)",
 				Provider: "Wasabi",
+			}, {
+				Value:    "storage.iran.liara.space",
+				Help:     "Liara Iran endpoint",
+				Provider: "Liara",
 			}, {
 				Value:    "s3.ir-thr-at1.arvanstorage.com",
 				Help:     "ArvanCloud Tehran Iran (Asiatech) endpoint",
@@ -1560,7 +1576,7 @@ func init() {
 		}, {
 			Name:     "location_constraint",
 			Help:     "Location constraint - must be set to match the Region.\n\nLeave blank if not sure. Used when creating buckets only.",
-			Provider: "!AWS,Alibaba,HuaweiOBS,ChinaMobile,Cloudflare,IBMCOS,IDrive,IONOS,ArvanCloud,Qiniu,RackCorp,Scaleway,StackPath,Storj,TencentCOS",
+			Provider: "!AWS,Alibaba,HuaweiOBS,ChinaMobile,Cloudflare,IBMCOS,IDrive,IONOS,Liara,ArvanCloud,Qiniu,RackCorp,Scaleway,StackPath,Storj,TencentCOS",
 		}, {
 			Name: "acl",
 			Help: `Canned ACL used when creating buckets and storing or copying objects.
@@ -1793,6 +1809,15 @@ If you leave it blank, this is calculated automatically from the sse_customer_ke
 				Value: "STANDARD_IA",
 				Help:  "Infrequent access storage mode",
 			}},
+		}, {
+			// Mapping from here: https://liara.ir/landing/object-storage
+			Name:     "storage_class",
+			Help:     "The storage class to use when storing new objects in Liara",
+			Provider: "Liara",
+			Examples: []fs.OptionExample{{
+				Value: "STANDARD",
+				Help:  "Standard storage class",
+			}},
 		}, {
 			// Mapping from here: https://www.arvancloud.com/en/products/cloud-storage
 			Name:     "storage_class",
@@ -2730,6 +2755,7 @@ func setQuirks(opt *Options) {
 		mightGzip = false // Never auto gzips objects
 	case "Alibaba":
 		useMultipartEtag = false // Alibaba seems to calculate multipart Etags differently from AWS
+		listObjectsV2 = false    // See #6600
 	case "HuaweiOBS":
 		// Huawei OBS PFS is not support listObjectV2, and if turn on the urlEncodeListing, marker will not work and keep list same page forever.
 		urlEncodeListings = false
@@ -2764,6 +2790,10 @@ func setQuirks(opt *Options) {
 		// listObjectsV2 supported - https://api.ionos.com/docs/s3/#Basic-Operations-get-Bucket-list-type-2
 		virtualHostStyle = false
 		urlEncodeListings = false
+	case "Liara":
+		virtualHostStyle = false
+		urlEncodeListings = false
+		useMultipartEtag = false
 	case "LyveCloud":
 		useMultipartEtag = false // LyveCloud seems to calculate multipart Etags differently from AWS
 	case "Minio":
@@ -3052,7 +3082,8 @@ func (f *Fs) newObjectWithInfo(ctx context.Context, remote string, info *s3.Obje
 		}
 		o.setMD5FromEtag(aws.StringValue(info.ETag))
 		o.bytes = aws.Int64Value(info.Size)
-		o.storageClass = info.StorageClass
+		storageClass := *info.StorageClass // To prevent reference to large XML structures
+		o.storageClass = &storageClass
 		o.versionID = versionID
 	} else if !o.fs.opt.NoHeadObject {
 		err := o.readMetaData(ctx) // reads info and meta, returning an error
@@ -3194,6 +3225,9 @@ func (ls *v2List) List(ctx context.Context) (resp *s3.ListObjectsV2Output, versi
 	if err != nil {
 		return nil, nil, err
 	}
+	if aws.BoolValue(resp.IsTruncated) && (resp.ContinuationToken == nil || *resp.ContinuationToken == "") {
+		return nil, nil, errors.New("s3 protocol error: received listing v2 with IsTruncated set and no ContinuationToken. Should you be using `--s3-list-version 1`?")
+	}
 	ls.req.ContinuationToken = resp.NextContinuationToken
 	return resp, nil, nil
 }
diff --git a/cmd/about/about.go b/cmd/about/about.go
index 967744b59..6d3afc91d 100644
--- a/cmd/about/about.go
+++ b/cmd/about/about.go
@@ -93,6 +93,9 @@ provided by a backend. Where the value is unlimited it is omitted.
 Some backends does not support the ` + "`rclone about`" + ` command at all,
 see complete list in [documentation](https://rclone.org/overview/#optional-features).
 `,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.41",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(1, 1, command, args)
 		f := cmd.NewFsSrc(args)
diff --git a/cmd/authorize/authorize.go b/cmd/authorize/authorize.go
index 87dda815b..84c0bc0f3 100644
--- a/cmd/authorize/authorize.go
+++ b/cmd/authorize/authorize.go
@@ -30,6 +30,9 @@ rclone config.
 
 Use the --auth-no-open-browser to prevent rclone to open auth
 link in default browser automatically.`,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.27",
+	},
 	RunE: func(command *cobra.Command, args []string) error {
 		cmd.CheckArgs(1, 3, command, args)
 		return config.Authorize(context.Background(), args, noAutoBrowser)
diff --git a/cmd/backend/backend.go b/cmd/backend/backend.go
index 3199f8a5d..67d499923 100644
--- a/cmd/backend/backend.go
+++ b/cmd/backend/backend.go
@@ -58,6 +58,9 @@ Pass arguments to the backend by placing them on the end of the line
 Note to run these commands on a running backend then see
 [backend/command](/rc/#backend-command) in the rc docs.
 `,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.52",
+	},
 	RunE: func(command *cobra.Command, args []string) error {
 		cmd.CheckArgs(2, 1e6, command, args)
 		name, remote := args[0], args[1]
diff --git a/cmd/bisync/cmd.go b/cmd/bisync/cmd.go
index c14edf2e7..6f7c8fc15 100644
--- a/cmd/bisync/cmd.go
+++ b/cmd/bisync/cmd.go
@@ -115,6 +115,9 @@ var commandDefinition = &cobra.Command{
 	Use:   "bisync remote1:path1 remote2:path2",
 	Short: shortHelp,
 	Long:  longHelp,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.58",
+	},
 	RunE: func(command *cobra.Command, args []string) error {
 		cmd.CheckArgs(2, 2, command, args)
 		fs1, file1, fs2, file2 := cmd.NewFsSrcDstFiles(args)
diff --git a/cmd/cachestats/cachestats.go b/cmd/cachestats/cachestats.go
index 7638db8f4..f5cbf0095 100644
--- a/cmd/cachestats/cachestats.go
+++ b/cmd/cachestats/cachestats.go
@@ -25,6 +25,10 @@ var commandDefinition = &cobra.Command{
 Print cache stats for a remote in JSON format
 `,
 	Hidden: true,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.39",
+		"status":            "Deprecated",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(1, 1, command, args)
 		fs.Logf(nil, `"rclone cachestats" is deprecated, use "rclone backend stats %s" instead`, args[0])
diff --git a/cmd/cat/cat.go b/cmd/cat/cat.go
index ead361455..03ed4e6e3 100644
--- a/cmd/cat/cat.go
+++ b/cmd/cat/cat.go
@@ -57,6 +57,9 @@ the end and |--offset| and |--count| to print a section in the middle.
 Note that if offset is negative it will count from the end, so
 |--offset -1 --count 1| is equivalent to |--tail 1|.
 `, "|", "`"),
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.33",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		usedOffset := offset != 0 || count >= 0
 		usedHead := head > 0
diff --git a/cmd/checksum/checksum.go b/cmd/checksum/checksum.go
index c25aa143d..4dee6611c 100644
--- a/cmd/checksum/checksum.go
+++ b/cmd/checksum/checksum.go
@@ -37,6 +37,9 @@ that don't support hashes or if you really want to check all the data.
 
 Note that hash values in the SUM file are treated as case insensitive.
 `, "|", "`") + check.FlagsHelp,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.56",
+	},
 	RunE: func(command *cobra.Command, args []string) error {
 		cmd.CheckArgs(3, 3, command, args)
 		var hashType hash.Type
diff --git a/cmd/cleanup/cleanup.go b/cmd/cleanup/cleanup.go
index e54577466..d23f0cb91 100644
--- a/cmd/cleanup/cleanup.go
+++ b/cmd/cleanup/cleanup.go
@@ -20,6 +20,9 @@ var commandDefinition = &cobra.Command{
 Clean up the remote if possible.  Empty the trash or delete old file
 versions. Not supported by all remotes.
 `,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.31",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(1, 1, command, args)
 		fsrc := cmd.NewFsSrc(args)
diff --git a/cmd/config/config.go b/cmd/config/config.go
index 66b2a1c6d..5fb21a0cf 100644
--- a/cmd/config/config.go
+++ b/cmd/config/config.go
@@ -44,6 +44,9 @@ var configCommand = &cobra.Command{
 remotes and manage existing ones. You may also set or remove a
 password to protect your configuration.
 `,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.39",
+	},
 	RunE: func(command *cobra.Command, args []string) error {
 		cmd.CheckArgs(0, 0, command, args)
 		return config.EditConfig(context.Background())
@@ -54,12 +57,18 @@ var configEditCommand = &cobra.Command{
 	Use:   "edit",
 	Short: configCommand.Short,
 	Long:  configCommand.Long,
-	Run:   configCommand.Run,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.39",
+	},
+	Run: configCommand.Run,
 }
 
 var configFileCommand = &cobra.Command{
 	Use:   "file",
 	Short: `Show path of configuration file in use.`,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.38",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(0, 0, command, args)
 		config.ShowConfigLocation()
@@ -69,6 +78,9 @@ var configFileCommand = &cobra.Command{
 var configTouchCommand = &cobra.Command{
 	Use:   "touch",
 	Short: `Ensure configuration file exists.`,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.56",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(0, 0, command, args)
 		config.SaveConfig()
@@ -78,6 +90,9 @@ var configTouchCommand = &cobra.Command{
 var configPathsCommand = &cobra.Command{
 	Use:   "paths",
 	Short: `Show paths used for configuration, cache, temp etc.`,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.57",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(0, 0, command, args)
 		fmt.Printf("Config file: %s\n", config.GetConfigPath())
@@ -89,6 +104,9 @@ var configPathsCommand = &cobra.Command{
 var configShowCommand = &cobra.Command{
 	Use:   "show [<remote>]",
 	Short: `Print (decrypted) config file, or the config for a single remote.`,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.38",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(0, 1, command, args)
 		if len(args) == 0 {
@@ -103,6 +121,9 @@ var configShowCommand = &cobra.Command{
 var configDumpCommand = &cobra.Command{
 	Use:   "dump",
 	Short: `Dump the config file as JSON.`,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.39",
+	},
 	RunE: func(command *cobra.Command, args []string) error {
 		cmd.CheckArgs(0, 0, command, args)
 		return config.Dump()
@@ -112,6 +133,9 @@ var configDumpCommand = &cobra.Command{
 var configProvidersCommand = &cobra.Command{
 	Use:   "providers",
 	Short: `List in JSON format all the providers and options.`,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.39",
+	},
 	RunE: func(command *cobra.Command, args []string) error {
 		cmd.CheckArgs(0, 0, command, args)
 		return config.JSONListProviders()
@@ -226,6 +250,9 @@ using remote authorization you would do this:
 
     rclone config create mydrive drive config_is_local=false
 `, "|", "`") + configPasswordHelp,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.39",
+	},
 	RunE: func(command *cobra.Command, args []string) error {
 		cmd.CheckArgs(2, 256, command, args)
 		in, err := argsToMap(args[2:])
@@ -289,6 +316,9 @@ require this add an extra parameter thus:
 
     rclone config update myremote env_auth=true config_refresh_token=false
 `, "|", "`") + configPasswordHelp,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.39",
+	},
 	RunE: func(command *cobra.Command, args []string) error {
 		cmd.CheckArgs(1, 256, command, args)
 		in, err := argsToMap(args[1:])
@@ -304,6 +334,9 @@ require this add an extra parameter thus:
 var configDeleteCommand = &cobra.Command{
 	Use:   "delete name",
 	Short: "Delete an existing remote.",
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.39",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(1, 1, command, args)
 		config.DeleteRemote(args[0])
@@ -326,6 +359,9 @@ For example, to set password of a remote of name myremote you would do:
 This command is obsolete now that "config update" and "config create"
 both support obscuring passwords directly.
 `, "|", "`"),
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.39",
+	},
 	RunE: func(command *cobra.Command, args []string) error {
 		cmd.CheckArgs(1, 256, command, args)
 		in, err := argsToMap(args[1:])
diff --git a/cmd/copyto/copyto.go b/cmd/copyto/copyto.go
index 85b54fa60..337a80b3a 100644
--- a/cmd/copyto/copyto.go
+++ b/cmd/copyto/copyto.go
@@ -46,6 +46,9 @@ the destination.
 
 **Note**: Use the ` + "`-P`" + `/` + "`--progress`" + ` flag to view real-time transfer statistics
 `,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.35",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(2, 2, command, args)
 		fsrc, srcFileName, fdst, dstFileName := cmd.NewFsSrcDstFiles(args)
diff --git a/cmd/copyurl/copyurl.go b/cmd/copyurl/copyurl.go
index a3c079b11..9b9063861 100644
--- a/cmd/copyurl/copyurl.go
+++ b/cmd/copyurl/copyurl.go
@@ -51,6 +51,9 @@ destination if there is one with the same name.
 Setting ` + "`--stdout`" + ` or making the output file name ` + "`-`" + `
 will cause the output to be written to standard output.
 `,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.43",
+	},
 	RunE: func(command *cobra.Command, args []string) (err error) {
 		cmd.CheckArgs(1, 2, command, args)
 
diff --git a/cmd/cryptcheck/cryptcheck.go b/cmd/cryptcheck/cryptcheck.go
index aab50ef06..481c4cece 100644
--- a/cmd/cryptcheck/cryptcheck.go
+++ b/cmd/cryptcheck/cryptcheck.go
@@ -47,6 +47,9 @@ the files in remote:path.
 
 After it has run it will log the status of the encryptedremote:.
 ` + check.FlagsHelp,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.36",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(2, 2, command, args)
 		fsrc, fdst := cmd.NewFsSrcDst(args)
diff --git a/cmd/cryptdecode/cryptdecode.go b/cmd/cryptdecode/cryptdecode.go
index 9c6178e9e..a657bb0c5 100644
--- a/cmd/cryptdecode/cryptdecode.go
+++ b/cmd/cryptdecode/cryptdecode.go
@@ -41,6 +41,9 @@ use it like this
 Another way to accomplish this is by using the ` + "`rclone backend encode` (or `decode`)" + ` command.
 See the documentation on the [crypt](/crypt/) overlay for more info.
 `,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.38",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(2, 11, command, args)
 		cmd.Run(false, false, command, func() error {
diff --git a/cmd/dedupe/dedupe.go b/cmd/dedupe/dedupe.go
index 94a136ca2..b56d62d9f 100644
--- a/cmd/dedupe/dedupe.go
+++ b/cmd/dedupe/dedupe.go
@@ -135,6 +135,9 @@ Or
 
     rclone dedupe rename "drive:Google Photos"
 `,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.27",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(1, 2, command, args)
 		if len(args) > 1 {
diff --git a/cmd/delete/delete.go b/cmd/delete/delete.go
index 047c320af..4dcc69ddd 100644
--- a/cmd/delete/delete.go
+++ b/cmd/delete/delete.go
@@ -53,6 +53,9 @@ delete all files bigger than 100 MiB.
 **Important**: Since this can cause data loss, test first with the
 |--dry-run| or the |--interactive|/|-i| flag.
 `, "|", "`"),
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.27",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(1, 1, command, args)
 		fsrc := cmd.NewFsSrc(args)
diff --git a/cmd/deletefile/deletefile.go b/cmd/deletefile/deletefile.go
index 9d222771d..4bf4ad72d 100644
--- a/cmd/deletefile/deletefile.go
+++ b/cmd/deletefile/deletefile.go
@@ -22,6 +22,9 @@ Remove a single file from remote.  Unlike ` + "`" + `delete` + "`" + ` it cannot
 remove a directory and it doesn't obey include/exclude filters - if the specified file exists,
 it will always be removed.
 `,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.42",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(1, 1, command, args)
 		fs, fileName := cmd.NewFsFile(args[0])
diff --git a/cmd/genautocomplete/genautocomplete.go b/cmd/genautocomplete/genautocomplete.go
index 9502bbbc9..c1408d9f7 100644
--- a/cmd/genautocomplete/genautocomplete.go
+++ b/cmd/genautocomplete/genautocomplete.go
@@ -17,4 +17,7 @@ var completionDefinition = &cobra.Command{
 Generates a shell completion script for rclone.
 Run with ` + "`--help`" + ` to list the supported shells.
 `,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.33",
+	},
 }
diff --git a/cmd/gendocs/gendocs.go b/cmd/gendocs/gendocs.go
index 4965aa7a3..83c54846d 100644
--- a/cmd/gendocs/gendocs.go
+++ b/cmd/gendocs/gendocs.go
@@ -31,6 +31,7 @@ type frontmatter struct {
 	Slug        string
 	URL         string
 	Source      string
+	Annotations map[string]string
 }
 
 var frontmatterTemplate = template.Must(template.New("frontmatter").Parse(`---
@@ -38,6 +39,9 @@ title: "{{ .Title }}"
 description: "{{ .Description }}"
 slug: {{ .Slug }}
 url: {{ .URL }}
+{{- range $key, $value := .Annotations }}
+{{ $key }}: {{  $value }}
+{{- end }}
 # autogenerated - DO NOT EDIT, instead edit the source code in {{ .Source }} and as part of making a release run "make commanddocs"
 ---
 `))
@@ -49,6 +53,9 @@ var commandDefinition = &cobra.Command{
 This produces markdown docs for the rclone commands to the directory
 supplied.  These are in a format suitable for hugo to render into the
 rclone.org website.`,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.33",
+	},
 	RunE: func(command *cobra.Command, args []string) error {
 		cmd.CheckArgs(1, 1, command, args)
 		now := time.Now().Format(time.RFC3339)
@@ -75,17 +82,24 @@ rclone.org website.`,
 			return err
 		}
 
-		// Look up name => description for prepender
-		var description = map[string]string{}
-		var addDescription func(root *cobra.Command)
-		addDescription = func(root *cobra.Command) {
+		// Look up name => details for prepender
+		type commandDetails struct {
+			Short       string
+			Annotations map[string]string
+		}
+		var commands = map[string]commandDetails{}
+		var addCommandDetails func(root *cobra.Command)
+		addCommandDetails = func(root *cobra.Command) {
 			name := strings.ReplaceAll(root.CommandPath(), " ", "_") + ".md"
-			description[name] = root.Short
+			commands[name] = commandDetails{
+				Short:       root.Short,
+				Annotations: root.Annotations,
+			}
 			for _, c := range root.Commands() {
-				addDescription(c)
+				addCommandDetails(c)
 			}
 		}
-		addDescription(cmd.Root)
+		addCommandDetails(cmd.Root)
 
 		// markup for the docs files
 		prepender := func(filename string) string {
@@ -94,10 +108,11 @@ rclone.org website.`,
 			data := frontmatter{
 				Date:        now,
 				Title:       strings.ReplaceAll(base, "_", " "),
-				Description: description[name],
+				Description: commands[name].Short,
 				Slug:        base,
 				URL:         "/commands/" + strings.ToLower(base) + "/",
 				Source:      strings.ReplaceAll(strings.ReplaceAll(base, "rclone", "cmd"), "_", "/") + "/",
+				Annotations: commands[name].Annotations,
 			}
 			var buf bytes.Buffer
 			err := frontmatterTemplate.Execute(&buf, data)
diff --git a/cmd/hashsum/hashsum.go b/cmd/hashsum/hashsum.go
index 4a1993716..6e39cf7ce 100644
--- a/cmd/hashsum/hashsum.go
+++ b/cmd/hashsum/hashsum.go
@@ -112,6 +112,9 @@ Then
 
 Note that hash names are case insensitive and values are output in lower case.
 `,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.41",
+	},
 	RunE: func(command *cobra.Command, args []string) error {
 		cmd.CheckArgs(0, 2, command, args)
 		if len(args) == 0 {
diff --git a/cmd/link/link.go b/cmd/link/link.go
index a0356cd33..380e5a46b 100644
--- a/cmd/link/link.go
+++ b/cmd/link/link.go
@@ -49,6 +49,9 @@ link. Exact capabilities depend on the remote, but the link will
 always by default be created with the least constraints – e.g. no
 expiry, no password protection, accessible without account.
 `,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.41",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(1, 1, command, args)
 		fsrc, remote := cmd.NewFsFile(args[0])
diff --git a/cmd/listremotes/listremotes.go b/cmd/listremotes/listremotes.go
index bf7b697f2..e473fcb21 100644
--- a/cmd/listremotes/listremotes.go
+++ b/cmd/listremotes/listremotes.go
@@ -30,6 +30,9 @@ rclone listremotes lists all the available remotes from the config file.
 
 When used with the ` + "`--long`" + ` flag it lists the types too.
 `,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.34",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(0, 0, command, args)
 		remotes := config.FileSections()
diff --git a/cmd/lsf/lsf.go b/cmd/lsf/lsf.go
index 2651e90e7..8322e7c25 100644
--- a/cmd/lsf/lsf.go
+++ b/cmd/lsf/lsf.go
@@ -142,6 +142,9 @@ those only (without traversing the whole directory structure):
     rclone copy --files-from-raw new_files /path/to/local remote:path
 
 ` + lshelp.Help,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.40",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(1, 1, command, args)
 		fsrc := cmd.NewFsSrc(args)
diff --git a/cmd/lsjson/lsjson.go b/cmd/lsjson/lsjson.go
index a17ade327..c7da1c863 100644
--- a/cmd/lsjson/lsjson.go
+++ b/cmd/lsjson/lsjson.go
@@ -112,6 +112,9 @@ will be shown ("2017-05-31T16:15:57+01:00").
 The whole output can be processed as a JSON blob, or alternatively it
 can be processed line by line as each item is written one to a line.
 ` + lshelp.Help,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.37",
+	},
 	RunE: func(command *cobra.Command, args []string) error {
 		cmd.CheckArgs(1, 1, command, args)
 		var fsrc fs.Fs
diff --git a/cmd/lsl/lsl.go b/cmd/lsl/lsl.go
index f8108c5f7..e7a7366f1 100644
--- a/cmd/lsl/lsl.go
+++ b/cmd/lsl/lsl.go
@@ -31,6 +31,9 @@ Eg
         37600 2016-06-25 18:55:40.814629136 fubuwic
 
 ` + lshelp.Help,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.02",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(1, 1, command, args)
 		fsrc := cmd.NewFsSrc(args)
diff --git a/cmd/md5sum/md5sum.go b/cmd/md5sum/md5sum.go
index 91bc66e44..94c0ed3ef 100644
--- a/cmd/md5sum/md5sum.go
+++ b/cmd/md5sum/md5sum.go
@@ -38,6 +38,9 @@ by not passing a remote:path, or by passing a hyphen as remote:path
 when there is data to read (if not, the hyphen will be treated literally,
 as a relative path).
 `,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.02",
+	},
 	RunE: func(command *cobra.Command, args []string) error {
 		cmd.CheckArgs(0, 1, command, args)
 		if found, err := hashsum.CreateFromStdinArg(hash.MD5, args, 0); found {
diff --git a/cmd/mountlib/mount.go b/cmd/mountlib/mount.go
index 907f16efd..af0eaf5a0 100644
--- a/cmd/mountlib/mount.go
+++ b/cmd/mountlib/mount.go
@@ -157,6 +157,9 @@ func NewMountCommand(commandName string, hidden bool, mount MountFn) *cobra.Comm
 		Hidden: hidden,
 		Short:  `Mount the remote as file system on a mountpoint.`,
 		Long:   strings.ReplaceAll(strings.ReplaceAll(mountHelp, "|", "`"), "@", commandName) + vfs.Help,
+		Annotations: map[string]string{
+			"versionIntroduced": "v1.33",
+		},
 		Run: func(command *cobra.Command, args []string) {
 			cmd.CheckArgs(2, 2, command, args)
 
diff --git a/cmd/move/move.go b/cmd/move/move.go
index 65c8adc23..fd1004db1 100644
--- a/cmd/move/move.go
+++ b/cmd/move/move.go
@@ -60,6 +60,9 @@ can speed transfers up greatly.
 
 **Note**: Use the |-P|/|--progress| flag to view real-time transfer statistics.
 `, "|", "`"),
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.19",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(2, 2, command, args)
 		fsrc, srcFileName, fdst := cmd.NewFsSrcFileDst(args)
diff --git a/cmd/moveto/moveto.go b/cmd/moveto/moveto.go
index 698d7ee18..f007fb182 100644
--- a/cmd/moveto/moveto.go
+++ b/cmd/moveto/moveto.go
@@ -49,6 +49,9 @@ successful transfer.
 
 **Note**: Use the ` + "`-P`" + `/` + "`--progress`" + ` flag to view real-time transfer statistics.
 `,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.35",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(2, 2, command, args)
 		fsrc, srcFileName, fdst, dstFileName := cmd.NewFsSrcDstFiles(args)
diff --git a/cmd/ncdu/ncdu.go b/cmd/ncdu/ncdu.go
index edf0a570c..6fdb210a5 100644
--- a/cmd/ncdu/ncdu.go
+++ b/cmd/ncdu/ncdu.go
@@ -74,6 +74,9 @@ For a non-interactive listing of the remote, see the
 [tree](/commands/rclone_tree/) command. To just get the total size of
 the remote you can also use the [size](/commands/rclone_size/) command.
 `,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.37",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(1, 1, command, args)
 		fsrc := cmd.NewFsSrc(args)
@@ -1032,8 +1035,7 @@ outer:
 	return nil
 }
 
-// key returns a rune representing the key k. It is larger than the maximum Unicode code-point.
+// key returns a rune representing the key k. It is a negative value, to not collide with Unicode code-points.
 func key(k tcell.Key) rune {
-	// This is the maximum possible Unicode code point. Anything greater fails to compile as a Go quoted rune literal.
-	return '\U0010FFFF' + rune(k)
+	return rune(-k)
 }
diff --git a/cmd/obscure/obscure.go b/cmd/obscure/obscure.go
index 08cf8a06a..80a473ae2 100644
--- a/cmd/obscure/obscure.go
+++ b/cmd/obscure/obscure.go
@@ -42,6 +42,9 @@ obfuscating the hyphen itself.
 If you want to encrypt the config file then please use config file
 encryption - see [rclone config](/commands/rclone_config/) for more
 info.`,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.36",
+	},
 	RunE: func(command *cobra.Command, args []string) error {
 		cmd.CheckArgs(1, 1, command, args)
 		var password string
diff --git a/cmd/rc/rc.go b/cmd/rc/rc.go
index 2bb163d03..b31e340eb 100644
--- a/cmd/rc/rc.go
+++ b/cmd/rc/rc.go
@@ -99,6 +99,9 @@ rclone rc server, e.g.:
     rclone rc --loopback operations/about fs=/
 
 Use ` + "`rclone rc`" + ` to see a list of all possible commands.`,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.40",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(0, 1e9, command, args)
 		cmd.Run(false, false, command, func() error {
diff --git a/cmd/rcat/rcat.go b/cmd/rcat/rcat.go
index 4328a8aa8..c66fe4650 100644
--- a/cmd/rcat/rcat.go
+++ b/cmd/rcat/rcat.go
@@ -56,6 +56,9 @@ Note that the upload can also not be retried because the data is
 not kept around until the upload succeeds. If you need to transfer
 a lot of data, you're better off caching locally and then
 ` + "`rclone move`" + ` it to the destination.`,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.38",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(1, 1, command, args)
 
diff --git a/cmd/rcd/rcd.go b/cmd/rcd/rcd.go
index 940c300a6..aaa57a065 100644
--- a/cmd/rcd/rcd.go
+++ b/cmd/rcd/rcd.go
@@ -32,6 +32,9 @@ the browser when rclone is run.
 
 See the [rc documentation](/rc/) for more info on the rc flags.
 `,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.45",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(0, 1, command, args)
 		if rcflags.Opt.Enabled {
diff --git a/cmd/reveal/reveal.go b/cmd/reveal/reveal.go
index e569a26b4..0839941bd 100644
--- a/cmd/reveal/reveal.go
+++ b/cmd/reveal/reveal.go
@@ -16,6 +16,9 @@ func init() {
 var commandDefinition = &cobra.Command{
 	Use:   "reveal password",
 	Short: `Reveal obscured password from rclone.conf`,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.43",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(1, 1, command, args)
 		cmd.Run(false, false, command, func() error {
diff --git a/cmd/rmdirs/rmdirs.go b/cmd/rmdirs/rmdirs.go
index 11a09ca20..62711dea4 100644
--- a/cmd/rmdirs/rmdirs.go
+++ b/cmd/rmdirs/rmdirs.go
@@ -38,6 +38,9 @@ used with option ` + "`--rmdirs`" + `).
 To delete a path and any objects in it, use [purge](/commands/rclone_purge/)
 command.
 `,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.35",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(1, 1, command, args)
 		fdst := cmd.NewFsDir(args)
diff --git a/cmd/selfupdate/selfupdate.go b/cmd/selfupdate/selfupdate.go
index c7f1a3cda..d045fede0 100644
--- a/cmd/selfupdate/selfupdate.go
+++ b/cmd/selfupdate/selfupdate.go
@@ -64,6 +64,9 @@ var cmdSelfUpdate = &cobra.Command{
 	Aliases: []string{"self-update"},
 	Short:   `Update the rclone binary.`,
 	Long:    strings.ReplaceAll(selfUpdateHelp, "|", "`"),
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.55",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(0, 0, command, args)
 		if Opt.Package == "" {
diff --git a/cmd/serve/dlna/dlna.go b/cmd/serve/dlna/dlna.go
index 579d695d8..2389ce638 100644
--- a/cmd/serve/dlna/dlna.go
+++ b/cmd/serve/dlna/dlna.go
@@ -47,6 +47,9 @@ media transcoding support. This means that some players might show
 files that they are not able to play back correctly.
 
 ` + dlnaflags.Help + vfs.Help,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.46",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(1, 1, command, args)
 		f := cmd.NewFsSrc(args)
diff --git a/cmd/serve/docker/docker.go b/cmd/serve/docker/docker.go
index 5e4971801..d16055865 100644
--- a/cmd/serve/docker/docker.go
+++ b/cmd/serve/docker/docker.go
@@ -48,7 +48,9 @@ var Command = &cobra.Command{
 	Use:   "docker",
 	Short: `Serve any remote on docker's volume plugin API.`,
 	Long:  strings.ReplaceAll(longHelp, "|", "`") + vfs.Help,
-
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.56",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(0, 0, command, args)
 		cmd.Run(false, false, command, func() error {
diff --git a/cmd/serve/ftp/ftp.go b/cmd/serve/ftp/ftp.go
index 2023ba851..28bd4325f 100644
--- a/cmd/serve/ftp/ftp.go
+++ b/cmd/serve/ftp/ftp.go
@@ -99,6 +99,9 @@ By default this will serve files without needing a login.
 
 You can set a single username and password with the --user and --pass flags.
 ` + vfs.Help + proxy.Help,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.44",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		var f fs.Fs
 		if proxyflags.Opt.AuthProxy == "" {
diff --git a/cmd/serve/http/http.go b/cmd/serve/http/http.go
index cdf8afd28..5db094dce 100644
--- a/cmd/serve/http/http.go
+++ b/cmd/serve/http/http.go
@@ -60,6 +60,9 @@ The server will log errors.  Use ` + "`-v`" + ` to see access logs.
 ` + "`--bwlimit`" + ` will be respected for file transfers.  Use ` + "`--stats`" + ` to
 control the stats printing.
 ` + httplib.Help + data.Help + auth.Help + vfs.Help,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.39",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(1, 1, command, args)
 		f := cmd.NewFsSrc(args)
diff --git a/cmd/serve/restic/restic.go b/cmd/serve/restic/restic.go
index 7dfed0e1e..3662fc58c 100644
--- a/cmd/serve/restic/restic.go
+++ b/cmd/serve/restic/restic.go
@@ -46,7 +46,7 @@ func init() {
 var Command = &cobra.Command{
 	Use:   "restic remote:path",
 	Short: `Serve the remote for restic's REST API.`,
-	Long: `Run a basic web server to serve a remove over restic's REST backend
+	Long: `Run a basic web server to serve a remote over restic's REST backend
 API over HTTP.  This allows restic to use rclone as a data storage
 mechanism for cloud providers that restic does not support directly.
 
@@ -128,6 +128,9 @@ these **must** end with /.  Eg
 The` + "`--private-repos`" + ` flag can be used to limit users to repositories starting
 with a path of ` + "`/<username>/`" + `.
 ` + httplib.Help,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.40",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(1, 1, command, args)
 		f := cmd.NewFsSrc(args)
diff --git a/cmd/serve/serve.go b/cmd/serve/serve.go
index f79162c82..8f523f52c 100644
--- a/cmd/serve/serve.go
+++ b/cmd/serve/serve.go
@@ -49,6 +49,9 @@ subcommand to specify the protocol, e.g.
 
 Each subcommand has its own options which you can see in their help.
 `,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.39",
+	},
 	RunE: func(command *cobra.Command, args []string) error {
 		if len(args) == 0 {
 			return errors.New("serve requires a protocol, e.g. 'rclone serve http remote:'")
diff --git a/cmd/serve/sftp/sftp.go b/cmd/serve/sftp/sftp.go
index 8244435ee..2a255224b 100644
--- a/cmd/serve/sftp/sftp.go
+++ b/cmd/serve/sftp/sftp.go
@@ -114,6 +114,9 @@ checksumming is possible but less secure and you could use the SFTP server
 provided by OpenSSH in this case.
 
 ` + vfs.Help + proxy.Help,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.48",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		var f fs.Fs
 		if proxyflags.Opt.AuthProxy == "" {
diff --git a/cmd/serve/webdav/webdav.go b/cmd/serve/webdav/webdav.go
index bfd0eda31..5ab881019 100644
--- a/cmd/serve/webdav/webdav.go
+++ b/cmd/serve/webdav/webdav.go
@@ -61,6 +61,9 @@ supported hash on the backend or you can use a named hash such as
 to see the full list.
 
 ` + httplib.Help + vfs.Help + proxy.Help,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.39",
+	},
 	RunE: func(command *cobra.Command, args []string) error {
 		var f fs.Fs
 		if proxyflags.Opt.AuthProxy == "" {
diff --git a/cmd/settier/settier.go b/cmd/settier/settier.go
index 8a1d234f0..d918ebdd0 100644
--- a/cmd/settier/settier.go
+++ b/cmd/settier/settier.go
@@ -40,6 +40,9 @@ Or just provide remote directory and all files in directory will be tiered
 
     rclone settier tier remote:path/dir
 `,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.44",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(2, 2, command, args)
 		tier := args[0]
diff --git a/cmd/sha1sum/sha1sum.go b/cmd/sha1sum/sha1sum.go
index 053ca0cbb..483832ec2 100644
--- a/cmd/sha1sum/sha1sum.go
+++ b/cmd/sha1sum/sha1sum.go
@@ -41,6 +41,9 @@ as a relative path).
 This command can also hash data received on STDIN, if not passing
 a remote:path.
 `,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.27",
+	},
 	RunE: func(command *cobra.Command, args []string) error {
 		cmd.CheckArgs(0, 1, command, args)
 		if found, err := hashsum.CreateFromStdinArg(hash.SHA1, args, 0); found {
diff --git a/cmd/size/size.go b/cmd/size/size.go
index c084de527..8be130529 100644
--- a/cmd/size/size.go
+++ b/cmd/size/size.go
@@ -44,6 +44,9 @@ Rclone will then show a notice in the log indicating how many such
 files were encountered, and count them in as empty files in the output
 of the size command.
 `,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.23",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(1, 1, command, args)
 		fsrc := cmd.NewFsSrc(args)
diff --git a/cmd/touch/touch.go b/cmd/touch/touch.go
index b0bea31b2..7ec4a0980 100644
--- a/cmd/touch/touch.go
+++ b/cmd/touch/touch.go
@@ -64,6 +64,9 @@ time instead of the current time. Times may be specified as one of:
 Note that value of ` + "`--timestamp`" + ` is in UTC. If you want local time
 then add the ` + "`--localtime`" + ` flag.
 `,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.39",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(1, 1, command, args)
 		f, remote := newFsDst(args)
diff --git a/cmd/tree/tree.go b/cmd/tree/tree.go
index 5705dcec8..ca2999aac 100644
--- a/cmd/tree/tree.go
+++ b/cmd/tree/tree.go
@@ -61,7 +61,6 @@ func init() {
 	flags.StringVarP(cmdFlags, &sort, "sort", "", "", "Select sort: name,version,size,mtime,ctime")
 	// Graphics
 	flags.BoolVarP(cmdFlags, &opts.NoIndent, "noindent", "", false, "Don't print indentation lines")
-	flags.BoolVarP(cmdFlags, &opts.Colorize, "color", "C", false, "Turn colorization on always")
 }
 
 var commandDefinition = &cobra.Command{
@@ -95,6 +94,9 @@ short options as they conflict with rclone's short options.
 For a more interactive navigation of the remote see the
 [ncdu](/commands/rclone_ncdu/) command.
 `,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.38",
+	},
 	RunE: func(command *cobra.Command, args []string) error {
 		cmd.CheckArgs(1, 1, command, args)
 		fsrc := cmd.NewFsSrc(args)
@@ -113,6 +115,7 @@ For a more interactive navigation of the remote see the
 		opts.SizeSort = sort == "size"
 		ci := fs.GetConfig(context.Background())
 		opts.UnitSize = ci.HumanReadable
+		opts.Colorize = ci.TerminalColorMode != fs.TerminalColorModeNever
 		if opts.DeepLevel == 0 {
 			opts.DeepLevel = ci.MaxDepth
 		}
diff --git a/cmd/version/version.go b/cmd/version/version.go
index 32416f89a..5d35cb98c 100644
--- a/cmd/version/version.go
+++ b/cmd/version/version.go
@@ -67,6 +67,9 @@ Or
       upgrade: https://beta.rclone.org/v1.42-005-g56e1e820
 
 `,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.33",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(0, 0, command, args)
 		if check {
diff --git a/docs/content/_index.md b/docs/content/_index.md
index 744a42b31..2940081d8 100644
--- a/docs/content/_index.md
+++ b/docs/content/_index.md
@@ -135,6 +135,7 @@ WebDAV or S3, that work out of the box.)
 {{< provider name="IDrive e2" home="https://www.idrive.com/e2/" config="/s3/#idrive-e2" >}}
 {{< provider name="IONOS Cloud" home="https://cloud.ionos.com/storage/object-storage" config="/s3/#ionos" >}}
 {{< provider name="Koofr" home="https://koofr.eu/" config="/koofr/" >}}
+{{< provider name="Liara Object Storage" home="https://liara.ir/landing/object-storage" config="/s3/#liara-object-storage" >}}
 {{< provider name="Mail.ru Cloud" home="https://cloud.mail.ru/" config="/mailru/" >}}
 {{< provider name="Memset Memstore" home="https://www.memset.com/cloud/storage/" config="/swift/" >}}
 {{< provider name="Mega" home="https://mega.nz/" config="/mega/" >}}
diff --git a/docs/content/authors.md b/docs/content/authors.md
index 5209d1362..eb0253776 100644
--- a/docs/content/authors.md
+++ b/docs/content/authors.md
@@ -665,3 +665,6 @@ put them back in again.` >}}
   * Kamui <fin-kamui@pm.me>
   * asdffdsazqqq <90116442+asdffdsazqqq@users.noreply.github.com>
   * Nathaniel Wesley Filardo <nfilardo@microsoft.com>
+  * ycdtosa <ycdtosa@users.noreply.github.com>
+  * Erik Agterdenbos <agterdenbos@users.noreply.github.com>
+  * Kevin Verstaen <48050031+kverstae@users.noreply.github.com>
diff --git a/docs/content/cache.md b/docs/content/cache.md
index bd28b037b..9b787366e 100644
--- a/docs/content/cache.md
+++ b/docs/content/cache.md
@@ -2,9 +2,10 @@
 title: "Cache"
 description: "Rclone docs for cache remote"
 versionIntroduced: "v1.39"
+status: Deprecated
 ---
 
-# {{< icon "fa fa-archive" >}} Cache (DEPRECATED)
+# {{< icon "fa fa-archive" >}} Cache
 
 The `cache` remote wraps another existing remote and stores file structure
 and its data for long running tasks like `rclone mount`.
diff --git a/docs/content/chunker.md b/docs/content/chunker.md
index 934f102ff..931e9a5d8 100644
--- a/docs/content/chunker.md
+++ b/docs/content/chunker.md
@@ -2,9 +2,10 @@
 title: "Chunker"
 description: "Split-chunking overlay remote"
 versionIntroduced: "v1.50.0"
+status: Beta
 ---
 
-# {{< icon "fa fa-cut" >}}Chunker (BETA)
+# {{< icon "fa fa-cut" >}} Chunker
 
 The `chunker` overlay transparently splits large files into smaller chunks
 during upload to wrapped remote and transparently assembles them back
diff --git a/docs/content/commands/rclone_tree.md b/docs/content/commands/rclone_tree.md
index 1995a108f..3ecc450e5 100644
--- a/docs/content/commands/rclone_tree.md
+++ b/docs/content/commands/rclone_tree.md
@@ -48,7 +48,6 @@ rclone tree remote:path [flags]
 
 ```
   -a, --all             All files are listed (list . files too)
-  -C, --color           Turn colorization on always
   -d, --dirs-only       List directories only
       --dirsfirst       List directories before files (-U disables)
       --full-path       Print the full path prefix for each file
diff --git a/docs/content/compress.md b/docs/content/compress.md
index e6bf533b7..27343236c 100644
--- a/docs/content/compress.md
+++ b/docs/content/compress.md
@@ -2,9 +2,10 @@
 title: "Compress"
 description: "Compression Remote"
 versionIntroduced: "v1.54.0"
+status: Experimental
 ---
 
-# {{< icon "fas fa-compress" >}}Compress (Experimental)
+# {{< icon "fas fa-compress" >}} Compress
 
 ## Warning
 
diff --git a/docs/content/crypt.md b/docs/content/crypt.md
index 3cde0ae02..813aa0830 100644
--- a/docs/content/crypt.md
+++ b/docs/content/crypt.md
@@ -4,7 +4,7 @@ description: "Encryption overlay remote"
 versionIntroduced: "v1.33"
 ---
 
-# {{< icon "fa fa-lock" >}}Crypt
+# {{< icon "fa fa-lock" >}} Crypt
 
 Rclone `crypt` remotes encrypt and decrypt other remotes.
 
diff --git a/docs/content/docs.md b/docs/content/docs.md
index 4b58920a2..1afa2a9a0 100644
--- a/docs/content/docs.md
+++ b/docs/content/docs.md
@@ -823,6 +823,16 @@ quicker than without the `--checksum` flag.
 When using this flag, rclone won't update mtimes of remote files if
 they are incorrect as it would normally.
 
+### --color WHEN ###
+
+Specifiy when colors (and other ANSI codes) should be added to the output.
+
+`AUTO` (default) only allows ANSI codes when the output is a terminal
+
+`NEVER` never allow ANSI codes
+
+`ALWAYS` always add ANSI codes, regardless of the output format (terminal or file)
+
 ### --compare-dest=DIR ###
 
 When using `sync`, `copy` or `move` DIR is checked in addition to the
diff --git a/docs/content/flags.md b/docs/content/flags.md
index 1cdce0e1d..c94a9e904 100644
--- a/docs/content/flags.md
+++ b/docs/content/flags.md
@@ -27,6 +27,7 @@ These flags are available for every command.
   -c, --checksum                             Skip based on checksum (if available) & size, not mod-time & size
       --client-cert string                   Client SSL certificate (PEM) for mutual TLS auth
       --client-key string                    Client SSL private key (PEM) for mutual TLS auth
+      --color                                Define when colors (and other ANSI codes) should be shown AUTO|ALWAYS|NEVER (default AUTO)
       --compare-dest stringArray             Include additional comma separated server-side paths during comparison
       --config string                        Config file (default "$HOME/.config/rclone/rclone.conf")
       --contimeout duration                  Connect timeout (default 1m0s)
diff --git a/docs/content/hasher.md b/docs/content/hasher.md
index aa492d3a7..b52fb3bc9 100644
--- a/docs/content/hasher.md
+++ b/docs/content/hasher.md
@@ -2,9 +2,10 @@
 title: "Hasher"
 description: "Better checksums for other remotes"
 versionIntroduced: "v1.57.0"
+status: Experimental
 ---
 
-# {{< icon "fa fa-check-double" >}} Hasher (EXPERIMENTAL)
+# {{< icon "fa fa-check-double" >}} Hasher
 
 Hasher is a special overlay backend to create remotes which handle
 checksums for other remotes. It's main functions include:
diff --git a/docs/content/s3.md b/docs/content/s3.md
index a5d8bf0e8..f58f67998 100644
--- a/docs/content/s3.md
+++ b/docs/content/s3.md
@@ -21,6 +21,7 @@ The S3 backend can be used with a number of different providers:
 {{< provider name="IBM COS S3" home="http://www.ibm.com/cloud/object-storage" config="/s3/#ibm-cos-s3" >}}
 {{< provider name="IDrive e2" home="https://www.idrive.com/e2/" config="/s3/#idrive-e2" >}}
 {{< provider name="IONOS Cloud" home="https://cloud.ionos.com/storage/object-storage" config="/s3/#ionos" >}}
+{{< provider name="Liara Object Storage" home="https://liara.ir/landing/object-storage" config="/s3/#liara-cloud" >}}
 {{< provider name="Minio" home="https://www.minio.io/" config="/s3/#minio" >}}
 {{< provider name="Qiniu Cloud Object Storage (Kodo)" home="https://www.qiniu.com/en/products/kodo" config="/s3/#qiniu" >}}
 {{< provider name="RackCorp Object Storage" home="https://www.rackcorp.com/" config="/s3/#RackCorp" >}}
@@ -77,7 +78,7 @@ name> remote
 Type of storage to configure.
 Choose a number from below, or type in your own value
 [snip]
-XX / Amazon S3 Compliant Storage Providers including AWS, Ceph, ChinaMobile, ArvanCloud, Dreamhost, IBM COS, Minio, and Tencent COS
+XX / Amazon S3 Compliant Storage Providers including AWS, Ceph, ChinaMobile, ArvanCloud, Dreamhost, IBM COS, Liara, Minio, and Tencent COS
    \ "s3"
 [snip]
 Storage> s3
@@ -3488,7 +3489,7 @@ Choose a number from below, or type in your own value
    	\ "alias"
  	2 / Amazon Drive
    	\ "amazon cloud drive"
- 	3 / Amazon S3 Complaint Storage Providers (Dreamhost, Ceph, ChinaMobile, ArvanCloud, Minio, IBM COS)
+ 	3 / Amazon S3 Complaint Storage Providers (Dreamhost, Ceph, ChinaMobile, Liara, ArvanCloud, Minio, IBM COS)
    	\ "s3"
  	4 / Backblaze B2
    	\ "b2"
@@ -4007,7 +4008,7 @@ Choose a number from below, or type in your own value
    \ (alias)
  4 / Amazon Drive
    \ (amazon cloud drive)
- 5 / Amazon S3 Compliant Storage Providers including AWS, Alibaba, Ceph, China Mobile, Cloudflare, ArvanCloud, Digital Ocean, Dreamhost, Huawei OBS, IBM COS, IDrive e2, Lyve Cloud, Minio, Netease, RackCorp, Scaleway, SeaweedFS, StackPath, Storj, Tencent COS, Qiniu and Wasabi
+ 5 / Amazon S3 Compliant Storage Providers including AWS, Alibaba, Ceph, China Mobile, Cloudflare, ArvanCloud, Digital Ocean, Dreamhost, Huawei OBS, IBM COS, IDrive e2, Liara, Lyve Cloud, Minio, Netease, RackCorp, Scaleway, SeaweedFS, StackPath, Storj, Tencent COS, Qiniu and Wasabi
    \ (s3)
 [snip]
 Storage> s3
@@ -4247,7 +4248,7 @@ Choose `s3` backend
 Type of storage to configure.
 Choose a number from below, or type in your own value.
 [snip]
-XX / Amazon S3 Compliant Storage Providers including AWS, Alibaba, Ceph, ChinaMobile, ArvanCloud, Digital Ocean, Dreamhost, Huawei OBS, IBM COS, Lyve Cloud, Minio, RackCorp, SeaweedFS, and Tencent COS
+XX / Amazon S3 Compliant Storage Providers including AWS, Alibaba, Ceph, ChinaMobile, ArvanCloud, Digital Ocean, Dreamhost, Huawei OBS, IBM COS, Liara, Lyve Cloud, Minio, RackCorp, SeaweedFS, and Tencent COS
    \ (s3)
 [snip]
 Storage> s3
@@ -4434,7 +4435,7 @@ name> wasabi
 Type of storage to configure.
 Choose a number from below, or type in your own value
 [snip]
-XX / Amazon S3 (also Dreamhost, Ceph, ChinaMobile, ArvanCloud, Minio)
+XX / Amazon S3 (also Dreamhost, Ceph, ChinaMobile, ArvanCloud, Minio, Liara)
    \ "s3"
 [snip]
 Storage> s3
@@ -4548,7 +4549,7 @@ Type of storage to configure.
 Enter a string value. Press Enter for the default ("").
 Choose a number from below, or type in your own value
 [snip]
- 4 / Amazon S3 Compliant Storage Providers including AWS, Alibaba, Ceph, ChinaMobile, ArvanCloud, Digital Ocean, Dreamhost, Huawei OBS, IBM COS, Minio, and Tencent COS
+ 4 / Amazon S3 Compliant Storage Providers including AWS, Alibaba, Ceph, ChinaMobile, ArvanCloud, Digital Ocean, Dreamhost, Huawei OBS, IBM COS, Liara, Minio, and Tencent COS
    \ "s3"
 [snip]
 Storage> s3
@@ -4888,6 +4889,107 @@ d) Delete this remote
 y/e/d> y
 ```
 
+### Liara {#liara-cloud}
+
+Here is an example of making a [Liara Object Storage](https://liara.ir/landing/object-storage)
+configuration.  First run:
+
+    rclone config
+
+This will guide you through an interactive setup process.
+
+```
+No remotes found, make a new one?
+n) New remote
+s) Set configuration password
+n/s> n
+name> Liara
+Type of storage to configure.
+Choose a number from below, or type in your own value
+[snip]
+XX / Amazon S3 (also Dreamhost, Ceph, ChinaMobile, ArvanCloud, Liara, Minio)
+   \ "s3"
+[snip]
+Storage> s3
+Get AWS credentials from runtime (environment variables or EC2/ECS meta data if no env vars). Only applies if access_key_id and secret_access_key is blank.
+Choose a number from below, or type in your own value
+ 1 / Enter AWS credentials in the next step
+   \ "false"
+ 2 / Get AWS credentials from the environment (env vars or IAM)
+   \ "true"
+env_auth> 1
+AWS Access Key ID - leave blank for anonymous access or runtime credentials.
+access_key_id> YOURACCESSKEY
+AWS Secret Access Key (password) - leave blank for anonymous access or runtime credentials.
+secret_access_key> YOURSECRETACCESSKEY
+Region to connect to.
+Choose a number from below, or type in your own value
+   / The default endpoint
+ 1 | US Region, Northern Virginia, or Pacific Northwest.
+   | Leave location constraint empty.
+   \ "us-east-1"
+[snip]
+region>
+Endpoint for S3 API.
+Leave blank if using Liara to use the default endpoint for the region.
+Specify if using an S3 clone such as Ceph.
+endpoint> storage.iran.liara.space
+Canned ACL used when creating buckets and/or storing objects in S3.
+For more info visit https://docs.aws.amazon.com/AmazonS3/latest/dev/acl-overview.html#canned-acl
+Choose a number from below, or type in your own value
+ 1 / Owner gets FULL_CONTROL. No one else has access rights (default).
+   \ "private"
+[snip]
+acl>
+The server-side encryption algorithm used when storing this object in S3.
+Choose a number from below, or type in your own value
+ 1 / None
+   \ ""
+ 2 / AES256
+   \ "AES256"
+server_side_encryption>
+The storage class to use when storing objects in S3.
+Choose a number from below, or type in your own value
+ 1 / Default
+   \ ""
+ 2 / Standard storage class
+   \ "STANDARD"
+storage_class>
+Remote config
+--------------------
+[Liara]
+env_auth = false
+access_key_id = YOURACCESSKEY
+secret_access_key = YOURSECRETACCESSKEY
+endpoint = storage.iran.liara.space
+location_constraint =
+acl =
+server_side_encryption =
+storage_class =
+--------------------
+y) Yes this is OK
+e) Edit this remote
+d) Delete this remote
+y/e/d> y
+```
+
+This will leave the config file looking like this.
+
+```
+[Liara]
+type = s3
+provider = Liara
+env_auth = false
+access_key_id = YOURACCESSKEY
+secret_access_key = YOURSECRETACCESSKEY
+region =
+endpoint = storage.iran.liara.space
+location_constraint =
+acl =
+server_side_encryption =
+storage_class =
+```
+
 ### ArvanCloud {#arvan-cloud}
 
 [ArvanCloud](https://www.arvancloud.com/en/products/cloud-storage) ArvanCloud Object Storage goes beyond the limited traditional file storage. 
@@ -4906,7 +5008,7 @@ name> ArvanCloud
 Type of storage to configure.
 Choose a number from below, or type in your own value
 [snip]
-XX / Amazon S3 (also Dreamhost, Ceph, ChinaMobile, ArvanCloud, Minio)
+XX / Amazon S3 (also Dreamhost, Ceph, ChinaMobile, ArvanCloud, Liara, Minio)
    \ "s3"
 [snip]
 Storage> s3
@@ -5029,7 +5131,7 @@ Choose a number from below, or type in your own value
    \ "alias"
  3 / Amazon Drive
    \ "amazon cloud drive"
- 4 / Amazon S3 Compliant Storage Providers including AWS, Alibaba, Ceph, ChinaMobile, ArvanCloud, Digital Ocean, Dreamhost, Huawei OBS, IBM COS, Minio, and Tencent COS
+ 4 / Amazon S3 Compliant Storage Providers including AWS, Alibaba, Ceph, ChinaMobile, ArvanCloud, Digital Ocean, Dreamhost, Huawei OBS, IBM COS, Liara, Minio, and Tencent COS
    \ "s3"
 [snip]
 Storage> s3
diff --git a/docs/content/seafile.md b/docs/content/seafile.md
index ab45184ec..55d2a178a 100644
--- a/docs/content/seafile.md
+++ b/docs/content/seafile.md
@@ -4,7 +4,7 @@ description: "Seafile"
 versionIntroduced: "v1.52.0"
 ---
 
-# {{< icon "fa fa-server" >}}Seafile
+# {{< icon "fa fa-server" >}} Seafile
 
 This is a backend for the [Seafile](https://www.seafile.com/) storage service:
 - It works with both the free community edition or the professional edition.
diff --git a/docs/content/swift.md b/docs/content/swift.md
index e768e35ff..b1334ac03 100644
--- a/docs/content/swift.md
+++ b/docs/content/swift.md
@@ -4,7 +4,7 @@ description: "Swift"
 versionIntroduced: "v0.91"
 ---
 
-# {{< icon "fa fa-space-shuttle" >}}Swift
+# {{< icon "fa fa-space-shuttle" >}} Swift
 
 Swift refers to [OpenStack Object Storage](https://docs.openstack.org/swift/latest/).
 Commercial implementations of that being:
diff --git a/docs/content/yandex.md b/docs/content/yandex.md
index f6c32e1a9..f5716ca70 100644
--- a/docs/content/yandex.md
+++ b/docs/content/yandex.md
@@ -4,7 +4,7 @@ description: "Yandex Disk"
 versionIntroduced: "v1.26"
 ---
 
-# {{< icon "fa fa-space-shuttle" >}}Yandex Disk
+# {{< icon "fa fa-space-shuttle" >}} Yandex Disk
 
 [Yandex Disk](https://disk.yandex.com) is a cloud storage solution created by [Yandex](https://yandex.com).
 
diff --git a/docs/content/zoho.md b/docs/content/zoho.md
index 26e5ccb69..c0143dcbf 100644
--- a/docs/content/zoho.md
+++ b/docs/content/zoho.md
@@ -4,7 +4,7 @@ description: "Zoho WorkDrive"
 versionIntroduced: "v1.54.0"
 ---
 
-# {{< icon "fas fa-folder" >}}Zoho Workdrive
+# {{< icon "fas fa-folder" >}} Zoho Workdrive
 
 [Zoho WorkDrive](https://www.zoho.com/workdrive/) is a cloud storage solution created by [Zoho](https://zoho.com).
 
diff --git a/docs/layouts/_default/baseof.html b/docs/layouts/_default/baseof.html
index 6598c2f8c..e84ba3f29 100644
--- a/docs/layouts/_default/baseof.html
+++ b/docs/layouts/_default/baseof.html
@@ -26,7 +26,20 @@
     <div class="container-fluid">
       <div class="row">
         <div class="col-md-9">
+          {{ $statusMap := dict
+            "deprecated" "This feature is currently deprecated."
+            "experimental" "This feature is currently experimental and any use is at your own risk."
+            "beta" "This feature is currently in beta must be used with care."
+          }}
+
           {{ if .Params.versionIntroduced }}<span class="badge badge-pill badge-secondary float-right" style="margin-top: 30px; font-size: 100%" title="This feature needs Rclone {{ .Params.versionIntroduced }} or later.">{{ .Params.versionIntroduced }}</span>{{ end }}
+
+          {{ with .Params.status | lower }}
+            {{ $statusCode := . }}
+            {{ $statusMessage := index $statusMap $statusCode }}
+            <span class="badge badge-pill badge-danger float-right" style="margin-top: 30px; font-size: 100%" title="{{ $statusMessage }}">{{ $statusCode }}</span>
+          {{ end }}
+
           {{ block "main" . }}
           {{ end }}
         </div>
diff --git a/fs/config.go b/fs/config.go
index 13d523558..d496b4d88 100644
--- a/fs/config.go
+++ b/fs/config.go
@@ -142,6 +142,7 @@ type ConfigInfo struct {
 	DisableHTTPKeepAlives   bool
 	Metadata                bool
 	ServerSideAcrossConfigs bool
+	TerminalColorMode       TerminalColorMode
 }
 
 // NewConfig creates a new config with everything set to the default
diff --git a/fs/config/configflags/configflags.go b/fs/config/configflags/configflags.go
index 42b2b4d05..abd5209d7 100644
--- a/fs/config/configflags/configflags.go
+++ b/fs/config/configflags/configflags.go
@@ -142,6 +142,7 @@ func AddFlags(ci *fs.ConfigInfo, flagSet *pflag.FlagSet) {
 	flags.BoolVarP(flagSet, &ci.DisableHTTPKeepAlives, "disable-http-keep-alives", "", ci.DisableHTTPKeepAlives, "Disable HTTP keep-alives and use each connection once.")
 	flags.BoolVarP(flagSet, &ci.Metadata, "metadata", "M", ci.Metadata, "If set, preserve metadata when copying objects")
 	flags.BoolVarP(flagSet, &ci.ServerSideAcrossConfigs, "server-side-across-configs", "", ci.ServerSideAcrossConfigs, "Allow server-side operations (e.g. copy) to work across different configs")
+	flags.FVarP(flagSet, &ci.TerminalColorMode, "color", "", "When to show colors (and other ANSI codes) AUTO|NEVER|ALWAYS")
 }
 
 // ParseHeaders converts the strings passed in via the header flags into HTTPOptions
diff --git a/fs/terminalcolormode.go b/fs/terminalcolormode.go
new file mode 100644
index 000000000..7da58e13f
--- /dev/null
+++ b/fs/terminalcolormode.go
@@ -0,0 +1,57 @@
+package fs
+
+import (
+	"fmt"
+	"strings"
+)
+
+// TerminalColorMode describes how ANSI codes should be handled
+type TerminalColorMode byte
+
+// TerminalColorMode constants
+const (
+	TerminalColorModeAuto TerminalColorMode = iota
+	TerminalColorModeNever
+	TerminalColorModeAlways
+)
+
+var terminalColorModeToString = []string{
+	TerminalColorModeAuto:   "AUTO",
+	TerminalColorModeNever:  "NEVER",
+	TerminalColorModeAlways: "ALWAYS",
+}
+
+// String converts a TerminalColorMode to a string
+func (m TerminalColorMode) String() string {
+	if m >= TerminalColorMode(len(terminalColorModeToString)) {
+		return fmt.Sprintf("TerminalColorMode(%d)", m)
+	}
+	return terminalColorModeToString[m]
+}
+
+// Set a TerminalColorMode
+func (m *TerminalColorMode) Set(s string) error {
+	for n, name := range terminalColorModeToString {
+		if s != "" && name == strings.ToUpper(s) {
+			*m = TerminalColorMode(n)
+			return nil
+		}
+	}
+	return fmt.Errorf("unknown terminal color mode %q", s)
+}
+
+// Type of TerminalColorMode
+func (m TerminalColorMode) Type() string {
+	return "string"
+}
+
+// UnmarshalJSON converts a string/integer in JSON to a TerminalColorMode
+func (m *TerminalColorMode) UnmarshalJSON(in []byte) error {
+	return UnmarshalJSONFlag(in, m, func(i int64) error {
+		if i < 0 || i >= int64(len(terminalColorModeToString)) {
+			return fmt.Errorf("out of range terminal color mode %d", i)
+		}
+		*m = (TerminalColorMode)(i)
+		return nil
+	})
+}
diff --git a/lib/terminal/terminal.go b/lib/terminal/terminal.go
index 4eda1abab..532cf1fcc 100644
--- a/lib/terminal/terminal.go
+++ b/lib/terminal/terminal.go
@@ -3,12 +3,14 @@
 package terminal
 
 import (
+	"context"
 	"io"
 	"os"
 	"runtime"
 	"sync"
 
 	colorable "github.com/mattn/go-colorable"
+	"github.com/rclone/rclone/fs"
 )
 
 // VT100 codes
@@ -73,13 +75,21 @@ var (
 // Start the terminal - must be called before use
 func Start() {
 	once.Do(func() {
+		ci := fs.GetConfig(context.Background())
+
 		f := os.Stdout
 		if !IsTerminal(int(f.Fd())) {
-			// If stdout not a tty then remove escape codes
-			Out = colorable.NewNonColorable(f)
+			// If stdout is not a tty, remove escape codes EXCEPT if terminal color mode equals "ALWAYS"
+			if ci.TerminalColorMode == fs.TerminalColorModeAlways {
+				Out = colorable.NewColorable(f)
+			} else {
+				Out = colorable.NewNonColorable(f)
+			}
 		} else if runtime.GOOS == "windows" && os.Getenv("TERM") != "" {
 			// If TERM is set just use stdout
 			Out = f
+		} else if ci.TerminalColorMode == fs.TerminalColorModeNever {
+			Out = colorable.NewNonColorable(f)
 		} else {
 			Out = colorable.NewColorable(f)
 		}


Test patch:
diff --git a/cmd/test/changenotify/changenotify.go b/cmd/test/changenotify/changenotify.go
index 70eab98f3..6b88e5f11 100644
--- a/cmd/test/changenotify/changenotify.go
+++ b/cmd/test/changenotify/changenotify.go
@@ -26,6 +26,9 @@ func init() {
 var commandDefinition = &cobra.Command{
 	Use:   "changenotify remote:",
 	Short: `Log any change notify requests for the remote passed in.`,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.56",
+	},
 	RunE: func(command *cobra.Command, args []string) error {
 		cmd.CheckArgs(1, 1, command, args)
 		f := cmd.NewFsSrc(args)
diff --git a/cmd/test/histogram/histogram.go b/cmd/test/histogram/histogram.go
index a3a2865b8..f2044fa69 100644
--- a/cmd/test/histogram/histogram.go
+++ b/cmd/test/histogram/histogram.go
@@ -28,6 +28,9 @@ in filenames in the remote:path specified.
 The data doesn't contain any identifying information but is useful for
 the rclone developers when developing filename compression.
 `,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.55",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(1, 1, command, args)
 		f := cmd.NewFsDir(args)
diff --git a/cmd/test/info/info.go b/cmd/test/info/info.go
index 89cc10a01..071554796 100644
--- a/cmd/test/info/info.go
+++ b/cmd/test/info/info.go
@@ -66,6 +66,9 @@ a bit of go code for each one.
 
 **NB** this can create undeletable files and other hazards - use with care
 `,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.55",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(1, 1e6, command, args)
 		if !checkNormalization && !checkControl && !checkLength && !checkStreaming && !all {
diff --git a/cmd/test/makefiles/makefiles.go b/cmd/test/makefiles/makefiles.go
index b778accd0..7b0ac57a8 100644
--- a/cmd/test/makefiles/makefiles.go
+++ b/cmd/test/makefiles/makefiles.go
@@ -74,6 +74,9 @@ func init() {
 var makefilesCmd = &cobra.Command{
 	Use:   "makefiles <dir>",
 	Short: `Make a random file hierarchy in a directory`,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.55",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(1, 1, command, args)
 		commonInit()
@@ -105,6 +108,9 @@ var makefilesCmd = &cobra.Command{
 var makefileCmd = &cobra.Command{
 	Use:   "makefile <size> [<file>]+ [flags]",
 	Short: `Make files with random contents of the size given`,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.59",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(1, 1e6, command, args)
 		commonInit()
diff --git a/cmd/test/memory/memory.go b/cmd/test/memory/memory.go
index 38b7b1683..432110881 100644
--- a/cmd/test/memory/memory.go
+++ b/cmd/test/memory/memory.go
@@ -20,6 +20,9 @@ func init() {
 var commandDefinition = &cobra.Command{
 	Use:   "memory remote:path",
 	Short: `Load all the objects at remote:path into memory and report memory stats.`,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.55",
+	},
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(1, 1, command, args)
 		fsrc := cmd.NewFsSrc(args)
diff --git a/cmd/test/test.go b/cmd/test/test.go
index 774bf6c3a..8888f85c6 100644
--- a/cmd/test/test.go
+++ b/cmd/test/test.go
@@ -25,4 +25,7 @@ Each subcommand has its own options which you can see in their help.
 **NB** Be careful running these commands, they may do strange things
 so reading their documentation first is recommended.
 `,
+	Annotations: map[string]string{
+		"versionIntroduced": "v1.55",
+	},
 }
diff --git a/fs/terminalcolormode_test.go b/fs/terminalcolormode_test.go
new file mode 100644
index 000000000..6060b4342
--- /dev/null
+++ b/fs/terminalcolormode_test.go
@@ -0,0 +1,74 @@
+package fs
+
+import (
+	"encoding/json"
+	"strconv"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestTerminalColorModeString(t *testing.T) {
+	for _, test := range []struct {
+		in   TerminalColorMode
+		want string
+	}{
+		{TerminalColorModeAuto, "AUTO"},
+		{TerminalColorModeAlways, "ALWAYS"},
+		{TerminalColorModeNever, "NEVER"},
+		{36, "TerminalColorMode(36)"},
+	} {
+		tcm := test.in
+		assert.Equal(t, test.want, tcm.String(), test.in)
+	}
+}
+
+func TestTerminalColorModeSet(t *testing.T) {
+	for _, test := range []struct {
+		in          string
+		want        TerminalColorMode
+		expectError bool
+	}{
+		{"auto", TerminalColorModeAuto, false},
+		{"ALWAYS", TerminalColorModeAlways, false},
+		{"Never", TerminalColorModeNever, false},
+		{"INVALID", 0, true},
+	} {
+		tcm := TerminalColorMode(0)
+		err := tcm.Set(test.in)
+		if test.expectError {
+			require.Error(t, err, test.in)
+		} else {
+			require.NoError(t, err, test.in)
+		}
+		assert.Equal(t, test.want, tcm, test.in)
+	}
+}
+
+func TestTerminalColorModeUnmarshalJSON(t *testing.T) {
+	for _, test := range []struct {
+		in          string
+		want        TerminalColorMode
+		expectError bool
+	}{
+		{`"auto"`, TerminalColorModeAuto, false},
+		{`"ALWAYS"`, TerminalColorModeAlways, false},
+		{`"Never"`, TerminalColorModeNever, false},
+		{`"Invalid"`, 0, true},
+		{strconv.Itoa(int(TerminalColorModeAuto)), TerminalColorModeAuto, false},
+		{strconv.Itoa(int(TerminalColorModeAlways)), TerminalColorModeAlways, false},
+		{strconv.Itoa(int(TerminalColorModeNever)), TerminalColorModeNever, false},
+		{`99`, 0, true},
+		{`-99`, 0, true},
+	} {
+		var tcm TerminalColorMode
+		err := json.Unmarshal([]byte(test.in), &tcm)
+		if test.expectError {
+			require.Error(t, err, test.in)
+		} else {
+			require.NoError(t, err, test.in)
+		}
+		assert.Equal(t, test.want, tcm, test.in)
+	}
+}