Automating Work with Transient: An Intermediate Example

2025-10-05

Note

If you already know what transient does, and just want to see my example of automating real work with it, skip this section.

Even folks who have never used Emacs before have often heard of magit, one of the finest examples of a text-based interface for a complex tool ever created. Even if you disagree, you don't have to search for long to find copious examples of comments online along the lines of "magit is the primary reason I even use Emacs".

Folks who have used magit in anger have likely encountered transient in some capacity, which is the library on which magit builds its menus, factored out to allow other projects to use the same functionality. It's a fantastically useful library, and something that deserves more attention. In the same way that magit wraps git, you can use transient to wrap your frequently-used CLI tools with surprisingly little work, and make them more configurable and more accessible within Emacs.

It's true that transient is already relatively widely used, but so far primarily by Emacs insiders and established package developers. But there are lots of Emacs users—and I consider myself among them—who are Emacs end-users rather than Emacs Lisp programmers and contributors. My claim is that even those users can build something genuinely useful without necessarily needing to immerse themselves in Emacs internals.

My journey with transient started with the fabulous interactive tutorial, good enough to be recommended even in the official documentation. I have no ambition to supersede that tutorial, but my experience was that it seems to move from simple to complex examples in a single step without any meaningful transition. I just want to show a single intermediate use case, as a kind of missing example. If you don't know what transient prefixes, infixes, and suffixes are, start with that tutorial then come back here when hit a wall.

The ideal targets for transient-based wrappers are CLI tools that you run frequently, have useful configuration options in the form of command-line arguments and switches, and aren't quite popular enough that they already have a fabulous Emacs package dedicated to them.

My intermediate example focuses on tooling used to build and maintain .deb packages for Ubuntu and Debian, but in retrospect it's easy to think of examples that would be equally good candidates:

  • You use tools like ffmpeg to get your own video content ready to publish, and don't want to remember all the arcane flags to do things like extracting audio from a video file.
  • You're running a system with systemd, and frequently want to run a number of systemctl commands with custom patterns to interact with system services.
  • Maybe you're a Rust programmer and want to run different kinds of builds with things like unstable flags (-Z) or without modifying a lockfile (--locked).

First Steps with dpkg-buildpackage

Part of the dpkg (Debian package) family, dpkg-buildpackage is a tool for building deb packages from source. It's man page is more than 500 lines, almost entirely focused on configuration options for specific packaging use-cases. I frequently invoke something like pkg-buildpackage -S -I -i -nc -d -sa. That's not a contrived example, it's extremely common to have 5+ opaque flags and arguments like this.

Let's start building a transient "prefix", which more-or-less corresponds to the menu popup itself, that lets us set the build type. There are basically three kinds of builds:

  • source builds (the -S flag)
  • architecture-specific binary builds (the -B flag)
  • architecture-independent binary builds (the -A flag)

There are also flags for basically every combination of these, but with transient we don't need to care about those because we can build combinations in the UI. This prefix intentionally makes these non-exclusive.

(transient-define-prefix dpkg-buildpackage-transient ()
  "Options to pass to `dpkg-buildpackage'."
  [["Build type (1+)"
    ("-S" "source build" "-S")
    ("-B" "any binary (arch specific)" "-B")
    ("-A" "all binary (arch indep)" "-A")]])

If you evaluate that and run it directly with M-x dpkg-buildpackage-transient, you should see something like this:

1st transient

And, unsurprisingly, you can type -S or -A to change which options are enabled.

A Custom "infix", a.k.a. the Interesting Part

Next I wanted to add the -i and -I flag, which allow configuration related to ignoring certain files, but they are a bit unorthodox. Typical CLI flags come in two core forms, namely those that are standalone flags or "switches" (something like --verbose), and those which indicate some data is to follow (something like --author=<name>). These -i and -I flags are a hybrid of the two, and support three different states. They can be not passed at all, they can be passed as switches with no data, or they can be immediately followed by an ignore pattern. So you might see -I -i<some_pattern> in a call.

