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
1 change: 1 addition & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"Bash(flow mcp:*)",
"Bash(flow exec:*)",
"mcp__flow__run_command",
"mcp__flow__run_python",
"mcp__flow__run_executable",
"mcp__flow__get_execution_logs",
"mcp__flow__sync_executables",
Expand Down
3 changes: 2 additions & 1 deletion .claude/skills/flow-context/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ This repository uses **flow** for all development automation. The `mcp__flow__*`

1. **Named task?** (build, test, lint, validate, generate, deploy, …) → `mcp__flow__list_executables` to find it, then `mcp__flow__execute` with its verb + name. Don't hand-roll a shell command a flow executable already covers.
2. **Arbitrary one-off command?** (a `git ...`, `go test ./...`, a script) → `mcp__flow__run_command` with the command and a short `label`. This is preferred over raw `Bash`: it runs with workspace env/secrets and lands in `flow logs` with provenance. Pass `commands` (array) + `mode` (`serial`/`parallel`) to run several in one call.
3. **One-off is Python?** → `mcp__flow__run_python` with `code` and a short `label`, rather than `python -c` or a scratch `.py` file. flow resolves the workspace's virtualenv, so imports see the project's dependencies, and tracebacks report real line numbers.
3. **Something richer than one command?** (a serial/parallel batch, an HTTP `request`) → `mcp__flow__run_executable` with an inline `spec`.
4. Only fall back to `Bash` when a command genuinely shouldn't be recorded or flow isn't the right tool (e.g. interactive/TTY programs).

