← Otto User GuideCustom Commands in Files

You can add your own commands to Files. Write a small script, put it in ~/.config/otto/files-scripts/, and its commands show up in the command palette and the right-click menu alongside the built-in ones. They get the same features:

A script is any executable file, in any language, that reads and writes JSON. There are two tutorials:

The reference after them describes everything a script can say.

Tutorial: a Duplicate command

We’ll build Duplicate, a command that copies the selected files next to themselves, as photo copy.jpg. It is about 70 lines of Python and uses only the standard library.

1. Describe the command

Files runs every script in the folder once, when a window opens, with the single argument describe. The script answers with the commands it offers. Create ~/.config/otto/files-scripts/duplicate:

#!/usr/bin/env python3
import json
import os
import shutil
import sys


def describe():
    return {
        "commands": [
            {
                "id": "duplicate",
                "title": "Duplicate",
                "keywords": ["copy", "clone"],
                "when": {"targets": "some"},
            }
        ]
    }


verb = sys.argv[1] if len(sys.argv) > 1 else ""
if verb == "describe":
    reply = describe()
else:
    sys.exit("usage: duplicate describe|preview|run")
json.dump(reply, sys.stdout)

"when": {"targets": "some"} means the command is offered only when there is something to act on. Make the file executable and check it:

chmod +x ~/.config/otto/files-scripts/duplicate
~/.config/otto/files-scripts/duplicate describe

2. Do the work

When you choose the command, Files runs the script with run and sends a request as JSON on standard input. The request’s targets field lists the selected paths, or the item under the cursor if nothing is selected. The script does the work and replies with what it changed. Add a run function and route to it:

def run(request):
    changes = []
    for source in request["targets"]:
        folder, name = os.path.split(source)
        stem, ext = os.path.splitext(name)
        destination = os.path.join(folder, stem + " copy" + ext)
        if os.path.exists(destination):
            sys.exit(f"“{os.path.basename(destination)}” is already there")
        if os.path.isdir(source):
            shutil.copytree(source, destination, symlinks=True)
        else:
            shutil.copy2(source, destination)
        changes.append({"kind": "created", "path": destination})
    return {
        "status": f"Duplicated {len(changes)} item(s)",
        "changes": changes,
        "reload": True,
    }
elif verb == "run":
    reply = run(json.load(sys.stdin))

Three parts of the reply matter:

If something goes wrong, exit with a non-zero status. Files shows the last line the script wrote to standard error; in Python, sys.exit("message") does both.

3. Try it

Open a new Files window, select a file and press Ctrl+P. Type dup and press Return, and the copy appears. Press Ctrl+Z to move it to the Trash. You can also right-click the file: Duplicate is in the menu.

4. Ask for a suffix, and show a preview

It would be better to choose the suffix and see the new names before anything is copied. Add an arg to the description, so the command asks for text first:

                "arg": {
                    "prompt": "Add to each name",
                    "label": "suffix",
                    "initial": " copy",
                    "preview": True,
                },

Because preview is true, Files runs the script with preview after every keystroke. The request is the same one run gets, with what you have typed so far in arg. A preview must not change anything. It replies with one row per file, marking any name that is already taken as a conflict:

def plan(request):
    """One (source, destination) per target, with the suffix added
    before the extension: "photo.jpg" -> "photo copy.jpg"."""
    suffix = request.get("arg") or " copy"
    out = []
    for source in request["targets"]:
        folder, name = os.path.split(source)
        stem, ext = os.path.splitext(name)
        out.append((source, os.path.join(folder, stem + suffix + ext)))
    return out


def preview(request):
    rows = []
    for source, destination in plan(request):
        rows.append({
            "from": os.path.basename(source),
            "to": os.path.basename(destination),
            "conflict": os.path.exists(destination),
        })
    taken = sum(row["conflict"] for row in rows)
    note = f"{taken} name already taken" if taken else None
    return {"rows": rows, "note": note}

Change run to use the same plan, so the preview and the result can’t disagree. The finished script is below.

Open a new window and choose Duplicate again. The field opens with copy in it, and the list shows photo.jpg → photo copy.jpg for each file, updating as you type. Names that are already taken are shown in red.

