# Writing your own command (/docs/pounce/writing-commands)



Pounce has no plugin SDK. A command is **one shell script** with an optional
metadata header. Drop it in a folder and it is in the palette on the next
open, with no build step, no restart, no manifest:

```bash
#!/bin/bash
# pounce: name = Say Hello
# pounce: description = A friendly notification
# pounce: icon = hand.wave
osascript -e 'display notification "🐾" with title "Pounce"'
```

Put it in `~/.config/pounce/commands/`, summon the palette, type `hello`.

That `osascript` line is the whole API speaking: a command is any executable, and
Pounce neither knows nor cares how it draws. The commands shipped in Pounce
itself go through a `notify()` helper, so they render through
[trill](https://github.com/hausfold/trill) where it exists and fall back to
exactly the line above where it doesn't.

The registry re-scans every time you summon. Scripts don't need the executable
bit (Pounce runs them with `bash` either way), but they **must** end in `.sh`,
or Pounce never lists them.

| Header key    | Meaning                                                      | Default                      |
| ------------- | ------------------------------------------------------------ | ---------------------------- |
| `name`        | Title shown in the palette                                   | the filename (without `.sh`) |
| `description` | Subtitle                                                     | *(empty)*                    |
| `icon`        | An [SF Symbol](https://developer.apple.com/sf-symbols/) name | `sparkles`                   |
| `submenu`     | `true` means the command re-invokes Pounce for a second step | `false`                      |
| `mutates`     | `true` means it changes something that outlives the palette  | `false`                      |
| `confirm`     | `true` means the palette asks before running it              | `false`                      |
| `network`     | `true` means it talks to the internet                        | `false`                      |
| `whenFile`    | A file that can hide the row (see below)                     | *(always listed)*            |

Header lines are read from the first 30 lines of the file; anything else in them
is skipped. Unknown keys are skipped too, which is what lets something else
write into the same header: haus adds `cheat` and `cheatWhen` for its
[cheatsheet](/docs/haus/rooms/launcher), and pounce reads straight past them.

<Callout type="warn" title="Spacing is forgiving, but the `#` needs a space after it">
  A line that fails to parse says nothing at all: the row quietly carries its
  filename instead of its name. So the parser is generous. The line may be
  indented, the `#` may be followed by any run of spaces or tabs, and `=` may have
  any spacing around it or none. The value is trimmed at both ends, spaces and tabs
  only, so a non-breaking space (what ⌥Space types on macOS) survives into the
  value and you won't see why.

  The one thing required is *some* whitespace after the `#`. Written
  `#pounce: name = Say Hello`, the line above is an ordinary comment and the row
  lists as `say-hello`.
</Callout>

## What a command says it will do [#what-a-command-says-it-will-do]

A command is a file, usually somebody else's, and the row says only its name.
`mutates`, `confirm` and `network` let the script say more before anything runs
it. They are the **author's claim**, and Pounce verifies none of them. What they
buy is [`pounce list [--json]`](/docs/pounce/cli#subcommands), which prints every
command with its declarations *and the path of the script*, so the claim can be
checked against the file. `confirm` buys one thing more: a sheet in front of the
command, where a person is browsing rows. `pounce run cmd:<id>` and a hotkey
deliberately do not ask, because naming a command is already deliberate.

<Callout type="warn" title="`confirm` on a `submenu` asks at the wrong moment">
  The sheet gates the palette's run of *the script*, so on a two-step command it
  appears before the picker does. The step that acts is a second `pounce`
  invocation the daemon cannot tell from any other, and it is not gated. Put
  `confirm` on the command that does the thing.

  The boolean grammar is `submenu`'s: `true` or `1` and nothing else, so
  `confirm = yes` is silently false. `pounce list` is how you see what actually
  parsed.
</Callout>

## A row that hides when there is nothing to act on [#a-row-that-hides-when-there-is-nothing-to-act-on]

[`workspaces` and `bundleIds`](/docs/pounce/config#rows-that-only-exist-where-they-work)
ask **where you are**. The other question a row can want asked is *is there
anything here for me to act on at all* (a pages picker on a Mac with no page on
it, a lane picker with no lanes), and no name can answer that. `whenFile` names a
file that can:

```bash
# pounce: whenFile = ~/.local/state/my-tool/anything-to-do
```

The file is a **veto**, read on every summon: the row is hidden only while the
file's first line is exactly `0`. A missing file, an unreadable one, an empty one
and any other content all list the row: a row that hid whenever nobody was
answering would hide forever, with nothing in any log.

Whatever already knows the answer writes it: a hook you have, writing `0` or `1`
where a summon can stat it. **A file rather than a command on purpose**: the
registry is rebuilt on the keystroke, and a fork there is paid on every summon
for a row that is usually listed anyway.

Like the config scoping it hides the **row**, never the command: `pounce run
cmd:<id>` and any hotkey on it keep working. `pounce doctor` names every command
that declares one, the file it is watching and whether that file is hiding it
right now.

<Callout title="haus does this to one row">
  The [Launcher room](/docs/haus/rooms/launcher)'s **Pages** picker watches
  `~/.local/state/haus/any-page`, which its window-manager hook writes: the row is
  gone on a Mac with no page open at all.
</Callout>

## Submenus and chaining [#submenus-and-chaining]

Set `submenu = true` and pipe your options through `pounce` again; the list
swaps in place with no flicker:

```bash
#!/bin/bash
# pounce: name = Brew Services
# pounce: submenu = true
service=$(list_services | pounce -p "Service:")
[ -n "$service" ] && toggle_service "$service"
```

When the second step is a **search** rather than a list, pass
[`--chain`](/docs/pounce/cli#flags): on an empty match Enter hands the raw text
back, and the window holds its loading skeleton instead of fading between
steps:

```bash
query=$(printf '' | pounce --chain -p "App Store — type a search, then Enter")
[ -n "$query" ] && mas search "$query" | pounce -p "Install:"
```

When the step is answered by **picking a row** instead, pass
[`--chain-rows`](/docs/pounce/cli#flags):

```bash
repo=$(list_repos | pounce --grid --chain-rows enter -p "Which repo?")
[ -n "$repo" ] && printf '' | pounce -p "What should it do in $repo?"
```

Without it a row pick dismisses the window on a short fade and the next step
opens into that fade, which reads as the palette losing a keystroke. Both flags
take an action list, so a picker whose Enter opens a window and whose `⌘↵`
reopens the picker asks for `--chain-rows cmd` alone.

## A step that takes a paragraph [#a-step-that-takes-a-paragraph]

A step whose answer is a sentence wants more than a filter box. Three
[flags](/docs/pounce/cli#flags) turn a `pounce` step into one, and haus's **Spawn
Agent** command is the worked example: `--actions` labels more than one verb on a
rowless prompt, `--draft <key>` files the query on every non-commit dismissal
(`pounce drafts <key> get <i>` reads it back, `--query <text>` reopens the box
pre-filled), and `⇧↵` inserts a newline as the box grows with the text.

```sh
sel=$(printf '' | pounce --chain enter,opt --draft my-prompt \
        --actions "Go|shift:New line|cmd:With a screenshot|opt:Drafts" \
        -p "What should it do?")
case "$(printf '%s' "$sel" | cut -f1)" in
  enter) go   "$(printf '%s' "$sel" | cut -f2-)" ;;
  cmd)   shot "$(printf '%s' "$sel" | cut -f2-)" ;;
  opt)   show_drafts ;;
esac
```

## Pounce as a generic picker [#pounce-as-a-generic-picker]

Beyond commands, `pounce` is a dmenu-style picker: pipe it lines, get the chosen
one back.

```sh
printf 'a\nb\nc\n' | pounce -p "pick one:"
```

A piped list keeps **your order**, so a ranking your script already computed
survives into the picker. Each line can carry extra tab-separated columns:

```
title <TAB> subtitle <TAB> icon <TAB> actions <TAB> group
```

`actions` is `label | key:label | key:label…` (the first is Return, the rest
are modifier combos); `group` is an optional section header, as Force Quit does
with *Applications* / *Background*.

Pass [`--grid`](/docs/pounce/cli#flags) and the same lines are drawn as a
two-column card grid instead, which is the shape a step that offers *things*
wants (projects, machines, images). Group headers are the one thing a grid drops:
a header is a full-width band and a card has no full width to give one.

### What a picker hands back [#what-a-picker-hands-back]

One line on stdout, tab-separated, and exit 1 with no output when the user
dismisses:

```
<action>	<the whole raw line you piped in>
```

`action` is `enter` / `cmd` / `opt` / `ctrl`, whichever key committed, so the
row's own text is field **2** and a `case` on field 1 sees the verb.

[`--dial`](/docs/pounce/cli#flags) is the one flag that changes that shape. A
dial is a small set of mutually exclusive values the step carries alongside
whatever is being typed or chosen, stepped in place with `⇥` / `⇧⇥` (`⌃⇥` moves
between dials), for the side-question too small to deserve a picker of its own:

```sh
sel=$(list_repos | pounce -p "Repo:" --dial "model=sonnet|opus|haiku")
# enter	model=opus	hausfold/pounce
```

The committed values arrive as **one extra middle field**, so field 2 is the
dials and field 3 is the row. `--dial` is repeatable, and several dials share
that one field as `name=value;name=value` in the order you declared them, so
an option value can contain neither `;` nor `=` nor `|` nor a tab.

Only callers that passed `--dial` ever see three fields. Split for the middle
field being absent anyway: a resident daemon older than the flag ignores it and
answers in two. Each dial reopens on the value it committed last time, per option
set, and cycling then pressing Esc changes nothing.

## Reading real ones [#reading-real-ones]

The [built-in
commands](https://github.com/hausfold/pounce/tree/main/pkgs/pounce-commands/commands)
are copy-pasteable examples of submenus, grouping and icons, and the same kind of
file yours is.
