Sheet R5 · Reference

Commands

How to add a command to the hammer command line.

A command adds hammer <name> to the command line. Commands are available on desktop only. Hammer reads standard input one line at a time and calls your plugin's command export once for each line. Your plugin never reads standard input or writes standard output itself. hammer-plugins' mcp plugin serves MCP this way.

Every request is a JSON object. It includes locale, the BCP 47 tag of Hammer's UI language (such as fr-CA), or null; write your messages in this language. Don't confuse it with the project's language, which is the language the story is written in. Every request also includes settings, with the current value of each setting your plugin declares. Every key is present. See the manifest.

Like every export, command takes no arguments and returns an i32: 0 for success, anything else for failure. A call also fails if it traps. Either way, the writer sees the message your plugin set with error_set. See the host functions.

Manifest

[[commands]]
name = "wordfreq"
help = "Counts words in stdin."
Key Meaning
name The command, run as hammer <name>. Lowercase and directory-safe, like a plugin id, and unique within your plugin. It's sent as the request's command
help A one-line description, shown by hammer help

A command can't be named help, or the first word of an operation's name (scene, project, and so on). If another plugin already has a command with the same name, Hammer rejects your plugin, both when you install it and at startup.

Request

{"command": "wordfreq", "args": ["--top", "10"], "settings": {}, "line": "Rain fell hard.", "locale": "en"}
Key Contains
command The name of the command being run
args The arguments after hammer <name>, as given
settings The value of every declared setting, read again from your plugin's settings file for each line
line One line of standard input, without its line break
locale The UI's BCP 47 tag, or null

Reply

If your plugin's output isn't empty, Hammer writes it to standard output as one line, adding a line break unless the output already ends with one. An empty output writes nothing.

Rules

  • The loop. Hammer calls your plugin once for each line and stops at the end of input, exiting with 0. If a call fails, Hammer writes its message to standard error and moves on to the next line.
  • State between lines lives in your plugin. Each line is a separate call. If your plugin needs to remember something across lines, such as an MCP session, it has to keep it in its own memory. Hammer may lose that memory if it reloads your plugin.
  • Settings apply immediately. Hammer reads them for every line, so a change made in Hammer's Settings affects a command that's already running.
  • Dispatches. Operations your plugin dispatches go through the CLI's dispatcher. If the app is running, the dispatch is forwarded to it; if not, it runs headless against the files. When it runs headless, another Hammer process may hold the writer lock, and the dispatch then fails with the kind Failed. Reply with the error rather than trapping, so the command can carry on. See the host.
  • Permissions apply. A dispatch runs within your plugin's grants, as it does from any other export. Operations withheld from plugins (account.login, account.logout, sync.run, plugin.install, and plugin.uninstall) are refused, even though the writer can run them from hammer directly.
  • Only when asked. A command runs only if the writer installed and enabled its plugin, and only when the command is invoked. Commands run outside any project, so turning your plugin off in a project doesn't affect them.
  • If your plugin can't be loaded, the command exits with 1 and reports an error, without reading any input.
  • Your plugin's Settings dialog in Hammer shows how to run each of its commands.

In each kit

Here's the same command in each kit: it answers each line of input with a greeting.

import hammer.CommandRequest
import hammer.command
import kotlin.wasm.WasmExport

fun helloCommand(request: CommandRequest): String = "Hello, ${request.line}!"

@WasmExport
fun command(): Int = command(::helloCommand)

Return null to write nothing. With this plugin installed, echo Alice | hammer hello prints Hello, Alice!.

use serde::Deserialize;

#[derive(Deserialize)]
struct CommandRequest {
    line: String,
}

#[unsafe(no_mangle)]
pub extern "C" fn command() -> i32 {
    hammer::run(|| {
        let request: CommandRequest = hammer::input_json()?;
        Ok(format!("Hello, {}!", request.line))
    })
}
#define HAMMER_IMPLEMENTATION
#include "hammer.h"
#include "hammer_json.h"
#include "hammer_out.h"

int32_t EXTISM_EXPORTED_FUNCTION(command) {
  hammer_alloc_reset();
  uint64_t size = extism_input_length();
  char *input = hammer_must_alloc(size);
  extism_load_input(0, input, size);

  HammerJson json;
  const char *line;
  uint64_t line_length;
  if (!hammer_json_find(input, size, "line", &json) || !hammer_json_string(&json, &line, &line_length)) {
    hammer_fail("No line to answer");
  }
  HammerBuffer out = {0};
  hammer_put_text(&out, "Hello, ");
  hammer_reserve(&out, line_length);
  out.length += hammer_json_unescape(line, line_length, out.data + out.length);
  hammer_put_char(&out, '!');
  extism_output_buf(out.data, out.length);
  return 0;
}

hammer_fail sets the error and traps, so it never returns.