SLOPSHOPPER

self-command

For mod development: lets the model run a slash command in its own session, such as /reload-plugins after a plugin update, and read the output as the next…

newguardtooltimer
A shopper browsing a rack in a slop shop
README

self-command

For mod development: lets the model run a slash command in its own session, such as /reload-plugins after a plugin update, and read the output as the next prompt.

A development tool, not an everyday mod. It was written to build and test the mods of this repository: the model updates a mod, runs /reload-plugins itself and checks the result in the same turn chain. It gives the model every slash command of the session (except the seven below), with your permissions, so a prompt injection that reaches the model can run a command that deletes or sends data. Install it on your own development machine while you work on mods, and disable it (claude plugin disable self-command@kilimcininkoroglu-mods) when you do not.

What it does

  • The model calls mcp__self-command__run with command (the name without its slash) and args. A leading slash is dropped, and a name written with its arguments (sage-memory triage) is split at the first space when args is left out. The tool is listed at the start, not behind ToolSearch.
  • Typical calls: reload-plugins after claude plugin update, sage-memory triage, or a mod's own command whose output the model must read to go on.
  • The model reads the command's output as the next prompt:

The command /reload-plugins, which you ran with the self-command tool, ran. Its output: Reloaded: 58 plugins · 8 skills · 6 agents · 0 hooks · 1 plugin MCP server · 6 plugin LSP servers

A command that fails reports did not run: with the engine's error instead.

  • Refused at once: a name the session does not list (an alias too), and /clear, /exit, /quit, /logout, /login, /resume and /rewind, which clear, end or swap the session.

How it works

  1. The engine runs a plugin's command only once the session is idle, and refuses one from a hook the turn waits on. So the tool queues the command and answers at once: queued: /reload-plugins runs once this turn ends ... do not report its outcome before that output arrives. The tool text tells the model to call it as the last step of a turn.
  2. After the turn ends, a $.clock.after(0) timer runs the command with $.command.run.
  3. The mod hands the output back through its own markdown command /self-command:send, whose body is its arguments alone, so the model reads the text as a prompt and not inside a plugin-message frame. A refused send goes out as a plugin prompt with one log line. The report never starts with a slash, because the engine refuses a plugin prompt that does.

Measured on Claude Code 2.1.283 (Sonnet 5): /reload-plugins ran after the turn, this mod kept its state through the reload because its own files had not changed, and the output reached the model as the next prompt. A reload that changes this mod's own files drops the pending timer, and no report follows.

Install

claude plugin marketplace add KilimcininKorOglu/claude-code-mods
claude plugin install self-command@kilimcininkoroglu-mods

Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.

After installing

  1. Restart Claude Code, or run /reload-plugins in each open session.
  2. Disable it when you are not developing mods: claude plugin disable self-command@kilimcininkoroglu-mods.

What it can reach

Validated with claude plugin validate on Claude Code 2.1.283:

❯ ./register.ts hooks: session.start, tool.describe{tool=/"^mcp__self-command__run$"/}, tool.call{tool=/"^mcp__self-command__run$"/} ❯ ./register.ts calls: $.clock.after (via runLater), $.command.list, $.command.run (via runLater, send), $.prompt.submit (via send), $.tool.register, $.ui.log (via send)

Reach L2, drives Claude.

Threat model

Threat model for self-command (reach L2)
1. Reads:         the session's command list.
2. Runs:          any slash command the model names, except the seven that clear, end or swap the session, with your permissions.
3. Sends:         nothing over the network itself; a command it runs may.
4. Persists:      nothing.
5. Hostile input: a prompt injection that reaches the model can run any allowed command, such as a plugin's own command that deletes or sends data. Keep it on a development machine, and off when you do not develop mods.

Development

make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test

Source 2 files
hooks/register.ts 49 lines
1import type { EngineInterface, Register } from 'claude-code'
2import { INPUT_SCHEMA, queuedText, reportText, requestOf, TOOL_DESCRIPTION, TOOL_NAME, type Request } from './commands.ts'
3
4const messageOf = (err: unknown): string => (err instanceof Error ? err.message : String(err))
5
6/** Hands the model a report as a prompt of its own, through the send command, else as a plugin prompt. */
7async function send($: EngineInterface, report: string): Promise<void> {
8  try {
9    await $.command.run({ command: 'self-command:send', args: report })
10  } catch (err) {
11    $.ui.log(`the report could not be sent (${messageOf(err)}), so it went as a plugin prompt`)
12    await $.prompt.submit({ text: report })
13  }
14}
15
16/**
17 * Runs the command from a timer, because the engine runs a plugin's command once the session is idle and
18 * refuses one from a hook the turn waits on; then reports its outcome to the model.
19 */
20function runLater($: EngineInterface, request: Request): void {
21  $.clock.after(0, async () => {
22    const outcome = await $.command.run(request).then(
23      r => ({ text: r.text }),
24      (err: unknown) => ({ error: messageOf(err) }),
25    )
26    await send($, reportText(request, outcome))
27  })
28}
29
30export const register: Register = on => {
31  on('session.start', async ($, e, next) => {
32    const r = await next(e)
33    // Declared once at the start, so the tool list the prompt cache holds does not change mid-session.
34    await $.tool.register({ name: TOOL_NAME, description: TOOL_DESCRIPTION, inputSchema: INPUT_SCHEMA })
35    return r
36  })
37
38  // A plugin's tool waits behind ToolSearch by default; this one is listed, so the model can call it at once.
39  on('tool.describe', { tool: /^mcp__self-command__run$/ }, async (_, e, next) => ({ ...(await next(e)), isDeferred: false }))
40
41  on('tool.call', { tool: /^mcp__self-command__run$/ }, async ($, e) => {
42    const known = (await $.command.list()).map(c => c.name)
43    const request = requestOf(e as Record<string, unknown>, known)
44    if (typeof request === 'string') return { deny: request }
45    runLater($, request)
46    return { result: queuedText(request) }
47  })
48}
49
hooks/commands.ts 51 lines
1/** The self-command tool: its name, what the model reads, the input it takes and the texts it answers. Pure code. */
2
3export const TOOL_NAME = 'run'
4
5/** The commands that clear, end or swap the session; the model may not run them. */
6export const BLOCKED: ReadonlySet<string> = new Set(['clear', 'exit', 'quit', 'logout', 'login', 'resume', 'rewind'])
7
8export const TOOL_DESCRIPTION = `Runs a slash command in this session as if the person typed it, such as /reload-plugins or /sage-memory triage.
9A command cannot run while your turn goes on: it is queued, runs once your turn ends, and its output comes back to you as the next prompt. So call this as the last step of a turn, then end the turn.
10The commands that clear, end or swap the session (${[...BLOCKED].map(c => `/${c}`).join(', ')}) are refused.`
11
12export const INPUT_SCHEMA = {
13  type: 'object',
14  properties: {
15    command: { type: 'string', description: 'The command name without its slash, such as reload-plugins or sage-memory' },
16    args: { type: 'string', description: 'Everything after the name, as the person would type it; leave it out for none' },
17  },
18  required: ['command'],
19  additionalProperties: false,
20}
21
22export type Request = { command: string; args: string }
23
24/**
25 * The command a tool call asks for, or why it is refused. A leading slash is dropped, and a name written
26 * with its arguments (`sage-memory triage`) is split at the first space when `args` is left out.
27 */
28export function requestOf(input: Record<string, unknown>, known: readonly string[]): Request | string {
29  const [name = '', ...rest] = (typeof input.command === 'string' ? input.command : '').trim().replace(/^\//, '').split(/\s+/)
30  if (name === '') return 'command is required: the command name without its slash'
31  if (BLOCKED.has(name)) return `/${name} clears, ends or swaps the session, so it is not run for the model`
32  if (!known.includes(name)) return `/${name} is not a command of this session`
33  const args = typeof input.args === 'string' ? input.args.trim() : rest.join(' ')
34  return { command: name, args }
35}
36
37const shown = (r: Request): string => (r.args === '' ? `/${r.command}` : `/${r.command} ${r.args}`)
38
39export const queuedText = (r: Request): string =>
40  `queued: ${shown(r)} runs once this turn ends, and its output comes back as the next prompt. It has not run yet, so do not report its outcome before that output arrives.`
41
42/**
43 * What the model reads once the command ran: its output, or why it did not run. It never starts with the
44 * command's slash, because the engine refuses a plugin prompt that does.
45 */
46export function reportText(r: Request, outcome: { text?: string } | { error: string }): string {
47  if ('error' in outcome) return `The command ${shown(r)}, which you ran with the self-command tool, did not run: ${outcome.error}`
48  const output = outcome.text?.trim() ?? ''
49  return `The command ${shown(r)}, which you ran with the self-command tool, ran. Its output:\n${output === '' ? '(no text)' : output}`
50}
51