command

package
v0.4.11 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 3, 2026 License: MIT Imports: 12 Imported by: 0

Documentation

Overview

Package command (commander.go) — the Commander dispatch surface. Gateway.WithCommander receives a DispatchFunc (not the Commander interface — see F-51 doc §1.2.7); the runtime in cmd/nightme/run.go wraps the Commander with a thin shim that translates *messages.InboundMessage to SlashInput and *SlashOutput back to *inbound.CommandResult. This package never imports internal/gateway.

2026-08-06: Commander.Dispatch gained a third return value `handled bool` so the caller can distinguish "was a slash command attempt" from "no slash command here". A slash-text input whose command name does not match any registered factory now reports handled=true, output.Consumed=false (the gateway falls through to the agent loop, preserving the existing passthrough behavior).

Package command hosts the F-51 slash command abstraction layer. It provides the Commander / SlashCommandFactory / RuntimeServices interfaces, the canonical inbound/outbound types (SlashInput / SlashOutput / Outbound / ReactionEvent), and the ReactionRouter service interface that command implementations depend on.

This package is the bottom of the command stack — it does NOT import internal/gateway, internal/chatsession, internal/gtw, or internal/channel. The runtime (cmd/nightme/) owns the boundary translation between gateway messages and command inputs/outputs.

The Commander refactor proposed deleting command.Outbound in favour of using messages.OutboundMessage directly. That would require command to import gateway, which creates an import cycle (gateway → command/gtw → command). So we keep the command-side mirror types; the runtime shim translates at the boundary as before.

Package command — preview helpers shared by slash commands that echo a short preview of the user's input back in an IM card reply (/steer, /queue, and any future echo-style command).

Index

Constants

View Source
const ArgTerminator = "--"

ArgTerminator is the conventional end-of-flags marker. Every token after it is treated as positional, even flag-shaped ones. This is what lets `/cwd -- -weird-dir-name` work without the path being misread as a flag.

View Source
const NoActiveCwdReply = "No active workspace. Send /cwd <path> first."

NoActiveCwdReply is the canonical user-facing reply when a slash command needs an active workspace but the chat has none. Exported so handlers that preflight outside the RequireActiveCwd helper (currently internal/command/gtw /close and /fix, which do their own preflight to chain additional work) stay in lockstep with the helper's wording. Keep in sync with RequireActiveCwd's SlashOutput.

View Source
const UnboundedArgs = -1

UnboundedArgs is the CmdSpec.MaxArgs value meaning "any number of positional args" — used by commands whose payload is a free-form multi-token body (/steer, /queue).

Variables

This section is empty.

Functions

func IsFlagToken added in v0.4.5

func IsFlagToken(tok string) bool

