Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ Every file in `.openfox/` must be **committable** and **meaningful in the projec

**Rationale:** Clean repo, no leaked personal config, reliable source of truth for CI and collaborators.

**Command arguments:** a command's Message template takes `{{name}}` placeholders, filled positionally by order of appearance, and `{{ARGUMENTS}}`, which takes everything typed after the id. Quoted values (`/cmd "two words"`) fill one placeholder whole. Parsing lives in `src/shared/slash-args.ts` and is shared by the chat composer and the task board's server-side resolution — change it there, not in either caller. Full design: [docs/DESIGN-SLASH-ARGUMENTS.md](DESIGN-SLASH-ARGUMENTS.md).

**Workflows:** When asked to create or edit a workflow, load the built-in `workflows` skill via `load_skill("workflows")` — it is the authoritative reference for workflow file format, storage locations (project `.openfox/workflows/` vs global `{configDir}/workflows/`), the full JSON schema, step types, transition conditions, and template variables ([docs/WORKFLOWS.md](docs/WORKFLOWS.md) is a pointer to it). Project workflows belong in `.openfox/workflows/` and are committable.

## TDD Workflow
Expand Down
87 changes: 87 additions & 0 deletions docs/DESIGN-SLASH-ARGUMENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Slash Command Arguments

## Goal

Make command arguments usable for the two things people actually type: a value that contains spaces, and a free-form instruction.

Commands already accepted positional arguments — `{{name}}` placeholders filled by order of appearance — but the invocation was split on `/\s+/` with no notion of quoting, and there was no way to capture a whole sentence. Both gaps push users back to editing the command every time they want to vary it.

```
/revue src/a.ts "gestion des erreurs" → {{angle}} got `"gestion`, the rest was dropped
/note rerun the flaky proxy test → no placeholder could hold this
```

## User Experience

Two additions, both in the command's Message template.

**Quoted values.** Wrap an argument in `"…"` or `'…'` and it fills one placeholder whole.

```markdown
Relis {{file}} en te concentrant sur {{angle}}.
```

`/revue src/a.ts "gestion des erreurs"` → _Relis src/a.ts en te concentrant sur gestion des erreurs._

**`{{ARGUMENTS}}`.** Receives everything typed after the command id, verbatim.

```markdown
Add a note to the plan: {{ARGUMENTS}}
```

`/note rerun the flaky proxy test` → _Add a note to the plan: rerun the flaky proxy test_

Both forms coexist with what already worked: unfilled placeholders still open the params modal, so `/revue` alone asks for `file` and `angle` in a dialog. The command editor now carries a one-line description of all of this, which nothing documented before.

## Technical Design

### One module, both callers

Parsing lived in two places that had drifted into near-duplicates: `web/src/lib/parse-slash-command.ts` for the chat composer and `src/server/tasks/slash.ts` for slash commands seeded into task sessions. Each had its own `extractTemplateParams` and its own `split(/\s+/)`.

`src/shared/slash-args.ts` is now the single implementation, reachable from web through the existing `@shared` alias. Both callers delegate to it, so a command typed in chat and the same command seeded into a task expand identically — which was the point of `src/server/tasks/slash.ts` existing in the first place.

```ts
tokenizeArgs(line): string[] // quote-aware split
parseSlashInput(prompt): { id, args, rest } | null
extractTemplateParams(template): string[] // every {{name}}, in order, deduplicated
positionalTemplateParams(template): string[] // …except {{ARGUMENTS}}
templateParamHints(template): string[] // positional first, {{ARGUMENTS}} last
resolveTemplateParams(template, args, rest): { params, unfilledParams }
applyTemplateParams(template, params): string
expandCommandPrompt(template, args, rest): { prompt, unfilledParams }
```

### Tokenizer

A `"…"` or `'…'` run is one token and the quotes are stripped. Inside double quotes a backslash escapes the next character; single quotes are literal, as in POSIX shells. Quoted and unquoted fragments that touch join into one token, so `src/"my file".ts` is a single path.

An unterminated quote takes the rest of the line rather than erroring. Someone mid-keystroke has an unterminated quote most of the time, and the inline hint reads the same buffer.

### `{{ARGUMENTS}}`

Exact, uppercase, case-sensitive — a placeholder named `arguments` stays an ordinary positional slot. It is excluded from positional numbering, so it never consumes a token another placeholder was waiting for. It takes `rest`: the raw text after the id, trimmed, quotes included.

Raw rather than re-joined tokens because `{{ARGUMENTS}}` means "what I typed". A template that mixes both forms sees each argument twice — once in its positional slot, once inside `{{ARGUMENTS}}` — which matches `$1` and `$ARGUMENTS` elsewhere and is what someone writing that template is asking for.

Nothing typed after the id leaves `{{ARGUMENTS}}` unfilled, and it flows through the existing unfilled-placeholder path: the modal asks, or `resolveSlashLaunch` declines and the task falls back to the raw prompt.

### Inline hints

The composer's `param=?` hint counted arguments with `split(/\s+/)`, so a quoted value made it skip ahead. It now counts with `tokenizeArgs`.

`templateParamHints` orders `{{ARGUMENTS}}` last regardless of where it appears in the template, so the hint walks the positional slots first and only then offers `ARGUMENTS=?` — the order in which they have to be typed. The server computes it in `routes/commands.ts`, so the composer and the task editor get the same hints from one place.

## Edge Cases

- **Backward compatibility.** With no `{{ARGUMENTS}}` in the template, positional resolution is what it was; with no quotes in the invocation, tokenization is what it was. Every pre-existing test passes with only the two new fields added to the parse result.
- **Empty quoted argument.** `/cmd "" b` yields `['', 'b']`; the placeholder is filled with an empty string rather than reported unfilled — the user said "nothing" explicitly.
- **Workflows.** They share the tokenizer, so quoted values now work for workflow parameters too. `{{ARGUMENTS}}` does not apply: workflows declare typed parameters instead of a template.
- **Repeated placeholder.** `{{a}} … {{a}}` counts as one slot and every occurrence is replaced.
- **Unfilled placeholder.** Left literally in the prompt and reported, never substituted with an empty string — that is what lets the caller ask instead of silently sending `{{file}}` to the model.

## Out of Scope

- Named arguments (`--file=x`)
- Default values and required markers for command placeholders — workflows have declared `parameters` with `required` and `position`; commands infer their names from the template
- Shell execution or file inclusion inside a command template
3 changes: 2 additions & 1 deletion src/server/routes/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from '../commands/registry.js'
import type { CommandDefinition } from '../commands/types.js'
import { createCrudRoutes, validateNameIdPrompt, type CrudRouteConfig } from './crud-helpers.js'
import { templateParamHints } from '../../shared/slash-args.js'

const config: CrudRouteConfig<CommandDefinition> = {
dirName: 'commands',
Expand All @@ -34,7 +35,7 @@ const config: CrudRouteConfig<CommandDefinition> = {
mapToResponse: (c) =>
({
...c.metadata,
paramNames: [...new Set(Array.from(c.prompt.matchAll(/\{\{(\w+)\}\}/g), (m) => m[1]!))],
paramNames: templateParamHints(c.prompt),
}) as unknown as { [key: string]: unknown },
}

Expand Down
60 changes: 59 additions & 1 deletion src/server/tasks/slash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@ describe('parseSlashInvocation', () => {
})

it('parses a bare command id with no args', () => {
expect(parseSlashInvocation('/lint')).toEqual({ id: 'lint', args: [] })
expect(parseSlashInvocation('/lint')).toEqual({ id: 'lint', args: [], rest: '' })
})

it('parses args, trimming surrounding whitespace', () => {
expect(parseSlashInvocation(' /fixme crash src/a.ts ')).toEqual({
id: 'fixme',
args: ['crash', 'src/a.ts'],
rest: 'crash src/a.ts',
})
})

Expand All @@ -31,6 +32,7 @@ describe('parseSlashInvocation', () => {
expect(parseSlashInvocation('/run vitest run src/x.test.ts')).toEqual({
id: 'run',
args: ['vitest', 'run', 'src/x.test.ts'],
rest: 'vitest run src/x.test.ts',
})
})
})
Expand Down Expand Up @@ -212,6 +214,62 @@ describe('resolveSlashLaunch (filesystem-backed)', () => {
expect(resolved).toEqual({ kind: 'command', prompt: 'Fix the crash bug in src/a.ts.', agentMode: 'builder' })
})

it('feeds a quoted multi-word argument into a single placeholder', async () => {
await writeFile(
join(configDir, 'commands', 'revue.command.md'),
'---\nid: revue\nname: Revue\n---\n\nRelis {{file}} en te concentrant sur {{angle}}.',
)
const resolved = await resolveSlashLaunch(configDir, undefined, '/revue src/a.ts "gestion des erreurs"')
expect(resolved).toEqual({ kind: 'command', prompt: 'Relis src/a.ts en te concentrant sur gestion des erreurs.' })
})

it('feeds the whole line into {{ARGUMENTS}}', async () => {
await writeFile(
join(configDir, 'commands', 'note.command.md'),
'---\nid: note\nname: Note\n---\n\nAdd a note: {{ARGUMENTS}}',
)
const resolved = await resolveSlashLaunch(configDir, undefined, '/note rerun the flaky proxy test')
expect(resolved).toEqual({ kind: 'command', prompt: 'Add a note: rerun the flaky proxy test' })
})

it('returns null for an {{ARGUMENTS}} command invoked with nothing after the id', async () => {
await writeFile(
join(configDir, 'commands', 'note.command.md'),
'---\nid: note\nname: Note\n---\n\nAdd a note: {{ARGUMENTS}}',
)
expect(await resolveSlashLaunch(configDir, undefined, '/note')).toBeNull()
})

it('combines a positional placeholder with {{ARGUMENTS}}', async () => {
await writeFile(
join(configDir, 'commands', 'revue.command.md'),
'---\nid: revue\nname: Revue\n---\n\nRelis {{file}}. Consignes : {{ARGUMENTS}}',
)
const resolved = await resolveSlashLaunch(configDir, undefined, '/revue src/a.ts sois impitoyable')
expect(resolved).toEqual({
kind: 'command',
prompt: 'Relis src/a.ts. Consignes : src/a.ts sois impitoyable',
})
})

it('honours quoted arguments for workflow parameters too', async () => {
await writeFile(
join(configDir, 'workflows', 'fixit.workflow.json'),
JSON.stringify(
fixtureWorkflow('fixit', [
{ id: 'issue', position: 0 },
{ id: 'file', position: 1 },
]),
),
)
const resolved = await resolveSlashLaunch(configDir, undefined, '/fixit "crash au boot" src/a.ts')
expect(resolved).toEqual({
kind: 'workflow',
workflowId: 'fixit',
params: { issue: 'crash au boot', file: 'src/a.ts' },
})
})

it('prefers workflows over commands when an id collides', async () => {
await writeFile(join(configDir, 'workflows', 'both.workflow.json'), JSON.stringify(fixtureWorkflow('both')))
await writeFile(join(configDir, 'commands', 'both.command.md'), '---\nid: both\nname: Both\n---\n\nCommand body')
Expand Down
51 changes: 7 additions & 44 deletions src/server/tasks/slash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,9 @@

import { loadAllCommands, findCommandById } from '../commands/registry.js'
import { loadAllWorkflows, findWorkflowById } from '../workflows/registry.js'
import { expandCommandPrompt, parseSlashInput, type SlashInput } from '../../shared/slash-args.js'

export interface SlashInvocation {
id: string
args: string[]
}
export type SlashInvocation = SlashInput

export interface WorkflowSlashLaunch {
kind: 'workflow'
Expand All @@ -35,16 +33,11 @@ export interface CommandSlashLaunch {
export type SlashLaunch = WorkflowSlashLaunch | CommandSlashLaunch | null

/**
* Split a prompt into a slash id + positional args. Returns null when the
* prompt is not a slash invocation (or is just a bare "/").
* Split a prompt into a slash id, its tokenized args and the raw remainder.
* Returns null when the prompt is not a slash invocation (or is a bare "/").
*/
export function parseSlashInvocation(prompt: string): SlashInvocation | null {
const trimmed = prompt.trim()
if (!trimmed.startsWith('/')) return null
const parts = trimmed.slice(1).split(/\s+/)
const id = parts[0]
if (!id) return null
return { id, args: parts.slice(1) }
return parseSlashInput(prompt)
}

/**
Expand All @@ -71,37 +64,7 @@ export function workflowParamsFromArgs(
return params
}

/** Named template placeholders ({{name}}) in order of first occurrence, deduplicated. */
export function extractTemplateParams(template: string): string[] {
const seen: string[] = []
const regex = /\{\{(\w+)\}\}/g
let match: RegExpExecArray | null
while ((match = regex.exec(template)) !== null) {
const key = match[1]!
if (!seen.includes(key)) seen.push(key)
}
return seen
}

/**
* Substitute positional args into a command's prompt template. Args map to
* placeholders by order of appearance. Returns the expanded prompt plus the
* placeholders that remain unfilled (so callers can degrade gracefully).
*/
export function expandCommandPrompt(template: string, args: string[]): { prompt: string; unfilledParams: string[] } {
const paramNames = extractTemplateParams(template)
const named: Record<string, string> = {}
paramNames.forEach((name, index) => {
const value = args[index]
if (value !== undefined) named[name] = value
})
let prompt = template
for (const [key, value] of Object.entries(named)) {
prompt = prompt.replaceAll(`{{${key}}}`, value)
}
const unfilledParams = paramNames.filter((name) => !(name in named))
return { prompt, unfilledParams }
}
export { expandCommandPrompt, extractTemplateParams } from '../../shared/slash-args.js'

/**
* Resolve a prompt against the available workflows and commands. Returns null
Expand Down Expand Up @@ -138,7 +101,7 @@ export async function resolveSlashLaunch(
const command = findCommandById(invocation.id, commands)
if (!command) return null

const expanded = expandCommandPrompt(command.prompt, invocation.args)
const expanded = expandCommandPrompt(command.prompt, invocation.args, invocation.rest)
if (expanded.unfilledParams.length > 0) return null

return {
Expand Down
Loading