Not being able to find a built-in class to represent this in transient, I determined that I needed to build a custom infix. The tutorial linked above actually has a thorough example for custom infixes, but it's just code with no explanation, and needlessly complicated because it deals with hydration and serialization while introducing the core concepts. That complexity is the main reason this post exists.

This section, out of necessity. Don't be intimidated. You don't need to be an Emacs Lisp expert to understand this example, I will explain all the code as thoroughly as I can, and if you're a programmer in any other language I hope to enable you to follow along.

Most of the complexity comes from the need to use the Emacs EIEIO system, which is a kind of object-oriented framework based on one developed for Common Lisp. Documentation for that is also scattered, but if you've used an OO language before (which I'm assuming about you, dear reader) then the basics are simple.

The first thing we need to do is define a new class. It will inherit most of what it needs from transient-infix, which is a class defined by transient.

(defclass transient-tristate (transient-infix)
  ()
  "A 3-state transient infix: off, on but empty, or on with value.")

We now need to override some of the parent class' methods to define our functionality. At a high level, we need functionality to:

  • transition between states
  • format the stored value for parsing and usage in code
  • format the stored value for human understanding
  • (optionally) set a specific starting state

Transition Between States

If you look at the transient documentation, you'll see that infix commands, generally, are defined as follows. Don't worry about knowing these functions. The things I want you to notice are the references to transient-infix-set and transient-infix-read, which are EIEIO methods (actually just plain Emacs Lisp functions, but following a specific pattern to allow EIEIO usage).

(defun transient--default-infix-command ()
  (interactive)
  (let ((obj (transient-suffix-object)))
    (transient-infix-set obj (transient-infix-read obj)))
  (transient--show))

Ignoring what's around those two methods, you can see a let expression that roughly says "get the command object itself, call transient-infix-read on it, and whatever value that provides, call transient-infix-set to set it as the new value.

Those two functions are "generic", which means they are intended to be overridden with the EIEIO system. We don't actually need to deal with the setter function, because the default is good enough for us. The read function is the interesting one, it's where we need to get a new value for the object based on the old one, corresponding to our 3-state option.

Here's my code.

