Initial public release
authorMikael Berthe <mikael@lilotux.net>
Wed, 19 Apr 2017 19:08:47 +0200
changeset 0 5abace724584
child 1 cc41d888fad5
Initial public release v0.0.9
.gitignore
LICENSE
README.md
cmd/README.md
cmd/accounts.go
cmd/completion.go
cmd/config.go
cmd/instance.go
cmd/madon.go
cmd/media.go
cmd/notifications.go
cmd/root.go
cmd/search.go
cmd/status.go
cmd/stream.go
cmd/timelines.go
cmd/toot.go
cmd/version.go
main.go
printer/README.md
printer/json.go
printer/plain.go
printer/printer.go
printer/templateprinter.go
printer/yaml.go
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.gitignore	Wed Apr 19 19:08:47 2017 +0200
@@ -0,0 +1,9 @@
+# Binaries
+madonctl
+*.exe
+*.test
+
+*.swp
+
+.goxc*
+doc
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/LICENSE	Wed Apr 19 19:08:47 2017 +0200
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2017 Mikael Berthe <mikael@lilotux.net>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/README.md	Wed Apr 19 19:08:47 2017 +0200
@@ -0,0 +1,28 @@
+# madonctl
+
+Golang command line interface for the Mastodon API
+
+[![license](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](https://raw.githubusercontent.com/McKael/madonctl/master/LICENSE)
+
+`madonctl` is a [Go](https://golang.org/) CLI tool to use the Mastondon REST API.
+
+## Installation
+
+To install the application (you need to have Go):
+
+    go get github.com/McKael/madonctl
+
+Some pre-built binaries are available on the home page (see below).
+
+## Usage
+
+This section has not been written yet.
+
+Please check the [Homepage](https://lilotux.net/~mikael/pub/madonctl/) for an
+introduction and a few examples.
+
+## References
+
+- [madon](https://github.com/McKael/madon), the Go library for Mastodon API
+- [Mastodon API documentation](https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md)
+- [Mastodon repository](https://github.com/tootsuite/mastodon)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cmd/README.md	Wed Apr 19 19:08:47 2017 +0200
@@ -0,0 +1,28 @@
+# madonctl
+
+Golang command line interface for the Mastodon API
+
+[![license](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](https://raw.githubusercontent.com/McKael/madonctl/master/LICENSE)
+
+`madonctl` is a [Go](https://golang.org/) CLI tool to use the Mastondon REST API.
+
+## Installation
+
+To install the application (you need to have Go):
+
+    go get github.com/McKael/madonctl
+
+Some pre-built binaries are available on the home page (see below).
+
+## Usage
+
+This section has not been written yet.
+
+Please check the [Homepage](https://lilotux.net/~mikael/pub/madonctl/) for an
+introduction and a few examples.
+
+## References
+
+- [madon](https://github.com/McKael/madon), the Go library for Mastodon API
+- [Mastodon API documentation](https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md)
+- [Mastodon repository](https://github.com/tootsuite/mastodon)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cmd/accounts.go	Wed Apr 19 19:08:47 2017 +0200
@@ -0,0 +1,391 @@
+// Copyright © 2017 Mikael Berthe <mikael@lilotux.net>
+//
+// Licensed under the MIT license.
+// Please see the LICENSE file is this directory.
+
+package cmd
+
+import (
+	"errors"
+	"strings"
+
+	"github.com/spf13/cobra"
+
+	"github.com/McKael/madon"
+)
+
+var accountsOpts struct {
+	accountID                 int
+	unset                     bool // TODO remove eventually?
+	limit                     int
+	onlyMedia, excludeReplies bool   // For acccount statuses
+	remoteUID                 string // For account follow
+	acceptFR, rejectFR        bool   // For account follow_requests
+	list                      bool   // For account follow_requests/reports
+	accountIDs                string // For account relationships
+	statusIDs                 string // For account reports
+	comment                   string // For account reports
+	show                      bool   // For account reports
+}
+
+func init() {
+	RootCmd.AddCommand(accountsCmd)
+
+	// Subcommands
+	accountsCmd.AddCommand(accountSubcommands...)
+
+	// Global flags
+	accountsCmd.PersistentFlags().IntVarP(&accountsOpts.accountID, "account-id", "a", 0, "Account ID number")
+	//accountsCmd.PersistentFlags().IntVarP(&accountsOpts.limit, "limit", "l", 0, "Limit number of results")
+
+	// Subcommand flags
+	accountStatusesSubcommand.Flags().BoolVar(&accountsOpts.onlyMedia, "only-media", false, "Only statuses with media attachments")
+	accountStatusesSubcommand.Flags().BoolVar(&accountsOpts.excludeReplies, "exclude-replies", false, "Exclude replies to other statuses")
+
+	accountFollowRequestsSubcommand.Flags().BoolVar(&accountsOpts.list, "list", false, "List pending follow requests")
+	accountFollowRequestsSubcommand.Flags().BoolVar(&accountsOpts.acceptFR, "accept", false, "Accept the follow request from the account ID")
+	accountFollowRequestsSubcommand.Flags().BoolVar(&accountsOpts.rejectFR, "reject", false, "Reject the follow request from the account ID")
+
+	accountBlockSubcommand.Flags().BoolVarP(&accountsOpts.unset, "unset", "", false, "Unblock the account")
+	accountMuteSubcommand.Flags().BoolVarP(&accountsOpts.unset, "unset", "", false, "Unmute the account")
+	accountFollowSubcommand.Flags().BoolVarP(&accountsOpts.unset, "unset", "", false, "Unfollow the account")
+	accountFollowSubcommand.Flags().StringVarP(&accountsOpts.remoteUID, "remote", "r", "", "Follow remote account (user@domain)")
+
+	accountRelationshipsSubcommand.Flags().StringVar(&accountsOpts.accountIDs, "account-ids", "", "Comma-separated list of account IDs")
+
+	accountReportsSubcommand.Flags().StringVar(&accountsOpts.statusIDs, "status-ids", "", "Comma-separated list of status IDs")
+	accountReportsSubcommand.Flags().StringVar(&accountsOpts.comment, "comment", "", "Report comment")
+	accountReportsSubcommand.Flags().BoolVar(&accountsOpts.list, "list", false, "List current user reports")
+}
+
+// accountsCmd represents the accounts command
+// This command does nothing without a subcommand
+var accountsCmd = &cobra.Command{
+	Use:     "accounts [--account-id ID] subcommand",
+	Aliases: []string{"account"},
+	Short:   "Account-related functions",
+	//Long:    `TBW...`, // TODO
+	//PersistentPreRunE: func(cmd *cobra.Command, args []string) error {},
+}
+
+// Note: Some account subcommands are not defined in this file.
+var accountSubcommands = []*cobra.Command{
+	&cobra.Command{
+		Use: "show",
+		Long: `Displays the details about the requested account.
+If no account ID is specified, the current user account is used.`,
+		Aliases: []string{"display"},
+		Short:   "Display the account",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return accountSubcommandsRunE(cmd.Name(), args)
+		},
+	},
+	&cobra.Command{
+		Use:   "followers",
+		Short: "Display the accounts following the specified account",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return accountSubcommandsRunE(cmd.Name(), args)
+		},
+	},
+	&cobra.Command{
+		Use:   "following",
+		Short: "Display the accounts followed by the specified account",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return accountSubcommandsRunE(cmd.Name(), args)
+		},
+	},
+	&cobra.Command{
+		Use:     "favourites",
+		Aliases: []string{"favorites", "favourited", "favorited"},
+		Short:   "Display the user's favourites",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return accountSubcommandsRunE(cmd.Name(), args)
+		},
+	},
+	&cobra.Command{
+		Use:     "blocks",
+		Aliases: []string{"blocked"},
+		Short:   "Display the user's blocked accounts",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return accountSubcommandsRunE(cmd.Name(), args)
+		},
+	},
+	&cobra.Command{
+		Use:     "mutes",
+		Aliases: []string{"muted"},
+		Short:   "Display the user's muted accounts",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return accountSubcommandsRunE(cmd.Name(), args)
+		},
+	},
+	&cobra.Command{
+		Use:   "search TEXT",
+		Short: "Search for user accounts",
+		Long: `Search for user accounts.
+The server will lookup an account remotely if the search term is in the
+username@domain format and not yet in the database.`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return accountSubcommandsRunE(cmd.Name(), args)
+		},
+	},
+	accountStatusesSubcommand,
+	accountFollowRequestsSubcommand,
+	accountFollowSubcommand,
+	accountBlockSubcommand,
+	accountMuteSubcommand,
+	accountRelationshipsSubcommand,
+	accountReportsSubcommand,
+}
+
+var accountStatusesSubcommand = &cobra.Command{
+	Use:     "statuses",
+	Aliases: []string{"st"},
+	Short:   "Display the account statuses",
+	RunE: func(cmd *cobra.Command, args []string) error {
+		return accountSubcommandsRunE(cmd.Name(), args)
+	},
+}
+
+var accountFollowRequestsSubcommand = &cobra.Command{
+	Use:     "follow-requests",
+	Aliases: []string{"follow-request", "fr"},
+	Example: `  madonctl accounts follow-requests --list
+  madonctl accounts follow-requests --account-id X --accept
+  madonctl accounts follow-requests --account-id Y --reject`,
+	Short: "List, accept or deny a follow request",
+	RunE: func(cmd *cobra.Command, args []string) error {
+		return accountSubcommandsRunE("follow-requests", args)
+	},
+}
+var accountFollowSubcommand = &cobra.Command{
+	Use:   "follow",
+	Short: "Follow or unfollow the account",
+	RunE: func(cmd *cobra.Command, args []string) error {
+		return accountSubcommandsRunE("follow", args)
+	},
+}
+
+var accountBlockSubcommand = &cobra.Command{
+	Use:   "block",
+	Short: "Block or unblock the account",
+	RunE: func(cmd *cobra.Command, args []string) error {
+		return accountSubcommandsRunE("block", args)
+	},
+}
+
+var accountMuteSubcommand = &cobra.Command{
+	Use:   "mute",
+	Short: "Mute or unmute the account",
+	RunE: func(cmd *cobra.Command, args []string) error {
+		return accountSubcommandsRunE("mute", args)
+	},
+}
+
+var accountRelationshipsSubcommand = &cobra.Command{
+	Use:   "relationships --account-ids ACC1,ACC2...",
+	Short: "List relationships with the accounts",
+	RunE: func(cmd *cobra.Command, args []string) error {
+		return accountSubcommandsRunE("relationships", args)
+	},
+}
+
+var accountReportsSubcommand = &cobra.Command{
+	Use:   "reports",
+	Short: "List reports or report a user account",
+	Example: `  madonctl accounts reports --list
+  madonctl accounts reports --account-id ACCOUNT --status-ids ID... --comment TEXT`,
+	RunE: func(cmd *cobra.Command, args []string) error {
+		return accountSubcommandsRunE("reports", args)
+	},
+}
+
+// accountSubcommandsRunE is a generic function for status subcommands
+func accountSubcommandsRunE(subcmd string, args []string) error {
+	opt := accountsOpts
+
+	switch subcmd {
+	case "show", "search":
+		// These subcommands do not require an account ID
+	case "favourites", "blocks", "mutes":
+		// Those subcommands can not use an account ID
+		if opt.accountID > 0 {
+			return errors.New("useless account ID")
+		}
+	case "follow":
+		if opt.accountID < 1 && opt.remoteUID == "" {
+			return errors.New("missing account ID or URI")
+		}
+		if opt.accountID > 0 && opt.remoteUID != "" {
+			return errors.New("cannot use both account ID and URI")
+		}
+		if opt.unset && opt.accountID < 1 {
+			return errors.New("unfollowing requires an account ID")
+		}
+	case "follow-requests":
+		if opt.list {
+			if opt.acceptFR || opt.rejectFR {
+				return errors.New("incompatible options")
+			}
+		} else {
+			if !opt.acceptFR && !opt.rejectFR { // No flag
+				return errors.New("missing parameter (--list, --accept or --reject)")
+			}
+			// This is a FR reply
+			if opt.acceptFR && opt.rejectFR {
+				return errors.New("incompatible options")
+			}
+			if opt.accountID < 1 {
+				return errors.New("missing account ID")
+			}
+		}
+	case "relationships":
+		if opt.accountID < 1 && len(opt.accountIDs) == 0 {
+			return errors.New("missing account IDs")
+		}
+		if opt.accountID > 0 && len(opt.accountIDs) > 0 {
+			return errors.New("incompatible options")
+		}
+	case "reports":
+		if opt.list {
+			break // No argument needed
+		}
+		if opt.accountID < 1 || len(opt.statusIDs) == 0 || opt.comment == "" {
+			return errors.New("missing parameter")
+		}
+	default:
+		// The other subcommands here require an account ID
+		if opt.accountID < 1 {
+			return errors.New("missing account ID")
+		}
+	}
+
+	// All account subcommands need to have signed in
+	if err := madonInit(true); err != nil {
+		return err
+	}
+
+	var obj interface{}
+	var err error
+
+	switch subcmd {
+	case "show":
+		var account *madon.Account
+		if opt.accountID > 0 {
+			account, err = gClient.GetAccount(opt.accountID)
+		} else {
+			account, err = gClient.GetCurrentAccount()
+		}
+		obj = account
+	case "search":
+		var accountList []madon.Account
+		limit := 0 // TODO use a global flag
+		accountList, err = gClient.SearchAccounts(strings.Join(args, " "), limit)
+		obj = accountList
+	case "followers":
+		var accountList []madon.Account
+		accountList, err = gClient.GetAccountFollowers(opt.accountID)
+		obj = accountList
+	case "following":
+		var accountList []madon.Account
+		accountList, err = gClient.GetAccountFollowing(opt.accountID)
+		obj = accountList
+	case "statuses":
+		var statusList []madon.Status
+		statusList, err = gClient.GetAccountStatuses(opt.accountID, opt.onlyMedia, opt.excludeReplies)
+		obj = statusList
+	case "follow":
+		if opt.unset {
+			err = gClient.UnfollowAccount(opt.accountID)
+		} else {
+			if opt.accountID > 0 {
+				err = gClient.FollowAccount(opt.accountID)
+			} else {
+				var account *madon.Account
+				account, err = gClient.FollowRemoteAccount(opt.remoteUID)
+				obj = account
+			}
+		}
+	case "follow-requests":
+		if opt.list {
+			var followRequests []madon.Account
+			followRequests, err = gClient.GetAccountFollowRequests()
+			obj = followRequests
+		} else {
+			err = gClient.FollowRequestAuthorize(opt.accountID, !opt.rejectFR)
+		}
+	case "block":
+		if opt.unset {
+			err = gClient.UnblockAccount(opt.accountID)
+		} else {
+			err = gClient.BlockAccount(opt.accountID)
+		}
+	case "mute":
+		if opt.unset {
+			err = gClient.UnmuteAccount(opt.accountID)
+		} else {
+			err = gClient.MuteAccount(opt.accountID)
+		}
+	case "favourites":
+		var statusList []madon.Status
+		statusList, err = gClient.GetFavourites()
+		obj = statusList
+	case "blocks":
+		var accountList []madon.Account
+		accountList, err = gClient.GetBlockedAccounts()
+		obj = accountList
+	case "mutes":
+		var accountList []madon.Account
+		accountList, err = gClient.GetMutedAccounts()
+		obj = accountList
+	case "relationships":
+		var ids []int
+		ids, err = splitIDs(opt.accountIDs)
+		if err != nil {
+			return errors.New("cannot parse account IDs")
+		}
+		if opt.accountID > 0 { // Allow --account-id
+			ids = []int{opt.accountID}
+		}
+		if len(ids) < 1 {
+			return errors.New("missing account IDs")
+		}
+		var relationships []madon.Relationship
+		relationships, err = gClient.GetAccountRelationships(ids)
+		obj = relationships
+	case "reports":
+		if opt.list {
+			var reports []madon.Report
+			reports, err = gClient.GetReports()
+			obj = reports
+			break
+		}
+		// Send a report
+		var ids []int
+		ids, err = splitIDs(opt.statusIDs)
+		if err != nil {
+			return errors.New("cannot parse status IDs")
+		}
+		if len(ids) < 1 {
+			return errors.New("missing status IDs")
+		}
+		var report *madon.Report
+		report, err = gClient.ReportUser(opt.accountID, ids, opt.comment)
+		obj = report
+	default:
+		return errors.New("accountSubcommand: internal error")
+	}
+
+	if err != nil {
+		errPrint("Error: %s", err.Error())
+		return nil
+	}
+	if obj == nil {
+		return nil
+	}
+
+	p, err := getPrinter()
+	if err != nil {
+		return err
+	}
+	return p.PrintObj(obj, nil, "")
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cmd/completion.go	Wed Apr 19 19:08:47 2017 +0200
@@ -0,0 +1,232 @@
+// Copyright © 2017 Mikael Berthe <mikael@lilotux.net>
+//
+// Licensed under the MIT license.
+// Please see the LICENSE file is this directory.
+
+package cmd
+
+import (
+	"bytes"
+	"io"
+	"os"
+	"strings"
+
+	"github.com/spf13/cobra"
+)
+
+var completionCmd = &cobra.Command{
+	Use:       "completion bash|zsh",
+	Short:     "Generate shell completion",
+	ValidArgs: []string{"bash", "zsh"},
+	Run: func(cmd *cobra.Command, args []string) {
+		if len(args) < 1 {
+			errPrint("Please specify your shell")
+			os.Exit(1)
+		}
+
+		switch args[0] {
+		case "bash":
+			if err := runCompletionBash(os.Stdout, RootCmd); err != nil {
+				errPrint("Error: %s", err.Error())
+				os.Exit(1)
+			}
+		case "zsh":
+			if err := runCompletionZsh(os.Stdout, RootCmd); err != nil {
+				errPrint("Error: %s", err.Error())
+				os.Exit(1)
+			}
+		default:
+			errPrint("Only bash is supported at the moment")
+			os.Exit(1)
+		}
+	},
+}
+
+func init() {
+	RootCmd.AddCommand(completionCmd)
+}
+
+func runCompletionBash(out io.Writer, c *cobra.Command) error {
+	return c.GenBashCompletion(out)
+}
+
+// Many thanks to the Kubernetes project for this one!
+func runCompletionZsh(out io.Writer, c *cobra.Command) error {
+	const zshInitialization = `# Copyright 2016 The Kubernetes Authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+__madonctl_bash_source() {
+	alias shopt=':'
+	alias _expand=_bash_expand
+	alias _complete=_bash_comp
+	emulate -L sh
+	setopt kshglob noshglob braceexpand
+
+	source "$@"
+}
+
+__madonctl_type() {
+	# -t is not supported by zsh
+	if [ "$1" == "-t" ]; then
+		shift
+
+		# fake Bash 4 to disable "complete -o nospace". Instead
+		# "compopt +-o nospace" is used in the code to toggle trailing
+		# spaces. We don't support that, but leave trailing spaces on
+		# all the time
+		if [ "$1" = "__madonctl_compopt" ]; then
+			echo builtin
+			return 0
+		fi
+	fi
+	type "$@"
+}
+
+__madonctl_compgen() {
+	local completions w
+	completions=( $(compgen "$@") ) || return $?
+
+	# filter by given word as prefix
+	while [[ "$1" = -* && "$1" != -- ]]; do
+		shift
+		shift
+	done
+	if [[ "$1" == -- ]]; then
+		shift
+	fi
+	for w in "${completions[@]}"; do
+		if [[ "${w}" = "$1"* ]]; then
+			echo "${w}"
+		fi
+	done
+}
+
+__madonctl_compopt() {
+	true # don't do anything. Not supported by bashcompinit in zsh
+}
+
+__madonctl_declare() {
+	if [ "$1" == "-F" ]; then
+		whence -w "$@"
+	else
+		builtin declare "$@"
+	fi
+}
+
+__madonctl_ltrim_colon_completions()
+{
+	if [[ "$1" == *:* && "$COMP_WORDBREAKS" == *:* ]]; then
+		# Remove colon-word prefix from COMPREPLY items
+		local colon_word=${1%${1##*:}}
+		local i=${#COMPREPLY[*]}
+		while [[ $((--i)) -ge 0 ]]; do
+			COMPREPLY[$i]=${COMPREPLY[$i]#"$colon_word"}
+		done
+	fi
+}
+
+__madonctl_get_comp_words_by_ref() {
+	cur="${COMP_WORDS[COMP_CWORD]}"
+	prev="${COMP_WORDS[${COMP_CWORD}-1]}"
+	words=("${COMP_WORDS[@]}")
+	cword=("${COMP_CWORD[@]}")
+}
+
+__madonctl_filedir() {
+	local RET OLD_IFS w qw
+
+	__debug "_filedir $@ cur=$cur"
+	if [[ "$1" = \~* ]]; then
+		# somehow does not work. Maybe, zsh does not call this at all
+		eval echo "$1"
+		return 0
+	fi
+
+	OLD_IFS="$IFS"
+	IFS=$'\n'
+	if [ "$1" = "-d" ]; then
+		shift
+		RET=( $(compgen -d) )
+	else
+		RET=( $(compgen -f) )
+	fi
+	IFS="$OLD_IFS"
+
+	IFS="," __debug "RET=${RET[@]} len=${#RET[@]}"
+
+	for w in ${RET[@]}; do
+		if [[ ! "${w}" = "${cur}"* ]]; then
+			continue
+		fi
+		if eval "[[ \"\${w}\" = *.$1 || -d \"\${w}\" ]]"; then
+			qw="$(__madonctl_quote "${w}")"
+			if [ -d "${w}" ]; then
+				COMPREPLY+=("${qw}/")
+			else
+				COMPREPLY+=("${qw}")
+			fi
+		fi
+	done
+}
+
+__madonctl_quote() {
+    if [[ $1 == \'* || $1 == \"* ]]; then
+        # Leave out first character
+        printf %q "${1:1}"
+    else
+        printf %q "$1"
+    fi
+}
+
+autoload -U +X bashcompinit && bashcompinit
+
+# use word boundary patterns for BSD or GNU sed
+LWORD='[[:<:]]'
+RWORD='[[:>:]]'
+if sed --help 2>&1 | grep -q GNU; then
+	LWORD='\<'
+	RWORD='\>'
+fi
+
+__madonctl_convert_bash_to_zsh() {
+	sed \
+	-e 's/declare -F/whence -w/' \
+	-e 's/local \([a-zA-Z0-9_]*\)=/local \1; \1=/' \
+	-e 's/flags+=("\(--.*\)=")/flags+=("\1"); two_word_flags+=("\1")/' \
+	-e 's/must_have_one_flag+=("\(--.*\)=")/must_have_one_flag+=("\1")/' \
+	-e "s/${LWORD}_filedir${RWORD}/__madonctl_filedir/g" \
+	-e "s/${LWORD}_get_comp_words_by_ref${RWORD}/__madonctl_get_comp_words_by_ref/g" \
+	-e "s/${LWORD}__ltrim_colon_completions${RWORD}/__madonctl_ltrim_colon_completions/g" \
+	-e "s/${LWORD}compgen${RWORD}/__madonctl_compgen/g" \
+	-e "s/${LWORD}compopt${RWORD}/__madonctl_compopt/g" \
+	-e "s/${LWORD}declare${RWORD}/__madonctl_declare/g" \
+	-e "s/\\\$(type${RWORD}/\$(__madonctl_type/g" \
+	<<'BASH_COMPLETION_EOF'
+`
+
+	const zshTail = `
+BASH_COMPLETION_EOF
+}
+
+__madonctl_bash_source <(__madonctl_convert_bash_to_zsh)
+`
+
+	buf := new(bytes.Buffer)
+	out.Write([]byte(zshInitialization))
+	err := c.GenBashCompletion(buf)
+	script := strings.Replace(buf.String(), "flaghash[${flagname}]", `flaghash[workaround]`, -1)
+	out.Write([]byte(script))
+	out.Write([]byte(zshTail))
+	return err
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cmd/config.go	Wed Apr 19 19:08:47 2017 +0200
@@ -0,0 +1,106 @@
+// Copyright © 2017 Mikael Berthe <mikael@lilotux.net>
+//
+// Licensed under the MIT license.
+// Please see the LICENSE file is this directory.
+
+package cmd
+
+import (
+	"github.com/spf13/cobra"
+	"github.com/spf13/viper"
+
+	"github.com/McKael/madonctl/printer"
+)
+
+var configCmd = &cobra.Command{
+	Use:   "config",
+	Short: "Display configuration",
+}
+
+func init() {
+	RootCmd.AddCommand(configCmd)
+
+	// Subcommands
+	configCmd.AddCommand(configSubcommands...)
+}
+
+var configSubcommands = []*cobra.Command{
+	&cobra.Command{
+		Use:   "dump",
+		Short: "Dump the configuration",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return configDump()
+		},
+	},
+	&cobra.Command{
+		Use:     "whoami",
+		Aliases: []string{"token"},
+		Short:   "Display user token",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return configDisplayToken()
+		},
+	},
+}
+
+const configurationTemplate = `---
+instance: '{{.InstanceURL}}'
+app_id: '{{.ID}}'
+app_secret: '{{.Secret}}'
+
+{{if .UserToken}}token: {{.UserToken.access_token}}{{else}}#token: ''{{end}}
+#login: ''
+#password: ''
+safe_mode: true
+...
+`
+
+func configDump() error {
+	if viper.GetBool("safe_mode") {
+		errPrint("Cannot dump: disabled by configuration (safe_mode)")
+		return nil
+	}
+
+	if err := madonInitClient(); err != nil {
+		return err
+	}
+	// Try to sign in, but don't mind if it fails
+	if err := madonLogin(); err != nil {
+		errPrint("Info: could not log in: %s", err)
+	}
+
+	var p printer.ResourcePrinter
+	var err error
+
+	if getOutputFormat() == "plain" {
+		cfile := viper.ConfigFileUsed()
+		if cfile == "" {
+			cfile = defaultConfigFile
+		}
+		errPrint("You can copy the following lines into a configuration file.")
+		errPrint("E.g. %s -i INSTANCE -L USERNAME -P PASS config dump > %s\n", AppName, cfile)
+		p, err = printer.NewPrinterTemplate(configurationTemplate)
+	} else {
+		p, err = getPrinter()
+	}
+	if err != nil {
+		return err
+	}
+	return p.PrintObj(gClient, nil, "")
+}
+
+func configDisplayToken() error {
+	if viper.GetBool("safe_mode") {
+		errPrint("Cannot dump: disabled by configuration (safe_mode)")
+		return nil
+	}
+
+	if err := madonInit(true); err != nil {
+		return err
+	}
+
+	p, err := getPrinter()
+	if err != nil {
+		return err
+	}
+	return p.PrintObj(gClient.UserToken, nil, "")
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cmd/instance.go	Wed Apr 19 19:08:47 2017 +0200
@@ -0,0 +1,39 @@
+// Copyright © 2017 Mikael Berthe <mikael@lilotux.net>
+//
+// Licensed under the MIT license.
+// Please see the LICENSE file is this directory.
+
+package cmd
+
+import (
+	"github.com/spf13/cobra"
+)
+
+// timelinesCmd represents the timelines command
+var instanceCmd = &cobra.Command{
+	Use:   "instance",
+	Short: "Display current instance information",
+	RunE:  instanceRunE,
+}
+
+func init() {
+	RootCmd.AddCommand(instanceCmd)
+}
+
+func instanceRunE(cmd *cobra.Command, args []string) error {
+	if err := madonInit(false); err != nil {
+		return err
+	}
+
+	i, err := gClient.GetCurrentInstance()
+	if err != nil {
+		errPrint("Error: %s", err.Error())
+		return nil
+	}
+
+	p, err := getPrinter()
+	if err != nil {
+		return err
+	}
+	return p.PrintObj(i, nil, "")
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cmd/madon.go	Wed Apr 19 19:08:47 2017 +0200
@@ -0,0 +1,115 @@
+// Copyright © 2017 Mikael Berthe <mikael@lilotux.net>
+//
+// Licensed under the MIT license.
+// Please see the LICENSE file is this directory.
+
+package cmd
+
+import (
+	"errors"
+	"fmt"
+	"strconv"
+	"strings"
+
+	"github.com/McKael/madon"
+	"github.com/spf13/viper"
+)
+
+var scopes = []string{"read", "write", "follow"}
+
+func madonInit(signIn bool) error {
+	if gClient == nil {
+		if err := madonInitClient(); err != nil {
+			return err
+		}
+	}
+	if signIn {
+		return madonLogin()
+	}
+	return nil
+}
+
+func madonInitClient() error {
+	if gClient != nil {
+		return nil
+	}
+	var err error
+
+	// Overwrite variables using Viper
+	instanceURL = viper.GetString("instance")
+	appID = viper.GetString("app_id")
+	appSecret = viper.GetString("app_secret")
+
+	if instanceURL == "" {
+		return errors.New("no instance provided")
+	}
+
+	if appID != "" && appSecret != "" {
+		// We already have an app key/secret pair
+		gClient, err = madon.RestoreApp(AppName, instanceURL, appID, appSecret, nil)
+		if err != nil {
+			return err
+		}
+		// Check instance
+		if _, err := gClient.GetCurrentInstance(); err != nil {
+			return fmt.Errorf("could not use provided app secrets")
+		}
+		if verbose {
+			errPrint("Using provided app secrets")
+		}
+		return nil
+	}
+
+	if appID != "" || appSecret != "" {
+		errPrint("Warning: provided app id/secrets incomplete -- registering again")
+	}
+
+	gClient, err = madon.NewApp(AppName, scopes, madon.NoRedirect, instanceURL)
+	if err != nil {
+		return fmt.Errorf("app registration failed: %s", err.Error())
+	}
+
+	errPrint("Registred new application.")
+	return nil
+}
+
+func madonLogin() error {
+	if gClient == nil {
+		return errors.New("application not registred")
+	}
+
+	token = viper.GetString("token")
+	login = viper.GetString("login")
+	password = viper.GetString("password")
+
+	if token != "" { // TODO check token validity?
+		if verbose {
+			errPrint("Reusing existing token.")
+		}
+		gClient.SetUserToken(token, login, password, []string{})
+		return nil
+	}
+
+	err := gClient.LoginBasic(login, password, scopes)
+	if err == nil {
+		return nil
+	}
+	return fmt.Errorf("login failed: %s", err.Error())
+}
+
+// splitIDs splits a list of IDs into an int array
+func splitIDs(ids string) (list []int, err error) {
+	var i int
+	if ids == "" {
+		return
+	}
+	l := strings.Split(ids, ",")
+	for _, s := range l {
+		i, err = strconv.Atoi(s)
+		if err != nil {
+			return
+		}
+		list = append(list, i)
+	}
+	return
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cmd/media.go	Wed Apr 19 19:08:47 2017 +0200
@@ -0,0 +1,68 @@
+// Copyright © 2017 Mikael Berthe <mikael@lilotux.net>
+//
+// Licensed under the MIT license.
+// Please see the LICENSE file is this directory.
+
+package cmd
+
+import (
+	"errors"
+
+	"github.com/spf13/cobra"
+)
+
+var mediaOpts struct {
+	filePath string
+}
+
+// mediaCmd represents the media command
+var mediaCmd = &cobra.Command{
+	Use:     "media --file FILENAME",
+	Aliases: []string{"upload"},
+	Short:   "Upload a media attachment",
+	//Long: `TBW...`,
+	RunE: mediaRunE,
+}
+
+func init() {
+	RootCmd.AddCommand(mediaCmd)
+
+	mediaCmd.Flags().StringVar(&mediaOpts.filePath, "file", "", "Path of the media file")
+	mediaCmd.MarkFlagRequired("file")
+}
+
+func mediaRunE(cmd *cobra.Command, args []string) error {
+	opt := mediaOpts
+
+	if opt.filePath == "" {
+		return errors.New("no media file name provided")
+	}
+
+	if err := madonInit(true); err != nil {
+		return err
+	}
+
+	attachment, err := gClient.UploadMedia(opt.filePath)
+	if err != nil {
+		errPrint("Error: %s", err.Error())
+		return nil
+	}
+
+	p, err := getPrinter()
+	if err != nil {
+		return err
+	}
+	return p.PrintObj(attachment, nil, "")
+}
+
+// uploadFile uploads a media file and returns the attachment ID
+func uploadFile(filePath string) (int, error) {
+	attachment, err := gClient.UploadMedia(filePath)
+	if err != nil {
+		return 0, err
+	}
+	if attachment == nil {
+		return 0, nil
+	}
+	return attachment.ID, nil
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cmd/notifications.go	Wed Apr 19 19:08:47 2017 +0200
@@ -0,0 +1,76 @@
+// Copyright © 2017 Mikael Berthe <mikael@lilotux.net>
+//
+// Licensed under the MIT license.
+// Please see the LICENSE file is this directory.
+
+package cmd
+
+import (
+	"errors"
+
+	"github.com/spf13/cobra"
+)
+
+var notificationsOpts struct {
+	list, clear bool
+	notifID     int
+}
+
+// notificationsCmd represents the notifications subcommand
+var notificationsCmd = &cobra.Command{
+	Use:     "notifications", // XXX
+	Aliases: []string{"notification", "notif"},
+	Short:   "Manage notifications",
+	Example: `  madonctl accounts notifications --list
+  madonctl accounts notifications --list --clear
+  madonctl accounts notifications --notification-id N`,
+	//Long:    `TBW...`,
+	RunE: notificationRunE,
+}
+
+func init() {
+	accountsCmd.AddCommand(notificationsCmd)
+
+	notificationsCmd.Flags().BoolVar(&notificationsOpts.list, "list", false, "List all current notifications")
+	notificationsCmd.Flags().BoolVar(&notificationsOpts.clear, "clear", false, "Clear all current notifications")
+	notificationsCmd.Flags().IntVar(&notificationsOpts.notifID, "notification-id", 0, "Get a notification")
+}
+
+func notificationRunE(cmd *cobra.Command, args []string) error {
+	opt := notificationsOpts
+
+	if !opt.list && !opt.clear && opt.notifID < 1 {
+		return errors.New("missing parameters")
+	}
+
+	if err := madonInit(true); err != nil {
+		return err
+	}
+
+	var obj interface{}
+	var err error
+
+	if opt.list {
+		obj, err = gClient.GetNotifications()
+	} else if opt.notifID > 0 {
+		obj, err = gClient.GetNotification(opt.notifID)
+	}
+
+	if err == nil && opt.clear {
+		err = gClient.ClearNotifications()
+	}
+
+	if err != nil {
+		errPrint("Error: %s", err.Error())
+		return nil
+	}
+	if obj == nil {
+		return nil
+	}
+
+	p, err := getPrinter()
+	if err != nil {
+		return err
+	}
+	return p.PrintObj(obj, nil, "")
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cmd/root.go	Wed Apr 19 19:08:47 2017 +0200
@@ -0,0 +1,190 @@
+// Copyright © 2017 Mikael Berthe <mikael@lilotux.net>
+//
+// Licensed under the MIT license.
+// Please see the LICENSE file is this directory.
+
+package cmd
+
+import (
+	"fmt"
+	"io/ioutil"
+	"os"
+
+	"github.com/spf13/cobra"
+	"github.com/spf13/viper"
+
+	"github.com/McKael/madon"
+	"github.com/McKael/madonctl/printer"
+)
+
+// AppName is the CLI application name
+const AppName = "madonctl"
+const defaultConfigFile = "$HOME/.config/" + AppName + "/" + AppName + ".yaml"
+
+var cfgFile string
+var safeMode bool
+var instanceURL, appID, appSecret string
+var login, password, token string
+var gClient *madon.Client
+var verbose bool
+var outputFormat string
+var outputTemplate, outputTemplateFile string
+
+// RootCmd represents the base command when called without any subcommands
+var RootCmd = &cobra.Command{
+	Use:               AppName,
+	Short:             "A CLI utility for Mastodon API",
+	PersistentPreRunE: checkOutputFormat,
+	Long: `madonctl is a CLI tool for the Mastodon REST API.
+
+You can use a configuration file to store common options.
+For example, create ` + defaultConfigFile + ` with the following
+contents:
+
+	---
+	instance: "INSTANCE"
+	login: "USERNAME"
+	password: "USERPASSWORD"
+	...
+
+The simplest way to generate a configuration file is to use the 'config dump'
+command.
+
+(Configuration files in JSON are also accepted.)
+
+If you want shell auto-completion (for bash or zsh), you can generate the
+completion scripts with "madonctl completion $SHELL".
+For example if you use bash:
+
+	madonctl completion bash > _bash_madonctl
+	source _bash_madonctl
+
+Now you should have tab completion for subcommands and flags.
+
+Note: Most examples assume the user's credentials are set in the configuration
+file.
+`,
+	Example: `  madonctl instance
+  madonctl toot "Hello, World"
+  madonctl toot --visibility direct "@McKael Hello, You"
+  madonctl toot --visibility private --spoiler CW "The answer was 42"
+  madonctl post --file image.jpg Selfie
+  madonctl --instance INSTANCE --login USERNAME --password PASS timeline
+  madonctl accounts notifications --list --clear
+  madonctl accounts blocked
+  madonctl accounts search Gargron
+  madonctl search --resolve https://mastodon.social/@Gargron
+  madonctl accounts follow --remote Gargron@mastodon.social
+  madonctl accounts --account-id 399 statuses
+  madonctl status --status-id 416671 show
+  madonctl status --status-id 416671 favourite
+  madonctl status --status-id 416671 boost
+  madonctl accounts show
+  madonctl accounts show -o yaml
+  madonctl accounts --account-id 1 followers --template '{{.acct}}{{"\n"}}'
+  madonctl config whoami
+  madonctl timeline :mastodon`,
+}
+
+// Execute adds all child commands to the root command sets flags appropriately.
+// This is called by main.main(). It only needs to happen once to the rootCmd.
+func Execute() {
+	if err := RootCmd.Execute(); err != nil {
+		errPrint("Error: %s", err.Error())
+		os.Exit(-1)
+	}
+}
+
+func init() {
+	cobra.OnInitialize(initConfig)
+
+	// Global flags
+	RootCmd.PersistentFlags().StringVar(&cfgFile, "config", "",
+		"config file (default is "+defaultConfigFile+")")
+	RootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Verbose mode")
+	RootCmd.PersistentFlags().StringVarP(&instanceURL, "instance", "i", "", "Mastodon instance")
+	RootCmd.PersistentFlags().StringVarP(&login, "login", "L", "", "Instance user login")
+	RootCmd.PersistentFlags().StringVarP(&password, "password", "P", "", "Instance user password")
+	RootCmd.PersistentFlags().StringVarP(&token, "token", "t", "", "User token")
+	RootCmd.PersistentFlags().StringVarP(&outputFormat, "output", "o", "plain",
+		"Output format (plain|json|yaml|template)")
+	RootCmd.PersistentFlags().StringVar(&outputTemplate, "template", "",
+		"Go template (for output=template)")
+	RootCmd.PersistentFlags().StringVar(&outputTemplateFile, "template-file", "",
+		"Go template file (for output=template)")
+
+	// Configuration file bindings
+	viper.BindPFlag("output", RootCmd.PersistentFlags().Lookup("output"))
+	viper.BindPFlag("verbose", RootCmd.PersistentFlags().Lookup("verbose"))
+	// XXX viper.BindPFlag("apiKey", RootCmd.PersistentFlags().Lookup("api-key"))
+	viper.BindPFlag("instance", RootCmd.PersistentFlags().Lookup("instance"))
+	viper.BindPFlag("login", RootCmd.PersistentFlags().Lookup("login"))
+	viper.BindPFlag("password", RootCmd.PersistentFlags().Lookup("password"))
+	viper.BindPFlag("token", RootCmd.PersistentFlags().Lookup("token"))
+}
+
+func checkOutputFormat(cmd *cobra.Command, args []string) error {
+	of := viper.GetString("output")
+	switch of {
+	case "", "plain", "json", "yaml", "template":
+		return nil // Accepted
+	}
+	return fmt.Errorf("output format '%s' not supported", of)
+}
+
+// initConfig reads in config file and ENV variables if set.
+func initConfig() {
+	if cfgFile != "" { // enable ability to specify config file via flag
+		viper.SetConfigFile(cfgFile)
+	}
+
+	viper.SetConfigName(AppName) // name of config file (without extension)
+	viper.AddConfigPath("$HOME/.config/" + AppName)
+	viper.AddConfigPath("$HOME/." + AppName)
+
+	// Read in environment variables that match, with a prefix
+	viper.SetEnvPrefix(AppName)
+	viper.AutomaticEnv()
+
+	// If a config file is found, read it in.
+	if err := viper.ReadInConfig(); viper.GetBool("verbose") && err == nil {
+		errPrint("Using config file: %s", viper.ConfigFileUsed())
+	}
+}
+
+// getOutputFormat return the requested output format, defaulting to "plain".
+func getOutputFormat() string {
+	of := viper.GetString("output")
+	if of == "" {
+		of = "plain"
+	}
+	// Override format if a template is provided
+	if of == "plain" && (outputTemplate != "" || outputTemplateFile != "") {
+		// If the format is plain and there is a template option,
+		// set the format to "template".
+		of = "template"
+	}
+	return of
+}
+
+// getPrinter returns a resource printer for the requested output format.
+func getPrinter() (printer.ResourcePrinter, error) {
+	var opt string
+	of := getOutputFormat()
+
+	if of == "template" {
+		opt = outputTemplate
+		if outputTemplateFile != "" {
+			tmpl, err := ioutil.ReadFile(outputTemplateFile)
+			if err != nil {
+				return nil, err
+			}
+			opt = string(tmpl)
+		}
+	}
+	return printer.NewPrinter(of, opt)
+}
+
+func errPrint(format string, a ...interface{}) (n int, err error) {
+	return fmt.Fprintf(os.Stderr, format+"\n", a...)
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cmd/search.go	Wed Apr 19 19:08:47 2017 +0200
@@ -0,0 +1,55 @@
+// Copyright © 2017 Mikael Berthe <mikael@lilotux.net>
+//
+// Licensed under the MIT license.
+// Please see the LICENSE file is this directory.
+
+package cmd
+
+import (
+	"errors"
+	"strings"
+
+	"github.com/spf13/cobra"
+)
+
+var searchOpts struct {
+	resolve bool
+}
+
+// searchCmd represents the search command
+var searchCmd = &cobra.Command{
+	Use:   "search [--resolve] STRING",
+	Short: "Search for contents (accounts or statuses)",
+	//Long: `TBW...`,
+	RunE: searchRunE,
+}
+
+func init() {
+	RootCmd.AddCommand(searchCmd)
+
+	searchCmd.Flags().BoolVar(&searchOpts.resolve, "resolve", false, "Resolve non-local accounts")
+}
+
+func searchRunE(cmd *cobra.Command, args []string) error {
+	opt := searchOpts
+
+	if len(args) == 0 {
+		return errors.New("no search string provided")
+	}
+
+	if err := madonInit(true); err != nil {
+		return err
+	}
+
+	results, err := gClient.Search(strings.Join(args, " "), opt.resolve)
+	if err != nil {
+		errPrint("Error: %s", err.Error())
+		return nil
+	}
+
+	p, err := getPrinter()
+	if err != nil {
+		return err
+	}
+	return p.PrintObj(results, nil, "")
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cmd/status.go	Wed Apr 19 19:08:47 2017 +0200
@@ -0,0 +1,218 @@
+// Copyright © 2017 Mikael Berthe <mikael@lilotux.net>
+//
+// Licensed under the MIT license.
+// Please see the LICENSE file is this directory.
+
+package cmd
+
+import (
+	"errors"
+	"strings"
+
+	"github.com/spf13/cobra"
+
+	"github.com/McKael/madon"
+)
+
+var statusOpts struct {
+	statusID int
+	unset    bool
+
+	// The following fields are used for the post/toot command
+	visibility  string
+	sensitive   bool
+	spoiler     string
+	inReplyToID int
+	filePath    string
+	mediaIDs    string
+
+	// TODO
+	limit int
+}
+
+func init() {
+	RootCmd.AddCommand(statusCmd)
+
+	// Subcommands
+	statusCmd.AddCommand(statusSubcommands...)
+
+	// Global flags
+	statusCmd.PersistentFlags().IntVarP(&statusOpts.statusID, "status-id", "s", 0, "Status ID number")
+	//statusCmd.PersistentFlags().IntVarP(&statusOpts.limit, "limit", "l", 0, "Limit number of results")
+
+	statusCmd.MarkPersistentFlagRequired("status-id")
+
+	// Subcommand flags
+	statusReblogSubcommand.Flags().BoolVar(&statusOpts.unset, "unset", false, "Unreblog the status")
+	statusFavouriteSubcommand.Flags().BoolVar(&statusOpts.unset, "unset", false, "Remove the status from the favourites")
+	statusPostSubcommand.Flags().BoolVar(&statusOpts.sensitive, "sensitive", false, "Mark post as sensitive (NSFW)")
+	statusPostSubcommand.Flags().StringVar(&statusOpts.visibility, "visibility", "", "Visibility (direct|private|unlisted|public)")
+	statusPostSubcommand.Flags().StringVar(&statusOpts.spoiler, "spoiler", "", "Spoiler warning (CW)")
+	statusPostSubcommand.Flags().StringVar(&statusOpts.mediaIDs, "media-ids", "", "Comma-separated list of media IDs")
+	statusPostSubcommand.Flags().StringVarP(&statusOpts.filePath, "file", "f", "", "File name")
+	statusPostSubcommand.Flags().IntVarP(&statusOpts.inReplyToID, "in-reply-to", "r", 0, "Status ID to reply to")
+}
+
+// statusCmd represents the status command
+// This command does nothing without a subcommand
+var statusCmd = &cobra.Command{
+	Use:     "status --status-id ID subcommand",
+	Aliases: []string{"st"},
+	Short:   "Get status details",
+	//Long:    `TBW...`, // TODO
+	PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
+		// This is common to status and all status subcommands but "post"
+		if statusOpts.statusID < 1 && cmd.Name() != "post" {
+			return errors.New("missing status ID")
+		}
+		if err := madonInit(true); err != nil {
+			return err
+		}
+		return nil
+	},
+}
+
+var statusSubcommands = []*cobra.Command{
+	&cobra.Command{
+		Use:     "show",
+		Aliases: []string{"display"},
+		Short:   "Get the status",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return statusSubcommandRunE(cmd.Name(), args)
+		},
+	},
+	&cobra.Command{
+		Use:   "context",
+		Short: "Get the status context",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return statusSubcommandRunE(cmd.Name(), args)
+		},
+	},
+	&cobra.Command{
+		Use:   "card",
+		Short: "Get the status card",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return statusSubcommandRunE(cmd.Name(), args)
+		},
+	},
+	&cobra.Command{
+		Use:   "reblogged-by",
+		Short: "Display accounts which reblogged the status",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return statusSubcommandRunE(cmd.Name(), args)
+		},
+	},
+	&cobra.Command{
+		Use:     "favourited-by",
+		Aliases: []string{"favorited-by"},
+		Short:   "Display accounts which favourited the status",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return statusSubcommandRunE(cmd.Name(), args)
+		},
+	},
+	&cobra.Command{
+		Use:     "delete",
+		Aliases: []string{"rm"},
+		Short:   "Delete the status",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return statusSubcommandRunE(cmd.Name(), args)
+		},
+	},
+	statusReblogSubcommand,
+	statusFavouriteSubcommand,
+	statusPostSubcommand,
+}
+
+var statusReblogSubcommand = &cobra.Command{
+	Use:     "boost",
+	Aliases: []string{"reblog"},
+	Short:   "Boost (reblog) or unreblog the status",
+	RunE: func(cmd *cobra.Command, args []string) error {
+		return statusSubcommandRunE(cmd.Name(), args)
+	},
+}
+
+var statusFavouriteSubcommand = &cobra.Command{
+	Use:     "favourite",
+	Aliases: []string{"favorite", "fave"},
+	Short:   "Mark/unmark the status as favourite",
+	RunE: func(cmd *cobra.Command, args []string) error {
+		return statusSubcommandRunE(cmd.Name(), args)
+	},
+}
+
+var statusPostSubcommand = &cobra.Command{
+	Use:     "post",
+	Aliases: []string{"toot", "pouet"},
+	Short:   "Post a message (same as 'madonctl toot')",
+	Example: `  madonctl status post --spoiler Warning "Hello, World"
+  madonctl status toot --sensitive --file image.jpg Image
+  madonctl status post --media-ids ID1,ID2,ID3 Image`,
+	RunE: func(cmd *cobra.Command, args []string) error {
+		return statusSubcommandRunE(cmd.Name(), args)
+	},
+}
+
+func statusSubcommandRunE(subcmd string, args []string) error {
+	opt := statusOpts
+
+	var obj interface{}
+	var err error
+
+	switch subcmd {
+	case "show":
+		var status *madon.Status
+		status, err = gClient.GetStatus(opt.statusID)
+		obj = status
+	case "context":
+		var context *madon.Context
+		context, err = gClient.GetStatusContext(opt.statusID)
+		obj = context
+	case "card":
+		var context *madon.Card
+		context, err = gClient.GetStatusCard(opt.statusID)
+		obj = context
+	case "reblogged-by":
+		var accountList []madon.Account
+		accountList, err = gClient.GetStatusRebloggedBy(opt.statusID)
+		obj = accountList
+	case "favourited-by":
+		var accountList []madon.Account
+		accountList, err = gClient.GetStatusFavouritedBy(opt.statusID)
+		obj = accountList
+	case "delete":
+		err = gClient.DeleteStatus(opt.statusID)
+	case "boost":
+		if opt.unset {
+			err = gClient.UnreblogStatus(opt.statusID)
+		} else {
+			err = gClient.ReblogStatus(opt.statusID)
+		}
+	case "favourite":
+		if opt.unset {
+			err = gClient.UnfavouriteStatus(opt.statusID)
+		} else {
+			err = gClient.FavouriteStatus(opt.statusID)
+		}
+	case "post": // toot
+		var s *madon.Status
+		s, err = toot(strings.Join(args, " "))
+		obj = s
+	default:
+		return errors.New("statusSubcommand: internal error")
+	}
+
+	if err != nil {
+		errPrint("Error: %s", err.Error())
+		return nil
+	}
+	if obj == nil {
+		return nil
+	}
+
+	p, err := getPrinter()
+	if err != nil {
+		return err
+	}
+	return p.PrintObj(obj, nil, "")
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cmd/stream.go	Wed Apr 19 19:08:47 2017 +0200
@@ -0,0 +1,136 @@
+// Copyright © 2017 Mikael Berthe <mikael@lilotux.net>
+//
+// Licensed under the MIT license.
+// Please see the LICENSE file is this directory.
+
+package cmd
+
+import (
+	"errors"
+	"io"
+
+	"github.com/spf13/cobra"
+
+	"github.com/McKael/madon"
+)
+
+/*
+var streamOpts struct {
+	local bool
+}
+*/
+
+// streamCmd represents the stream command
+var streamCmd = &cobra.Command{
+	Use:   "stream [user|local|public|:HASHTAG]",
+	Short: "Listen to an event stream",
+	Long: `
+The stream command stays connected to the server and listen to a stream of
+events (user, local or federated).
+It can also get a hashtag-based stream if the keyword or prefixed with
+':' or '#'.`,
+	Example: `  madonctl stream           # User timeline stream
+  madonctl stream local     # Local timeline stream
+  madonctl stream public    # Public timeline stream
+  madonctl stream :mastodon # Hashtag
+  madonctl stream #madonctl`,
+	RunE:       streamRunE,
+	ValidArgs:  []string{"user", "public"},
+	ArgAliases: []string{"home"},
+}
+
+func init() {
+	RootCmd.AddCommand(streamCmd)
+
+	//streamCmd.Flags().BoolVar(&streamOpts.local, "local", false, "Events from the local instance")
+}
+
+func streamRunE(cmd *cobra.Command, args []string) error {
+	streamName := "user"
+	tag := ""
+
+	if len(args) > 0 {
+		if len(args) != 1 {
+			return errors.New("too many parameters")
+		}
+		arg := args[0]
+		switch arg {
+		case "", "user":
+		case "public":
+			streamName = arg
+		case "local":
+			streamName = "public:local"
+		default:
+			if arg[0] != ':' && arg[0] != '#' {
+				return errors.New("invalid argument")
+			}
+			streamName = "hashtag"
+			tag = arg[1:]
+			if len(tag) == 0 {
+				return errors.New("empty hashtag")
+			}
+		}
+	}
+
+	if err := madonInit(true); err != nil {
+		return err
+	}
+
+	evChan := make(chan madon.StreamEvent, 10)
+	stop := make(chan bool)
+	done := make(chan bool)
+
+	// StreamListener(name string, hashTag string, events chan<- madon.StreamEvent, stopCh <-chan bool, doneCh chan<- bool) error
+	err := gClient.StreamListener(streamName, tag, evChan, stop, done)
+	if err != nil {
+		errPrint("Error: %s", err.Error())
+		return nil
+	}
+
+	p, err := getPrinter()
+	if err != nil {
+		close(stop)
+		<-done
+		close(evChan)
+		return err
+	}
+
+LISTEN:
+	for {
+		select {
+		case _, ok := <-done:
+			if !ok { // done is closed, end of streaming
+				done = nil
+				break LISTEN
+			}
+		case ev := <-evChan:
+			switch ev.Event {
+			case "error":
+				if ev.Error != nil {
+					if ev.Error == io.ErrUnexpectedEOF {
+						errPrint("The stream connection was unexpectedly closed")
+						continue
+					}
+					errPrint("Error event: [%s] %s", ev.Event, ev.Error)
+					continue
+				}
+				errPrint("Event: [%s]", ev.Event)
+			case "update":
+				s := ev.Data.(madon.Status)
+				p.PrintObj(&s, nil, "")
+				continue
+			case "notification":
+				n := ev.Data.(madon.Notification)
+				p.PrintObj(&n, nil, "")
+				continue
+			case "delete":
+				// TODO PrintObj ?
+				errPrint("Event: [%s] Status %d was deleted", ev.Event, ev.Data.(int))
+			default:
+				errPrint("Unhandled event: [%s] %T", ev.Event, ev.Data)
+			}
+		}
+	}
+	close(evChan)
+	return nil
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cmd/timelines.go	Wed Apr 19 19:08:47 2017 +0200
@@ -0,0 +1,62 @@
+// Copyright © 2017 Mikael Berthe <mikael@lilotux.net>
+//
+// Licensed under the MIT license.
+// Please see the LICENSE file is this directory.
+
+package cmd
+
+import (
+	"github.com/spf13/cobra"
+)
+
+var timelineOpts struct {
+	local bool
+}
+
+// timelineCmd represents the timelines command
+var timelineCmd = &cobra.Command{
+	Use:     "timeline [home|public|:HASHTAG] [--local]",
+	Aliases: []string{"tl"},
+	Short:   "Fetch a timeline",
+	Long: `
+The timeline command fetches a timeline (home, local or federated).
+It can also get a hashtag-based timeline if the keyword or prefixed with
+':' or '#'.`,
+	Example: `  madonctl timeline
+  madonctl timeline public --local
+  madonctl timeline :mastodon`,
+	RunE:      timelineRunE,
+	ValidArgs: []string{"home", "public"},
+}
+
+func init() {
+	RootCmd.AddCommand(timelineCmd)
+
+	timelineCmd.Flags().BoolVar(&timelineOpts.local, "local", false, "Posts from the local instance")
+}
+
+func timelineRunE(cmd *cobra.Command, args []string) error {
+	opt := timelineOpts
+
+	tl := "home"
+	if len(args) > 0 {
+		tl = args[0]
+	}
+
+	// The home timeline is the only one requiring to be logged in
+	if err := madonInit(tl == "home"); err != nil {
+		return err
+	}
+
+	sl, err := gClient.GetTimelines(tl, opt.local)
+	if err != nil {
+		errPrint("Error: %s", err.Error())
+		return nil
+	}
+
+	p, err := getPrinter()
+	if err != nil {
+		return err
+	}
+	return p.PrintObj(sl, nil, "")
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cmd/toot.go	Wed Apr 19 19:08:47 2017 +0200
@@ -0,0 +1,79 @@
+// Copyright © 2017 Mikael Berthe <mikael@lilotux.net>
+//
+// Licensed under the MIT license.
+// Please see the LICENSE file is this directory.
+
+package cmd
+
+import (
+	"errors"
+
+	"github.com/spf13/cobra"
+
+	"github.com/McKael/madon"
+)
+
+// toot is a kind of alias for status post
+
+func init() {
+	RootCmd.AddCommand(tootAliasCmd)
+
+	tootAliasCmd.Flags().BoolVar(&statusOpts.sensitive, "sensitive", false, "Mark post as sensitive (NSFW)")
+	tootAliasCmd.Flags().StringVar(&statusOpts.visibility, "visibility", "", "Visibility (direct|private|unlisted|public)")
+	tootAliasCmd.Flags().StringVar(&statusOpts.spoiler, "spoiler", "", "Spoiler warning (CW)")
+	tootAliasCmd.Flags().StringVar(&statusOpts.mediaIDs, "media-ids", "", "Comma-separated list of media IDs")
+	tootAliasCmd.Flags().StringVarP(&statusOpts.filePath, "file", "f", "", "Media attachment file name")
+	tootAliasCmd.Flags().IntVarP(&statusOpts.inReplyToID, "in-reply-to", "r", 0, "Status ID to reply to")
+}
+
+var tootAliasCmd = &cobra.Command{
+	Use:     "toot",
+	Aliases: []string{"post", "pouet"},
+	Short:   "Post a message (toot)",
+	Example: `  madonctl toot message
+  madonctl toot --spoiler Warning "Hello, World"
+  madonctl status post --media-ids ID1,ID2 "Here are the photos"
+  madonctl post --sensitive --file image.jpg Image`,
+	RunE: func(cmd *cobra.Command, args []string) error {
+		if err := madonInit(true); err != nil {
+			return err
+		}
+		return statusSubcommandRunE("post", args)
+	},
+}
+
+func toot(tootText string) (*madon.Status, error) {
+	opt := statusOpts
+
+	switch opt.visibility {
+	case "", "direct", "private", "unlisted", "public":
+		// OK
+	default:
+		return nil, errors.New("invalid visibility argument value")
+	}
+
+	if opt.inReplyToID < 0 {
+		return nil, errors.New("invalid in-reply-to argument value")
+	}
+
+	ids, err := splitIDs(opt.mediaIDs)
+	if err != nil {
+		return nil, errors.New("cannot parse media IDs")
+	}
+
+	if opt.filePath != "" {
+		if len(ids) > 3 {
+			return nil, errors.New("too many media attachments")
+		}
+
+		fileMediaID, err := uploadFile(opt.filePath)
+		if err != nil {
+			return nil, errors.New("cannot attach media file: " + err.Error())
+		}
+		if fileMediaID > 0 {
+			ids = append(ids, fileMediaID)
+		}
+	}
+
+	return gClient.PostStatus(tootText, opt.inReplyToID, ids, opt.sensitive, opt.spoiler, opt.visibility)
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cmd/version.go	Wed Apr 19 19:08:47 2017 +0200
@@ -0,0 +1,27 @@
+// Copyright © 2017 Mikael Berthe <mikael@lilotux.net>
+//
+// Licensed under the MIT license.
+// Please see the LICENSE file is this directory.
+
+package cmd
+
+import (
+	"fmt"
+
+	"github.com/spf13/cobra"
+)
+
+// VERSION of the madonctl application
+var VERSION = "0.0.9"
+
+var versionCmd = &cobra.Command{
+	Use:   "version",
+	Short: "Display " + AppName + " version",
+	Run: func(cmd *cobra.Command, args []string) {
+		fmt.Printf("This is %s version %s\n", AppName, VERSION)
+	},
+}
+
+func init() {
+	RootCmd.AddCommand(versionCmd)
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/main.go	Wed Apr 19 19:08:47 2017 +0200
@@ -0,0 +1,29 @@
+// Copyright © 2017 Mikael Berthe <mikael@lilotux.net>
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+
+package main
+
+import (
+	"github.com/McKael/madonctl/cmd"
+)
+
+func main() {
+	cmd.RootCmd.Execute()
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/printer/README.md	Wed Apr 19 19:08:47 2017 +0200
@@ -0,0 +1,28 @@
+# madonctl
+
+Golang command line interface for the Mastodon API
+
+[![license](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](https://raw.githubusercontent.com/McKael/madonctl/master/LICENSE)
+
+`madonctl` is a [Go](https://golang.org/) CLI tool to use the Mastondon REST API.
+
+## Installation
+
+To install the application (you need to have Go):
+
+    go get github.com/McKael/madonctl
+
+Some pre-built binaries are available on the home page (see below).
+
+## Usage
+
+This section has not been written yet.
+
+Please check the [Homepage](https://lilotux.net/~mikael/pub/madonctl/) for an
+introduction and a few examples.
+
+## References
+
+- [madon](https://github.com/McKael/madon), the Go library for Mastodon API
+- [Mastodon API documentation](https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md)
+- [Mastodon repository](https://github.com/tootsuite/mastodon)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/printer/json.go	Wed Apr 19 19:08:47 2017 +0200
@@ -0,0 +1,34 @@
+// Copyright © 2017 Mikael Berthe <mikael@lilotux.net>
+//
+// Licensed under the MIT license.
+// Please see the LICENSE file is this directory.
+
+package printer
+
+import (
+	"encoding/json"
+	"io"
+	"os"
+)
+
+// JSONPrinter represents a JSON printer
+type JSONPrinter struct {
+}
+
+// NewPrinterJSON returns a JSON ResourcePrinter
+func NewPrinterJSON(option string) (*JSONPrinter, error) {
+	return &JSONPrinter{}, nil
+}
+
+// PrintObj sends the object as text to the writer
+// If the writer w is nil, standard output will be used.
+// For JSONPrinter, the option parameter is currently not used.
+func (p *JSONPrinter) PrintObj(obj interface{}, w io.Writer, option string) error {
+	if w == nil {
+		w = os.Stdout
+	}
+
+	jsonEncoder := json.NewEncoder(w)
+	//jsonEncoder.SetIndent("", "  ")
+	return jsonEncoder.Encode(obj)
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/printer/plain.go	Wed Apr 19 19:08:47 2017 +0200
@@ -0,0 +1,272 @@
+// Copyright © 2017 Mikael Berthe <mikael@lilotux.net>
+//
+// Licensed under the MIT license.
+// Please see the LICENSE file is this directory.
+
+package printer
+
+import (
+	"fmt"
+	"io"
+	"os"
+	"reflect"
+	"time"
+
+	"github.com/jaytaylor/html2text"
+
+	"github.com/McKael/madon"
+)
+
+// PlainPrinter is the default "plain text" printer
+type PlainPrinter struct {
+	Indent      string
+	NoSubtitles bool
+}
+
+// NewPrinterPlain returns a plaintext ResourcePrinter
+// For PlainPrinter, the option parameter contains the indent prefix.
+func NewPrinterPlain(option string) (*PlainPrinter, error) {
+	indentInc := "  "
+	if option != "" {
+		indentInc = option
+	}
+	return &PlainPrinter{Indent: indentInc}, nil
+}
+
+// PrintObj sends the object as text to the writer
+// If the writer w is nil, standard output will be used.
+// For PlainPrinter, the option parameter contains the initial indent.
+func (p *PlainPrinter) PrintObj(obj interface{}, w io.Writer, initialIndent string) error {
+	if w == nil {
+		w = os.Stdout
+	}
+	switch o := obj.(type) {
+	case []madon.Account, []madon.Attachment, []madon.Card, []madon.Context,
+		[]madon.Instance, []madon.Mention, []madon.Notification,
+		[]madon.Relationship, []madon.Report, []madon.Results,
+		[]madon.Status, []madon.StreamEvent, []madon.Tag:
+		return p.plainForeach(o, w, initialIndent)
+	case *madon.Account:
+		return p.plainPrintAccount(o, w, initialIndent)
+	case madon.Account:
+		return p.plainPrintAccount(&o, w, initialIndent)
+	case *madon.Attachment:
+		return p.plainPrintAttachment(o, w, initialIndent)
+	case madon.Attachment:
+		return p.plainPrintAttachment(&o, w, initialIndent)
+	case *madon.Card:
+		return p.plainPrintCard(o, w, initialIndent)
+	case madon.Card:
+		return p.plainPrintCard(&o, w, initialIndent)
+	case *madon.Context:
+		return p.plainPrintContext(o, w, initialIndent)
+	case madon.Context:
+		return p.plainPrintContext(&o, w, initialIndent)
+	case *madon.Instance:
+		return p.plainPrintInstance(o, w, initialIndent)
+	case madon.Instance:
+		return p.plainPrintInstance(&o, w, initialIndent)
+	case *madon.Notification:
+		return p.plainPrintNotification(o, w, initialIndent)
+	case madon.Notification:
+		return p.plainPrintNotification(&o, w, initialIndent)
+	case *madon.Relationship:
+		return p.plainPrintRelationship(o, w, initialIndent)
+	case madon.Relationship:
+		return p.plainPrintRelationship(&o, w, initialIndent)
+	case *madon.Report:
+		return p.plainPrintReport(o, w, initialIndent)
+	case madon.Report:
+		return p.plainPrintReport(&o, w, initialIndent)
+	case *madon.Results:
+		return p.plainPrintResults(o, w, initialIndent)
+	case madon.Results:
+		return p.plainPrintResults(&o, w, initialIndent)
+	case *madon.Status:
+		return p.plainPrintStatus(o, w, initialIndent)
+	case madon.Status:
+		return p.plainPrintStatus(&o, w, initialIndent)
+	case *madon.UserToken:
+		return p.plainPrintUserToken(o, w, initialIndent)
+	case madon.UserToken:
+		return p.plainPrintUserToken(&o, w, initialIndent)
+	}
+	// TODO: Mention
+	// TODO: StreamEvent
+	// TODO: Tag
+
+	return fmt.Errorf("PlainPrinter not yet implemented for %T (try json or yaml...)", obj)
+}
+
+func (p *PlainPrinter) plainForeach(ol interface{}, w io.Writer, ii string) error {
+	switch reflect.TypeOf(ol).Kind() {
+	case reflect.Slice:
+		s := reflect.ValueOf(ol)
+
+		for i := 0; i < s.Len(); i++ {
+			o := s.Index(i).Interface()
+			if err := p.PrintObj(o, w, ii); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+func html2string(h string) string {
+	t, err := html2text.FromString(h)
+	if err == nil {
+		return t
+	}
+	return h // Failed: return initial string
+}
+
+func indentedPrint(w io.Writer, indent string, title, skipIfEmpty bool, label string, format string, args ...interface{}) {
+	prefix := indent
+	if title {
+		prefix += "- "
+	} else {
+		prefix += "  "
+	}
+	value := fmt.Sprintf(format, args...)
+	if !title && skipIfEmpty && len(value) == 0 {
+		return
+	}
+	fmt.Fprintf(w, "%s%s: %s\n", prefix, label, value)
+}
+
+func (p *PlainPrinter) plainPrintAccount(a *madon.Account, w io.Writer, indent string) error {
+	indentedPrint(w, indent, true, false, "Account ID", "%d (%s)", a.ID, a.Username)
+	indentedPrint(w, indent, false, false, "User ID", "%s", a.Acct)
+	indentedPrint(w, indent, false, false, "Display name", "%s", a.DisplayName)
+	indentedPrint(w, indent, false, false, "Creation date", "%v", a.CreatedAt.Local())
+	indentedPrint(w, indent, false, false, "URL", "%s", a.URL)
+	indentedPrint(w, indent, false, false, "Statuses count", "%d", a.StatusesCount)
+	indentedPrint(w, indent, false, false, "Followers count", "%d", a.FollowersCount)
+	indentedPrint(w, indent, false, false, "Following count", "%d", a.FollowingCount)
+	if a.Locked {
+		indentedPrint(w, indent, false, false, "Locked", "%v", a.Locked)
+	}
+	indentedPrint(w, indent, false, true, "User note", "%s", html2string(a.Note)) // XXX too long?
+	return nil
+}
+
+func (p *PlainPrinter) plainPrintAttachment(a *madon.Attachment, w io.Writer, indent string) error {
+	indentedPrint(w, indent, true, false, "Attachment ID", "%d", a.ID)
+	indentedPrint(w, indent, false, false, "Type", "%s", a.Type)
+	indentedPrint(w, indent, false, false, "Local URL", "%s", a.URL)
+	indentedPrint(w, indent, false, true, "Remote URL", "%s", a.RemoteURL)
+	indentedPrint(w, indent, false, true, "Preview URL", "%s", a.PreviewURL)
+	indentedPrint(w, indent, false, true, "Text URL", "%s", a.PreviewURL)
+	return nil
+}
+
+func (p *PlainPrinter) plainPrintCard(c *madon.Card, w io.Writer, indent string) error {
+	indentedPrint(w, indent, true, false, "Card title", "%s", c.Title)
+	indentedPrint(w, indent, false, true, "Description", "%s", c.Description)
+	indentedPrint(w, indent, false, true, "URL", "%s", c.URL)
+	indentedPrint(w, indent, false, true, "Image", "%s", c.Image)
+	return nil
+}
+
+func (p *PlainPrinter) plainPrintContext(c *madon.Context, w io.Writer, indent string) error {
+	indentedPrint(w, indent, true, false, "Context", "%d relative(s)", len(c.Ancestors)+len(c.Descendents))
+	if len(c.Ancestors) > 0 {
+		indentedPrint(w, indent, false, false, "Ancestors", "")
+		p.PrintObj(c.Ancestors, w, indent+p.Indent)
+	}
+	if len(c.Descendents) > 0 {
+		indentedPrint(w, indent, false, false, "Descendents", "")
+		p.PrintObj(c.Descendents, w, indent+p.Indent)
+	}
+	return nil
+}
+
+func (p *PlainPrinter) plainPrintInstance(i *madon.Instance, w io.Writer, indent string) error {
+	indentedPrint(w, indent, true, false, "Instance title", "%s", i.Title)
+	indentedPrint(w, indent, false, true, "Description", "%s", html2string(i.Description))
+	indentedPrint(w, indent, false, true, "URL", "%s", i.URI)
+	indentedPrint(w, indent, false, true, "Email", "%s", i.Email)
+	return nil
+}
+
+func (p *PlainPrinter) plainPrintNotification(n *madon.Notification, w io.Writer, indent string) error {
+	indentedPrint(w, indent, true, false, "Notification ID", "%d", n.ID)
+	indentedPrint(w, indent, false, false, "Type", "%s", n.Type)
+	indentedPrint(w, indent, false, false, "Timestamp", "%v", n.CreatedAt.Local())
+	if n.Account != nil {
+		indentedPrint(w, indent+p.Indent, true, false, "Account", "(%d) @%s - %s",
+			n.Account.ID, n.Account.Acct, n.Account.DisplayName)
+	}
+	if n.Status != nil {
+		p.plainPrintStatus(n.Status, w, indent+p.Indent)
+	}
+	return nil
+}
+
+func (p *PlainPrinter) plainPrintRelationship(r *madon.Relationship, w io.Writer, indent string) error {
+	indentedPrint(w, indent, true, false, "ID", "%d", r.ID)
+	indentedPrint(w, indent, false, false, "Following", "%v", r.Following)
+	indentedPrint(w, indent, false, false, "Followed-by", "%v", r.FollowedBy)
+	indentedPrint(w, indent, false, false, "Blocking", "%v", r.Blocking)
+	indentedPrint(w, indent, false, false, "Muting", "%v", r.Muting)
+	indentedPrint(w, indent, false, false, "Requested", "%v", r.Requested)
+	return nil
+}
+
+func (p *PlainPrinter) plainPrintReport(r *madon.Report, w io.Writer, indent string) error {
+	indentedPrint(w, indent, true, false, "Report ID", "%d", r.ID)
+	indentedPrint(w, indent, false, false, "Action taken", "%s", r.ActionTaken)
+	return nil
+}
+
+func (p *PlainPrinter) plainPrintResults(r *madon.Results, w io.Writer, indent string) error {
+	indentedPrint(w, indent, true, false, "Results", "%d account(s), %d status(es), %d hashtag(s)",
+		len(r.Accounts), len(r.Statuses), len(r.Hashtags))
+	if len(r.Accounts) > 0 {
+		indentedPrint(w, indent, false, false, "Accounts", "")
+		p.PrintObj(r.Accounts, w, indent+p.Indent)
+	}
+	if len(r.Statuses) > 0 {
+		indentedPrint(w, indent, false, false, "Statuses", "")
+		p.PrintObj(r.Statuses, w, indent+p.Indent)
+	}
+	if len(r.Hashtags) > 0 {
+		indentedPrint(w, indent, false, false, "Hashtags", "")
+		for _, tag := range r.Hashtags {
+			indentedPrint(w, indent+p.Indent, true, false, "Tag", "%s", tag)
+		}
+	}
+	return nil
+}
+
+func (p *PlainPrinter) plainPrintStatus(s *madon.Status, w io.Writer, indent string) error {
+	indentedPrint(w, indent, true, false, "Status ID", "%d", s.ID)
+	indentedPrint(w, indent, false, false, "From", "%s", s.Account.Acct)
+	indentedPrint(w, indent, false, false, "Timestamp", "%v", s.CreatedAt.Local())
+
+	if s.Reblog != nil {
+		indentedPrint(w, indent, false, false, "Reblogged from", "%s", s.Reblog.Account.Username)
+		return p.plainPrintStatus(s.Reblog, w, indent+p.Indent)
+	}
+
+	indentedPrint(w, indent, false, false, "Contents", "%s", html2string(s.Content))
+	if s.InReplyToID > 0 {
+		indentedPrint(w, indent, false, false, "In-Reply-To", "%d", s.InReplyToID)
+	}
+	if s.Reblogged {
+		indentedPrint(w, indent, false, false, "Reblogged", "%v", s.Reblogged)
+	}
+	indentedPrint(w, indent, false, false, "URL", "%s", s.URL)
+	return nil
+}
+
+func (p *PlainPrinter) plainPrintUserToken(s *madon.UserToken, w io.Writer, indent string) error {
+	indentedPrint(w, indent, true, false, "User token", "%s", s.AccessToken)
+	indentedPrint(w, indent, false, true, "Type", "%s", s.TokenType)
+	if s.CreatedAt != 0 {
+		indentedPrint(w, indent, false, true, "Timestamp", "%v", time.Unix(int64(s.CreatedAt), 0))
+	}
+	indentedPrint(w, indent, false, true, "Scope", "%s", s.Scope)
+	return nil
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/printer/printer.go	Wed Apr 19 19:08:47 2017 +0200
@@ -0,0 +1,33 @@
+// Copyright © 2017 Mikael Berthe <mikael@lilotux.net>
+//
+// Licensed under the MIT license.
+// Please see the LICENSE file is this directory.
+
+package printer
+
+import (
+	"fmt"
+	"io"
+)
+
+// ResourcePrinter is an interface used to print objects.
+type ResourcePrinter interface {
+	// PrintObj receives a runtime object, formats it and prints it to a writer.
+	PrintObj(interface{}, io.Writer, string) error
+}
+
+// NewPrinter returns a ResourcePrinter for the specified kind of output.
+// It returns nil if the output is not supported.
+func NewPrinter(output, option string) (ResourcePrinter, error) {
+	switch output {
+	case "", "plain":
+		return NewPrinterPlain(option)
+	case "json":
+		return NewPrinterJSON(option)
+	case "yaml":
+		return NewPrinterYAML(option)
+	case "template":
+		return NewPrinterTemplate(option)
+	}
+	return nil, fmt.Errorf("unhandled output format")
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/printer/templateprinter.go	Wed Apr 19 19:08:47 2017 +0200
@@ -0,0 +1,112 @@
+// Copyright © 2017 Mikael Berthe <mikael@lilotux.net>
+//
+// Licensed under the MIT license.
+// Please see the LICENSE file is this directory.
+
+package printer
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"os"
+	"reflect"
+	"text/template"
+
+	"github.com/McKael/madon"
+)
+
+// TemplatePrinter represents a Template printer
+type TemplatePrinter struct {
+	rawTemplate string
+	template    *template.Template
+}
+
+// NewPrinterTemplate returns a Template ResourcePrinter
+// For TemplatePrinter, the option parameter contains the template string.
+func NewPrinterTemplate(option string) (*TemplatePrinter, error) {
+	tmpl := option
+	t, err := template.New("output").Parse(tmpl)
+	if err != nil {
+		return nil, err
+	}
+	return &TemplatePrinter{
+		rawTemplate: tmpl,
+		template:    t,
+	}, nil
+}
+
+// PrintObj sends the object as text to the writer
+// If the writer w is nil, standard output will be used.
+func (p *TemplatePrinter) PrintObj(obj interface{}, w io.Writer, tmpl string) error {
+	if w == nil {
+		w = os.Stdout
+	}
+
+	if p.template == nil {
+		return fmt.Errorf("template not built")
+	}
+
+	switch ot := obj.(type) { // I wish I knew a better way...
+	case []madon.Account, []madon.Application, []madon.Attachment, []madon.Card,
+		[]madon.Client, []madon.Context, []madon.Instance, []madon.Mention,
+		[]madon.Notification, []madon.Relationship, []madon.Report,
+		[]madon.Results, []madon.Status, []madon.StreamEvent, []madon.Tag:
+		return p.templateForeach(ot, w)
+	}
+
+	return p.templatePrintSingleObj(obj, w)
+}
+
+func (p *TemplatePrinter) templatePrintSingleObj(obj interface{}, w io.Writer) error {
+	// This code comes from Kubernetes.
+	data, err := json.Marshal(obj)
+	if err != nil {
+		return err
+	}
+	out := map[string]interface{}{}
+	if err := json.Unmarshal(data, &out); err != nil {
+		return err
+	}
+	if err = p.safeExecute(w, out); err != nil {
+		return fmt.Errorf("error executing template %q: %v", p.rawTemplate, err)
+	}
+	return nil
+}
+
+// safeExecute tries to execute the template, but catches panics and returns an error
+// should the template engine panic.
+// This code comes from Kubernetes.
+func (p *TemplatePrinter) safeExecute(w io.Writer, obj interface{}) error {
+	var panicErr error
+	// Sorry for the double anonymous function. There's probably a clever way
+	// to do this that has the defer'd func setting the value to be returned, but
+	// that would be even less obvious.
+	retErr := func() error {
+		defer func() {
+			if x := recover(); x != nil {
+				panicErr = fmt.Errorf("caught panic: %+v", x)
+			}
+		}()
+		return p.template.Execute(w, obj)
+	}()
+	if panicErr != nil {
+		return panicErr
+	}
+	return retErr
+}
+
+func (p *TemplatePrinter) templateForeach(ol interface{}, w io.Writer) error {
+	switch reflect.TypeOf(ol).Kind() {
+	case reflect.Slice:
+		s := reflect.ValueOf(ol)
+
+		for i := 0; i < s.Len(); i++ {
+			o := s.Index(i).Interface()
+			if err := p.templatePrintSingleObj(o, w); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/printer/yaml.go	Wed Apr 19 19:08:47 2017 +0200
@@ -0,0 +1,42 @@
+// Copyright © 2017 Mikael Berthe <mikael@lilotux.net>
+//
+// Licensed under the MIT license.
+// Please see the LICENSE file is this directory.
+
+package printer
+
+import (
+	"fmt"
+	"io"
+	"os"
+
+	"github.com/ghodss/yaml"
+)
+
+// YAMLPrinter represents a YAML printer
+type YAMLPrinter struct {
+}
+
+// NewPrinterYAML returns a YAML ResourcePrinter
+func NewPrinterYAML(option string) (*YAMLPrinter, error) {
+	return &YAMLPrinter{}, nil
+}
+
+// PrintObj sends the object as text to the writer
+// If the writer w is nil, standard output will be used.
+// For YAMLPrinter, the option parameter is currently not used.
+func (p *YAMLPrinter) PrintObj(obj interface{}, w io.Writer, option string) error {
+	if w == nil {
+		w = os.Stdout
+	}
+
+	//yamlEncoder := yaml.NewEncoder(w)
+	//return yamlEncoder.Encode(obj)
+
+	output, err := yaml.Marshal(obj)
+	if err != nil {
+		return err
+	}
+	_, err = fmt.Fprint(w, string(output))
+	return err
+}