Skip to content

Commit b55a3e0

Browse files
8bitAlexclaudeCopilot
authored
feat: agent-oriented metadata on commands (closes #51) (#88)
* feat: agent-oriented metadata on commands (closes #51) User-defined commands now accept an optional `agent:` block — `safe`, `reads`, `writes`, `description` — surfaced to MCP clients via the `raid://workspace/commands` resource. Clients use it to decide whether to auto-execute a command or prompt the user. Defaults are "unsafe": commands without an `agent:` block surface as `{safe: false}` in the always-present `agent` JSON object, preserving the current "requires confirmation" semantics. The shared command schema $ref means both top-level profile commands and per-repo raid.yaml commands carry the block; per-repo commands flow through naturally via the existing mergeCommands path at load time. `agent.description` falls back to the command's `usage` when empty, so MCP entries always have a non-empty descriptor. Hint only — raid surfaces the metadata; the agent client implements the policy. `raid_run_task` is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address Copilot review — fall back to command name for descriptor Schema only requires name+tasks, so a command can have neither usage nor agent.description. resolveAgent now falls back to cmd.Name as a last resort so MCP clients always see a non-empty descriptor, matching the contract in the doc comment. Co-Authored-By: Copilot <copilot@github.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Copilot <copilot@github.com>
1 parent d8b7a65 commit b55a3e0

14 files changed

Lines changed: 494 additions & 3 deletions

File tree

llms.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Raid is written in Go, distributed as a single self-contained binary, and publis
2323
- [Tasks](https://raidcli.dev/docs/features/tasks): All 11 task types — Shell, Script, Set, Git, HTTP, Wait, Prompt, Confirm, Print, Template, Group
2424
- [Task groups](https://raidcli.dev/docs/features/task-groups): Reusable task sequences invoked by `ref`
2525
- [Variables](https://raidcli.dev/docs/features/variables): Variable scoping, substitution, defaults, and overrides
26+
- [Agent metadata on commands](https://raidcli.dev/docs/references/schema#agent-metadata): Optional `agent:` block (`safe`, `reads`, `writes`, `description`) so MCP clients can decide whether to auto-execute. Defaults to `safe: false` for commands without the block.
2627

2728
## Usage
2829

schemas/raid-defs.schema.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,9 @@
547547
"options": {
548548
"$ref": "#/$defs/taskOptions"
549549
},
550+
"agent": {
551+
"$ref": "#/$defs/commandAgent"
552+
},
550553
"out": {
551554
"type": "object",
552555
"description": "Output configuration for the command",
@@ -634,6 +637,32 @@
634637
},
635638
"additionalProperties": false
636639
},
640+
"commandAgent": {
641+
"type": "object",
642+
"description": "Agent-facing safety metadata for a command. MCP clients read this from the `raid://workspace/commands` resource to decide whether to auto-execute or require user confirmation. Absence of the block is equivalent to `{safe: false}` — agents should treat unannotated commands as requiring confirmation.",
643+
"properties": {
644+
"safe": {
645+
"type": "boolean",
646+
"description": "When true, the command is idempotent with no side effects and may be auto-executed by MCP clients. Defaults to false.",
647+
"default": false
648+
},
649+
"reads": {
650+
"type": "array",
651+
"items": {"type": "string"},
652+
"description": "Paths or globs the command reads. Informational only — raid does not parse or enforce these. Mirrors Claude Code's permission-model fields."
653+
},
654+
"writes": {
655+
"type": "array",
656+
"items": {"type": "string"},
657+
"description": "Paths or globs the command writes. Informational only — raid does not parse or enforce these."
658+
},
659+
"description": {
660+
"type": "string",
661+
"description": "Agent-facing description. Overrides the command's `usage` field in the MCP workspace resource when set."
662+
}
663+
},
664+
"additionalProperties": false
665+
},
637666
"verifyArray": {
638667
"type": "array",
639668
"description": "Declarative precondition checks. Each entry runs `tasks:` to assert a dependency or environmental precondition; if any task exits non-zero and `onFail:` is provided, raid runs the remediation once and re-runs `tasks:` exactly once. `raid doctor` runs every verify entry on the active profile and per-repo `raid.yaml` files and surfaces each as a finding (ok / warn / error). Keep checks small and fast: each entry is run on every doctor invocation.",

site/docs/references/schema.mdx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ commands:
151151
| `flags` | list | No | Declared flags / options. See [Flags](#command-flags). |
152152
| [`tasks`](#task) | list | Yes | Task sequence to run |
153153
| [`options`](#options) | object | No | Shared options block. Same shape as on tasks. Fires once per command — independent of per-task `options`. |
154+
| [`agent`](#agent-metadata) | object | No | MCP-facing safety hint. Absence equates to `{safe: false}`. |
154155
| [`out`](#output) | object | No | Output configuration |
155156

156157
Command names cannot shadow built-in names: `install`, `env`, `profile`, `doctor`.
@@ -180,6 +181,44 @@ Each entry under `flags` declares a long-form `--name` flag (and optionally a `-
180181
| `required` | bool | No | If true, cobra rejects the invocation when the flag is omitted. |
181182
| `default` | string \| bool \| int | No | Value used when the flag is omitted. Must match `type`. |
182183

184+
### Agent metadata
185+
186+
Optional `agent:` block on a command surfaces safety hints to MCP clients (the `raid context serve` agent toolkit). Clients read the resolved form from the [`raid://workspace/commands`](../usage/context#workspace-resources) resource and use it to decide whether to auto-execute a command or prompt the user for approval.
187+
188+
**Default unsafe:** a command without an `agent:` block surfaces to MCP clients as `{safe: false}`. Existing config keeps its current "requires confirmation" semantics; opt commands in by setting `safe: true`.
189+
190+
**Hint only:** raid does not gate execution on these fields. The agent client implements the policy.
191+
192+
```yaml
193+
commands:
194+
- name: test
195+
usage: "Run unit tests"
196+
tasks:
197+
- type: Shell
198+
cmd: "go test ./..."
199+
agent:
200+
safe: true
201+
reads: ["./..."]
202+
writes: []
203+
description: "Run the Go unit-test suite. Idempotent, no side effects."
204+
205+
- name: deploy
206+
usage: "Deploy to production"
207+
tasks:
208+
- type: Shell
209+
cmd: "./scripts/deploy.sh"
210+
agent:
211+
safe: false
212+
description: "Deploys live infrastructure. Requires user confirmation."
213+
```
214+
215+
| Field | Type | Required | Description |
216+
|---|---|---|---|
217+
| `safe` | bool | No | When `true`, the command is idempotent and free of side effects — MCP clients that respect the hint may auto-execute it. Defaults to `false`. |
218+
| `reads` | list&lt;string&gt; | No | Paths or globs the command reads. Informational only — raid does not parse or enforce these. |
219+
| `writes` | list&lt;string&gt; | No | Paths or globs the command writes. Informational only. |
220+
| `description` | string | No | Agent-facing description. Overrides `usage` in the MCP workspace resource when set. |
221+
183222
### Output
184223

185224
When `out` is omitted, both stdout and stderr are shown. When `out` is present, only streams explicitly set to `true` are displayed. The `file` field writes all output to the specified path regardless of the `stdout`/`stderr` settings.

site/docs/usage/context.mdx

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ The same data the snapshot exposes statically is also available as a live [Model
173173
raid context serve # blocks; intended to be launched by an MCP host
174174
```
175175

176-
### What's exposed
176+
### Workspace resources
177177

178178
**Resources** — six `raid://workspace/*` URIs that mirror the static snapshot's `workspace` object. Each is fetched live on demand:
179179

@@ -182,10 +182,35 @@ raid context serve # blocks; intended to be launched by an MCP host
182182
| `raid://workspace/profile` | `text/plain` | Active profile name |
183183
| `raid://workspace/env` | `text/plain` | Active environment name |
184184
| `raid://workspace/repos` | `application/json` | Repositories with current git state |
185-
| `raid://workspace/commands` | `application/json` | User-defined raid commands |
185+
| `raid://workspace/commands` | `application/json` | User-defined raid commands, including [agent safety metadata](#agent-metadata-on-workspace-commands) |
186186
| `raid://workspace/recent` | `application/json` | Recent command invocations |
187187
| `raid://workspace/vars` | `application/json` | Persisted raid variables (`Set` task values + `RAID_REPO_*`). The server watches `~/.raid/vars` and reloads when another process modifies it, so reads are live across concurrent raid invocations. |
188188

189+
#### Agent metadata on workspace commands
190+
191+
Each entry in `raid://workspace/commands` carries an `agent` object so MCP clients can decide whether to auto-execute a command or prompt the user. `agent.safe` is always present in the JSON (no `omitempty`); a command authored without an [`agent:`](../references/schema#agent-metadata) block in the YAML surfaces here as `{"safe": false}`.
192+
193+
```json
194+
{
195+
"name": "test",
196+
"description": "Run unit tests",
197+
"agent": {
198+
"safe": true,
199+
"reads": ["./..."],
200+
"description": "Run unit tests. Idempotent, no side effects."
201+
}
202+
}
203+
```
204+
205+
| Field | Always present | Description |
206+
|---|---|---|
207+
| `safe` | yes | `true` only when explicitly authored. Defaults to `false`. |
208+
| `reads` | no (omitted when empty) | Paths or globs the command reads. Informational. |
209+
| `writes` | no (omitted when empty) | Paths or globs the command writes. Informational. |
210+
| `description` | no (omitted when empty) | Agent-facing description. Falls back to the command's `usage` field when no explicit `agent.description` is set. |
211+
212+
Raid never gates the `raid_run_task` tool on this metadata — it surfaces the hint and the client implements the policy.
213+
189214
**Tools** — the canonical raid agent toolkit defined in [issue #45](https://github.com/8bitalex/raid/issues/45):
190215

191216
| Tool | Purpose |

site/docs/whats-new.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ User-visible changes per release, latest first. For full commit history see the
1111

1212
## 0.14.0 — upcoming
1313

14+
**Agent-oriented metadata on commands.** Commands now accept an optional `agent:` block (`safe`, `reads`, `writes`, `description`) so MCP clients can decide whether to auto-execute a command or prompt the user for approval. The `raid://workspace/commands` resource always emits `agent.safe` for every command — entries authored without an `agent:` block surface as `{safe: false}`, preserving the current "requires confirmation" semantics by default. The metadata is a hint: raid surfaces it; the agent client implements the policy. Both top-level profile commands and per-repo `raid.yaml` commands carry the metadata. See [Schema → Agent metadata](/docs/references/schema#agent-metadata) and [Context → Agent metadata on workspace commands](/docs/usage/context#agent-metadata-on-workspace-commands). Closes [#51](https://github.com/8bitAlex/raid/issues/51).
15+
1416
**`raid doctor` runs `verify:` entries with self-heal.** Every `verify:` entry on the active profile and per-repo `raid.yaml` files now runs as part of `raid doctor` and produces its own finding: `ok` for a first-try pass, `warn` when the optional `onFail:` block recovered a failing precondition (the verify holds *now*, but didn't before — worth knowing), and `error` when the precondition can't be made to hold. Failures don't short-circuit subsequent entries, so a single `raid doctor` run reports the full picture. Doctor invokes the actual `tasks:` and `onFail:` blocks — any task type raid supports (shell, HTTP, Git, Template, Prompt/Confirm, SetVar, …), not just shell. Keep verify checks small and fast, since they'll run every time you (or CI, or an agent) checks raid's health. See [Doctor → Verify entries](/docs/usage/doctor#verify-entries). Closes [#42](https://github.com/8bitAlex/raid/issues/42).
1517

1618
**Declarative `verify:` blocks.** Profiles and per-repo `raid.yaml`s now accept a top-level `verify:` list. Each entry runs `tasks:` to assert a precondition (a tool is installed, a port is reachable, a credentials file exists), and an optional `onFail:` remediation gets exactly one chance to fix things — if it succeeds, raid re-runs `tasks:` once and the verify is reported as remediated; otherwise it surfaces as a structured `VERIFY_FAILED` error. Verify entries share execution context with `install:` tasks (active env + raid vars + task options). `raid doctor` integration is now wired (see entry above). See [Schema → Verify](/docs/references/schema#verify). Closes [#38](https://github.com/8bitAlex/raid/issues/38).

src/internal/lib/agent.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package lib
2+
3+
// Agent is the optional safety / metadata hint on a user-defined
4+
// command. MCP clients read the resolved form (see WorkspaceAgent)
5+
// from the `raid://workspace/commands` resource and use it to decide
6+
// whether to auto-execute a command or prompt the user for approval.
7+
//
8+
// Raid never gates execution on Agent — it surfaces the hint and the
9+
// client implements the policy. Absence of the block is equivalent to
10+
// `{Safe: false}` so unannotated commands stay opt-in to automation.
11+
type Agent struct {
12+
// Safe declares the command idempotent and side-effect free. MCP
13+
// clients that respect the hint may auto-run safe commands; unsafe
14+
// commands require explicit user approval. Defaults to false.
15+
Safe bool `json:"safe" yaml:"safe"`
16+
// Reads lists the paths or globs the command reads. Informational
17+
// only — raid does not parse or enforce these. Mirrors Claude
18+
// Code's permission-model fields so authors can reason about
19+
// safety the same way across tools.
20+
Reads []string `json:"reads,omitempty" yaml:"reads,omitempty"`
21+
// Writes lists the paths or globs the command writes. Same
22+
// semantics as Reads — informational, not enforced.
23+
Writes []string `json:"writes,omitempty" yaml:"writes,omitempty"`
24+
// Description is an agent-facing description of the command. When
25+
// set, it overrides the command's `usage` field in the workspace
26+
// resource — useful when the human-facing usage line is too terse
27+
// to give an agent enough context to choose the command.
28+
Description string `json:"description,omitempty" yaml:"description,omitempty"`
29+
}

src/internal/lib/agent_test.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package lib
2+
3+
import (
4+
"encoding/json"
5+
"reflect"
6+
"strings"
7+
"testing"
8+
)
9+
10+
func TestResolveAgent_nilProducesSafeFalse(t *testing.T) {
11+
got := resolveAgent(Command{Name: "lint", Usage: "Run linters"})
12+
if got.Safe {
13+
t.Errorf("Safe = true, want false for nil Agent")
14+
}
15+
if got.Description != "Run linters" {
16+
t.Errorf("Description = %q, want fallback to Usage", got.Description)
17+
}
18+
if got.Reads != nil || got.Writes != nil {
19+
t.Errorf("Reads/Writes = %v/%v, want nil for nil Agent", got.Reads, got.Writes)
20+
}
21+
}
22+
23+
func TestResolveAgent_safeTrueRoundTrips(t *testing.T) {
24+
got := resolveAgent(Command{
25+
Name: "test",
26+
Usage: "Run tests",
27+
Agent: &Agent{Safe: true},
28+
})
29+
if !got.Safe {
30+
t.Error("Safe = false, want true")
31+
}
32+
}
33+
34+
func TestResolveAgent_descriptionOverridesUsage(t *testing.T) {
35+
got := resolveAgent(Command{
36+
Name: "deploy",
37+
Usage: "deploy",
38+
Agent: &Agent{Description: "Deploy the service to prod — destructive"},
39+
})
40+
if got.Description != "Deploy the service to prod — destructive" {
41+
t.Errorf("Description = %q, want explicit override", got.Description)
42+
}
43+
}
44+
45+
func TestResolveAgent_emptyDescriptionFallsBackToUsage(t *testing.T) {
46+
got := resolveAgent(Command{
47+
Name: "test",
48+
Usage: "Run tests",
49+
Agent: &Agent{Safe: true}, // Description left empty
50+
})
51+
if got.Description != "Run tests" {
52+
t.Errorf("Description = %q, want fallback to Usage when Agent.Description empty", got.Description)
53+
}
54+
}
55+
56+
func TestResolveAgent_fallsBackToNameWhenUsageAndDescriptionEmpty(t *testing.T) {
57+
// Schema only requires name+tasks, so a valid command can have neither
58+
// usage nor agent.description. MCP clients still need a non-empty
59+
// descriptor, so the command name is the last-resort fallback.
60+
got := resolveAgent(Command{Name: "lint"})
61+
if got.Description != "lint" {
62+
t.Errorf("Description = %q, want fallback to Name", got.Description)
63+
}
64+
65+
got = resolveAgent(Command{Name: "deploy", Agent: &Agent{Safe: false}})
66+
if got.Description != "deploy" {
67+
t.Errorf("Description = %q, want fallback to Name when Agent present but empty", got.Description)
68+
}
69+
}
70+
71+
func TestResolveAgent_readsWritesPreserved(t *testing.T) {
72+
reads := []string{"src/**/*.go", "go.mod"}
73+
writes := []string{"dist/"}
74+
got := resolveAgent(Command{
75+
Name: "build",
76+
Usage: "Build",
77+
Agent: &Agent{Safe: false, Reads: reads, Writes: writes},
78+
})
79+
if !reflect.DeepEqual(got.Reads, reads) {
80+
t.Errorf("Reads = %v, want %v", got.Reads, reads)
81+
}
82+
if !reflect.DeepEqual(got.Writes, writes) {
83+
t.Errorf("Writes = %v, want %v", got.Writes, writes)
84+
}
85+
}
86+
87+
func TestWorkspaceAgent_JSONAlwaysEmitsSafeField(t *testing.T) {
88+
// Public-contract assertion: even when the Command has no Agent block,
89+
// the marshalled WorkspaceCommand must include "safe":false. MCP
90+
// clients rely on reading agent.safe without checking presence first.
91+
wc := WorkspaceCommand{
92+
Name: "lint",
93+
Description: "Lint everything",
94+
Agent: resolveAgent(Command{Name: "lint", Usage: "Lint everything"}),
95+
}
96+
buf, err := json.Marshal(wc)
97+
if err != nil {
98+
t.Fatalf("Marshal: %v", err)
99+
}
100+
if !strings.Contains(string(buf), `"safe":false`) {
101+
t.Errorf("JSON missing safe field: %s", buf)
102+
}
103+
}
104+
105+
func TestWorkspaceAgent_omitsEmptySlicesAndDescription(t *testing.T) {
106+
// A nil-Agent command falls back to Usage for Description; Reads and
107+
// Writes should be omitted from the JSON since they're nil.
108+
wc := WorkspaceCommand{
109+
Name: "lint",
110+
Agent: resolveAgent(Command{Name: "lint", Usage: "Lint"}),
111+
}
112+
buf, err := json.Marshal(wc)
113+
if err != nil {
114+
t.Fatalf("Marshal: %v", err)
115+
}
116+
got := string(buf)
117+
if strings.Contains(got, "reads") || strings.Contains(got, "writes") {
118+
t.Errorf("JSON should omit empty reads/writes: %s", got)
119+
}
120+
}

src/internal/lib/command.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ type Command struct {
2020
Flags []Flag `json:"flags,omitempty"`
2121
Tasks []Task `json:"tasks"`
2222
Options *TaskOptions `json:"options,omitempty"`
23-
Out *Output `json:"out,omitempty"`
23+
// Agent carries optional MCP-facing safety metadata. A nil Agent
24+
// surfaces to MCP clients as `{safe: false}` so unannotated
25+
// commands keep their "requires confirmation" semantics.
26+
Agent *Agent `json:"agent,omitempty"`
27+
Out *Output `json:"out,omitempty"`
2428
}
2529

2630
// Arg declares a positional argument for a custom command. The supplied value

src/internal/lib/context.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,50 @@ type WorkspaceCommand struct {
7474
Name string `json:"name"`
7575
Description string `json:"description,omitempty"`
7676
Steps []WorkspaceStep `json:"steps,omitempty"`
77+
// Agent is the resolved MCP-facing safety hint. Always emitted so
78+
// clients can rely on `agent.safe` being present — a command with
79+
// no `agent:` block in YAML surfaces as `{safe: false}`.
80+
Agent WorkspaceAgent `json:"agent"`
81+
}
82+
83+
// WorkspaceAgent is the wire form of Agent. `Safe` has no `omitempty`
84+
// so it's always present in the JSON, which is load-bearing for the
85+
// "default unsafe" contract — clients can read `agent.safe` without
86+
// distinguishing absence from `false`.
87+
type WorkspaceAgent struct {
88+
Safe bool `json:"safe"`
89+
Reads []string `json:"reads,omitempty"`
90+
Writes []string `json:"writes,omitempty"`
91+
Description string `json:"description,omitempty"`
92+
}
93+
94+
// resolveAgent flattens a Command's optional Agent block into the
95+
// always-emitted wire form. A nil Agent yields `{Safe: false}` with
96+
// Description falling back to the command's `usage`. When the block
97+
// is present, an empty Description still falls back to `usage` so
98+
// MCP clients always see a non-empty descriptor.
99+
func resolveAgent(cmd Command) WorkspaceAgent {
100+
if cmd.Agent == nil {
101+
return WorkspaceAgent{Description: descriptionFallback(cmd, "")}
102+
}
103+
return WorkspaceAgent{
104+
Safe: cmd.Agent.Safe,
105+
Reads: cmd.Agent.Reads,
106+
Writes: cmd.Agent.Writes,
107+
Description: descriptionFallback(cmd, cmd.Agent.Description),
108+
}
109+
}
110+
111+
// descriptionFallback guarantees a non-empty descriptor for MCP clients:
112+
// agent.description → usage → command name.
113+
func descriptionFallback(cmd Command, desc string) string {
114+
if desc != "" {
115+
return desc
116+
}
117+
if cmd.Usage != "" {
118+
return cmd.Usage
119+
}
120+
return cmd.Name
77121
}
78122

79123
// WorkspaceStep describes one named task inside a command's task sequence.
@@ -133,6 +177,7 @@ func GetWorkspaceContext() WorkspaceContext {
133177
Name: cmd.Name,
134178
Description: cmd.Usage,
135179
Steps: collectSteps(cmd.Tasks),
180+
Agent: resolveAgent(cmd),
136181
})
137182
}
138183
return wc

0 commit comments

Comments
 (0)