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
8 changes: 4 additions & 4 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,19 +40,19 @@
],
"deny": [
"Edit(types/**/*.gen.go)",
"Write(types/**/*.gen.go)",
"Edit(docs/cli/**)",
"Write(docs/cli/**)",
"Edit(docs/types/**)",
"Write(docs/types/**)",
"Edit(docs/public/schemas/**)",
"Write(docs/public/schemas/**)",
"Bash(flow secret get:*)",
"Bash(flow secret list:*)",
"Bash(env)",
"Bash(printenv:*)",
"Read(./.env)",
"Read(./.env.*)"
]
},
"env": {
"FLOW_RUN_CLIENT": "claude-code",
"FLOW_RUN_SESSION": "${CLAUDE_CODE_SESSION_ID}"
}
}
9 changes: 9 additions & 0 deletions .claude/skills/setup/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
name: setup
description: Set up a fresh clone of the flow repo for development (Go toolchain, workspace registration, dev tools, validation).
---

1. Go 1.25+, flow CLI installed
2. `flow workspace add flow . --set`
3. `flow install tools`
4. `flow validate`
10 changes: 1 addition & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,7 @@ The CLI emits a structured error envelope (`{"error":{"code","message","details"
`--output json|yaml`, plain text otherwise. Both paths go through `cmd/internal/errors.HandleFatal`.

Typed errors in `pkg/errors/errors.go` implement `Code() string`. Extend that set rather than
returning bare `fmt.Errorf` when a stable machine-readable code matters. Codes: `INVALID_INPUT`,
`NOT_FOUND`, `EXECUTION_FAILED`, `TIMEOUT`, `CANCELLED`, `VALIDATION_FAILED`, `INTERNAL_ERROR`,
`PERMISSION_DENIED`.
returning bare `fmt.Errorf` when a stable machine-readable code matters.

---

Expand All @@ -144,9 +142,3 @@ Run the `validate` executable before marking a PR ready. Commit messages: impera
- **`.claude/settings.local.json`** — gitignored, per-user. Where absolute paths belong, e.g.
`permissions.additionalDirectories` for the module cache and sibling checkouts.

## Development Setup

1. Go 1.25+, flow CLI installed
2. `flow workspace add flow . --set`
3. `flow install tools`
4. `flow validate`
4 changes: 2 additions & 2 deletions cmd/internal/browse.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ func executableLibrary(ctx *context.Context, cmd *cobra.Command, _ []string) {
wsFilter := flags.ValueFor[string](cmd, *flags.FilterWorkspaceFlag, false)
switch wsFilter {
case ".":
wsFilter = ctx.Config.CurrentWorkspace
wsFilter = ctx.CurrentWorkspaceName()
case executable.WildcardWorkspace:
wsFilter = ""
}
Expand Down Expand Up @@ -140,7 +140,7 @@ func executableLibrary(ctx *context.Context, cmd *cobra.Command, _ []string) {
func listExecutables(ctx *context.Context, cmd *cobra.Command, _ []string) {
wsFilter := flags.ValueFor[string](cmd, *flags.FilterWorkspaceFlag, false)
if wsFilter == "." {
wsFilter = ctx.Config.CurrentWorkspace
wsFilter = ctx.CurrentWorkspaceName()
}

nsFilter := flags.ValueFor[string](cmd, *flags.FilterNamespaceFlag, false)
Expand Down
66 changes: 46 additions & 20 deletions cmd/internal/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,19 @@ func RegisterExecCmd(ctx *context.Context, rootCmd *cobra.Command) {
rootCmd.AddCommand(subCmd)
}

func execPreRun(_ *context.Context, _ *cobra.Command, _ []string) {
func execPreRun(ctx *context.Context, cmd *cobra.Command, _ []string) {
// --workspace has to move the whole context, not just the transient run's: a named
// executable is looked up by a ref expanded from the current workspace, and running one from
// a different workspace is refused outright.
if explicit := flags.ValueFor[string](cmd, *flags.RunWorkspaceFlag, false); explicit != "" {
res, err := filesystem.ResolveWorkspace(ctx.Config, filesystem.ResolveOptions{Override: explicit})
if err != nil {
errhandler.HandleUsage(ctx, cmd, "%v", err)
return
}
ctx.SetCurrentWorkspace(res)
}

runner.RegisterRunner(exec.NewRunner())
runner.RegisterRunner(launch.NewRunner())
runner.RegisterRunner(request.NewRunner())
Expand Down Expand Up @@ -210,7 +222,7 @@ func resolveExecutableForRun(
"executable '%s' belongs to workspace '%s' and cannot be run from the current workspace '%s'",
ref,
e.Workspace(),
ctx.Config.CurrentWorkspace,
ctx.CurrentWorkspaceName(),
))
}
return e, ref
Expand Down Expand Up @@ -357,37 +369,51 @@ func resolveSpecContent(spec string) (string, error) {
func setTransientContext(ctx *context.Context, cmd *cobra.Command, e *executable.Executable, runDir string) {
wsName, wsPath := resolveRunWorkspace(ctx, cmd, runDir)

currentName := ""
if ctx.CurrentWorkspace != nil {
currentName = ctx.CurrentWorkspace.AssignedName()
}
currentName := ctx.CurrentWorkspaceName()
if wsName != "" && wsName != currentName {
logger.Log().Infof("Running in workspace '%s' (current workspace is '%s')", wsName, currentName)
} else if wsName != "" && !ctx.WorkspaceIsRegistered() {
logger.Log().Debugf("Running in unregistered workspace '%s' (%s)", wsName, wsPath)
}

var flowFilePath string
if wsPath != "" {
flowFilePath = filepath.Join(wsPath, "flow.yaml")
flowFilePath = filepath.Join(wsPath, filesystem.WorkspaceConfigFileName)
}
e.SetContext(wsName, wsPath, ctx.Config.CurrentNamespace, flowFilePath)
}

// resolveRunWorkspace picks the workspace a transient run should use, in priority order:
// an explicit --workspace flag, then the workspace whose location contains runDir (longest match),
// then the global current workspace. It never changes the global current workspace.
// resolveRunWorkspace picks the workspace a transient run should use, in priority order: an
// explicit --workspace flag (a registered name or a path), then the nearest workspace at or above
// runDir, then a registered workspace containing runDir, then the global current workspace. It
// never changes the global current workspace.
func resolveRunWorkspace(ctx *context.Context, cmd *cobra.Command, runDir string) (name, path string) {
wsList, err := ctx.WorkspacesCache.GetWorkspaceConfigList()
if err != nil {
logger.Log().Debugf("unable to load workspaces for transient run resolution: %v", err)
if explicit := flags.ValueFor[string](cmd, *flags.RunWorkspaceFlag, false); explicit != "" {
res, err := filesystem.ResolveWorkspace(ctx.Config, filesystem.ResolveOptions{
Dir: runDir, Override: explicit,
})
if err != nil || res == nil {
errhandler.HandleUsage(ctx, cmd, "unable to resolve workspace %q: %v", explicit, err)
return "", ""
}
return res.Name, res.Path
}

if explicit := flags.ValueFor[string](cmd, *flags.RunWorkspaceFlag, false); explicit != "" {
if ws := wsList.FindByName(explicit); ws != nil {
return ws.AssignedName(), ws.Location()
// Discovery covers a worktree or clone that is registered nowhere; the cache lookup below
// still handles a registered workspace whose own flow.yaml has gone missing.
if runDir != "" {
res, err := filesystem.ResolveWorkspace(ctx.Config, filesystem.ResolveOptions{Dir: runDir})
if err != nil {
logger.Log().Debugf("unable to resolve workspace for transient run: %v", err)
} else if res != nil && res.Source != filesystem.SourceCurrent {
return res.Name, res.Path
}
errhandler.HandleUsage(ctx, cmd, "unknown workspace %q", explicit)
}

wsList, err := ctx.WorkspacesCache.GetWorkspaceConfigList()
if err != nil {
logger.Log().Debugf("unable to load workspaces for transient run resolution: %v", err)
}
if ws := workspaceForPath(wsList, runDir); ws != nil {
return ws.AssignedName(), ws.Location()
}
Expand Down Expand Up @@ -702,15 +728,15 @@ type provenance struct {
// runProvenanceFromEnv resolves run provenance from environment variables set by the caller
// (e.g. the MCP server). Source defaults to "cli".
func runProvenanceFromEnv() provenance {
source := os.Getenv(store.RunSourceEnv)
source := store.RunEnvValue(store.RunSourceEnv)
if source == "" {
source = store.RunSourceCLI
}
wd, _ := os.Getwd()
return provenance{
source: source,
client: os.Getenv(store.RunClientEnv),
session: os.Getenv(store.RunSessionEnv),
client: store.RunEnvValue(store.RunClientEnv),
session: store.RunEnvValue(store.RunSessionEnv),
dir: wd,
}
}
Expand Down
31 changes: 18 additions & 13 deletions cmd/internal/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,22 +105,27 @@ func printContext(ctx *context.Context, cmd *cobra.Command) {
}

func workspaceOrCurrent(ctx *context.Context, workspaceName string) *workspace.Workspace {
var ws *workspace.Workspace
if workspaceName == "" {
ws = ctx.CurrentWorkspace
workspaceName = ws.AssignedName()
} else {
wsPath, wsFound := ctx.Config.Workspaces[workspaceName]
if !wsFound {
// An empty name, or the name of the workspace already resolved for this command, is answered
// from the context — which is the only place a workspace discovered from the working
// directory exists, since it is in no config.
if workspaceName == "" || ctx.CurrentWorkspaceName() == workspaceName {
if ctx.CurrentWorkspace == nil {
return nil
}
var err error
ws, err = filesystem.LoadWorkspaceConfig(workspaceName, wsPath)
if err != nil {
logger.Log().WrapError(err, "unable to load workspace config")
}
ws.SetContext(workspaceName, wsPath)
logger.Log().Debugf("'%s' workspace set", ctx.CurrentWorkspaceName())
return ctx.CurrentWorkspace
}

wsPath, wsFound := ctx.Config.Workspaces[workspaceName]
if !wsFound {
return nil
}
ws, err := filesystem.LoadWorkspaceConfig(workspaceName, wsPath)
if err != nil {
logger.Log().WrapError(err, "unable to load workspace config")
return nil
}
ws.SetContext(workspaceName, wsPath)
logger.Log().Debugf("'%s' workspace set", workspaceName)
return ws
}
Expand Down
22 changes: 20 additions & 2 deletions cmd/internal/vault.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,25 @@ func registerCreateVaultCmd(ctx *context.Context, vaultCmd *cobra.Command) {
vaultCmd.AddCommand(createCmd)
}

// vaultWorkspacePath returns the directory a workspace-relative vault path resolves against. A
// vault outlives the command that created it, so anchoring one in a directory flow only found by
// walking up from here is almost never what the user meant — warn before doing it.
func vaultWorkspacePath(ctx *context.Context, cmd *cobra.Command) string {
if ctx.CurrentWorkspace == nil {
return ""
}
path := ctx.CurrentWorkspace.Location()
// Machine-readable output is parsed by the caller, so a loose warning line would corrupt it.
outputFormat := flags.ValueFor[string](cmd, *flags.OutputFormatFlag, false)
if !ctx.WorkspaceIsRegistered() && outputFormat == "" {
logger.Log().Warnf(
"workspace '%s' is not registered; run 'flow workspace add %s %s' to keep this vault reachable",
ctx.CurrentWorkspaceName(), ctx.CurrentWorkspaceName(), path,
)
}
return path
}

func createVaultFunc(ctx *context.Context, cmd *cobra.Command, args []string) {
vaultName := args[0]
vaultType := flags.ValueFor[string](cmd, *flags.VaultTypeFlag, false)
Expand Down Expand Up @@ -120,9 +139,8 @@ func createVaultFunc(ctx *context.Context, cmd *cobra.Command, args []string) {
ctx.Config.Vaults = make(map[string]string)
}

curWs := ctx.Config.CurrentWorkspace
vaultPath = utils.ExpandDirectory(
vaultPath, ctx.Config.Workspaces[curWs], vault.CacheDirectory(vaultName), nil,
vaultPath, vaultWorkspacePath(ctx, cmd), vault.CacheDirectory(vaultName), nil,
)

ctx.Config.Vaults[vaultName] = vaultPath
Expand Down
26 changes: 26 additions & 0 deletions cmd/internal/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,15 @@ func switchWorkspaceFunc(ctx *context.Context, cmd *cobra.Command, args []string
ws := args[0]
userConfig := ctx.Config
if _, found := userConfig.Workspaces[ws]; !found {
// Switching persists a name into the config, so a workspace that exists only because
// flow found its flow.yaml from here cannot be switched to — say so rather than
// reporting it as missing when the user is standing in it.
if ctx.CurrentWorkspaceName() == ws && !ctx.WorkspaceIsRegistered() {
errhandler.HandleUsage(ctx, cmd,
"workspace '%s' was discovered at %s but is not registered - run 'flow workspace add %s %s' first",
ws, ctx.CurrentWorkspace.Location(), ws, ctx.CurrentWorkspace.Location())
return
}
errhandler.HandleFatal(ctx, cmd, flowerrors.WorkspaceNotFoundError{Workspace: ws})
}
userConfig.CurrentWorkspace = ws
Expand Down Expand Up @@ -367,10 +376,19 @@ func removeWorkspaceFunc(ctx *context.Context, cmd *cobra.Command, args []string
errhandler.HandleFatal(ctx, cmd, flowerrors.WorkspaceNotFoundError{Workspace: name})
}

wsPath := userConfig.Workspaces[name]
delete(userConfig.Workspaces, name)
if err := filesystem.WriteConfig(userConfig); err != nil {
errhandler.HandleFatal(ctx, cmd, err)
}
if filesystem.WorkspaceConfigExists(wsPath) {
// The directory keeps its flow.yaml, so flow will still find it by walking up — just
// under its directory name rather than the name being removed here.
logger.Log().Infof(
"%s still has a %s, so running from %s resolves to the workspace '%s'",
wsPath, filesystem.WorkspaceConfigFileName, wsPath, filepath.Base(wsPath),
)
}

if err := cache.UpdateAll(ctx.DataStore); err != nil {
errhandler.HandleFatal(ctx, cmd, errors.Wrap(err, "unable to update cache"))
Expand Down Expand Up @@ -468,12 +486,20 @@ func getWorkspaceFunc(ctx *context.Context, cmd *cobra.Command, args []string) {
if len(args) == 1 {
workspaceName = args[0]
wsPath = ctx.Config.Workspaces[workspaceName]
if wsPath == "" && ctx.CurrentWorkspaceName() == workspaceName {
wsPath = ctx.CurrentWorkspace.Location()
}
} else {
if ctx.CurrentWorkspace == nil {
errhandler.HandleUsage(ctx, cmd, "no current workspace set — run 'flow workspace add' to get started")
return
}
workspaceName = ctx.CurrentWorkspace.AssignedName()
wsPath = ctx.CurrentWorkspace.Location()
if !ctx.WorkspaceIsRegistered() {
logger.Log().Infof(
"workspace '%s' was discovered at %s and is not registered", workspaceName, wsPath)
}
}

wsCfg, err := filesystem.LoadWorkspaceConfig(workspaceName, wsPath)
Expand Down
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ func NewRootCmd(ctx *context.Context) *cobra.Command {
case "fatal":
logger.Log().SetLevel(-1)
}
ctx.LogWorkspaceResolution()
sync := flags.ValueFor[bool](cmd.Root(), *flags.SyncCacheFlag, true)
if sync {
if err := cache.UpdateAll(ctx.DataStore); err != nil {
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export default defineConfig({
items: [
{ text: 'Interactive UI', link: '/guides/interactive' },
{ text: 'AI Tools', link: '/guides/ai-tools' },
{ text: 'Run Provenance', link: '/guides/run-provenance' },
{ text: 'Integrations', link: '/guides/integrations' },
]
},
Expand Down
15 changes: 13 additions & 2 deletions docs/guides/ai-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@ below: the `.mcp.json` supplies the tools, the skill tells the assistant to reac
| `list_workspaces` | All registered workspaces |
| `get_workspace` | Details and config for a specific workspace |
| `switch_workspace` | Change the active workspace |
| `list_executables` | Browse executables — filterable by tag, verb, workspace |
| `list_executables` | Browse executables — filterable by tag, verb, workspace, and resolvable from a `dir` |
| `get_executable` | Full definition and metadata for a specific executable |
| `execute` | Run a **named** executable by ref |
| `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_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 |
Expand All @@ -68,6 +68,17 @@ below: the `.mcp.json` supplies the tools, the skill tells the assistant to reac

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.

**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
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
unregistered workspace runs normally but cannot be switched to. Exporting `FLOW_WORKSPACE` in the
server's environment pins it for every call instead. See
[Unregistered workspaces](workspaces.md#unregistered-workspaces).

**Prompts**

Structured prompts the assistant can invoke for common tasks:
Expand Down
Loading
Loading