(cl-defmethod transient-infix-read ((obj transient-tristate))
  "Cycle through states or prompt for value."
  (let ((current (oref obj value)))
    (cond current
      ('nil t)
      ('t (read-string (concat (oref obj description) ": ")))
      (_ nil))))

We're defining a new overridden method for transient-tristate, our class. The code works like this. First, we use a let expression to grab the existing value. The oref function grabs a field from an object in EIEIO, and so here were asking for the value field (they call it a slot, in case you see that word somewhere). It comes from the parent class, we never needed to define it.

Then we use pcase, which does pattern matching on the value we just extracted. Since there are 3 states, there are three patterns. The first is just 'nil, meaning value is empty. This corresponds to the "not passed as a flag at all" case. If we match that, we return the value t, which in Emacs Lisp is the value that more or less corresponds to "true" or "not nil". That's how we define our "turned on, but with no explicit pattern".

Our second case is designed to match exactly scenario. We match against the symbol 't, and if we find it, we want to enter the state where we are "on, and with a pattern". To achieve that I've used the read-string function, which is a simple way to prompt the user for a string in a minibuffer in Emacs. The argument being passed is a prompt, which may appear complex. You could just write "Pattern: ", but since I wanted this to be reusable, I didn't want to hardcode that. Instead, I'm once again using the EIEIO system and getting the "description" field of our object. This is something that all infix objects have, and it corresponds to the text description you see in the actual menu. So this code just means, prompt the user for a string by printing out the description of the object followed by ": ". The string the user provides is returned by the function.

Finally, in our third case, we theoretically want to match on an arbitrary string value. We could be fancy, but since it's our only remaining case, we can use the wildcard pattern _. It will match anything that didn't match case 1 or 2. In that scenario we just want to cycle back to empty, so we return nil.

And that alone, is the bulk of functionality for this 3-state option, but I continued from here because I wanted this code to be reusable and generic. It will currently just return t or a string with no indication of which flag it's associated with. That makes it hard to ultimately turn this into the format we want, with -I or -i.

Format the Stored Value for Parsing and Usage in Code

In order to control how a value is represented when a transient suffix asks for it, we can override the transient-infix-value method. My code follows, which is basically identical in structure to the last one.

(cl-defmethod transient-infix-value ((obj transient-tristate))
  "Format the argument value for the args list."
  (let ((value (oref obj value))
        (argument (oref obj argument)))
    (pcase value
     ('nil nil)
     ('t argument)
     (_ (format "%s%s" argument value)))))

The let binds two EIEIO fields from our object. The first is the value, exactly as we did before. The second is the "argument" field, which corresponds to one of the 3 values you see in most uses of transient.

For instance, we previously wrote this:

(transient-define-prefix dpkg-buildpackage-transient ()
  "Options to pass to `dpkg-buildpackage'."
  [["Build type (1+)"
    ("-S" "source build" "-S")
    ("-B" "any binary (arch specific)" "-B")
    ("-A" "all binary (arch indep)" "-A")]])

For our 3 infix definitions, the "argument" corresponds to the third value in the lists, namely "-S", "-B", and "-A". We haven't defined our -I and -i versions yet, but we can decide now that we'll set the argument to be exactly -I and -i in order to make our lives easier, since we're dealing with the arguments in a custom way.

Now we enter the cond expression, which again matches our 3 states. For the 'nil case, we don't want to pass anything, so we just return 'nil. For the 't case we return the argument. That means we return the value '"-I"' or '"-i"' depending on what's stored in our object. And finally, if we have data, we use the format method to concatenate two strings together, the argument and the data being stored. So that means if value contains "pattern" and argument contains "-I", we return "-Ipattern", which is what dpkg-buildpackage expexts.

Format the Stored Value for Human Understanding

We can now, optionally, make our value look nice for users (or for ourselves). The formatted value in the transient need not be the same as what's passed to a suffix in code. To do that, we can override transient-format-value. Here's my code again, which might look familiar at this point:

(cl-defmethod transient-format-value ((obj transient-tristate))
  "Format the current state for display."
  (let ((value (oref obj value)))
    (propertize
     (pcase value
       ('nil "[ ]")
       ('t "[✓]")
       (_ (format "[✓:%s]" value)))
     'face (if value 'transient-value 'transient-inactive-value))))

We again start with a let expression to grab the value out of the object. Skipping a line, we see another familiar 3-condition pcase. In this case we're returning string representations. I'm not a designer, but I've tried to be thoughtful, and so used a unicode checkmark representation to mean "on", with the pattern data appended when it exists. So we get either an empty checkbox, a filled checkbox, or a filled checkbox with extra data.

I am also using propertize to set how the text is displayed. If the value is nil I applied the existing face transient-inactive-value and if it's in either "on" state, I set transient-value. This is not necessary, but gives some nice, extra visual indication of the current state.

Back to Our Transient

We are now ready to use our totally custom infix object to add -I and -i to our transient! All we really need to do is add these lines:

("-I" "tar ignore pattern" "-I" :class transient-tristate)
("-i" "diff ignore pattern" "-i" :class transient-tristate)

These look just like the infixes you have already seen, just with the :class keyword followed by our custom class name. Don't forget that we're depending on those third values to be "-I" and "-i" for our formatting.

If you update your transient, you should now see something like this, depending on the headers and layout you choose (out of scope here). Notice how I've toggled mine to be "on" and "on with the pattern *.* (not a useful pattern!)".

2nd transient

Set a Specific Starting State

One very last step you can take, should you so choose, is to set a default value for your custom infixes. For instance, most of the time I run dpkg-buildpackage, I want to pass -I and -i with no data. For built-in arguments and switches this is easy. You can just add a :value definition at the top of your transient:

(transient-define-prefix dpkg-buildpackage-transient ()
  "Options to pass to `dpkg-buildpackage'."
  :value '("-S")
  [["Build type (1+)"
    ("-S" "source build" "-S")
    ("-B" "any binary (arch specific)" "-B")
    ("-A" "all binary (arch indep)" "-A")]
    ...

By setting the value to be '("-S")', transient knows that the -S flag should default to on.

However, this gets harder with our custom infix definition. Remember, our internal value is just the value 'nil, 't, or the extra data. Transient won't know what to do if we have some initial state with 't in it because it doesn't match the "argument" value of any of our definitions.

We could refactor everything to store the argument and data both, but this would complicate our pcase patterns, impose extra requirements on the user to define arguments in a particular, systematic way, and require me to rewrite a big chunk of this post. Instead, we're just going to override some more functionality to help transient compute an initial value.

The method we want is called transient-init-value. Here's my current definition, which probably reveals more than I'd prefer about my poor Emacs Lisp skills.

(cl-defmethod transient-init-value ((obj kjs--transient-tristate))
  "Initialize the value from the prefix's :value."
  (let* ((argument (oref obj argument))
         (value (oref transient--prefix value))
         (found (seq-find
                 (lambda (v)
                   (and (stringp v)
                        (string-prefix-p argument v)))
                 value)))
    (oset obj value
          (cond
           ((member argument value) t)
           (found (substring found (length argument)))
           (t nil)))))

We grab the value of argument again, and we also grab the value of the prefix object. This is not the same thing that we've been doing, despite the overloaded names. The value of the prefix object is that thing we set with the :value keyword pair, namely something like ("-S" "-I" "-i"). So we have some work to do to turn that into a value for our actual object.

I've used seq-find, which is a built-in way to scan through a list of things until an element matches the supplied predicate function. Our predicate function is an anonymous function which checks that it's argument is a string and, if so, checks whether the value of argument is a prefix of that string. In a concrete run of this code, that might mean it's looking to see if "-I" is a prefix of "-Ipattern", which would return true and so cause seq-find to finish execution and give us that element.

Based on what that find operation returns, we then set the initial value of our oibject to one of 3 things, using a cond, which is just like a pcase but uses predicate functions that return true to indicate a case match instead of pattern matching the structure.

The first case says that argument is itself a member of the initial state value. That would be the case if it finds exactly "-I", for instance. The second case matches when found is not nil, meaning we found the argument as a prefix somewhere in our value, but with other data attached to it. In that case, we use substring to grab just the extra data by dropping the characters from the argument. Finally, if we don't find out argument at all, we just set nil.

And with that, our transient is basically complete!

Finishing Up

All of our argument handling is in place, but this isn't actually calling out to dpkg-buildpackage yet, of course. What we need to do is add a trivial suffix to our transient. The details are out of scope, but something like:

("b" "Run build" prep-dpkg-buildpackage)

Where prep-dpkg-buildpackage puts all the arguments together into a command and calls it with the compile function. In my case, I wanted some extra buffer naming customizations to let me run commands in parallel, and also to prompt for a working directory. Those things are out of scope, but you are welcome to poke through my config to see the code:

https://github.com/karljs/dotfiles/blob/main/emacs.d/config/kjs-deb.el

Summary

Thanks for reading this. It's intended for a very specific audience, namely folks who understand a tiny bit of transient, but want to learn a little more without taking multiple steps in one go. If you are in that audience, or aspire to be, I hope this helps you use transient to build your own little Emacs-based automations to make your life and job easier!

As always, feel free to reach out if you see something I've done incorrectly or non-idiomatically, or just have questions.

https://karlsmeltzer.com/posts/feed.xml