Expand All @@ -21,5 +22,5 @@ Common refs: `test unit`, `test e2e`, `lint`, `validate`, `generate`, `build bin

- Call `mcp__flow__get_info` at the start of a session, or when you need schema URLs to author `.flow` files.
- Author or edit flow files with `mcp__flow__write_flowfile` (validates against the schema server-side) rather than writing YAML by hand.
- Runs are scoped to the current workspace by default; `run_command`/`run_executable` accept an optional `workspace` to target another without switching the global current workspace.
- Runs are scoped to the current workspace by default; `run_command`/`run_python`/`run_executable` accept an optional `workspace` to target another without switching the global current workspace.
- To review what you've run this session, call `mcp__flow__get_execution_logs` with `mine: true`; `source`/`session`/`status` filter history more broadly (also `flow logs --source mcp --session <id>`).
19 changes: 19 additions & 0 deletions cmd/internal/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ func RegisterExecCmd(ctx *context.Context, rootCmd *cobra.Command) {
RegisterFlag(ctx, subCmd, *flags.LogModeFlag)
RegisterFlag(ctx, subCmd, *flags.BackgroundFlag)
RegisterFlag(ctx, subCmd, *flags.CmdFlag)
RegisterFlag(ctx, subCmd, *flags.InterpreterFlag)
RegisterFlag(ctx, subCmd, *flags.CmdModeFlag)
RegisterFlag(ctx, subCmd, *flags.LabelFlag)
RegisterFlag(ctx, subCmd, *flags.CmdDirFlag)
Expand Down Expand Up @@ -250,6 +251,21 @@ func execAdHoc(ctx *context.Context, cmd *cobra.Command, verb executable.Verb, c
logMode = tuikitIO.LogMode(lm)
}

interpreter := executable.ExecInterpreter(flags.ValueFor[string](cmd, *flags.InterpreterFlag, false))
if interpreter != "" {
// Inline serial/parallel steps carry no interpreter of their own, so a
// multi-command batch could only run the first one as requested.
if len(commands) > 1 {
errhandler.HandleUsage(ctx, cmd, "--interpreter cannot be combined with multiple --cmd values")
return
}
probe := &executable.ExecExecutableType{Interpreter: &interpreter}
if err := probe.Validate(); err != nil {
errhandler.HandleUsage(ctx, cmd, "%v", err)
return
}
}

joined := strings.Join(commands, "\n")
e := &executable.Executable{
Verb: verb,
Expand All @@ -262,6 +278,9 @@ func execAdHoc(ctx *context.Context, cmd *cobra.Command, verb executable.Verb, c
Dir: executable.Directory(dir),
LogMode: logMode,
}
if interpreter != "" {
e.Exec.Interpreter = &interpreter
}
} else {
steps := make(executable.SerialRefConfigList, len(commands))
for i, c := range commands {
Expand Down
8 changes: 8 additions & 0 deletions cmd/internal/flags/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,14 @@ var CmdFlag = &Metadata{
Required: false,
}

var InterpreterFlag = &Metadata{
Name: "interpreter",
Usage: "The interpreter to run an ad-hoc --cmd with: 'sh' (default) or 'python'. " +
"Only valid with a single --cmd.",
Default: "",
Required: false,
}

var CmdModeFlag = &Metadata{
Name: "mode",
Usage: "How to run multiple --cmd commands: 'serial' (default) or 'parallel'.",
Expand Down
21 changes: 11 additions & 10 deletions docs/cli/flow_exec.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,17 @@ flow exec EXECUTABLE_ID [-- args...] [flags]
### Options

```
-b, --background Run the executable in the background and return a run ID immediately.
--cmd flow logs Run an ad-hoc shell command through flow instead of a named executable. The command runs with the current workspace's environment and is recorded in flow logs. Repeat --cmd to run multiple commands in one invocation (see --mode).
--dir string Working directory for an ad-hoc command (defaults to the current directory). Only valid with --cmd.
-h, --help help for exec
--label string A short, human-readable label for an ad-hoc command (used in history). Only valid with --cmd.
-m, --log-mode string Log mode (text, logfmt, json, hidden)
--mode string How to run multiple --cmd commands: 'serial' (default) or 'parallel'. (default "serial")
-p, --param stringArray Set a parameter value by env key. (i.e. KEY=value) Use multiple times to set multiple parameters. This will override any existing parameter values defined for the executable.
--spec flow logs Run a transient executable from an inline definition (any type: exec, serial, parallel, request, render, launch). Accepts inline YAML/JSON, '@path' to read a file, or '-' to read stdin. The executable is not saved to disk but is recorded in flow logs.
--workspace string Workspace whose environment the ad-hoc/transient run should use (only with --cmd or --spec). Defaults to the workspace containing the run directory, then the current workspace. Does not change the global current workspace.
-b, --background Run the executable in the background and return a run ID immediately.
--cmd flow logs Run an ad-hoc shell command through flow instead of a named executable. The command runs with the current workspace's environment and is recorded in flow logs. Repeat --cmd to run multiple commands in one invocation (see --mode).
--dir string Working directory for an ad-hoc command (defaults to the current directory). Only valid with --cmd.
-h, --help help for exec
--interpreter string The interpreter to run an ad-hoc --cmd with: 'sh' (default) or 'python'. Only valid with a single --cmd.
--label string A short, human-readable label for an ad-hoc command (used in history). Only valid with --cmd.
-m, --log-mode string Log mode (text, logfmt, json, hidden)
--mode string How to run multiple --cmd commands: 'serial' (default) or 'parallel'. (default "serial")
-p, --param stringArray Set a parameter value by env key. (i.e. KEY=value) Use multiple times to set multiple parameters. This will override any existing parameter values defined for the executable.
--spec flow logs Run a transient executable from an inline definition (any type: exec, serial, parallel, request, render, launch). Accepts inline YAML/JSON, '@path' to read a file, or '-' to read stdin. The executable is not saved to disk but is recorded in flow logs.
--workspace string Workspace whose environment the ad-hoc/transient run should use (only with --cmd or --spec). Defaults to the workspace containing the run directory, then the current workspace. Does not change the global current workspace.
```

### Options inherited from parent commands
Expand Down
14 changes: 9 additions & 5 deletions docs/guides/ai-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,17 +61,18 @@ below: the `.mcp.json` supplies the tools, the skill tells the assistant to reac
| `get_executable` | Full definition and metadata for a specific executable |
| `execute` | Run a **named** executable by ref, in a given `dir` or `workspace` |
| `run_command` | Run one or more **arbitrary** shell commands through flow (with a `label`, working `dir`, and optional `workspace`) — captured in history like any executable |
| `run_python` | Run **Python** code through flow (with `code`, a `label`, working `dir`, and optional `workspace`) — uses the workspace's virtualenv when there is one |
| `run_executable` | Run a **transient executable of any type** from an inline `spec` — a serial/parallel batch, an HTTP `request`, a `render`, or a `launch` — without saving a file |
| `get_execution_logs` | Output from recent runs, filterable by `source`/`session`/`status`, or `mine` for this session's own runs |
| `sync_executables` | Refresh cached workspace and executable state |
| `write_flowfile` | Create or update a `.flow` file, validated before writing |

The three run tools form a ladder, closest-fit first: **`execute`** for a task you've already named, **`run_command`** for a one-off shell command, **`run_executable`** for something richer than a single command. Reaching for flow before a raw shell tool means every run inherits the workspace's environment and secrets and is recorded — see [Observability](#observability) below.
The run tools form a ladder, closest-fit first: **`execute`** for a task you've already named, **`run_command`** for a one-off shell command, **`run_python`** when the one-off is Python rather than shell, **`run_executable`** for something richer than a single command. Reaching for flow before a raw shell tool means every run inherits the workspace's environment and secrets and is recorded — see [Observability](#observability) below.

**Working in a worktree or a fresh clone**

The MCP server inherits whatever directory it was started in, which is often not where you are
working. Pass `dir` on `execute`, `run_command`, `run_executable`, or `list_executables` and flow
working. Pass `dir` on `execute`, `run_command`, `run_python`, `run_executable`, or `list_executables` and flow
resolves the workspace by walking up from *that* directory to the nearest `flow.yaml` — so a git
worktree or a just-cloned repo works without being registered first. `get_info` reports
`workspaceRegistered` and `workspaceSource` so you can tell which case you are in; an
Expand Down Expand Up @@ -117,9 +118,12 @@ prefer them over raw Bash for anything runnable.
2. **Arbitrary one-off command?** (a `git ...`, a script) → `mcp__flow__run_command` with the
command and a short `label`. Runs with workspace env/secrets and lands in `flow logs`.
Pass `commands` (array) + `mode` (`serial`/`parallel`) to run several in one call.
3. **Something richer than one command?** (a serial/parallel batch, an HTTP `request`) →
3. **One-off is Python?** → `mcp__flow__run_python` with `code` and a short `label`, rather
than `python -c` or a scratch `.py` file. flow resolves the workspace's virtualenv, so
imports see the project's dependencies, and tracebacks report real line numbers.
4. **Something richer than one command?** (a serial/parallel batch, an HTTP `request`) →
`mcp__flow__run_executable` with an inline `spec`.
4. Only fall back to Bash for things that genuinely shouldn't be recorded or that flow isn't
5. Only fall back to Bash for things that genuinely shouldn't be recorded or that flow isn't
suited to (e.g. interactive/TTY programs).

Call `mcp__flow__get_info` at the start of a session, or when you need schema URLs to author
Expand All @@ -131,7 +135,7 @@ with `mine: true`.

## Observability

Every run flow launches — whether a named `execute`, a `run_command`, or a `run_executable` — is recorded as one lifecycle-aware history entry: written as `running` when it starts and updated to `completed` or `failed` when it finishes. Runs launched over MCP also capture **provenance**: which tool called (`claude`, `cursor`, …) and its session ID. That turns flow's history into an audit trail of what your assistant did.
Every run flow launches — whether a named `execute`, a `run_command`, a `run_python`, or a `run_executable` — is recorded as one lifecycle-aware history entry: written as `running` when it starts and updated to `completed` or `failed` when it finishes. Runs launched over MCP also capture **provenance**: which tool called (`claude`, `cursor`, …) and its session ID. That turns flow's history into an audit trail of what your assistant did.

Query it from the CLI:

Expand Down
3 changes: 2 additions & 1 deletion internal/mcp/resources/server-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ Prefer running work **through flow** over a raw shell tool — you get the works

- `execute` — run a **named** executable (build, test, lint, deploy, …) by verb + optional ID. Try this first; discover names with `list_executables`.
- `run_command` — run an **arbitrary** shell command when no named executable fits (a one-off `git status`, `npm ci`, a script). Pass a short `label` so the history entry is self-documenting; `dir` sets the working directory. To run several commands in one call, pass `commands` (array) with `mode: serial` (default) or `parallel`.
- `run_python` — run **Python** code through flow instead of `python -c` or a scratch file. Pass `code` (multi-line is fine — it runs from a file, so tracebacks report real line numbers) and a short `label`. flow picks the workspace's virtualenv when there is one, so imports resolve against the project's installed dependencies; read parameters and secrets from `os.environ`.
- `run_executable` — run a **transient executable of any type** from an inline `spec` when a single command isn't enough: a `serial`/`parallel` batch, an HTTP `request`, a `render`, or a `launch`. The `spec` is one executable definition (same shape as an entry under a flowfile's `executables:`); author non-trivial ones against `schemaUrls.flowFile`.

`run_command` and `run_executable` take an optional `workspace` to scope a run to another workspace's environment **without** changing the current workspace; otherwise the workspace is inferred from the run directory, then the current one.
`run_command`, `run_python`, and `run_executable` take an optional `workspace` to scope a run to another workspace's environment **without** changing the current workspace; otherwise the workspace is inferred from the run directory, then the current one.

Every run you launch is attributed to this session. `get_execution_logs` with `mine: true` returns only what *this* session has run — use it to review your own recent work; `source`/`session`/`status` filter more broadly.

Expand Down
66 changes: 66 additions & 0 deletions internal/mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ var _ = Describe("MCP Server", func() {
"list_executables",
"execute",
"run_command",
"run_python",
"run_executable",
"get_execution_logs",
"sync_executables",
Expand All @@ -113,6 +114,7 @@ var _ = Describe("MCP Server", func() {
toolsWithSchema := map[string]bool{
"execute": true,
"run_command": true,
"run_python": true,
"run_executable": true,
}

Expand Down Expand Up @@ -397,6 +399,70 @@ var _ = Describe("MCP Server", func() {
})
})

Context("run_python tool", func() {
It("should run python via flow exec --interpreter python --cmd", func() {
mockExecutor.EXPECT().
ExecuteContext(gomock.Any(), "exec", "--interpreter", "python", "--cmd", "print(1)",
"--label", "compute", "--dir", "/tmp").
Return("1", nil)

result, err := mcpClient.CallTool(ctx, newCallToolRequest("run_python", map[string]interface{}{
"code": "print(1)",
"label": "compute",
"dir": "/tmp",
}))

Expect(err).ToNot(HaveOccurred())
Expect(getTextContent(result)).To(ContainSubstring("1"))
})

It("should forward workspace and sync", func() {
mockExecutor.EXPECT().
ExecuteContext(gomock.Any(), "exec", "--interpreter", "python", "--cmd", "print(2)",
"--workspace", "other", "--sync").
Return("2", nil)

result, err := mcpClient.CallTool(ctx, newCallToolRequest("run_python", map[string]interface{}{
"code": "print(2)",
"workspace": "other",
"sync": true,
}))

Expect(err).ToNot(HaveOccurred())
Expect(getTextContent(result)).To(ContainSubstring("2"))
})

It("should preserve multi-line code as a single argument", func() {
// Line structure has to survive intact or tracebacks point at the
// wrong line.
code := "import sys\nif True:\n print(sys.version)\n"
mockExecutor.EXPECT().
ExecuteContext(gomock.Any(), "exec", "--interpreter", "python", "--cmd", code).
Return("ok", nil)

result, err := mcpClient.CallTool(ctx, newCallToolRequest("run_python", map[string]interface{}{
"code": code,
}))

Expect(err).ToNot(HaveOccurred())
Expect(getTextContent(result)).To(ContainSubstring("ok"))
})

It("should require code", func() {
result, err := mcpClient.CallTool(ctx, newCallToolRequest("run_python", map[string]interface{}{}))
Expect(err).ToNot(HaveOccurred())
Expect(result.IsError).To(BeTrue())
})

It("should reject empty code rather than running an empty script", func() {
result, err := mcpClient.CallTool(ctx, newCallToolRequest("run_python", map[string]interface{}{
"code": "",
}))
Expect(err).ToNot(HaveOccurred())
Expect(result.IsError).To(BeTrue())
})
})

Context("run_executable tool", func() {
It("should run a transient executable from an inline spec", func() {
spec := `{"verb":"run","serial":{"execs":[{"cmd":"echo one"}]}}`
Expand Down
Loading