From e5cd934004a8138eb22076f1552f15017c4e466d Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 12 May 2026 20:34:57 -0700 Subject: [PATCH 1/4] feat: shared options block on every task type and command (closes #54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a composable `options:` block on the base task definition (via `$defs/taskOptions` in the schema) so cross-task fields stay in one place. Every task type inherits the block — no per-type duplication in YAML. User-defined commands accept the same shape so options compose at both layers. Initial field: `showExeTime` (bool, default false). When true, raid prints a dim line to stderr after the task or command completes: → task-name (1.2s) It fires for both success and failure so the user always sees how long the task ran. Task-level and command-level flags are independent — set both to time the command end-to-end and see per-task breakdowns: commands: - name: build options: {showExeTime: true} tasks: - type: Shell name: server-build cmd: swift build options: {showExeTime: true} → server-build (164.1s) → build (164.1s) Notes on the implementation: - TaskProps gains an Options *TaskOptions pointer; Command gains its own. ExecuteTask wraps the dispatch with timing; runCommand wraps the inner runCommandTasks similarly. timeNowFn is the seam for deterministic test output. - Task.Label() returns Name when set, falling back to the raw task type so unnamed tasks still produce a meaningful line. - formatExeDuration mirrors the recent-runs formatter in cmd/context (250ms / 1.5s / 1m15s / 2h00m) so durations read consistently across raid's surfaces. - Schema rejects unknown option fields (additionalProperties: false on the taskOptions $def) so the future `quiet`/`timeout` work doesn't silently accept typos. - The static schema copy under site/static/schema/v1/ is re-synced — the embedded vs published cross-check test guards this. Tests: - TestExecuteTask_showExeTime_* cover the emit line, the type-fallback label, the failure-path emission, and the no-options default. - TestRunCommand_showExeTime_* cover command-level emission and the default-off behavior. - TestValidateProfile_optionsAccepted_onEveryTaskType locks the taskCommon inheritance — every task variant accepts `options:` plus the command-level block. - TestValidateProfile_optionsRejectsUnknownField guards against the future `quiet` field landing silently. - TestFormatExeDuration locks the bucket boundaries. Version 0.13.0-beta → 0.14.0-beta. Docs updated: schema reference adds an Options section + command-level row, whats-new entry for 0.14.0. Co-Authored-By: Claude Opus 4.7 (1M context) --- schemas/raid-defs.schema.json | 18 ++++ site/docs/references/schema.mdx | 36 ++++++++ site/docs/whats-new.mdx | 4 + src/internal/lib/command.go | 30 +++++-- src/internal/lib/command_test.go | 45 ++++++++++ src/internal/lib/profile_test.go | 81 +++++++++++++++++ src/internal/lib/task.go | 24 +++++ src/internal/lib/task_runner.go | 47 ++++++++++ src/internal/lib/task_runner_test.go | 126 +++++++++++++++++++++++++++ src/resources/app.properties | 2 +- 10 files changed, 406 insertions(+), 7 deletions(-) diff --git a/schemas/raid-defs.schema.json b/schemas/raid-defs.schema.json index 680c926..134f54c 100644 --- a/schemas/raid-defs.schema.json +++ b/schemas/raid-defs.schema.json @@ -544,6 +544,9 @@ "tasks": { "$ref": "#/properties/tasks" }, + "options": { + "$ref": "#/$defs/taskOptions" + }, "out": { "type": "object", "description": "Output configuration for the command", @@ -608,8 +611,23 @@ } }, "additionalProperties": false + }, + "options": { + "$ref": "#/$defs/taskOptions" } } + }, + "taskOptions": { + "type": "object", + "description": "Shared task / command options. Compose cleanly across task types and on commands; omitting `options` (or any field within) leaves default behavior unchanged. Additional fields will be added in future releases.", + "properties": { + "showExeTime": { + "type": "boolean", + "description": "When true, print a dim line to stderr after the task (or command) completes showing the elapsed time, e.g. `→ task-name (1.2s)`.", + "default": false + } + }, + "additionalProperties": false } } } diff --git a/site/docs/references/schema.mdx b/site/docs/references/schema.mdx index 3efb7f6..758af73 100644 --- a/site/docs/references/schema.mdx +++ b/site/docs/references/schema.mdx @@ -149,6 +149,7 @@ commands: | `args` | list | No | Declared positional arguments. See [Args](#command-args). | | `flags` | list | No | Declared flags / options. See [Flags](#command-flags). | | [`tasks`](#task) | list | Yes | Task sequence to run | +| [`options`](#options) | object | No | Shared options block. Same shape as on tasks. Fires once per command — independent of per-task `options`. | | [`out`](#output) | object | No | Output configuration | Command names cannot shadow built-in names: `install`, `env`, `profile`, `doctor`. @@ -236,6 +237,41 @@ All tasks share these common fields: | `name` | string | No | Human-readable label, surfaced in logs and agent output. Does not affect execution. | | `concurrent` | bool | No | Run in parallel with adjacent concurrent tasks | | [`condition`](#condition) | object | No | Conditions that must all pass for the task to run | +| [`options`](#options) | object | No | Shared options block. Composes across every task type and is also accepted on commands. | + +### Options + +The `options:` block is shared by every task type and by user-defined commands. Omitting it (or any field within) leaves default behavior unchanged. New fields ship additively. + +| Field | Type | Default | Description | +|---|---|---|---| +| `showExeTime` | bool | `false` | When true, raid prints a dim line to stderr after the task (or command) completes: `→ (1.2s)`. Fires for both success and failure. On a command this fires once for the whole command's elapsed time; on a task it fires for that single task. The two are independent — set both to time the command end-to-end *and* see per-task breakdowns. | + +```yaml +commands: + - name: build + options: + showExeTime: true # one final line after the whole command + tasks: + - type: Shell + name: server-build + cmd: swift build + options: + showExeTime: true # per-task line too + - type: Shell + name: client-install + cmd: npm install + options: + showExeTime: true +``` + +Output: + +``` +→ server-build (164.1s) +→ client-install (0.9s) +→ build (165.0s) +``` ### Condition diff --git a/site/docs/whats-new.mdx b/site/docs/whats-new.mdx index b88de99..4b4ef86 100644 --- a/site/docs/whats-new.mdx +++ b/site/docs/whats-new.mdx @@ -9,6 +9,10 @@ description: Feature-by-feature release notes for Raid. User-visible changes per release, latest first. For full commit history see the [GitHub releases page](https://github.com/8bitalex/raid/releases). +## 0.14.0 — upcoming + +**Shared `options:` block on every task type.** A new `options:` block on the base task definition composes uniformly across every task type (and on user-defined commands) so cross-cutting fields don't have to be re-declared per type. The initial field, `showExeTime: bool`, prints a dim line to stderr after a task or command completes with the elapsed time: `→ task-name (1.2s)`. Omitting `options` (or any field within it) leaves current behavior unchanged, so the addition is fully backwards compatible. Additional fields (`quiet`, `timeout`, …) will ship additively. Closes [#54](https://github.com/8bitAlex/raid/issues/54). + ## 0.13.0 — upcoming **Structured error output.** Every raid failure now carries a stable `code` (e.g. `PROFILE_NOT_FOUND`, `REPO_NOT_CLONED`, `CLONE_FAILED`, `TASK_SHELL_FAILED`), a category that maps directly to one of five exit codes (`1` generic / `2` config / `3` task / `4` network / `5` not-found), and an optional `hint` explaining what the user can do next. Pass `--json` to any command (including `raid install`, `raid profile add`, custom commands) and errors come back as a single line of `{"error": {"code": "...", "category": "...", "message": "...", "hint": "..."}}` — agents can pivot on the code instead of parsing prose. The `--json` flag itself moved from per-command to a persistent flag on `rootCmd`, so it now works on every command. Error codes are treated as semver-stable: new codes ship additively; existing codes never change name or category across minor versions. See the new [Errors reference](/docs/references/errors) for the full table and JSON shape. Closes [#47](https://github.com/8bitAlex/raid/issues/47). diff --git a/src/internal/lib/command.go b/src/internal/lib/command.go index 658424b..f5b708b 100644 --- a/src/internal/lib/command.go +++ b/src/internal/lib/command.go @@ -13,12 +13,13 @@ import ( // Command is a named, user-defined CLI command that can be invoked via 'raid '. type Command struct { - Name string `json:"name"` - Usage string `json:"usage"` - Args []Arg `json:"args,omitempty"` - Flags []Flag `json:"flags,omitempty"` - Tasks []Task `json:"tasks"` - Out *Output `json:"out,omitempty"` + Name string `json:"name"` + Usage string `json:"usage"` + Args []Arg `json:"args,omitempty"` + Flags []Flag `json:"flags,omitempty"` + Tasks []Task `json:"tasks"` + Options *TaskOptions `json:"options,omitempty"` + Out *Output `json:"out,omitempty"` } // Arg declares a positional argument for a custom command. The supplied value @@ -220,6 +221,23 @@ func clearRaidArgs() { } func runCommand(cmd Command) error { + // Command-level showExeTime is independent of per-task timing — both + // flags can be set together. The command line is emitted after the + // final task (and after any per-task lines) so the timeline reads + // top-down. Like the task variant, it fires for both success and + // failure so the elapsed time is always visible. + if cmd.Options != nil && cmd.Options.ShowExeTime { + start := timeNowFn() + err := runCommandTasks(cmd) + emitExeTime(cmd.Name, timeNowFn().Sub(start)) + return err + } + return runCommandTasks(cmd) +} + +// runCommandTasks applies the optional Out wrapping and runs the task +// sequence. Split out so the showExeTime wrapper above stays focused. +func runCommandTasks(cmd Command) error { if cmd.Out == nil { return ExecuteTasks(cmd.Tasks) } diff --git a/src/internal/lib/command_test.go b/src/internal/lib/command_test.go index ff62cde..9c1f972 100644 --- a/src/internal/lib/command_test.go +++ b/src/internal/lib/command_test.go @@ -8,6 +8,7 @@ import ( "runtime" "strings" "testing" + "time" ) // --- Command.IsZero --- @@ -295,6 +296,50 @@ func TestExecuteCommand_namedBindings_skipsInvalidKeys(t *testing.T) { } } +func TestRunCommand_showExeTime_emitsLine(t *testing.T) { + origNow := timeNowFn + calls := 0 + t0 := time.Date(2026, 5, 12, 0, 0, 0, 0, time.UTC) + timeNowFn = func() time.Time { + defer func() { calls++ }() + if calls == 0 { + return t0 + } + return t0.Add(750 * time.Millisecond) + } + t.Cleanup(func() { timeNowFn = origNow }) + + var buf bytes.Buffer + restore := SetCommandOutput(io.Discard, &buf) + t.Cleanup(restore) + + cmd := Command{ + Name: "build", + Options: &TaskOptions{ShowExeTime: true}, + Tasks: []Task{{Type: Shell, Cmd: "exit 0"}}, + } + if err := runCommand(cmd); err != nil { + t.Fatalf("runCommand: %v", err) + } + if !strings.Contains(buf.String(), "→ build (750ms)") { + t.Errorf("stderr %q should carry command-level exe-time line", buf.String()) + } +} + +func TestRunCommand_showExeTime_off_byDefault(t *testing.T) { + var buf bytes.Buffer + restore := SetCommandOutput(io.Discard, &buf) + t.Cleanup(restore) + + cmd := Command{Name: "noop", Tasks: []Task{{Type: Shell, Cmd: "exit 0"}}} + if err := runCommand(cmd); err != nil { + t.Fatalf("runCommand: %v", err) + } + if buf.Len() != 0 { + t.Errorf("default runCommand must not emit on stderr, got %q", buf.String()) + } +} + func TestExecuteCommand_namedBindings(t *testing.T) { setupTestConfig(t) origOut, origErr := commandStdout, commandStderr diff --git a/src/internal/lib/profile_test.go b/src/internal/lib/profile_test.go index 3134904..78050d9 100644 --- a/src/internal/lib/profile_test.go +++ b/src/internal/lib/profile_test.go @@ -552,6 +552,87 @@ install: } } +// TestValidateProfile_optionsAccepted_onEveryTaskType locks in the +// taskCommon `options` block: every task variant must accept it so +// schema authors don't have to re-declare per type. Also covers the +// command-level options block. +func TestValidateProfile_optionsAccepted_onEveryTaskType(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "profile.yaml") + body := `name: opts +install: + tasks: + - type: Shell + cmd: 'true' + options: {showExeTime: true} + - type: Script + path: ./x.sh + options: {showExeTime: false} + - type: HTTP + url: 'https://example.com/a' + dest: /tmp/a + options: {showExeTime: true} + - type: Wait + url: 'https://example.com/ready' + options: {showExeTime: true} + - type: Template + src: ./t.tmpl + dest: /tmp/out + options: {showExeTime: true} + - type: Git + op: pull + options: {showExeTime: true} + - type: Prompt + var: NAME + options: {showExeTime: true} + - type: Confirm + message: ok? + options: {showExeTime: true} + - type: Print + message: hi + options: {showExeTime: true} + - type: Set + var: X + value: y + options: {showExeTime: true} +commands: + - name: build + options: {showExeTime: true} + tasks: + - type: Shell + cmd: 'true' +` + if err := os.WriteFile(path, []byte(body), 0644); err != nil { + t.Fatalf("setup: %v", err) + } + if err := ValidateProfile(path); err != nil { + t.Fatalf("ValidateProfile() unexpected error: %v", err) + } +} + +func TestValidateProfile_optionsRejectsUnknownField(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "profile.yaml") + // `quiet` is reserved for a future option (#54 mentions it explicitly + // as out of scope). Until it lands, the schema must reject it so + // authors get a clear error instead of silent acceptance. + body := `name: t +install: + tasks: + - type: Shell + cmd: 'true' + options: + showExeTime: true + quiet: true +` + if err := os.WriteFile(path, []byte(body), 0644); err != nil { + t.Fatalf("setup: %v", err) + } + if err := ValidateProfile(path); err == nil { + t.Fatal("ValidateProfile() should reject unknown option fields") + } +} + // TestValidateProfile_taskRejectsUnknownField verifies that an unknown // property on a task is still rejected after the schema was refactored to use // allOf + unevaluatedProperties:false instead of additionalProperties:false on diff --git a/src/internal/lib/task.go b/src/internal/lib/task.go index fbce545..2741dea 100644 --- a/src/internal/lib/task.go +++ b/src/internal/lib/task.go @@ -25,6 +25,30 @@ type TaskProps struct { // Name is an optional human-readable label for the task, surfaced in logs // and agent-facing output. It does not affect execution. Name string `json:"name,omitempty" yaml:"name,omitempty"` + // Options is the shared options block — see TaskOptions. Composes across + // every task type and is also accepted on user-defined commands. Omitting + // the block (or any field within) leaves default behavior unchanged. + Options *TaskOptions `json:"options,omitempty" yaml:"options,omitempty"` +} + +// TaskOptions is the shared `options:` block applied uniformly across all +// task types and to user-defined commands. New fields ship additively; old +// fields keep their semantics across minor versions. +type TaskOptions struct { + // ShowExeTime, when true, prints a dim "→