Web command JSON execution and text parser -- plan
Problem
Web command forms already submit structured JSON arguments/options, but the current execution path serializes that JSON back into a whitespace-split command string before dispatching. This breaks long free-text inputs such as raw Nostr event JSON when content contains dash-prefixed text (--foo) or multi-line prose.
Example observed in nr list parse form:
{
"content": "Do Your Job\n\n... 'whatever anyone does or says...' -- Marcus Aurelius",
"kind": 1,
"tags": []
}
The text can be split into tokens before command parsing, so a token beginning with -- is interpreted as an option.
We want two separate improvements:
- Web commands: keep structured JSON structured; do not round-trip through text tokenization.
- Text invocations: support conventional bare
--as “end of options; the rest is positional text”.
Current execution flow
Web form path today
Relevant files:
src/web/execute.tssrc/commands/dispatch.tssrc/commands/parse-prefixed.ts- plugin
adapter.tsfiles, e.g.plugins/nr/adapter.ts src/system/parser-cli.ts
Current flow:
web form JSON
-> src/web/execute.ts executeBuiltinCommand()
-> buildInvocationInput()
-> pushArgumentTokens()
-> command string
-> routeCommand(input)
-> parseBuiltinTokens(input) [whitespace split]
-> plugin handler(args[])
-> plugin adapter parseCliInput(tokens: args)
The lossy part is in src/web/execute.ts:
if (argument.variadic) {
if (typeof value === 'string') {
for (const item of value.split(/\s+/).filter(Boolean)) {
tokens.push(item);
}
}
}
Even though the browser submitted JSON, the value is split as if it were a shell command.
Existing JSON-ish path
src/web/execute.ts already has:
executeBuiltinJsonCommand(...)
but normal web command execution still uses executeBuiltinCommand(...), which reconstructs text. jsonPayload is passed through routeCommand to plugin context, but plugin adapters generally ignore it and parse args[].
Desired architecture
Structured web command path
Web command execution should preserve this shape all the way to command adapters:
{
command: 'nr',
subcommand: 'parse',
arguments: {
event_json: '{... full JSON string ...}'
},
options: {
force_reclassify: true
}
}
Adapters should receive a parsed invocation equivalent to parseCliInput() output, but created directly from structured values rather than text tokens.
Target output shape remains:
ParsedCliInvocation
This minimizes command handler churn because existing adapters already expect params.parsed.arguments and params.parsed.options.
Text command path
Text invocations should keep using text parsing, but support:
/nr parse -- {"content":"hello --force-reclassify"}
Rules:
- Before bare
--, parse options normally. - After bare
--, treat every token as positional, even if it starts with-. - The bare
--itself is not included in positional arguments.
Quote-aware tokenization can be a later improvement. Bare -- is simpler and matches common CLI behavior.
Proposed implementation plan
User-approved phase split:
- Phase 1: create the core structured parser helper, migrate
nr parse/nrweb execution to usejsonPayload, and document remaining migrations. - Phase 2: implement text-command bare
--parsing and verifynr parsetext invocations can use it. - Phase 3: migrate official plugins, update plugin core dependency ranges, and use
--majorcommits for plugin releases if the plugin API/core requirement changes.
Current status:
- Phase 1 core helper exists as
parseStructuredInput(...)insrc/system/parser-cli.ts. plugins/nr/adapter.tsusesjsonPayloadfor web-origin invocations and falls back to text parsing otherwise.- Phase 2 bare
--parsing is implemented inparseCliInput(...)for command/subcommand text arguments. - Phase 3 is still pending for official plugins and the plugin template.
Phase 1 — add structured parser helper
Add a helper near src/system/parser-cli.ts, for example:
parseStructuredInput({
command,
subcommand,
arguments,
options,
rawInput,
}): ParsedCliInvocation
Responsibilities:
- Validate subcommand exists.
- Validate required arguments/options.
- Coerce values using command definition kinds:
string-> stringinteger-> numberboolean-> boolean
- Preserve string values exactly as submitted.
- For
variadicarguments:- if structured value is an array, parse each item and return array
- if structured value is a string, return a single-item array or a string-compatible representation consistent with
ParsedCliInvocationSchema
- For
multipleoptions:- accept arrays
- accept scalar as one value if needed
- Fill
raw.inputwith a descriptive non-lossy label such as/nr parse (web json).
Open design point:
- Current
parseCliInput()represents variadic values as arrays. Keep that shape for compatibility:
arguments.event_json = ['full JSON string']
Existing helpers such as stringFromVariadicArgument() already handle arrays by joining with spaces, and a single-item array preserves content exactly.
Phase 2 — route web commands through structured parsing
Update web/plugin dispatch path so web form requests do not reconstruct text.
Possible approaches:
Option A: plugin adapters prefer structured payload
Plugin context already includes:
jsonPayload?: unknown
Update plugin adapters to do:
const parsed = context.jsonPayload
? parseStructuredInput({ command, ...context.jsonPayload })
: parseCliInput({ command, tokens: normalizedArgs, rawInput })
Pros:
- Smallest core change.
- Can migrate plugin adapters incrementally.
Cons:
- Repeated boilerplate in each plugin adapter.
- Easy for future plugins to forget.
Option B: add a shared plugin adapter helper
Create a shared helper, for example:
parsePluginInvocation({
command,
args,
prefix,
alias,
jsonPayload,
})
It chooses structured parsing for web JSON payloads and text parsing otherwise.
Pros:
- One standard implementation.
- Plugins can migrate with a small adapter change.
Cons:
- Still requires updating each plugin adapter to use the helper.
Option C: extend plugin handler interface
Longer-term API:
handler(args, context)
where context includes a normalized parsed invocation for web commands when available.
Pros:
- Cleanest model long term.
Cons:
- Broader plugin API change.
- Requires plugin core API version bump and coordinated plugin updates.
Recommended path: Option B first. It is explicit, testable, and avoids a large plugin interface redesign.
Phase 1 implementation can start with nr using the core structured parser directly. A shared plugin adapter helper should follow before migrating the official plugins, to avoid copy/paste parsing logic.
Phase 3 — stop building lossy text for web execution
In src/web/execute.ts, avoid using buildInvocationInput() as the source of truth for web command execution.
Keep a display-only invocation string if the timeline needs it, but do not use it for parsing structured web requests.
Potential change:
executeBuiltinCommand()passesjsonPayloadand an input like/${command.name} ${subcommand.name}only for display/route command identification.- Plugin adapter helper uses
jsonPayloadfor actual parsing when present.
Important: routeCommand() currently starts by calling parseBuiltinTokens({ input, prefix }) to identify cmd. So an input string is still needed for routing unless routeCommand grows a structured command entry point.
Minimal safe route:
input = `${prefix}${command.name} ${subcommand.name}`
jsonPayload = original structured payload
Then plugin adapter helper reconstructs parsed arguments from jsonPayload.
Phase 4 — add bare -- support for text parsing
Update src/system/parser-cli.ts option scanning:
let optionsEnded = false;
for token of subcommandTokens:
if (!optionsEnded && token === '--') {
optionsEnded = true;
continue;
}
if (!optionsEnded && token startsWith('-')) parse option
else positionalTokens.push(token)
This should replace the temporary/defensive behavior that treats unknown dash tokens as positional for variadic string commands.
Expected behavior:
/cmd sub --flag value text --not-option
--not-option is still an option candidate before bare --.
/cmd sub -- text --not-option
--not-option is positional after bare --.
Phase 5 — plugin migration
Update plugin adapters to use the shared parse helper.
Also make plugin invocation types generic so structured web payloads can be represented without repeated unknown narrowing:
export type PluginInvocationContext<TJsonPayload = unknown> = {
prefix: string;
source: MessageSource;
runAgent: RunAgentFn;
sendReply?: SendReplyFn;
promptFn?: PromptFn;
jsonPayload?: TJsonPayload;
};
export type BotPlugin<TJsonPayload = unknown> = {
handler: (
args: string[],
context: PluginInvocationContext<TJsonPayload>,
) => Promise<string | WebNodeRoot>;
// existing fields unchanged
};
Add a shared payload type for normal web command form submissions:
export type StructuredCommandJsonPayload = {
arguments?: Record<string, unknown>;
options?: Record<string, unknown>;
};
Do not blindly narrow the existing global jsonPayload?: unknown field to StructuredCommandJsonPayload without checking other consumers. src/commands/ai/handler.ts currently uses ctx.jsonPayload for AI agent/config JSON flows, which may not have the normal { arguments, options } command-form shape. If we want a strongly typed structured command payload everywhere, consider adding a separate field such as:
structuredCommandPayload?: StructuredCommandJsonPayload;
jsonPayload?: unknown;
or use generics so each command/plugin path can specify its own payload shape.
Then plugins that use structured parsing can declare:
export const NrPlugin: BotPlugin<StructuredCommandJsonPayload> = { ... };
This is a plugin API typing change. Runtime behavior is already compatible because jsonPayload exists, but published plugin packages that import/use the generic types should update their core API dependency during migration.
Likely files:
src/core/plugin.tsplugins/nr/adapter.tsplugins/todo/adapter.tsplugins/bm/adapter.tsplugins/file/adapter.tsplugins/job/adapter.tsplugins/journal/adapter.tsplugins/browser/adapter.ts
The helper should require minimal adapter diff:
const parsed = parsePluginInvocation({
command,
args: normalizedArgs,
prefix,
alias,
jsonPayload: storedCtxOrInvocationContext.jsonPayload,
});
Current issue: generated template adapters may not pass jsonPayload into NrCommandAdapterParams. The plugin invocation context already has it (src/core/plugin.ts), but generated adapters need to propagate it.
Also update scripts/plugin-template/adapter.ts.template so new plugins inherit the structured path.
Phase 6 — core API version bump
Because plugin adapters and plugin template behavior change, bump plugin coreApiVersion expectations when published plugins are updated.
Notes:
- The already-added
webInput?: 'text' | 'textarea'field is a command-definition schema/API addition. If published plugins use it, theircoreApiVersionshould require a core version that supports it. - If structured parsing is implemented in core but plugins must opt into the helper, each migrated plugin should bump its core API dependency.
- If core handles structured parsing without plugin code changes, plugin dependency bumps may be less urgent, but generated skills/registries should still be regenerated.
Codebase search results
Dispatch/parser touchpoints
Search:
parseCliInput|jsonPayload|executeBuiltinJsonCommand|pushArgumentTokens|parseBuiltinTokens
Matches of interest:
src/system/parser-cli.tsparseCliInput(...)
src/web/execute.tspushArgumentTokens(...)executeBuiltinCommand(...)executeBuiltinJsonCommand(...)- passes
jsonPayload
src/commands/dispatch.tsparseBuiltinTokens(...)- passes
jsonPayloadinto plugin context
src/commands/parse-prefixed.ts- current root command tokenization uses
rest.split(/\s+/)
- current root command tokenization uses
src/core/plugin.ts- plugin invocation context includes
jsonPayload?: unknown
- plugin invocation context includes
src/web/ws.ts- imports/uses
executeBuiltinCommandandexecuteBuiltinJsonCommand
- imports/uses
Variadic command definitions in core
Search:
variadic: true
Core matches:
src/commands/roadmap/definition.tssrc/commands/definitions-registry.tssrc/commands/ai/agent/save/definition.tssrc/commands/wot/definition.tssrc/commands/wallet/history/definition.tssrc/commands/bot/push/definition.tssrc/commands/bot/log/definition.tssrc/commands/bot/lint/definition.ts
Variadic command definitions in plugins
Plugin matches:
plugins/nr/commands/parse/definition.tsplugins/nr/commands/accept/definition.tsplugins/nr/commands/revise/definition.tsplugins/nr/commands/ai/definition.tsplugins/nr/commands/add/definition.tsplugins/file/commands/tree/definition.tsplugins/file/commands/commit/definition.tsplugins/file/commands/edit/definition.tsplugins/file/commands/search/definition.tsplugins/bm/commands/search/definition.tsplugins/bm/commands/ai/definition.tsplugins/bm/commands/update/definition.tsplugins/bm/commands/revise/definition.tsplugins/journal/definition.tsplugins/todo/commands/duel/definition.tsplugins/todo/commands/add/definition.tsplugins/todo/commands/update/definition.tsplugins/todo/commands/revise/definition.tsplugins/todo/commands/ai/definition.tsplugins/browser/commands/run/definition.tsplugins/job/commands/revise/definition.tsplugins/job/commands/ai/definition.ts
These are the commands most likely to be affected by text-token semantics and should be included in regression tests.
Test plan
Unit tests for structured parsing
Add tests for parseStructuredInput():
- simple required string argument
- variadic string argument with spaces
- variadic string argument containing
--flagtext - integer coercion
- boolean option coercion
- required argument missing
- unknown argument/option payload keys, if we choose to reject them
- multiple option arrays
Unit tests for text --
Add tests for parseCliInput():
sub --flag value text
sub -- text --flag value
sub text -- unknown-before-end
sub -- --literal
Integration smoke tests
Use nr because it exposes the original failure clearly:
- Web-style structured call to
nr parsewith event JSON containing:-- Marcus Aurelius--force-reclassify- newlines
- quotes
- Text call:
/nr parse -- {"content":"hello --force-reclassify"...}
- Existing common variadic commands still work:
todo add hello worldbm search nostr bitcoinfile search some phrasebrowser run open example.com and click login
Risks and mitigations
Risk: plugin adapters ignore structured payload
Mitigation: shared helper and plugin-template update.
Risk: web timeline display loses full invocation text
Mitigation: separate display string from parsed execution data. Do not require display string to be reparsable.
Risk: variadic arrays vs single string shape changes
Mitigation: structured parser should mimic current ParsedCliInvocation shape. For a variadic string submitted as a string, use a single-element array.
Risk: existing text commands using literal --
Mitigation: bare -- behavior is standard. Document it. Literal -- can be passed after -- as the first positional token if needed:
/cmd sub -- --
Risk: core API mismatch for published plugins
Mitigation: after migrating official plugins, update package appweaver.coreApiVersion / coreApiVersion to the new core version and regenerate plugin skills/registries.
Open decisions
- Should structured web parsing reject unknown argument/option keys, or ignore them?
- Recommended: reject unknown keys for safety and debugging.
- Should
parseStructuredInput()live insrc/system/parser-cli.tsor a new file likesrc/system/parser-structured.ts?- Recommended: same module initially, because output schema and coercion are shared.
- Should quote-aware text tokenization be included now?
- Recommended: no. Add bare
--first, then consider quote-aware tokenizer as separate work.
- Recommended: no. Add bare
- Should
webInput: 'textarea'remain?- Recommended: yes, as a UI hint only. Do not require it for transport correctness.