IsFlagToken reports whether tok is flag-shaped per contract rule 1: starts with "-" and is not the literal "-". Exported because handlers occasionally need the same classification outside a full parse (e.g. to decide whether a stray token is a typo'd flag or a bad positional).

func PreviewForIM added in v0.3.2

func PreviewForIM(s string) string

PreviewForIM returns a preview of s suitable for echo in an IM card reply. Strings already at or below previewRuneCap runes are returned unchanged (no ellipsis appended — there's no reason to flag a short body as truncated). Longer strings are truncated at a rune boundary (never splitting a multi-byte UTF-8 sequence — CJK / emoji would render as U+FFFD in the IM card if cut mid-byte) with "..." appended.

Shared by /steer and /queue so the preview contract can be tweaked in one place.

func RegisterBuilder added in v0.3.3

func RegisterBuilder(b func(rt Deps) SlashCommandFactory)

RegisterBuilder adds a factory builder. Called from each command package's init(). Re-calling with a new builder appends to the list — the next SetDeps rebuilds every factory. Tests can call Reset() to wipe both the builders list and the default registry.

RegisterBuilder does NOT immediately instantiate the factory. SetDeps is the single point where every builder runs against currentDeps. This avoids the double-build bug (Phase 2.5: late RegisterBuilder after SetDeps was building the factory twice — once at register time, once on the next SetDeps — which was wasted work for every builder and observable for gtw, whose builder creates a *Manager + sets up routes).

func Reset added in v0.3.3

func Reset()

Reset clears the registry + builders list + Deps. Tests-only — production code MUST NOT call this. After Reset, RegisterBuilder + SetDeps work as if starting from a fresh process.

func SetDeps added in v0.3.3

func SetDeps(d Deps)

SetDeps initializes the global Deps and finalizes every registered builder into the default registry. Idempotent: a second call re-builds every factory with the new Deps.

Callers MUST call SetDeps before Default() returns a useful registry. The orchestrator (internal/runtime) is the only production caller; tests can drive SetDeps directly.

Types

type CmdSpec added in v0.4.5

type CmdSpec struct {
	// Name is the user-facing command name used in error
	// messages, with a leading slash: "/use", "/gtw push".
	Name string

	// Usage is the one-line usage string echoed after every
	// parse error, e.g. "/use <agent>". Optional, but every
	// caller in this repo sets it — the "Usage: ..." tail is
	// what the existing handler tests assert on.
	Usage string

	// Flags lists the recognised flag tokens. Nil/empty means
	// the command takes no flags, and any flag-shaped token is
	// rejected as unknown (which is how /stop distinguishes
	// `/stop --typo` from `/stop extra-word`).
	Flags map[string]FlagSpec

	// MinArgs is the number of positional args the command
	// requires. 0 means all positionals are optional.
	MinArgs int

	// MaxArgs is the number of positional args the command
	// accepts. The zero value (0) means "no positional args" —
	// commands that take some MUST set this. Use UnboundedArgs
	// for a free-form multi-token body.
	MaxArgs int
}

CmdSpec declares one command's argv grammar: which flags exist and how many positional args are legal.

func (CmdSpec) UsageTail added in v0.4.11

func (s CmdSpec) UsageTail() string

UsageTail is the exported form of usageTail. Use it from post-parse error sites (e.g. cross-flag validation the shared lexer can't express) so user-facing errors carry the same Usage suffix as the lexer's own errors.

type Commander

type Commander interface {
	// Match performs the cheap routing decision without
	// executing any command. Returns the resolved command
	// name (lower-cased) and true when text is a slash command
	// that names a registered factory; otherwise ("", false).
	//
	// Used by the inbound.Router's tryCommandDispatch to
	// decide synchronously whether the slash branch should
	// claim the inbound (handled=true) or fall through to the
	// next tryDispatch (e.g. tryMessageDispatch for
	// "/etc/passwd"). The dispatch itself happens async in a
	// worker goroutine spawned by tryCommandDispatch; Match
	// exists so the dispatch chain's handled decision is
	// synchronous and the monitor never blocks.
	//
	// Match is pure: no command is run, no state is mutated,
	// no MessageState reaction is emitted. Identical
	// slash-prefix detection rules as parseCommand below; the
	// command name lookup goes through the same Registry
	// path as Dispatch.
	Match(text string) (cmdName string, matched bool)

	// Dispatch runs the slash command implied by input.Text.
	//
	// Returns (output, handled, err) where:
	//
	//   handled=false, output=nil: input.Text does not start
	//     with "/" (or starts with "/" but the command name
	//     is empty). The gateway treats this as a plain
	//     message and falls through to the agent loop.
	//
	//   handled=true, output={Consumed: false}: input.Text
	//     was a slash command attempt but the command name
	//     did not match any registered factory. The gateway
	//     forwards the original text to the agent loop so
	//     paths like "/etc/passwd" still reach the agent
	//     (preserves the v1.2.x passthrough characteristic).
	//
	//   handled=true, output={Consumed: true, Reply: "..."}:
	//     a registered command handled the input. The gateway
	//     sends output.Reply to the channel and does NOT
	//     forward to the agent loop.
	//
	//   err != nil: the registered command's Handle returned
	//     an error. The gateway reports the error as a reply
	//     (handled=true, output={Consumed: true, Reply: "❌ ..."}).
	//
	// The runtime shim is responsible for obtaining cs (the
	// per-chat ChatSession) BEFORE calling Dispatch — typically
	// via mgr.GetOrCreate(chatID, primaryAgent). Dispatch itself
	// does not GetOrCreate (it has no *chatsession.Manager); it
	// only passes the cs through to cmd.Handle.
	//
	// v1.3+ multi-channel: mgr is the per-channel
	// chatsession.Manager that produced the inbound. Dispatch
	// forwards mgr to cmd.Handle so commands can do per-chat
	// lookups (mgr.Get / mgr.SendPermission / mgr.RestoreFromRegistry)
	// against the channel that owns this chatID.
	Dispatch(ctx context.Context, rt RuntimeServices, mgr *chatsession.Manager, cs *chatsession.ChatSession, input SlashInput) (*SlashOutput, bool, error)
}

Commander is the slash command dispatch surface. Constructed at startup with a Registry; Dispatch routes by the first whitespace-separated token of input.Text to the registered factory.

func NewCommander

func NewCommander(reg *Registry) Commander

NewCommander constructs a Commander backed by reg. The returned Commander is safe for concurrent use.

type Config

type Config struct {
	// Primary is the default agent name (cmdline
	// `nightme --primary` or cfg.Primary). Previously each
	// Factory received this directly as `defaultPrimary`; now
	// it lives in rt.Config.Primary and Factories no longer
	// carry the field.
	Primary string
}

Config is the read-only configuration slice RuntimeServices exposes to commands. Currently just Primary; grows as commands need more shared config.

type Deps added in v0.3.3

type Deps struct {
	// Primary is the primary agent name.
	Primary string
	// GTWExt is the gtw.HandlerDeps the gtw command needs
	// (git runner, HTTP prober, PR invalidator). Typed as
	// `any` to avoid command � gtw import cycle (gtw already
	// imports command for SlashCommandFactory). The gtw
	// package's builder closure type-asserts.
	GTWExt any
}

Deps carries the runtime-constructed state each command factory needs at registration time. Distinct from RuntimeServices (which carries per-dispatch deps): Deps is the wiring context the orchestrator provides once at startup; RuntimeServices is what every Handle() invocation receives.

Each command package's init() calls RegisterBuilder with a closure that knows how to build its SlashCommandFactory from Deps. The orchestrator calls SetDeps once at startup to finalize every registered builder.

type FlagSpec added in v0.4.5

type FlagSpec struct {
	// Name is the canonical key ParsedArgs stores the flag
	// under. Aliases share it. Empty means "derive from the
	// map key by trimming leading dashes".
	Name string

	// TakesValue marks a value-taking flag: it consumes the
	// next token as its value (contract rule 3). When false the
	// flag is boolean (contract rule 4).
	TakesValue bool
}

FlagSpec declares one recognised flag. Register every alias as its own key in CmdSpec.Flags pointing at the same Name so `-a` and `--agent` land in the same slot:

Flags: map[string]FlagSpec{
    "-a":      {Name: "agent", TakesValue: true},
    "--agent": {Name: "agent", TakesValue: true},
}

type ParsedArgs added in v0.4.5

type ParsedArgs struct {
	// Args holds the positional args verbatim, in argv order.
	// Tokens are NOT trimmed — callers that care about
	// whitespace-only input keep their own strings.TrimSpace
	// check (several handlers reply "Usage: ..." for `/use "  "`
	// and that behaviour is preserved).
	Args []string
	// contains filtered or unexported fields
}

ParsedArgs is the result of ParseCmdArgs: the positional args in order, plus the flag values keyed by canonical name.

func ParseCmdArgs added in v0.4.5

func ParseCmdArgs(argv []string, spec CmdSpec) (ParsedArgs, error)

ParseCmdArgs is the standard CLI lexer for slash commands. argv is the token list AFTER the command name (input.Args[1:] for top-level commands, input.Args[2:] for /gtw subcommands).

It returns an error — never a partially-applied result — for any unknown flag, missing flag value, or arity violation. The error text is user-facing: handlers reply with it directly (usually prefixed with "❌ ").

func (ParsedArgs) Arg added in v0.4.5

func (p ParsedArgs) Arg(i int) string

Arg returns the i-th positional arg, or "" when there is no such arg. Lets handlers read an optional trailing arg without a bounds check.

func (ParsedArgs) Bool added in v0.4.5

func (p ParsedArgs) Bool(name string) bool

Bool reports whether a boolean flag was supplied at least once.

func (ParsedArgs) Has added in v0.4.11

func (p ParsedArgs) Has(name string) bool

Has reports whether a flag (boolean OR value-taking) was supplied in argv. Useful for post-parse checks that need to distinguish "absent" from "supplied with empty value" — e.g. mutual exclusion between a value-taking flag and a bare positional, where an empty --name value still counts as "--name was used".

func (ParsedArgs) NArgs added in v0.4.5

func (p ParsedArgs) NArgs() int

NArgs returns the number of positional args.

func (ParsedArgs) Value added in v0.4.5

func (p ParsedArgs) Value(name string) string

Value returns the value of a value-taking flag, or "" when it was not supplied. Cannot distinguish "not supplied" from "supplied with empty value" — use Has for that.

type ReactionEvent

type ReactionEvent = services.ReactionEvent

ReactionEvent is the inbound reaction / action payload.

Canonical location: services.ReactionEvent (services/reaction.go). This alias lets callers in the command package use `command.ReactionEvent` without importing the services subpackage twice. The underlying type lives in services because ReactionRouter (which lives in services) takes / returns ReactionEvent in its signatures — placing the type in services avoids a `command <-> services` import cycle (command.RuntimeServices already depends on services for ReactionRouter).

type Registry

type Registry struct {
	// contains filtered or unexported fields
}

Registry holds the command dispatch table. The runtime owns one; the gateway sees only the Commander.

func Default added in v0.3.3

func Default() *Registry

Default returns the package-level default registry. Empty until SetDeps has been called at least once.

func NewRegistry

func NewRegistry() *Registry

NewRegistry returns an empty Registry.

func (*Registry) FindByName

func (r *Registry) FindByName(name string) SlashCommandFactory

FindByName returns the factory for the given command name (case-insensitive, with alias resolution). nil if not found.

func (*Registry) Register

func (r *Registry) Register(cmd SlashCommandFactory)

Register adds cmd to the dispatch table. cmd.Spec().Name is the primary key; cmd.Spec().Aliases are secondary keys (lower-cased). A second Register for the same Name or alias overwrites the previous binding (last-wins).

func (*Registry) Specs

func (r *Registry) Specs() []Spec

Specs returns the registered commands' Specs in registration order. Used by /help to enumerate.

func (*Registry) SpecsVisible

func (r *Registry) SpecsVisible() []Spec

SpecsVisible returns Specs() with Hidden commands filtered out.

type RuntimeServices

type RuntimeServices struct {
	// Config provides cross-command read-only configuration.
	// Currently exposes only Primary (default agent name).
	Config Config

	// Logger is the structured logger used for diagnostic output.
	// May be nil; commands should fall back to slog.Default() in
	// that case.
	Logger *slog.Logger

	// Clock returns the current time. May be nil; commands
	// fall back to time.Now in that case. Test code overrides
	// for deterministic timestamps.
	Clock func() time.Time
}

RuntimeServices aggregates the dependencies a slash command receives at Handle() time. The runtime (cmd/nightme/run.go) builds this once at startup; the Commander passes it to every dispatched Handle() call.

Commands that need per-chat state hold *chatsession.Manager directly in their Factory. The remaining fields are shared interfaces with multiple implementations or cross-cutting concerns.

type SlashCommandFactory

type SlashCommandFactory interface {
	// Spec returns the static Spec for this command. Safe to
	// call concurrently; the runtime reads it once per
	// registration.
	Spec() Spec
	// Handle dispatches one inbound that named this command.
	//
	// v1.3+ multi-channel: mgr is the per-channel chatsession.Manager
	// that produced the inbound — it owns the ChatSession the user
	// is talking from, and its Emitter is bound to the channel
	// that delivered the message. Factories MUST use this mgr
	// (not any stashed mgr) for any per-chat lookup, so the
	// right chat goes to the right channel on outbound.
	//
	// The runtime has already GetOrCreate'd cs before calling Handle;
	// commands can read/write per-chat state via cs.SetXxx / cs.GetXxx
	// and send replies via cs.Emitter().Send (which routes to
	// mgr.emitter = the originating channel's Send).
	//
	// Handle is responsible for parsing args out of input.Text
	// and returning a SlashOutput. nil error means success
	// (even if the result's Reply is empty).
	Handle(ctx context.Context, rt RuntimeServices, mgr *chatsession.Manager, cs *chatsession.ChatSession, input SlashInput) (*SlashOutput, error)
}

SlashCommandFactory builds the per-command implementation. The runtime calls Spec() at registration time; Handle() is called once per inbound that names this command.

type SlashInput

type SlashInput struct {
	// ChatID is the IM-side chat id (D1 model; see SPEC §3.1).
	ChatID string
	// UserID is the sender's IM-side id.
	UserID string
	// Text is the full message text, including any "/cmd args..."
	// prefix. gateway's parser may have already pre-parsed this
	// into Args, but the raw text is preserved for commands that
	// want to re-parse (e.g. /gtw with subcommands).
	Text string
	// MessageID is the channel-native message id; used as
	// ReplyTo for outbound threading.
	MessageID string
	// HasMention indicates whether the bot was @-mentioned.
	// Used by the WatchMode gate (silent-drop non-mentions in
	// group chats when /watch off).
	HasMention bool
	// Reaction is non-nil for reaction / action events.
	// Slash commands ignore this; ReactionRouter consumers use it.
	Reaction *services.ReactionEvent
	// Args is the pre-parsed argv (gateway's parser fills this).
	// Element 0 is the command name; elements 1+ are the args.
	// Empty for reaction events.
	Args []string
}

SlashInput is the command-package's view of one inbound message.

gateway.WithCommander receives *messages.InboundMessage and translates to this struct before calling Commander.Dispatch. Likewise channel adapters (e.g. feishu/adapter.go) construct SlashInput.Reaction from the channel's native reaction event.

type SlashOutput

type SlashOutput struct {
	// Reply is the human-readable reply text. When Outbound is
	// empty, the runtime shim emits this as a single Send.
	Reply string
	// Consumed=true means the message was handled; gateway will
	// NOT forward to the agent loop. false → fall through.
	Consumed bool
	// Dropped=true means the runtime shim should silently drop
	// the message (e.g. /watch off + not @-mentioned). Distinct
	// from Consumed for log clarity.
	Dropped bool
	// Outbound is an explicit list of outbound messages to send
	// in order. When non-empty, the runtime shim forwards each
	// via the chat session's Emitter. Uses the canonical
	// messages.OutboundMessage so commands build messages with
	// the same type the Emitter accepts (no mirror types in
	// this package).
	Outbound []messages.OutboundMessage
}

SlashOutput is the command-package's view of one command's result. The runtime shim consumes Reply / Outbound and routes them through cs.Emitter().Send (PATCH semantics fold into Send with Kind=OutChoicePatch). Consumed + Dropped flow back to the gateway for legacy fall-through handling.

func Reply

Reply builds a SlashOutput with the given text, marked as Consumed. The runtime shim consumes the SlashOutput and routes Reply / Outbound to cs.Emitter().Send.

This is the ONE canonical reply helper — all commands use it instead of constructing SlashOutput by hand. Keeping construction in one place lets us evolve the output shape (e.g. add per-reply metadata, drop metadata, log all replies) without touching every command.

Returns only *SlashOutput (no error) so callers can do

return command.Reply(ctx, rt, "..."), nil

matching the (*SlashOutput, error) signature of SlashCommandFactory.Handle. Callers that need to surface a failure construct the SlashOutput directly with a non-nil error.

func RequireActiveCwd

func RequireActiveCwd(cs *chatsession.ChatSession) (cwd string, failOut *SlashOutput)

RequireActiveCwd is a preflight check used by every slash command that operates on the current workspace (/cwd /use /close /new /gtw etc.). It returns ("", nil) when the session has an active workspace; otherwise it returns the current cwd (always "" in that case) and a SlashOutput with the "send /cwd first" hint reply — the caller should return this output directly without further work.

cs == nil is treated identically to SelectedCwd() == "" (both indicate "no session yet").

Usage:

cwd, fail := command.RequireActiveCwd(cs)
if fail != nil {
    return fail, nil
}
// ... proceed using cwd

type Spec

type Spec struct {
	// Name is the bare command name without the leading slash,
	// e.g. "gtw" for /gtw. Must be unique across the registry.
	Name string
	// Aliases are alternative names that route to the same
	// factory (e.g. "h" -> help). Lower-cased for matching.
	Aliases []string
	// Summary is a one-line help description.
	Summary string
	// Usage is a short usage hint surfaced when args are
	// missing or invalid. Free-form; may be multi-line.
	Usage string
	// Category groups commands for /help display. Free-form;
	// runtime doesn't enforce a whitelist.
	Category string
	// Hidden suppresses the command from /help listings.
	Hidden bool
	// Subcommands lists per-subcommand metadata for commands
	// that have sub-commands (e.g. /gtw fix / list / reset).
	Subcommands []SubcommandSpec
}

Spec describes one registered slash command. The runtime reads Name + Aliases at registration time to build the dispatch table; Summary + Usage surface in /help and in "unknown command" replies.

type SubcommandSpec

type SubcommandSpec struct {
	Name    string
	Summary string
	Usage   string
	Hidden  bool
}

SubcommandSpec describes one sub-command. Used by /help to surface per-subcommand usage rather than cramming everything into the parent's Usage string.

Directories

Path Synopsis
Package close — /close's "stop + kill the bridge process" logic.
Package close — /close's "stop + kill the bridge process" logic.
Package cwd implements the `/cwd <path>` slash command.
Package cwd implements the `/cwd <path>` slash command.
Package format provides shared rendering helpers for slash command reply text (IM-friendly plain-text payloads).
Package format provides shared rendering helpers for slash command reply text (IM-friendly plain-text payloads).
Package gtw — agent invocation + unified reply sink.
Package gtw — agent invocation + unified reply sink.
Package newcmd implements the `/new [<agent>]` slash command.
Package newcmd implements the `/new [<agent>]` slash command.
Package queue implements the `/queue <message>` slash command.
Package queue implements the `/queue <message>` slash command.
Package review implements the `/review` slash command.
Package review implements the `/review` slash command.
Package services — EventBus[T] (F-54).
Package services — EventBus[T] (F-54).
Package steer implements the `/steer <message>` slash command.
Package steer implements the `/steer <message>` slash command.
Package stop implements the `/stop` slash command.
Package stop implements the `/stop` slash command.
Package think implements the `/think on|off` slash command.
Package think implements the `/think on|off` slash command.
Package tools implements the `/tools on|off` slash command.
Package tools implements the `/tools on|off` slash command.
Package use implements the `/use <agent>` slash command.
Package use implements the `/use <agent>` slash command.
Package watch implements the `/watch on|off` slash command.
Package watch implements the `/watch on|off` slash command.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL