Skip to content

Commit 755f29b

Browse files
8bitAlexclaude
andauthored
feat: declared arguments and flags on custom commands (closes #26) (#75)
* feat: declared arguments and flags on custom commands (closes #26) Profile and repo `commands[]` entries can now declare: - `args[]` — named positional arguments with optional `required: true`. - `flags[]` — long-form `--name` (and optional `-x` short) options with `type: string|bool|int`, `default`, and `required`. Declared values are bound to env vars named after each entry's `name` (uppercased) for the duration of the command, so a task can reference them as `$NAME` exactly the way `$RAID_ARG_N` works today. Bool flags bind as the literal strings "true" / "false". Cobra enforces required flags and validates positional cardinality (ExactArgs / MaximumNArgs / RangeArgs depending on the required count) before raid runs anything, so authors get clear `--help` and error messages without writing validation in their tasks. `RAID_ARG_N` continues to work for unnamed positional arguments — declarations are additive. Internal: ExecuteCommand / ExecuteRepoCommand grew a `named map[string]string` parameter (callers that don't care pass nil, including the MCP `raid_run_task` handler which doesn't yet surface named bindings). Version 0.10.1-beta → 0.11.0-beta. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: sanitize env names, restore pre-existing values, tighten schema Address Copilot review feedback on #75: - setCommandArgs runs each declared name through sanitizeEnvName (the same scheme as sanitizeRepoVarName: lowercase→upper, anything outside [A-Za-z0-9_] → '_'). Names that sanitise to all-underscore / empty are skipped so os.Setenv never sees a useless or empty key. - Cleanup now snapshots each env var before overwriting it and restores the original value (or unsets if there was none). A command declaring e.g. `name: PATH` no longer permanently clobbers the parent process's PATH after the command exits. - Schema-level: `args[].name` and `flags[].name` get a `pattern` (`^[A-Za-z_][A-Za-z0-9_]*$`) so authors get clear rejection at config load time rather than discovering the issue when `$DRY-RUN` silently fails to expand. `flags[].default` is now constrained by `flags[].type` via a conditional `allOf` block — `{type: int, default: "x"}` is rejected at load instead of silently coerced to zero. - Docs: drop the inaccurate "declarations are additive" claim. Once `args:` is declared, cobra caps total positional arity at len(args). Authors who want unbounded positional args should omit `args:` and read `$RAID_ARG_N` (still populated for declared positional values too). Tests: TestSanitizeEnvName (table-driven), restore/unset/skip-invalid ExecuteCommand variants, and TestValidateProfile_commandArgsAndFlags covering the new schema rejections (hyphenated names, leading-digit names, default-type mismatches, multi-char short). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 84e1eaa commit 755f29b

14 files changed

Lines changed: 904 additions & 57 deletions

File tree

schemas/raid-defs.schema.json

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,90 @@
457457
"type": "string",
458458
"description": "Short description shown in 'raid --help'"
459459
},
460+
"args": {
461+
"type": "array",
462+
"description": "Declared positional arguments. Required args must be supplied; declarations cap the total number of positional args cobra accepts. The supplied value is exported as an env var named after `name` (uppercased) for the duration of the command, available in tasks as $NAME.",
463+
"items": {
464+
"type": "object",
465+
"properties": {
466+
"name": {
467+
"type": "string",
468+
"pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
469+
"description": "Argument name. Must be a valid env var identifier ([A-Za-z_][A-Za-z0-9_]*). Uppercased to form the variable name."
470+
},
471+
"usage": {
472+
"type": "string",
473+
"description": "Short description shown in '--help'."
474+
},
475+
"required": {
476+
"type": "boolean",
477+
"description": "If true the command fails when the positional value is omitted.",
478+
"default": false
479+
}
480+
},
481+
"required": ["name"],
482+
"additionalProperties": false
483+
}
484+
},
485+
"flags": {
486+
"type": "array",
487+
"description": "Declared flags / options. Long form is --<name>; an optional `short` adds a single-character -<x>. Values bind to an env var named after `name` (uppercased) for the duration of the command. Booleans bind as the literal strings 'true' or 'false'.",
488+
"items": {
489+
"type": "object",
490+
"properties": {
491+
"name": {
492+
"type": "string",
493+
"pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
494+
"description": "Flag name. Must be a valid env var identifier ([A-Za-z_][A-Za-z0-9_]*). The long form is --<name> and the env var is the uppercased name."
495+
},
496+
"short": {
497+
"type": "string",
498+
"minLength": 1,
499+
"maxLength": 1,
500+
"description": "Single-character short form, e.g. 'v' to bind -v."
501+
},
502+
"usage": {
503+
"type": "string",
504+
"description": "Short description shown in '--help'."
505+
},
506+
"type": {
507+
"type": "string",
508+
"enum": ["string", "bool", "int"],
509+
"description": "Value type. Defaults to 'string' when omitted.",
510+
"default": "string"
511+
},
512+
"required": {
513+
"type": "boolean",
514+
"description": "If true cobra rejects the invocation when the flag is omitted.",
515+
"default": false
516+
},
517+
"default": {
518+
"description": "Value used when the flag is omitted. Must match `type` — the conditional `allOf` below enforces this so a string default on an int flag is rejected at config-load time."
519+
}
520+
},
521+
"required": ["name"],
522+
"additionalProperties": false,
523+
"allOf": [
524+
{
525+
"if": {"properties": {"type": {"const": "bool"}}, "required": ["type"]},
526+
"then": {"properties": {"default": {"type": "boolean"}}}
527+
},
528+
{
529+
"if": {"properties": {"type": {"const": "int"}}, "required": ["type"]},
530+
"then": {"properties": {"default": {"type": "integer"}}}
531+
},
532+
{
533+
"if": {
534+
"anyOf": [
535+
{"not": {"required": ["type"]}},
536+
{"properties": {"type": {"const": "string"}}, "required": ["type"]}
537+
]
538+
},
539+
"then": {"properties": {"default": {"type": "string"}}}
540+
}
541+
]
542+
}
543+
},
460544
"tasks": {
461545
"$ref": "#/properties/tasks"
462546
},

site/docs/references/schema.mdx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,12 +121,37 @@ commands:
121121
|---|---|---|---|
122122
| `name` | string | Yes | Command name — invoked as `raid <name>` |
123123
| `usage` | string | No | Description shown in `raid --help` |
124+
| `args` | list | No | Declared positional arguments. See [Args](#command-args). |
125+
| `flags` | list | No | Declared flags / options. See [Flags](#command-flags). |
124126
| [`tasks`](#task) | list | Yes | Task sequence to run |
125127
| [`out`](#output) | object | No | Output configuration |
126128

127129
Command names cannot shadow built-in names: `install`, `env`, `profile`, `doctor`.
128130

129-
Arguments passed after the command name are available as `RAID_ARG_1`, `RAID_ARG_2`, etc.
131+
Arguments passed after the command name are available as `RAID_ARG_1`, `RAID_ARG_2`, etc. Declared `args` and `flags` (below) additionally bind to env vars named after each entry's `name` (uppercased).
132+
133+
### Command args
134+
135+
Each entry under `args` declares a positional argument. The supplied value is exported as an env var named after `name` (uppercased) for the duration of the command.
136+
137+
| Field | Type | Required | Description |
138+
|---|---|---|---|
139+
| `name` | string | Yes | Argument name — uppercased to form the env var. |
140+
| `usage` | string | No | Short description shown in `--help`. |
141+
| `required` | bool | No | If true, the command fails when the positional value is omitted. Defaults to false. |
142+
143+
### Command flags
144+
145+
Each entry under `flags` declares a long-form `--name` flag (and optionally a `-x` short form). Values bind to an env var named after `name` (uppercased).
146+
147+
| Field | Type | Required | Description |
148+
|---|---|---|---|
149+
| `name` | string | Yes | Flag name — long form is `--name`, env var is `NAME`. |
150+
| `short` | string (1 char) | No | Single-character short form, e.g. `v` → `-v`. |
151+
| `usage` | string | No | Short description shown in `--help`. |
152+
| `type` | enum | No | One of `string` (default), `bool`, `int`. Bool flags bind as the literal strings `"true"` / `"false"`. |
153+
| `required` | bool | No | If true, cobra rejects the invocation when the flag is omitted. |
154+
| `default` | string \| bool \| int | No | Value used when the flag is omitted. Must match `type`. |
130155

131156
### Output
132157

site/docs/usage/custom.mdx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,56 @@ commands:
6464

6565
The variables are set for the duration of the command and cleared afterwards.
6666

67+
### Declared arguments and flags
68+
69+
Commands can also declare named positional arguments and flags. Declared values are bound to env vars named after the field's `name` (uppercased) and made available to tasks for the duration of the command — so `name: ticket` becomes `$TICKET`. Required values that aren't supplied cause raid to reject the invocation with a clear cobra error.
70+
71+
```yaml title="profile.yaml"
72+
commands:
73+
- name: "patch"
74+
usage: "Apply a patch to a SUV host"
75+
args:
76+
- name: ticket
77+
usage: "Ticket number"
78+
required: true
79+
- name: comment
80+
usage: "Optional changelog note"
81+
flags:
82+
- name: suv
83+
short: s
84+
usage: "Target SUV hostname"
85+
required: true
86+
- name: verbose
87+
short: v
88+
type: bool
89+
- name: retries
90+
type: int
91+
default: 3
92+
tasks:
93+
- type: Print
94+
message: "Patching $TICKET on $SUV (retries=$RETRIES, verbose=$VERBOSE)"
95+
- type: Shell
96+
cmd: ./scripts/patch.sh "$TICKET" --host "$SUV" --retries "$RETRIES"
97+
```
98+
99+
Invocation:
100+
101+
```bash
102+
raid patch --suv host.example.com -v 12345 "rolling out the fix"
103+
raid patch -s host.example.com 67890 # comment optional
104+
```
105+
106+
| Field on `args[]` / `flags[]` | Meaning |
107+
|---|---|
108+
| `name` | Long form (`--name`) and the env var (uppercased). |
109+
| `short` (flags only) | Single-character short form, e.g. `s` → `-s`. |
110+
| `usage` | Short description shown in `--help`. |
111+
| `type` (flags only) | `string` (default), `bool`, or `int`. Bool flags bind as the literal strings `"true"` / `"false"`. |
112+
| `required` | When true, cobra rejects the invocation if the value is omitted. |
113+
| `default` (flags only) | Used when the flag is omitted. Type must match `type`. |
114+
115+
Once a command declares `args`, cobra rejects extra positional values beyond the declared list — the cap is `len(args)`. To accept an unbounded number of positional arguments, omit `args:` and read them from `$RAID_ARG_1`, `$RAID_ARG_2`, … as before. `RAID_ARG_N` remains populated for declared positional values too, so a task can reference either `$TICKET` (named) or `$RAID_ARG_1` interchangeably.
116+
67117
## Output configuration
68118

69119
Use the `out` field to control what output a command shows and optionally write it to a file.

site/docs/whats-new.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ description: Feature-by-feature release notes for Raid.
99

1010
User-visible changes per release, latest first. For full commit history see the [GitHub releases page](https://github.com/8bitalex/raid/releases).
1111

12+
## 0.11.0 — upcoming
13+
14+
**Declared arguments and flags on custom commands.** `commands[]` entries now accept an `args` list (named positional arguments) and a `flags` list (long/short options with optional default and type — `string`, `bool`, or `int`). Declared values are exported as env vars named after each entry (uppercased), so `args: [{name: ticket, required: true}]` makes `$TICKET` available in tasks for the duration of the command. Cobra enforces required values and validates positional cardinality before raid runs anything. `RAID_ARG_N` continues to work for unnamed positional arguments. Closes [#26](https://github.com/8bitAlex/raid/issues/26).
15+
1216
## 0.10.1 — upcoming
1317

1418
**Plain-yaml profiles via `raid profile add <url>`.** When the URL points at a git repo (or a single-file gist) whose profile file isn't named with the `*.raid.yaml` convention — e.g. just `profile.yaml`, `myprofile.yml`, or `config.json` — raid now picks it up as a fallback after no `*.raid.yaml`/`profile.json` matches are found. Schema validation runs the same way, so non-profile YAML lying around in a repo root is still rejected with a clear "invalid profile" message. Makes ad-hoc gists usable without renaming the file.

src/cmd/context/serve.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,10 @@ func handleRunTask(_ stdctx.Context, req mcp.CallToolRequest) (*mcp.CallToolResu
451451
lockErr := raid.WithMutationLock(func() error {
452452
var runErr error
453453
output, runErr = captureCommandOutput(func() error {
454-
return raid.ExecuteCommand(command, args)
454+
// MCP `raid_run_task` doesn't currently accept named args/flags;
455+
// pass nil so cobra-side declared bindings stay unset (commands
456+
// without declared args/flags are unaffected).
457+
return raid.ExecuteCommand(command, args, nil)
455458
})
456459
if runErr == nil {
457460
// ExecuteCommand mutates env vars and recent.json. ForceLoad

src/cmd/raid.go

Lines changed: 130 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"os"
77
"os/exec"
8+
"strconv"
89
"strings"
910
"time"
1011

@@ -185,16 +186,20 @@ func registerUserCommands(root *cobra.Command, cmds []lib.Command) {
185186
continue
186187
}
187188
name := cmd.Name
188-
root.AddCommand(&cobra.Command{
189-
Use: name,
189+
def := cmd
190+
coCmd := &cobra.Command{
191+
Use: buildCommandUse(name, def.Args),
190192
Short: cmd.Usage,
191193
Annotations: map[string]string{CommandSourceAnnotation: CommandSourceUser},
192-
RunE: func(c *cobra.Command, args []string) error {
193-
return raid.WithMutationLock(func() error {
194-
return raid.ExecuteCommand(name, args)
195-
})
196-
},
197-
})
194+
}
195+
attachCommandArgsAndFlags(coCmd, def)
196+
coCmd.RunE = func(c *cobra.Command, args []string) error {
197+
named := gatherCommandValues(c, def, args)
198+
return raid.WithMutationLock(func() error {
199+
return raid.ExecuteCommand(name, args, named)
200+
})
201+
}
202+
root.AddCommand(coCmd)
198203
}
199204
}
200205

@@ -222,20 +227,129 @@ func registerRepoCommands(root *cobra.Command, repos []lib.Repo) {
222227
}
223228
for _, cmd := range repo.Commands {
224229
cmdName := cmd.Name
225-
repoCmd.AddCommand(&cobra.Command{
226-
Use: cmdName,
230+
def := cmd
231+
subCmd := &cobra.Command{
232+
Use: buildCommandUse(cmdName, def.Args),
227233
Short: cmd.Usage,
228-
RunE: func(c *cobra.Command, args []string) error {
229-
return raid.WithMutationLock(func() error {
230-
return raid.ExecuteRepoCommand(repoName, cmdName, args)
231-
})
232-
},
233-
})
234+
}
235+
attachCommandArgsAndFlags(subCmd, def)
236+
subCmd.RunE = func(c *cobra.Command, args []string) error {
237+
named := gatherCommandValues(c, def, args)
238+
return raid.WithMutationLock(func() error {
239+
return raid.ExecuteRepoCommand(repoName, cmdName, args, named)
240+
})
241+
}
242+
repoCmd.AddCommand(subCmd)
234243
}
235244
root.AddCommand(repoCmd)
236245
}
237246
}
238247

248+
// buildCommandUse renders the cobra Use string with declared positional
249+
// args so `--help` shows the expected invocation shape, e.g.
250+
// `patch <ticket> [comment]`.
251+
func buildCommandUse(name string, args []lib.Arg) string {
252+
if len(args) == 0 {
253+
return name
254+
}
255+
var b strings.Builder
256+
b.WriteString(name)
257+
for _, a := range args {
258+
b.WriteByte(' ')
259+
if a.Required {
260+
b.WriteString("<")
261+
b.WriteString(a.Name)
262+
b.WriteString(">")
263+
} else {
264+
b.WriteString("[")
265+
b.WriteString(a.Name)
266+
b.WriteString("]")
267+
}
268+
}
269+
return b.String()
270+
}
271+
272+
// attachCommandArgsAndFlags configures a cobra subcommand from the lib.Command
273+
// definition: positional-arg cardinality validators and declared flags. Type
274+
// defaults to "string" when unset; "bool" / "int" are also recognised. A
275+
// missing or wrong-typed Default falls back to the zero value rather than
276+
// failing — the schema's `oneOf` already guards the YAML side.
277+
func attachCommandArgsAndFlags(co *cobra.Command, cmd lib.Command) {
278+
if n := len(cmd.Args); n > 0 {
279+
req := 0
280+
for _, a := range cmd.Args {
281+
if a.Required {
282+
req++
283+
}
284+
}
285+
switch {
286+
case req == n:
287+
co.Args = cobra.ExactArgs(n)
288+
case req == 0:
289+
co.Args = cobra.MaximumNArgs(n)
290+
default:
291+
co.Args = cobra.RangeArgs(req, n)
292+
}
293+
}
294+
295+
for _, f := range cmd.Flags {
296+
switch f.Type {
297+
case "bool":
298+
d, _ := f.Default.(bool)
299+
co.Flags().BoolP(f.Name, f.Short, d, f.Usage)
300+
case "int":
301+
d := 0
302+
switch v := f.Default.(type) {
303+
case int:
304+
d = v
305+
case float64:
306+
// YAML numbers unmarshal into float64 through interface{}.
307+
d = int(v)
308+
}
309+
co.Flags().IntP(f.Name, f.Short, d, f.Usage)
310+
default:
311+
d, _ := f.Default.(string)
312+
co.Flags().StringP(f.Name, f.Short, d, f.Usage)
313+
}
314+
if f.Required {
315+
_ = co.MarkFlagRequired(f.Name)
316+
}
317+
}
318+
}
319+
320+
// gatherCommandValues builds the name → value map that ExecuteCommand binds
321+
// to env vars. Returns nil when the command has no declared args/flags so
322+
// the legacy positional-only path stays untouched.
323+
func gatherCommandValues(co *cobra.Command, cmd lib.Command, posArgs []string) map[string]string {
324+
if len(cmd.Args) == 0 && len(cmd.Flags) == 0 {
325+
return nil
326+
}
327+
out := make(map[string]string, len(cmd.Args)+len(cmd.Flags))
328+
for i, a := range cmd.Args {
329+
if i < len(posArgs) {
330+
out[a.Name] = posArgs[i]
331+
}
332+
}
333+
for _, f := range cmd.Flags {
334+
switch f.Type {
335+
case "bool":
336+
v, _ := co.Flags().GetBool(f.Name)
337+
if v {
338+
out[f.Name] = "true"
339+
} else {
340+
out[f.Name] = "false"
341+
}
342+
case "int":
343+
v, _ := co.Flags().GetInt(f.Name)
344+
out[f.Name] = strconv.Itoa(v)
345+
default:
346+
v, _ := co.Flags().GetString(f.Name)
347+
out[f.Name] = v
348+
}
349+
}
350+
return out
351+
}
352+
239353
func hasCommand(root *cobra.Command, name string) bool {
240354
for _, c := range root.Commands() {
241355
if c.Name() == name {

0 commit comments

Comments
 (0)