Skip to content
Merged
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
18 changes: 18 additions & 0 deletions schemas/raid-defs.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,9 @@
"tasks": {
"$ref": "#/properties/tasks"
},
"options": {
"$ref": "#/$defs/taskOptions"
},
"out": {
"type": "object",
"description": "Output configuration for the command",
Expand Down Expand Up @@ -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 complete in 1.2s`.",
"default": false
}
},
"additionalProperties": false
}
}
}
36 changes: 36 additions & 0 deletions site/docs/references/schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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: `<name> complete in 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 complete in 164.1s
client-install complete in 0.9s
build complete in 165.0s
```

### Condition

Expand Down
4 changes: 4 additions & 0 deletions site/docs/whats-new.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 complete in 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).
Expand Down
49 changes: 41 additions & 8 deletions src/internal/lib/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,21 @@ import (
"os"
"path/filepath"
"strings"
"time"

liberrs "github.com/8bitalex/raid/src/internal/lib/errs"
"github.com/8bitalex/raid/src/internal/sys"
)

// Command is a named, user-defined CLI command that can be invoked via 'raid <name>'.
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
Expand Down Expand Up @@ -219,13 +221,44 @@ func clearRaidArgs() {
}
}

// runCommand applies the optional Out wrapping and runs the task
// sequence. 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.
//
// The exe-time line is emitted *inside* the Out wrapping so it respects
// `out.stderr: false` (suppression) and `out.file` (capture) the same way
// task output does. Emitting it after the wrappers unwound would bypass
// both, so we keep all emission ordered against the Out lifecycle here.
func runCommand(cmd Command) error {
showExeTime := cmd.Options != nil && cmd.Options.ShowExeTime
var start time.Time
if showExeTime {
start = timeNowFn()
}

if cmd.Out == nil {
return ExecuteTasks(cmd.Tasks)
err := ExecuteTasks(cmd.Tasks)
if showExeTime {
emitExeTime(cmd.Name, timeNowFn().Sub(start))
}
return err
}

origOut, origErr := commandStdout, commandStderr
var outFile *os.File
defer func() {
// Emit the exe-time line BEFORE the file is closed and the
// original writers are restored, so it lands in the same place
// as the task output (file capture, stderr suppression).
if showExeTime {
emitExeTime(cmd.Name, timeNowFn().Sub(start))
}
if outFile != nil {
outFile.Close()
}
commandStdout = origOut
commandStderr = origErr
}()
Expand All @@ -246,7 +279,7 @@ func runCommand(cmd Command) error {
if err != nil {
return liberrs.Newf(liberrs.CodeTaskFailed, liberrs.CategoryTask, "failed to open output file '%s': %v", cmd.Out.File, err)
}
defer f.Close()
outFile = f
commandStdout = io.MultiWriter(commandStdout, f)
commandStderr = io.MultiWriter(commandStderr, f)
}
Expand Down
89 changes: 89 additions & 0 deletions src/internal/lib/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"runtime"
"strings"
"testing"
"time"
)

// --- Command.IsZero ---
Expand Down Expand Up @@ -295,6 +296,94 @@ 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 complete in 750ms") {
t.Errorf("stderr %q should carry command-level exe-time line", buf.String())
}
}

func TestRunCommand_showExeTime_respectsOutSuppressionAndFile(t *testing.T) {
// When `out.stderr: false` and `out.file: ...` are both set, the
// exe-time line must (a) NOT appear on the original stderr, and
// (b) BE captured in the output file — same rules as task output.
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 stderrBuf bytes.Buffer
restore := SetCommandOutput(io.Discard, &stderrBuf)
t.Cleanup(restore)

outFile := filepath.Join(t.TempDir(), "captured.txt")
cmd := Command{
Name: "build",
Options: &TaskOptions{ShowExeTime: true},
Tasks: []Task{{Type: Shell, Cmd: "exit 0"}},
Out: &Output{Stdout: true, Stderr: false, File: outFile},
}
if err := runCommand(cmd); err != nil {
t.Fatalf("runCommand: %v", err)
}

if strings.Contains(stderrBuf.String(), "build complete in") {
t.Errorf("exe-time line leaked to suppressed stderr: %q", stderrBuf.String())
}

data, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if !strings.Contains(string(data), "build complete in 750ms") {
t.Errorf("exe-time line missing from out.file capture: %q", string(data))
}
}

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
Expand Down
81 changes: 81 additions & 0 deletions src/internal/lib/profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions src/internal/lib/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 "→ <label> (1.2s)" line to
// stderr after the task (or command) completes — for both success and
// failure, so the elapsed time is always visible.
ShowExeTime bool `json:"showExeTime,omitempty" yaml:"showExeTime,omitempty"`
}

// Label returns the human-readable identifier for a task: its `name:`
// field when set, otherwise the task type. Used by showExeTime so a task
// without a name still prints something meaningful (e.g. "Shell").
func (t Task) Label() string {
if t.Name != "" {
return t.Name
}
return string(t.Type)
}

// Task represents a single unit of work in a task sequence.
Expand Down
Loading
Loading