Press ↓ and then Space on a row to leave that file out. Files then asks your script for a preview of the smaller selection, and runs the command on that selection too. The script doesn’t need any code for this. It only needs each row’s from to be the file’s name.

The finished script

#!/usr/bin/env python3
import json
import os
import shutil
import sys


def describe():
    return {
        "commands": [
            {
                "id": "duplicate",
                "title": "Duplicate",
                "keywords": ["copy", "clone"],
                "when": {"targets": "some"},
                "arg": {
                    "prompt": "Add to each name",
                    "label": "suffix",
                    "initial": " copy",
                    "preview": True,
                },
            }
        ]
    }


def plan(request):
    """One (source, destination) per target, with the suffix added
    before the extension: "photo.jpg" -> "photo copy.jpg"."""
    suffix = request.get("arg") or " copy"
    out = []
    for source in request["targets"]:
        folder, name = os.path.split(source)
        stem, ext = os.path.splitext(name)
        out.append((source, os.path.join(folder, stem + suffix + ext)))
    return out


def preview(request):
    rows = []
    for source, destination in plan(request):
        rows.append({
            "from": os.path.basename(source),
            "to": os.path.basename(destination),
            "conflict": os.path.exists(destination),
        })
    taken = sum(row["conflict"] for row in rows)
    note = f"{taken} name already taken" if taken else None
    return {"rows": rows, "note": note}


def run(request):
    changes = []
    for source, destination in plan(request):
        if os.path.exists(destination):
            sys.exit(f"“{os.path.basename(destination)}” is already there")
        if os.path.isdir(source):
            shutil.copytree(source, destination, symlinks=True)
        else:
            shutil.copy2(source, destination)
        changes.append({"kind": "created", "path": destination})
    return {
        "status": f"Duplicated {len(changes)} item(s)",
        "changes": changes,
        "reload": True,
    }


verb = sys.argv[1] if len(sys.argv) > 1 else ""
if verb == "describe":
    reply = describe()
elif verb == "preview":
    reply = preview(json.load(sys.stdin))
elif verb == "run":
    reply = run(json.load(sys.stdin))
else:
    sys.exit("usage: duplicate describe|preview|run")
json.dump(reply, sys.stdout)

Testing it without Files

Each call is an ordinary process, so you can run the script by hand with a request you write yourself:

cat > /tmp/request.json <<'EOF'
{ "command": "duplicate", "arg": " backup",
  "targets": ["/home/me/Documents/report.pdf"],
  "situation": { "path": "/home/me/Documents" } }
EOF
~/.config/otto/files-scripts/duplicate preview < /tmp/request.json

Reference

Where scripts live

Files looks in $XDG_CONFIG_HOME/otto/files-scripts/, which is usually ~/.config/otto/files-scripts/. To use a different folder, set OTTO_FILES_SCRIPTS to its path before starting otto-files.

Files skips anything that isn’t executable, and anything whose name starts with a dot. Scripts are read once, when a window opens, so open a new window after adding or changing one.

Script commands aren’t offered in the Trash or in Recent, because neither is a real folder.

The three calls

CallWhenStandard inputTime limit
describeOnce, when a window opensnothing3 seconds
previewAfter each keystroke, for an arg with preview setthe request0.6 seconds
runWhen the command is chosenthe requestnone

A run happens in the background. While it is running, the status line says so and the window stays usable.

Every call also gets the window’s language in the OTTO_LOCALE environment variable. Files doesn’t set the working directory, so always use the absolute paths from the request.

describe

Answer with { "commands": [ … ] }, where each command is:

FieldRequiredMeaning
idyesIdentifies the command within this script. Sent back as command in every request
titleyesThe name shown in the palette and the menu
keywordsnoExtra words the palette matches. They are never shown
groupnogo, file (the default), edit or view
whennoWhen the command is offered; see below
argnoMakes the command ask for text first; see below
undonoThe name of the undo step. Defaults to title
progressnotrue to report progress while the command runs; see below

when. Files checks these conditions itself, without running the script. That’s why opening the palette stays instant.

FieldValues
targetssome (default): one or more items. one: exactly one. none: only when there is nothing to act on. any: always
extensionsA list such as ["jpg", "png"]. Every target must have one of them. Case doesn’t matter; leave out the dot
kindsany (default), files or folders

To keep the palette fast, Files checks kinds without reading the disk. It only knows whether the item under the cursor is a folder. That means folders matches only a single folder, and files can’t rule out a folder somewhere in a larger selection. If this matters, check again in run.

arg

FieldMeaning
promptShown before the text field, as in Add to each name:. Required
labelThe short name shown after the title in the list, as in Duplicate › suffix. Defaults to name
placeholderDimmed text shown while the field is empty
initialText the field starts with. If it has an extension, only the part before the extension is selected, so typing a new name keeps the extension
choicesA set to pick from instead of a field to type in. Either a list of strings, or objects with value, title and an optional subtitle. The palette completes them
previewtrue to receive preview calls while the user types

A command without an arg runs as soon as it is chosen.

The request

preview and run both receive:

{ "command": "duplicate",
  "arg": " copy",
  "locale": "en-GB",
  "targets": ["/home/me/Pictures/a.png", "/home/me/Pictures/b.png"],
  "situation": { "path": "/home/me/Pictures", … } }

situation describes the window:

FieldMeaning
pathThe folder being shown
selectionThe selected paths
siblingsEvery name in that folder’s listing. Useful for checking whether a name is taken without reading the disk
cursor_name, cursor_is_dirThe item under the cursor, and whether it is a folder
show_hiddenWhether hidden files are shown
viewlist, grid or columns
sortname, size, kind or modified
placesThe sidebar, as { "label", "path" } pairs
can_paste, can_undo, can_go_back, can_go_forward, can_go_up, has_entries, trash, recentWhat the window can do right now

The preview reply

{ "rows": [ { "from": "a.png", "to": "a copy.png", "conflict": false } ],
  "note": "1 name already taken" }

If the preview exits with a non-zero status, the last line of standard error is shown as the note. If it takes too long or prints something that isn’t valid JSON, no preview is shown.

The run reply

{ "status": "Duplicated 2 item(s)",
  "changes": [ { "kind": "created", "path": "/home/me/Pictures/a copy.png" },
               { "kind": "moved", "from": "/home/me/a.png", "to": "/home/me/Old/a.png" } ],
  "reload": true }

Every field is optional, and so is the reply itself. A script that prints nothing and exits with status 0 has succeeded.

changes is the undo record. A command gets an undo step only if it reports at least one change:

KindWhat Undo does
createdMoves path to the Trash. An empty folder is simply removed
movedMoves to back to from. It refuses if something is now at from

A non-zero exit means the run failed, and the last line of standard error is shown as the error. Anything the script did before failing is not recorded for undo.

Progress while a command runs

A command that declares "progress": true is run in line mode. While it works, it may print one JSON object per line saying where it has got to:

{ "done": 3, "total": 12, "item": "holiday.png" }

The last line that isn’t one of those is the reply. Files turns the progress lines into the window’s status line, the island and the progress ring on the app’s icon in the dock, so a script gets all three without knowing any of them exist.

total may grow as the script finds more to do, and 0 means it doesn’t know yet. Flush after each line: in Python, output that isn’t a terminal is buffered, so without a flush every line arrives at the end.

Without the declaration, a run’s output is read as one JSON document, so a script that prints its result over several lines is unaffected.

Translations

Every text a person reads can be given once, or once per language:

"title": { "en": "Duplicate", "it": "Duplica", "de": "Duplizieren" }

This works for title, undo, and the arg fields prompt, label, placeholder and initial. Files picks the window’s language: first the exact tag (pt-BR), then the language on its own (pt), then English. keywords can be split by language the same way, and the palette matches keywords in every language.

For text the script writes at run time, such as status, notes and errors, use locale from the request or OTTO_LOCALE.

When a script doesn’t show up

Run otto-files from a terminal. When a script can’t be run, exits with an error while describing itself, or prints invalid JSON, Files logs a warning that names the script and the reason.

Also check:

More examples

The Otto source tree includes three complete scripts in components/otto-files/scripts/:

All three are translated into Italian and show how to handle conflicts. To install them:

mkdir -p ~/.config/otto/files-scripts
cp components/otto-files/scripts/{zip,unzip,pdf} ~/.config/otto/files-scripts/
chmod +x ~/.config/otto/files-scripts/{zip,unzip,pdf}

Not there yet