From 136eab6cbc99fa6542803800af9a0b64e2791659 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Wed, 1 Jul 2026 07:48:53 +0300 Subject: [PATCH 01/59] feat: Initialize CLI prompt entity and generate CLI interface --- cmd/captain/main.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cmd/captain/main.go b/cmd/captain/main.go index 512dabd..c7bb1c7 100644 --- a/cmd/captain/main.go +++ b/cmd/captain/main.go @@ -169,6 +169,9 @@ func main() { clicky.AddNamedCommand("build", containerCmd, cli.ContainerBuildOptions{}, cli.RunContainerBuild).Short = "Build container image" clicky.AddNamedCommand("run", containerCmd, cli.ContainerRunOptions{}, cli.RunContainerRun).Short = "Run container sandbox" + cli.RegisterPromptEntity() + clicky.GenerateCLI(rootCmd) + mcpConfig := &mcp.Config{ Name: "captain", Version: version, From d15f5ff841b14b5875c56e82299d73d433e5945a Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Wed, 1 Jul 2026 07:48:53 +0300 Subject: [PATCH 02/59] feat(ai): Add support for Claude and Codex Cmux backends Add support for Cmux backends (Claude and Codex) by updating the backend availability checks to include BackendClaudeCmux and BackendCodexCmux cases. These backends require the same dependencies as their CLI counterparts (tsx for Claude, codex for Codex). Also add a new embedded file system for prompt examples. --- pkg/ai/catalog_info.go | 4 ++-- pkg/ai/prompt/examples.go | 8 ++++++++ pkg/ai/provider_test.go | 2 ++ 3 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 pkg/ai/prompt/examples.go diff --git a/pkg/ai/catalog_info.go b/pkg/ai/catalog_info.go index a607ed0..40df054 100644 --- a/pkg/ai/catalog_info.go +++ b/pkg/ai/catalog_info.go @@ -66,9 +66,9 @@ func CatalogInfo(configuredProviders []string) []ModelInfo { // claude-cli need `tsx` on PATH. A turn still fails loud if the probe is wrong. func agentBackendAvailable(b Backend) bool { switch b { - case BackendCodexCLI: + case BackendCodexCLI, BackendCodexCmux: return binaryOnPath("codex") - case BackendClaudeAgent, BackendClaudeCLI: + case BackendClaudeAgent, BackendClaudeCLI, BackendClaudeCmux: return binaryOnPath("tsx") default: return false diff --git a/pkg/ai/prompt/examples.go b/pkg/ai/prompt/examples.go new file mode 100644 index 0000000..34231e3 --- /dev/null +++ b/pkg/ai/prompt/examples.go @@ -0,0 +1,8 @@ +package prompt + +import "embed" + +// Examples exposes the prompt examples embedded in the Captain binary. +// +//go:embed testdata +var Examples embed.FS diff --git a/pkg/ai/provider_test.go b/pkg/ai/provider_test.go index 0304b00..afa0d88 100644 --- a/pkg/ai/provider_test.go +++ b/pkg/ai/provider_test.go @@ -67,8 +67,10 @@ func TestAuthEnvVars(t *testing.T) { BackendAnthropic: {"ANTHROPIC_API_KEY"}, BackendClaudeCLI: {"ANTHROPIC_API_KEY"}, BackendClaudeAgent: {"ANTHROPIC_API_KEY"}, + BackendClaudeCmux: {"ANTHROPIC_API_KEY"}, BackendOpenAI: {"OPENAI_API_KEY"}, BackendCodexCLI: {"OPENAI_API_KEY"}, + BackendCodexCmux: {"OPENAI_API_KEY"}, BackendGemini: {"GEMINI_API_KEY", "GOOGLE_API_KEY"}, BackendGeminiCLI: {"GEMINI_API_KEY", "GOOGLE_API_KEY"}, } From 5a0484b2dfa364d3d715b1a1c5efb8381263e2b6 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Wed, 1 Jul 2026 07:48:54 +0300 Subject: [PATCH 03/59] refactor: include cmux backends in AllBackends() and simplify documentation --- pkg/api/enums.go | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/pkg/api/enums.go b/pkg/api/enums.go index 422f776..8938f24 100644 --- a/pkg/api/enums.go +++ b/pkg/api/enums.go @@ -25,11 +25,7 @@ const ( BackendClaudeAgent Backend = "claude-agent" // BackendClaudeCmux / BackendCodexCmux drive an interactive claude/codex TUI // inside a tmux/cmux surface (the cmux provider), tailing the session JSONL. - // They are registered providers selected explicitly by a host (gavel), not - // part of AllBackends(): they are not user-facing, carry no prompt-rendering - // fixture (the provider pastes the host's prompt verbatim), and are never - // inferred from a model name. Backend.Valid() is therefore false for them, - // which is fine — nothing in the run path validates the backend. + // They are selected explicitly, not inferred from a model name. BackendClaudeCmux Backend = "claude-cmux" BackendCodexCmux Backend = "codex-cmux" ) @@ -39,7 +35,8 @@ const ( func AllBackends() []Backend { return []Backend{ BackendAnthropic, BackendGemini, BackendOpenAI, - BackendClaudeCLI, BackendClaudeAgent, BackendCodexCLI, BackendGeminiCLI, + BackendClaudeCLI, BackendClaudeAgent, BackendClaudeCmux, + BackendCodexCLI, BackendCodexCmux, BackendGeminiCLI, } } From c1f4df26302e6b7f3fec97fce260ae7c1d203563 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Wed, 1 Jul 2026 07:48:54 +0300 Subject: [PATCH 04/59] feat(captainconfig): add PromptDefaults config type and improve test assertions --- pkg/captainconfig/config.go | 7 ++++++- pkg/captainconfig/config_test.go | 5 +++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/pkg/captainconfig/config.go b/pkg/captainconfig/config.go index 47bfbaf..fb57720 100644 --- a/pkg/captainconfig/config.go +++ b/pkg/captainconfig/config.go @@ -16,7 +16,8 @@ import ( ) type Config struct { - AI AIDefaults `yaml:"ai"` + AI AIDefaults `yaml:"ai"` + Prompts PromptDefaults `yaml:"prompts"` } type AIDefaults struct { @@ -36,6 +37,10 @@ type AIDefaults struct { NoMemory bool `yaml:"noMemory,omitempty"` } +type PromptDefaults struct { + Dirs []string `yaml:"dirs,omitempty"` +} + // pathOverride lets tests redirect Path() to a temp directory without touching // $HOME. Empty string means "use os.UserHomeDir". var pathOverride string diff --git a/pkg/captainconfig/config_test.go b/pkg/captainconfig/config_test.go index b379bb6..e3d23fd 100644 --- a/pkg/captainconfig/config_test.go +++ b/pkg/captainconfig/config_test.go @@ -3,6 +3,7 @@ package captainconfig import ( "os" "path/filepath" + "reflect" "testing" ) @@ -24,7 +25,7 @@ func TestLoad_MissingFileReturnsZero(t *testing.T) { if exists { t.Errorf("Load() exists = true, want false for missing file") } - if cfg != (Config{}) { + if !reflect.DeepEqual(cfg, Config{}) { t.Errorf("Load() cfg = %+v, want zero", cfg) } } @@ -65,7 +66,7 @@ func TestSaveLoad_RoundTrip(t *testing.T) { if !exists { t.Fatalf("Load() exists = false after Save") } - if got != want { + if !reflect.DeepEqual(got, want) { t.Errorf("round-trip mismatch:\n got = %+v\n want = %+v", got, want) } } From a21c44a0f2aafa6d548af7057376088c28058bb6 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Wed, 1 Jul 2026 07:48:54 +0300 Subject: [PATCH 05/59] refactor(cli): extract executePromptRequest function and support new backends --- pkg/cli/ai.go | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/pkg/cli/ai.go b/pkg/cli/ai.go index 5de1dba..caeacc3 100644 --- a/pkg/cli/ai.go +++ b/pkg/cli/ai.go @@ -31,7 +31,7 @@ func loadSavedAI() captainconfig.AIDefaults { type AIProviderOptions struct { Model string `flag:"model" help:"Model name, e.g. claude-sonnet-4, gemini-2.0-flash (defaults to the value saved by 'captain configure')" short:"m"` - Backend string `flag:"backend" help:"Force backend: anthropic|gemini|openai|claude-cli|claude-agent|codex-cli|gemini-cli (default: inferred from model or saved by 'captain configure')" short:"b"` + Backend string `flag:"backend" help:"Force backend: anthropic|gemini|openai|claude-cli|claude-agent|claude-cmux|codex-cli|codex-cmux|gemini-cli (default: inferred from model or saved by 'captain configure')" short:"b"` APIKey string `flag:"api-key" help:"API key (env: ANTHROPIC_API_KEY, GEMINI_API_KEY, GOOGLE_API_KEY)"` NoCache bool `flag:"no-cache" help:"Disable response caching"` Budget string `flag:"budget" help:"Max spend in USD, 0=unlimited" default:"0"` @@ -293,6 +293,14 @@ func RunAIPrompt(opts AIPromptOptions) (any, error) { return nil, err } + timeout, _ := time.ParseDuration(opts.Timeout) + if timeout <= 0 { + timeout = 120 * time.Second + } + return executePromptRequest(context.Background(), req, cfg, timeout, opts.NoStream) +} + +func executePromptRequest(parent context.Context, req ai.Request, cfg ai.Config, timeout time.Duration, noStream bool) (any, error) { p, err := ai.NewProvider(cfg) if err != nil { return nil, err @@ -301,14 +309,16 @@ func RunAIPrompt(opts AIPromptOptions) (any, error) { return nil, err } - timeout, _ := time.ParseDuration(opts.Timeout) + if parent == nil { + parent = context.Background() + } if timeout <= 0 { timeout = 120 * time.Second } - ctx, cancel := context.WithTimeout(context.Background(), timeout) + ctx, cancel := context.WithTimeout(parent, timeout) defer cancel() - if streamer, ok := p.(ai.StreamingProvider); ok && !opts.NoStream { + if streamer, ok := p.(ai.StreamingProvider); ok && !noStream { return runStreaming(ctx, streamer, req) } return runBuffered(ctx, p, req) From e2972f9c23593f8ed76545c1edb1a49bc9fb2c75 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Wed, 1 Jul 2026 07:48:54 +0300 Subject: [PATCH 06/59] feat: Add claude-cmux and codex-cmux backend options to AI models filter --- pkg/cli/ai_models.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cli/ai_models.go b/pkg/cli/ai_models.go index 3a24a5f..dde7449 100644 --- a/pkg/cli/ai_models.go +++ b/pkg/cli/ai_models.go @@ -67,7 +67,7 @@ func isLegacyModelID(id string) bool { type AIModelsOptions struct { Filter string `flag:"filter" help:"Filter models by name substring" short:"f"` - Backend string `flag:"backend" help:"Filter by backend: anthropic|gemini|openai|claude-cli|claude-agent|codex-cli|gemini-cli" short:"b"` + Backend string `flag:"backend" help:"Filter by backend: anthropic|gemini|openai|claude-cli|claude-agent|claude-cmux|codex-cli|codex-cmux|gemini-cli" short:"b"` Limit int `flag:"limit" help:"Maximum models to show" default:"50" short:"l"` All bool `flag:"all" help:"Include all OpenRouter models" short:"a"` } From ece427f0e3b4538a141718ce39a1364dfd97b9e4 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Wed, 1 Jul 2026 07:48:54 +0300 Subject: [PATCH 07/59] fix: fallback to config defaults for model and budget fields --- pkg/cli/ai_prompt_file.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/pkg/cli/ai_prompt_file.go b/pkg/cli/ai_prompt_file.go index 404ce1f..4376c8c 100644 --- a/pkg/cli/ai_prompt_file.go +++ b/pkg/cli/ai_prompt_file.go @@ -63,6 +63,18 @@ func overlayCLI(base ai.Request, baseCfg ai.Config, o AIPromptOptions) (ai.Reque req := base bm := base.Model + if bm.Name == "" { + bm.Name = baseCfg.Model.Name + } + if bm.Backend == "" { + bm.Backend = baseCfg.Model.Backend + } + if bm.Temperature == nil { + bm.Temperature = baseCfg.Model.Temperature + } + if bm.Effort == "" { + bm.Effort = baseCfg.Model.Effort + } m := bm m.Name = firstNonEmpty(o.Model, bm.Name, saved.Model) m.Backend = api.Backend(firstNonEmpty(o.Backend, string(bm.Backend), saved.Backend)) @@ -73,8 +85,8 @@ func overlayCLI(base ai.Request, baseCfg ai.Config, o AIPromptOptions) (ai.Reque m.Effort = api.Effort(firstNonEmpty(o.Effort, string(bm.Effort), saved.ReasoningEffort)) req.Model = m - req.Budget.MaxTokens = firstPositive(o.MaxTokens, base.Budget.MaxTokens, saved.MaxTokens, 4096) - req.Budget.Cost = firstPositiveFloat(budget, base.Budget.Cost, saved.BudgetUSD) + req.Budget.MaxTokens = firstPositive(o.MaxTokens, base.Budget.MaxTokens, baseCfg.Budget.MaxTokens, saved.MaxTokens, 4096) + req.Budget.Cost = firstPositiveFloat(budget, base.Budget.Cost, baseCfg.Budget.Cost, saved.BudgetUSD) if o.System != "" { req.Prompt.System = o.System From 2008fa326a33e2a4d790cb4bb739c189caf02677 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Wed, 1 Jul 2026 07:48:54 +0300 Subject: [PATCH 08/59] feat(cli): add Claude cmux and Codex cmux backend options Add Claude cmux and Codex cmux backend options to the CLI configuration. Expands the available backend choices for users to include the new cmux variants for both Claude and Codex AI providers. --- pkg/cli/configure.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/cli/configure.go b/pkg/cli/configure.go index bec23d1..c6df1b3 100644 --- a/pkg/cli/configure.go +++ b/pkg/cli/configure.go @@ -208,7 +208,9 @@ func backendOptions() []huh.Option[string] { huh.NewOption("OpenAI API", string(ai.BackendOpenAI)), huh.NewOption("Claude CLI", string(ai.BackendClaudeCLI)), huh.NewOption("Claude Agent (SDK)", string(ai.BackendClaudeAgent)), + huh.NewOption("Claude cmux", string(ai.BackendClaudeCmux)), huh.NewOption("Codex CLI", string(ai.BackendCodexCLI)), + huh.NewOption("Codex cmux", string(ai.BackendCodexCmux)), huh.NewOption("Gemini CLI", string(ai.BackendGeminiCLI)), } } From 3718a41d22204b6167c9ce12c672d7336a164777 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Wed, 1 Jul 2026 07:56:32 +0300 Subject: [PATCH 09/59] feat: add prompt workbench UI and entity management --- pkg/cli/configure_test.go | 2 +- pkg/cli/prompt_entity.go | 1282 +++++++++++++++++ pkg/cli/prompt_entity_test.go | 293 ++++ pkg/cli/serve.go | 7 +- pkg/cli/webapp/src/App.tsx | 23 +- pkg/cli/webapp/src/PromptWorkbench.tsx | 1765 ++++++++++++++++++++++++ pkg/cli/webapp/src/index.css | 68 + pkg/cli/webapp/vite.config.ts | 8 + pkg/cli/whoami.go | 2 +- 9 files changed, 3446 insertions(+), 4 deletions(-) create mode 100644 pkg/cli/prompt_entity.go create mode 100644 pkg/cli/prompt_entity_test.go create mode 100644 pkg/cli/webapp/src/PromptWorkbench.tsx diff --git a/pkg/cli/configure_test.go b/pkg/cli/configure_test.go index 7b42df1..400b716 100644 --- a/pkg/cli/configure_test.go +++ b/pkg/cli/configure_test.go @@ -38,7 +38,7 @@ func TestBuildConfigFromForm_TogglesInvert(t *testing.T) { NoMemory: true, }, } - if got != want { + if !reflect.DeepEqual(got, want) { t.Errorf("buildConfigFromForm()\n got = %+v\n want = %+v", got, want) } } diff --git a/pkg/cli/prompt_entity.go b/pkg/cli/prompt_entity.go new file mode 100644 index 0000000..a2d9691 --- /dev/null +++ b/pkg/cli/prompt_entity.go @@ -0,0 +1,1282 @@ +package cli + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/flanksource/captain/pkg/ai" + promptlib "github.com/flanksource/captain/pkg/ai/prompt" + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/clicky" + clickyapi "github.com/flanksource/clicky/api" + clickyrpc "github.com/flanksource/clicky/rpc" + dp "github.com/google/dotprompt/go/dotprompt" +) + +type promptDirsContextKey struct{} + +var registerPromptEntityOnce sync.Once + +type PromptListOptions struct { + Query string `flag:"query" help:"Search prompt name, description, model, or path"` + Source string `flag:"source" help:"Filter by source: all|embedded|local|"` +} + +type PromptVariable struct { + Name string `json:"name"` + Type string `json:"type,omitempty"` + Description string `json:"description,omitempty"` + Required bool `json:"required,omitempty"` +} + +type PromptSummary struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + SourceKind string `json:"sourceKind"` + SourceID string `json:"sourceId"` + Source string `json:"source"` + Path string `json:"path"` + RelPath string `json:"relPath"` + Writable bool `json:"writable"` + Model string `json:"model,omitempty"` + Backend string `json:"backend,omitempty"` + Variables []PromptVariable `json:"variables,omitempty"` + ParseError string `json:"parseError,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +func (p PromptSummary) GetID() string { return p.ID } +func (p PromptSummary) GetName() string { return p.Name } + +func (p PromptSummary) Columns() []clickyapi.ColumnDef { + return []clickyapi.ColumnDef{ + clickyapi.Column("name").Label("Name").Build(), + clickyapi.Column("source").Label("Source").Build(), + clickyapi.Column("relPath").Label("Path").MaxWidth(54).Build(), + clickyapi.Column("model").Label("Model").Build(), + clickyapi.Column("description").Label("Description").MaxWidth(80).Build(), + } +} + +func (p PromptSummary) Row() map[string]any { + return map[string]any{ + "name": p.Name, + "source": p.Source, + "relPath": p.RelPath, + "model": p.Model, + "description": p.Description, + } +} + +type PromptDetail struct { + PromptSummary + Content string `json:"content"` + InputSchema map[string]any `json:"inputSchema,omitempty"` + InputDefault map[string]any `json:"inputDefault,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type PromptWriteRequest struct { + Target string `json:"target"` + RelPath string `json:"relPath"` + Name string `json:"name"` + Content string `json:"content"` +} + +type PromptRuntimeOptions struct { + Model string `json:"model,omitempty"` + Backend string `json:"backend,omitempty"` + System string `json:"system,omitempty"` + AppendSystem string `json:"appendSystem,omitempty"` + Timeout string `json:"timeout,omitempty"` + MaxTokens int `json:"maxTokens,omitempty"` + Temperature string `json:"temperature,omitempty"` + Effort string `json:"effort,omitempty"` + Budget string `json:"budget,omitempty"` + MaxTurns int `json:"maxTurns,omitempty"` + Resume string `json:"resume,omitempty"` + Edit bool `json:"edit,omitempty"` + AllowedTools []string `json:"allowedTools,omitempty"` + DisallowedTools []string `json:"disallowedTools,omitempty"` + PermissionMode string `json:"permissionMode,omitempty"` + NoMCP bool `json:"noMcp,omitempty"` + NoHooks bool `json:"noHooks,omitempty"` + NoSkills bool `json:"noSkills,omitempty"` + SkillDirs []string `json:"skillDirs,omitempty"` + NoUser bool `json:"noUser,omitempty"` + NoProject bool `json:"noProject,omitempty"` + NoMemory bool `json:"noMemory,omitempty"` + Bare bool `json:"bare,omitempty"` + NoCache bool `json:"noCache,omitempty"` + Spec *api.Spec `json:"spec,omitempty"` +} + +type PromptRenderRequest struct { + Variables map[string]any `json:"variables,omitempty"` + Runtime PromptRuntimeOptions `json:"runtime,omitempty"` +} + +type PromptRenderResult struct { + ID string `json:"id"` + Name string `json:"name"` + Model string `json:"model,omitempty"` + Backend string `json:"backend,omitempty"` + User string `json:"user,omitempty"` + System string `json:"system,omitempty"` + Input ai.Request `json:"input"` + Config ai.Config `json:"config"` + InputSchema map[string]any `json:"inputSchema,omitempty"` + InputDefault map[string]any `json:"inputDefault,omitempty"` + ValidationError string `json:"validationError,omitempty"` +} + +type PromptActionFlags struct { + Vars string `flag:"vars" help:"JSON object of template variables"` + Model string `flag:"model" help:"Model override"` + Backend string `flag:"backend" help:"Backend override"` + Timeout string `flag:"timeout" help:"Request timeout" default:"120s"` + MaxTokens int `flag:"max-tokens" help:"Maximum output tokens"` +} + +func (PromptActionFlags) ClickyActionFlags() {} + +type promptSource struct { + Kind string + ID string + Label string + Root string + WalkRoot string + FS fs.FS + Writable bool + Implicit bool +} + +type promptRecord struct { + Source promptSource + ID string + Path string + Rel string +} + +type promptRef struct { + Kind string + SourceID string + RelPath string +} + +type promptInspection struct { + Metadata map[string]any + InputSchema map[string]any + InputDefault map[string]any + Variables []PromptVariable +} + +func RegisterPromptEntity() { + registerPromptEntityOnce.Do(func() { + clicky.NewEntity[PromptSummary, PromptListOptions, PromptDetail]("prompt"). + Aliases("prompts"). + ToolGroup("captain.prompts"). + ListWithContext(listPrompts). + GetWithContext(getPrompt). + CreateWithContext(createPrompt). + UpdateWithContext(updatePrompt). + DeleteWithContext(deletePrompt). + WithAction(clicky.ActionWithFlagsAndContext("render", PromptActionFlags{}, renderPromptAction). + WithShort("Render the prompt without calling a model")). + WithAction(clicky.ActionWithFlagsAndContext("run", PromptActionFlags{}, runPromptAction). + WithShort("Render and execute the prompt")). + Register() + }) +} + +func ContextWithPromptDirs(ctx context.Context, dirs []string) context.Context { + return context.WithValue(ctx, promptDirsContextKey{}, append([]string(nil), dirs...)) +} + +func PromptDirsMiddleware(next http.Handler, dirs []string) http.Handler { + if len(dirs) == 0 { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(w, r.WithContext(ContextWithPromptDirs(r.Context(), dirs))) + }) +} + +func listPrompts(ctx context.Context, opts PromptListOptions) ([]PromptSummary, error) { + records, err := listPromptRecords(ctx) + if err != nil { + return nil, err + } + filter := strings.ToLower(strings.TrimSpace(opts.Query)) + sourceFilter := strings.ToLower(strings.TrimSpace(opts.Source)) + var out []PromptSummary + for _, record := range records { + summary, err := promptSummary(record) + if err != nil { + return nil, err + } + if !promptSourceMatches(summary, sourceFilter) || !promptMatches(summary, filter) { + continue + } + out = append(out, summary) + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].SourceKind != out[j].SourceKind { + return out[i].SourceKind < out[j].SourceKind + } + return out[i].RelPath < out[j].RelPath + }) + return out, nil +} + +func getPrompt(ctx context.Context, id string) (PromptDetail, error) { + record, err := resolvePromptRecord(ctx, id) + if err != nil { + return PromptDetail{}, err + } + return promptDetail(record) +} + +func createPrompt(ctx context.Context, body map[string]any) (PromptDetail, error) { + var req PromptWriteRequest + if err := decodePromptBody(ctx, body, &req); err != nil { + return PromptDetail{}, err + } + sources, err := buildPromptSources(ctx) + if err != nil { + return PromptDetail{}, err + } + source, ok := firstWritableSource(sources) + if req.Target != "" { + source, ok = writableSourceByID(sources, req.Target) + } + if !ok { + return PromptDetail{}, fmt.Errorf("no writable prompt source configured") + } + rel, err := normalizeWriteRelPath(req.RelPath, req.Name) + if err != nil { + return PromptDetail{}, err + } + full, err := safeLocalPromptPath(source, rel) + if err != nil { + return PromptDetail{}, err + } + if strings.TrimSpace(req.Content) == "" { + return PromptDetail{}, fmt.Errorf("prompt content cannot be empty") + } + if _, err := os.Stat(full); err == nil { + return PromptDetail{}, fmt.Errorf("prompt %s already exists", rel) + } else if !errors.Is(err, fs.ErrNotExist) { + return PromptDetail{}, fmt.Errorf("stat %s: %w", full, err) + } + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + return PromptDetail{}, fmt.Errorf("ensure prompt directory: %w", err) + } + if err := os.WriteFile(full, []byte(req.Content), 0o644); err != nil { + return PromptDetail{}, fmt.Errorf("write prompt: %w", err) + } + return promptDetail(promptRecord{Source: source, ID: encodePromptID(source.Kind, source.ID, rel), Path: full, Rel: rel}) +} + +func updatePrompt(ctx context.Context, id string, body map[string]any) (PromptDetail, error) { + record, err := resolvePromptRecord(ctx, id) + if err != nil { + return PromptDetail{}, err + } + if !record.Source.Writable { + return PromptDetail{}, fmt.Errorf("embedded prompts are read-only") + } + var req PromptWriteRequest + if err := decodePromptBody(ctx, body, &req); err != nil { + return PromptDetail{}, err + } + if strings.TrimSpace(req.Content) == "" { + return PromptDetail{}, fmt.Errorf("prompt content cannot be empty") + } + full, err := safeLocalPromptPath(record.Source, record.Rel) + if err != nil { + return PromptDetail{}, err + } + if err := os.WriteFile(full, []byte(req.Content), 0o644); err != nil { + return PromptDetail{}, fmt.Errorf("write prompt: %w", err) + } + return promptDetail(record) +} + +func deletePrompt(ctx context.Context, id string) error { + record, err := resolvePromptRecord(ctx, id) + if err != nil { + return err + } + if !record.Source.Writable { + return fmt.Errorf("embedded prompts are read-only") + } + full, err := safeLocalPromptPath(record.Source, record.Rel) + if err != nil { + return err + } + if err := os.Remove(full); err != nil { + return fmt.Errorf("delete prompt: %w", err) + } + return nil +} + +func renderPromptAction(ctx context.Context, id string, flags map[string]string) (PromptRenderResult, error) { + req, err := readRenderRequest(ctx, flags) + if err != nil { + return PromptRenderResult{}, err + } + return renderPrompt(ctx, id, req) +} + +func runPromptAction(ctx context.Context, id string, flags map[string]string) (AIPromptResult, error) { + req, err := readRenderRequest(ctx, flags) + if err != nil { + return AIPromptResult{}, err + } + rendered, err := renderPrompt(ctx, id, req) + if err != nil { + return AIPromptResult{}, err + } + if rendered.ValidationError != "" { + return AIPromptResult{}, errors.New(rendered.ValidationError) + } + timeout := runtimeTimeout(req.Runtime.Timeout) + got, err := executePromptRequest(ctx, rendered.Input, rendered.Config, timeout, true) + if err != nil { + return AIPromptResult{}, err + } + result, ok := got.(AIPromptResult) + if !ok { + return AIPromptResult{}, fmt.Errorf("unexpected prompt result %T", got) + } + return result, nil +} + +func renderPrompt(ctx context.Context, id string, renderReq PromptRenderRequest) (PromptRenderResult, error) { + record, err := resolvePromptRecord(ctx, id) + if err != nil { + return PromptRenderResult{}, err + } + content, err := readPromptContent(record) + if err != nil { + return PromptRenderResult{}, err + } + vars := renderReq.Variables + if vars == nil { + vars = map[string]any{} + } + fileReq, fileCfg, err := promptlib.Load(content).Render(vars, nil) + if err != nil { + return PromptRenderResult{}, err + } + req, cfg, err := overlayCLI(fileReq, fileCfg, renderReq.Runtime.toAIPromptOptions()) + if err != nil { + return PromptRenderResult{}, err + } + req.Prompt.Source = record.Rel + if renderReq.Runtime.Spec != nil { + overlayRuntimeSpec(&req, &cfg, *renderReq.Runtime.Spec) + } + cwd, err := os.Getwd() + if err != nil { + return PromptRenderResult{}, fmt.Errorf("get working directory: %w", err) + } + if err := normalizePromptContextDir(&req, cwd); err != nil { + return PromptRenderResult{}, err + } + detail, err := promptDetailFromContent(record, content) + if err != nil { + return PromptRenderResult{}, err + } + result := PromptRenderResult{ + ID: detail.ID, + Name: detail.Name, + Model: cfg.Model.Name, + Backend: string(cfg.Model.Backend), + User: req.Prompt.User, + System: req.Prompt.System, + Input: req, + Config: cfg, + InputSchema: detail.InputSchema, + InputDefault: detail.InputDefault, + } + switch { + case req.Prompt.User == "": + result.ValidationError = "prompt text required" + case cfg.Model.Name == "": + result.ValidationError = "no model: set prompt frontmatter, pass a model override, or run 'captain configure'" + default: + if err := req.Validate(); err != nil { + result.ValidationError = err.Error() + } + } + return result, nil +} + +func (o PromptRuntimeOptions) toAIPromptOptions() AIPromptOptions { + return AIPromptOptions{ + AIRuntimeOptions: AIRuntimeOptions{ + AIProviderOptions: AIProviderOptions{ + Model: o.Model, + Backend: o.Backend, + NoCache: o.NoCache, + Budget: o.Budget, + }, + MaxTokens: o.MaxTokens, + Temperature: o.Temperature, + Effort: o.Effort, + MaxTurns: o.MaxTurns, + Resume: o.Resume, + Edit: o.Edit, + AllowedTools: o.AllowedTools, + DisallowedTools: o.DisallowedTools, + PermissionMode: o.PermissionMode, + NoMCP: o.NoMCP, + NoHooks: o.NoHooks, + NoSkills: o.NoSkills, + SkillDirs: o.SkillDirs, + NoUser: o.NoUser, + NoProject: o.NoProject, + NoMemory: o.NoMemory, + Bare: o.Bare, + }, + System: o.System, + AppendSystem: o.AppendSystem, + Timeout: o.Timeout, + NoStream: true, + } +} + +func overlayRuntimeSpec(req *ai.Request, cfg *ai.Config, spec api.Spec) { + if spec.Model.Name != "" { + req.Model.Name = spec.Model.Name + cfg.Model.Name = spec.Model.Name + } + if spec.Model.ID != "" { + req.Model.ID = spec.Model.ID + cfg.Model.ID = spec.Model.ID + } + if spec.Model.Backend != "" { + req.Model.Backend = spec.Model.Backend + cfg.Model.Backend = spec.Model.Backend + } + if spec.Model.Temperature != nil { + req.Model.Temperature = spec.Model.Temperature + cfg.Model.Temperature = spec.Model.Temperature + } + if spec.Model.Effort != "" { + req.Model.Effort = spec.Model.Effort + cfg.Model.Effort = spec.Model.Effort + } + if spec.Budget.Cost > 0 { + req.Budget.Cost = spec.Budget.Cost + cfg.Budget.Cost = spec.Budget.Cost + } + if spec.Budget.MaxTokens > 0 { + req.Budget.MaxTokens = spec.Budget.MaxTokens + cfg.Budget.MaxTokens = spec.Budget.MaxTokens + } + + if spec.Prompt.User != "" { + req.Prompt.User = spec.Prompt.User + } + if spec.Prompt.System != "" { + req.Prompt.System = spec.Prompt.System + } + if spec.Prompt.AppendSystem != "" { + req.Prompt.AppendSystem = spec.Prompt.AppendSystem + } + if spec.Prompt.Source != "" { + req.Prompt.Source = spec.Prompt.Source + } + req.Prompt.Metadata = mergeStringMaps(req.Prompt.Metadata, spec.Prompt.Metadata) + + if spec.Permissions.Mode != "" { + req.Permissions.Mode = spec.Permissions.Mode + } + req.Permissions.Presets = mergePresets(req.Permissions.Presets, spec.Permissions.Presets) + if len(spec.Permissions.Tools.Allow) > 0 { + req.Permissions.Tools.Allow = append([]string(nil), spec.Permissions.Tools.Allow...) + } + if len(spec.Permissions.Tools.Deny) > 0 { + req.Permissions.Tools.Deny = append([]string(nil), spec.Permissions.Tools.Deny...) + } + req.Permissions.Tools.Modes = mergeToolModes(req.Permissions.Tools.Modes, spec.Permissions.Tools.Modes) + req.Permissions.MCP.Disabled = req.Permissions.MCP.Disabled || spec.Permissions.MCP.Disabled + if len(spec.Permissions.MCP.Servers) > 0 { + req.Permissions.MCP.Servers = append([]string(nil), spec.Permissions.MCP.Servers...) + } + if len(spec.Permissions.Plugins) > 0 { + req.Permissions.Plugins = append([]string(nil), spec.Permissions.Plugins...) + } + + if len(spec.Memory.Skills) > 0 { + req.Memory.Skills = append([]string(nil), spec.Memory.Skills...) + } + req.Memory.SkipProject = req.Memory.SkipProject || spec.Memory.SkipProject + req.Memory.SkipUser = req.Memory.SkipUser || spec.Memory.SkipUser + req.Memory.SkipSkills = req.Memory.SkipSkills || spec.Memory.SkipSkills + req.Memory.SkipHooks = req.Memory.SkipHooks || spec.Memory.SkipHooks + req.Memory.SkipMemory = req.Memory.SkipMemory || spec.Memory.SkipMemory + req.Memory.Bare = req.Memory.Bare || spec.Memory.Bare + + if spec.Context.Dir != "" { + req.Context.Dir = spec.Context.Dir + } + if spec.Context.Diff != "" { + req.Context.Diff = spec.Context.Diff + } + if len(spec.Context.Files) > 0 { + req.Context.Files = append([]string(nil), spec.Context.Files...) + } + if spec.Context.Git != nil { + if req.Context.Git == nil { + req.Context.Git = &api.Git{} + } + if spec.Context.Git.Repo != "" { + req.Context.Git.Repo = spec.Context.Git.Repo + } + if spec.Context.Git.SHA != "" { + req.Context.Git.SHA = spec.Context.Git.SHA + } + if spec.Context.Git.PR != "" { + req.Context.Git.PR = spec.Context.Git.PR + } + } + if spec.Context.Worktree != nil { + if req.Context.Worktree == nil { + req.Context.Worktree = &api.Worktree{} + } + if spec.Context.Worktree.Branch != "" { + req.Context.Worktree.Branch = spec.Context.Worktree.Branch + } + if spec.Context.Worktree.Base != "" { + req.Context.Worktree.Base = spec.Context.Worktree.Base + } + if spec.Context.Worktree.CommitMsg != "" { + req.Context.Worktree.CommitMsg = spec.Context.Worktree.CommitMsg + } + req.Context.Worktree.KeepOnExit = req.Context.Worktree.KeepOnExit || spec.Context.Worktree.KeepOnExit + if spec.Context.Worktree.Path != "" { + req.Context.Worktree.Path = spec.Context.Worktree.Path + } + if spec.Context.Worktree.Commit != "" { + req.Context.Worktree.Commit = spec.Context.Worktree.Commit + } + } + req.Context.Env = mergeStringMaps(req.Context.Env, spec.Context.Env) + + if spec.SessionID != "" { + req.SessionID = spec.SessionID + cfg.SessionID = spec.SessionID + } + if spec.MaxTurns > 0 { + req.MaxTurns = spec.MaxTurns + } +} + +func mergeStringMaps(base, overlay map[string]string) map[string]string { + if len(overlay) == 0 { + return base + } + out := make(map[string]string, len(base)+len(overlay)) + for k, v := range base { + out[k] = v + } + for k, v := range overlay { + out[k] = v + } + return out +} + +func mergeToolModes(base, overlay map[string]api.ToolMode) map[string]api.ToolMode { + if len(overlay) == 0 { + return base + } + out := make(map[string]api.ToolMode, len(base)+len(overlay)) + for k, v := range base { + out[k] = v + } + for k, v := range overlay { + out[k] = v + } + return out +} + +func mergePresets(base, overlay []api.Preset) []api.Preset { + if len(overlay) == 0 { + return base + } + seen := make(map[api.Preset]bool, len(base)+len(overlay)) + out := make([]api.Preset, 0, len(base)+len(overlay)) + for _, preset := range base { + if seen[preset] { + continue + } + seen[preset] = true + out = append(out, preset) + } + for _, preset := range overlay { + if seen[preset] { + continue + } + seen[preset] = true + out = append(out, preset) + } + return out +} + +func readRenderRequest(ctx context.Context, flags map[string]string) (PromptRenderRequest, error) { + var req PromptRenderRequest + if err := decodePromptBody(ctx, map[string]any{}, &req); err != nil { + return PromptRenderRequest{}, err + } + if req.Variables == nil { + req.Variables = map[string]any{} + } + if err := mergePromptActionFlags(&req, flags); err != nil { + return PromptRenderRequest{}, err + } + return req, nil +} + +func mergePromptActionFlags(req *PromptRenderRequest, flags map[string]string) error { + if len(flags) == 0 { + return nil + } + if raw := strings.TrimSpace(flags["vars"]); raw != "" { + var vars map[string]any + if err := json.Unmarshal([]byte(raw), &vars); err != nil { + return fmt.Errorf("parse --vars JSON: %w", err) + } + req.Variables = vars + } + if v := strings.TrimSpace(flags["model"]); v != "" { + req.Runtime.Model = v + } + if v := strings.TrimSpace(flags["backend"]); v != "" { + req.Runtime.Backend = v + } + if v := strings.TrimSpace(flags["timeout"]); v != "" { + req.Runtime.Timeout = v + } + if v := strings.TrimSpace(flags["max-tokens"]); v != "" { + n, err := strconv.Atoi(v) + if err != nil { + return fmt.Errorf("invalid --max-tokens %q: %w", v, err) + } + req.Runtime.MaxTokens = n + } + return nil +} + +func decodePromptBody(ctx context.Context, flat map[string]any, dst any) error { + if r, ok := clickyrpc.RequestFromContext(ctx); ok && r.Body != nil { + body, err := io.ReadAll(r.Body) + if err != nil { + return fmt.Errorf("read request body: %w", err) + } + r.Body = io.NopCloser(strings.NewReader(string(body))) + if len(strings.TrimSpace(string(body))) > 0 { + if err := json.Unmarshal(body, dst); err != nil { + return fmt.Errorf("decode request body: %w", err) + } + return nil + } + } + if len(flat) == 0 { + return nil + } + data, err := json.Marshal(flat) + if err != nil { + return err + } + if err := json.Unmarshal(data, dst); err != nil { + return fmt.Errorf("decode command body: %w", err) + } + return nil +} + +func runtimeTimeout(raw string) time.Duration { + timeout, _ := time.ParseDuration(raw) + if timeout <= 0 { + return 120 * time.Second + } + return timeout +} + +func listPromptRecords(ctx context.Context) ([]promptRecord, error) { + sources, err := buildPromptSources(ctx) + if err != nil { + return nil, err + } + var records []promptRecord + for _, source := range sources { + recs, err := listPromptRecordsFromSource(source) + if err != nil { + return nil, err + } + records = append(records, recs...) + } + return records, nil +} + +func listPromptRecordsFromSource(source promptSource) ([]promptRecord, error) { + var records []promptRecord + add := func(rel string, info fs.FileInfo) { + rel = filepath.ToSlash(rel) + path := rel + if source.Root != "" { + path = filepath.Join(source.Root, filepath.FromSlash(rel)) + } + updatedAt := "" + if info != nil && !info.ModTime().IsZero() { + updatedAt = info.ModTime().Format(time.RFC3339) + } + records = append(records, promptRecord{ + Source: source, + ID: encodePromptID(source.Kind, source.ID, rel), + Path: path + "\x00" + updatedAt, + Rel: rel, + }) + } + + if source.FS != nil { + err := fs.WalkDir(source.FS, source.WalkRoot, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + if !strings.HasSuffix(path, ".prompt") { + return nil + } + info, _ := d.Info() + add(path, info) + return nil + }) + return records, err + } + + err := filepath.WalkDir(source.Root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + name := d.Name() + if d.IsDir() { + if name == ".git" || name == "node_modules" || name == "vendor" || name == "dist" { + return filepath.SkipDir + } + return nil + } + if d.Type()&fs.ModeSymlink != 0 || !strings.HasSuffix(name, ".prompt") { + return nil + } + rel, err := filepath.Rel(source.Root, path) + if err != nil { + return err + } + info, _ := d.Info() + add(rel, info) + return nil + }) + if errors.Is(err, fs.ErrNotExist) && source.Implicit { + return records, nil + } + return records, err +} + +func resolvePromptRecord(ctx context.Context, id string) (promptRecord, error) { + ref, err := decodePromptID(id) + if err != nil { + return promptRecord{}, err + } + sources, err := buildPromptSources(ctx) + if err != nil { + return promptRecord{}, err + } + for _, source := range sources { + if source.Kind != ref.Kind || source.ID != ref.SourceID { + continue + } + path := ref.RelPath + if source.Root != "" { + path = filepath.Join(source.Root, filepath.FromSlash(ref.RelPath)) + } + return promptRecord{Source: source, ID: id, Path: path, Rel: ref.RelPath}, nil + } + return promptRecord{}, fmt.Errorf("prompt source %q not found", ref.SourceID) +} + +func promptSummary(record promptRecord) (PromptSummary, error) { + content, err := readPromptContent(record) + if err != nil { + return PromptSummary{}, err + } + summary, err := promptSummaryFromContent(record, content) + if err != nil { + summary = basePromptSummary(record) + summary.ParseError = err.Error() + } + if idx := strings.LastIndex(record.Path, "\x00"); idx >= 0 { + summary.UpdatedAt = strings.TrimPrefix(record.Path[idx+1:], "\x00") + } + return summary, nil +} + +func promptDetail(record promptRecord) (PromptDetail, error) { + content, err := readPromptContent(record) + if err != nil { + return PromptDetail{}, err + } + return promptDetailFromContent(record, content) +} + +func promptDetailFromContent(record promptRecord, content string) (PromptDetail, error) { + summary, err := promptSummaryFromContent(record, content) + if err != nil { + return PromptDetail{}, err + } + inspection, err := inspectPrompt(content, nil) + if err != nil { + return PromptDetail{}, err + } + return PromptDetail{ + PromptSummary: summary, + Content: content, + InputSchema: inspection.InputSchema, + InputDefault: inspection.InputDefault, + Metadata: inspection.Metadata, + }, nil +} + +func promptSummaryFromContent(record promptRecord, content string) (PromptSummary, error) { + tmpl := promptlib.Load(content) + req, cfg, err := tmpl.Render(map[string]any{}, nil) + if err != nil { + return PromptSummary{}, err + } + inspection, err := inspectPrompt(content, nil) + if err != nil { + return PromptSummary{}, err + } + summary := basePromptSummary(record) + if v, ok := inspection.Metadata["name"].(string); ok && strings.TrimSpace(v) != "" { + summary.Name = strings.TrimSpace(v) + } + if v, ok := inspection.Metadata["description"].(string); ok { + summary.Description = strings.TrimSpace(v) + } + summary.Model = firstNonEmpty(cfg.Model.Name, req.Model.Name) + summary.Backend = firstNonEmpty(string(cfg.Model.Backend), string(req.Model.Backend)) + summary.Variables = inspection.Variables + return summary, nil +} + +func basePromptSummary(record promptRecord) PromptSummary { + name := strings.TrimSuffix(filepath.Base(record.Rel), ".prompt") + return PromptSummary{ + ID: record.ID, + Name: name, + SourceKind: record.Source.Kind, + SourceID: record.Source.ID, + Source: record.Source.Label, + Path: displayPromptPath(record), + RelPath: record.Rel, + Writable: record.Source.Writable, + } +} + +func displayPromptPath(record promptRecord) string { + if idx := strings.LastIndex(record.Path, "\x00"); idx >= 0 { + return record.Path[:idx] + } + return record.Path +} + +func readPromptContent(record promptRecord) (string, error) { + if record.Source.FS != nil { + data, err := fs.ReadFile(record.Source.FS, record.Rel) + if err != nil { + return "", fmt.Errorf("read embedded prompt %s: %w", record.Rel, err) + } + return string(data), nil + } + full, err := safeLocalPromptPath(record.Source, record.Rel) + if err != nil { + return "", err + } + data, err := os.ReadFile(full) + if err != nil { + return "", fmt.Errorf("read prompt %s: %w", full, err) + } + return string(data), nil +} + +func inspectPrompt(content string, data map[string]any) (promptInspection, error) { + if data == nil { + data = map[string]any{} + } + rendered, err := dp.NewDotprompt(nil).Render(content, &dp.DataArgument{Input: data}, nil) + if err != nil { + return promptInspection{}, err + } + metadata := map[string]any{} + if rendered.Raw != nil { + for k, v := range rendered.Raw { + metadata[k] = v + } + } + if rendered.Name != "" { + metadata["name"] = rendered.Name + } + if rendered.Description != "" { + metadata["description"] = rendered.Description + } + if rendered.Model != "" { + metadata["model"] = rendered.Model + } + inputSchema := anyToMap(rendered.Input.Schema) + inputDefault := map[string]any{} + for k, v := range rendered.Input.Default { + inputDefault[k] = v + } + return promptInspection{ + Metadata: metadata, + InputSchema: inputSchema, + InputDefault: inputDefault, + Variables: variablesFromSchema(inputSchema), + }, nil +} + +func anyToMap(v any) map[string]any { + if v == nil { + return nil + } + data, err := json.Marshal(v) + if err != nil { + return nil + } + var out map[string]any + if err := json.Unmarshal(data, &out); err != nil { + return nil + } + return out +} + +func variablesFromSchema(schema map[string]any) []PromptVariable { + props, _ := schema["properties"].(map[string]any) + if len(props) == 0 { + return nil + } + required := map[string]bool{} + if raw, ok := schema["required"].([]any); ok { + for _, item := range raw { + if s, ok := item.(string); ok { + required[s] = true + } + } + } + keys := make([]string, 0, len(props)) + for k := range props { + keys = append(keys, k) + } + sort.Strings(keys) + var vars []PromptVariable + for _, name := range keys { + prop, _ := props[name].(map[string]any) + item := PromptVariable{Name: name, Required: required[name]} + if v, ok := prop["type"].(string); ok { + item.Type = v + } + if v, ok := prop["description"].(string); ok { + item.Description = v + } + vars = append(vars, item) + } + return vars +} + +func promptSourceMatches(summary PromptSummary, source string) bool { + switch source { + case "", "all": + return true + case "embedded": + return summary.SourceKind == "embedded" + case "local": + return summary.SourceKind == "local" + default: + return summary.SourceID == source || strings.EqualFold(summary.Source, source) + } +} + +func promptMatches(summary PromptSummary, filter string) bool { + if filter == "" { + return true + } + haystack := strings.ToLower(strings.Join([]string{ + summary.Name, + summary.Description, + summary.Source, + summary.Path, + summary.RelPath, + summary.Model, + summary.Backend, + }, "\n")) + return strings.Contains(haystack, filter) +} + +func buildPromptSources(ctx context.Context) ([]promptSource, error) { + sources := []promptSource{{ + Kind: "embedded", + ID: "embedded", + Label: "Embedded examples", + WalkRoot: "testdata", + FS: promptlib.Examples, + Writable: false, + }} + + seen := map[string]bool{} + addLocal := func(raw, base string) error { + dir, err := resolvePromptDir(raw, base) + if err != nil { + return err + } + if seen[dir] { + return nil + } + seen[dir] = true + sources = append(sources, promptSource{ + Kind: "local", + ID: hashPromptDir(dir), + Label: dir, + Root: dir, + Writable: true, + }) + return nil + } + + cfg, exists, err := captainconfig.Load() + if err != nil { + return nil, err + } + if exists { + configPath, err := captainconfig.Path() + if err != nil { + return nil, err + } + base := filepath.Dir(configPath) + for _, dir := range cfg.Prompts.Dirs { + if err := addLocal(dir, base); err != nil { + return nil, err + } + } + } + cwd, err := os.Getwd() + if err != nil { + return nil, err + } + for _, dir := range promptDirsFromContext(ctx) { + if err := addLocal(dir, cwd); err != nil { + return nil, err + } + } + if _, ok := firstWritableSource(sources); !ok { + dir := filepath.Join(cwd, ".captain", "prompts") + sources = append(sources, promptSource{ + Kind: "local", + ID: hashPromptDir(dir), + Label: dir, + Root: dir, + Writable: true, + Implicit: true, + }) + } + return sources, nil +} + +func promptDirsFromContext(ctx context.Context) []string { + if ctx == nil { + return nil + } + if dirs, ok := ctx.Value(promptDirsContextKey{}).([]string); ok { + return dirs + } + return nil +} + +func resolvePromptDir(raw, base string) (string, error) { + dir := strings.TrimSpace(raw) + if dir == "" { + return "", fmt.Errorf("prompt dir cannot be empty") + } + if strings.HasPrefix(dir, "~") { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + switch { + case dir == "~": + dir = home + case strings.HasPrefix(dir, "~/"): + dir = filepath.Join(home, dir[2:]) + default: + return "", fmt.Errorf("unsupported home-relative prompt dir %q", raw) + } + } + if !filepath.IsAbs(dir) { + dir = filepath.Join(base, dir) + } + abs, err := filepath.Abs(dir) + if err != nil { + return "", err + } + info, err := os.Stat(abs) + if err != nil { + return "", fmt.Errorf("prompt dir %s: %w", abs, err) + } + if !info.IsDir() { + return "", fmt.Errorf("prompt dir %s is not a directory", abs) + } + if resolved, err := filepath.EvalSymlinks(abs); err == nil { + abs = resolved + } + return filepath.Clean(abs), nil +} + +func writableSourceByID(sources []promptSource, id string) (promptSource, bool) { + for _, source := range sources { + if source.ID == id && source.Writable { + return source, true + } + } + return promptSource{}, false +} + +func firstWritableSource(sources []promptSource) (promptSource, bool) { + for _, source := range sources { + if source.Writable { + return source, true + } + } + return promptSource{}, false +} + +func safeLocalPromptPath(source promptSource, rel string) (string, error) { + cleanRel := strings.TrimPrefix(filepath.Clean(filepath.FromSlash(rel)), string(filepath.Separator)) + if cleanRel == "." || cleanRel == "" || filepath.IsAbs(rel) || strings.HasPrefix(cleanRel, ".."+string(filepath.Separator)) || cleanRel == ".." { + return "", fmt.Errorf("invalid prompt path %q", rel) + } + if filepath.Ext(cleanRel) != ".prompt" { + return "", fmt.Errorf("prompt path must end with .prompt") + } + full := filepath.Join(source.Root, cleanRel) + abs, err := filepath.Abs(full) + if err != nil { + return "", err + } + relToRoot, err := filepath.Rel(source.Root, abs) + if err != nil { + return "", err + } + if strings.HasPrefix(relToRoot, ".."+string(filepath.Separator)) || relToRoot == ".." || filepath.IsAbs(relToRoot) { + return "", fmt.Errorf("prompt path escapes source root") + } + if info, err := os.Lstat(abs); err == nil && info.Mode()&fs.ModeSymlink != 0 { + return "", fmt.Errorf("prompt symlinks are not supported") + } + return abs, nil +} + +func normalizeWriteRelPath(relPath, name string) (string, error) { + rel := strings.TrimSpace(relPath) + if rel == "" { + rel = slugPromptName(name) + } + if rel == "" { + return "", fmt.Errorf("prompt name or path required") + } + if !strings.HasSuffix(rel, ".prompt") { + rel += ".prompt" + } + rel = filepath.ToSlash(filepath.Clean(filepath.FromSlash(rel))) + if strings.HasPrefix(rel, "../") || rel == ".." || strings.HasPrefix(rel, "/") { + return "", fmt.Errorf("invalid prompt path %q", relPath) + } + return rel, nil +} + +func slugPromptName(name string) string { + name = strings.ToLower(strings.TrimSpace(name)) + var b strings.Builder + lastDash := false + for _, r := range name { + ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') + if ok { + b.WriteRune(r) + lastDash = false + continue + } + if !lastDash { + b.WriteRune('-') + lastDash = true + } + } + return strings.Trim(b.String(), "-") +} + +func encodePromptID(kind, sourceID, rel string) string { + return base64.RawURLEncoding.EncodeToString([]byte(kind + "\x00" + sourceID + "\x00" + filepath.ToSlash(rel))) +} + +func decodePromptID(id string) (promptRef, error) { + data, err := base64.RawURLEncoding.DecodeString(id) + if err != nil { + return promptRef{}, fmt.Errorf("invalid prompt id: %w", err) + } + parts := strings.SplitN(string(data), "\x00", 3) + if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { + return promptRef{}, fmt.Errorf("invalid prompt id") + } + return promptRef{Kind: parts[0], SourceID: parts[1], RelPath: filepath.ToSlash(parts[2])}, nil +} + +func hashPromptDir(dir string) string { + sum := sha256.Sum256([]byte(dir)) + return hex.EncodeToString(sum[:])[:12] +} + +func ValidatePromptDirs(dirs []string) error { + cwd, err := os.Getwd() + if err != nil { + return err + } + for _, dir := range dirs { + if _, err := resolvePromptDir(dir, cwd); err != nil { + return err + } + } + return nil +} + +var _ clicky.EntityItem = PromptSummary{} +var _ clickyapi.TableProvider = PromptSummary{} diff --git a/pkg/cli/prompt_entity_test.go b/pkg/cli/prompt_entity_test.go new file mode 100644 index 0000000..f8c4adc --- /dev/null +++ b/pkg/cli/prompt_entity_test.go @@ -0,0 +1,293 @@ +package cli + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/captainconfig" +) + +func TestPromptEntityListsEmbeddedExamples(t *testing.T) { + isolateCaptainConfig(t) + + prompts, err := listPrompts(context.Background(), PromptListOptions{Source: "embedded"}) + if err != nil { + t.Fatalf("listPrompts() err = %v", err) + } + + var commit *PromptSummary + for i := range prompts { + if prompts[i].RelPath == "testdata/commit.prompt" { + commit = &prompts[i] + break + } + } + if commit == nil { + t.Fatalf("embedded commit.prompt not found in %d prompts", len(prompts)) + } + if commit.Writable { + t.Fatalf("embedded prompt reported writable") + } + if commit.Model != "claude-sonnet-4-6" { + t.Fatalf("embedded prompt model = %q, want claude-sonnet-4-6", commit.Model) + } + if len(commit.Variables) != 1 || commit.Variables[0].Name != "diff" { + t.Fatalf("embedded prompt variables = %+v, want diff", commit.Variables) + } +} + +func TestPromptEntityUsesProjectPromptDirFallback(t *testing.T) { + isolateCaptainConfig(t) + cwd := t.TempDir() + t.Chdir(cwd) + defaultDir := filepath.Join(cwd, ".captain", "prompts") + + local, err := listPrompts(context.Background(), PromptListOptions{Source: "local"}) + if err != nil { + t.Fatalf("listPrompts(local) err = %v", err) + } + if len(local) != 0 { + t.Fatalf("local prompt count = %d, want 0", len(local)) + } + if _, err := os.Stat(defaultDir); !os.IsNotExist(err) { + t.Fatalf("default prompt dir stat err = %v, want not exist", err) + } + + created, err := createPrompt(context.Background(), map[string]any{ + "name": "Fallback", + "content": `--- +name: Fallback +--- +{{role "user"}} +Hello from fallback +`, + }) + if err != nil { + t.Fatalf("createPrompt() err = %v", err) + } + if !created.Writable { + t.Fatalf("created prompt reported read-only") + } + wantPath := filepath.Join(defaultDir, "fallback.prompt") + if created.Path != wantPath { + t.Fatalf("created path = %q, want %q", created.Path, wantPath) + } + if _, err := os.Stat(wantPath); err != nil { + t.Fatalf("created prompt missing at fallback path: %v", err) + } +} + +func TestPromptEntityCreatesUpdatesRendersAndDeletesLocalPrompt(t *testing.T) { + isolateCaptainConfig(t) + + dir := t.TempDir() + ctx := ContextWithPromptDirs(context.Background(), []string{dir}) + content := `--- +name: Greeting +description: Test prompt +model: claude-sonnet-4-6 +input: + schema: + name: string +--- +{{role "user"}} +Hello {{name}} +` + + created, err := createPrompt(ctx, map[string]any{ + "name": "Greeting", + "content": content, + }) + if err != nil { + t.Fatalf("createPrompt() err = %v", err) + } + if !created.Writable { + t.Fatalf("created prompt reported read-only") + } + if created.RelPath != "greeting.prompt" { + t.Fatalf("created relPath = %q, want greeting.prompt", created.RelPath) + } + if _, err := os.Stat(filepath.Join(dir, "greeting.prompt")); err != nil { + t.Fatalf("created prompt file missing: %v", err) + } + + localPrompts, err := listPrompts(ctx, PromptListOptions{Source: "local", Query: "greet"}) + if err != nil { + t.Fatalf("list local prompts: %v", err) + } + if len(localPrompts) != 1 { + t.Fatalf("local prompt count = %d, want 1", len(localPrompts)) + } + if err := captainconfig.Save(captainconfig.Config{AI: captainconfig.AIDefaults{ + Backend: "codex-cli", + Model: "gpt-5-codex", + }}); err != nil { + t.Fatalf("save config: %v", err) + } + + rendered, err := renderPrompt(ctx, created.ID, PromptRenderRequest{ + Variables: map[string]any{"name": "Ada"}, + }) + if err != nil { + t.Fatalf("renderPrompt() err = %v", err) + } + if rendered.ValidationError != "" { + t.Fatalf("render validation error = %q", rendered.ValidationError) + } + if !strings.Contains(rendered.User, "Hello Ada") { + t.Fatalf("rendered user prompt = %q, want greeting", rendered.User) + } + if rendered.Model != "claude-sonnet-4-6" || rendered.Backend != "anthropic" { + t.Fatalf("rendered model/backend = %s/%s, want claude-sonnet-4-6/anthropic", rendered.Model, rendered.Backend) + } + if rendered.Input.Context.Dir == "" || !filepath.IsAbs(rendered.Input.Context.Dir) { + t.Fatalf("rendered context dir = %q, want absolute", rendered.Input.Context.Dir) + } + + updated, err := updatePrompt(ctx, created.ID, map[string]any{ + "content": strings.Replace(content, "Hello {{name}}", "Goodbye {{name}}", 1), + }) + if err != nil { + t.Fatalf("updatePrompt() err = %v", err) + } + if !strings.Contains(updated.Content, "Goodbye") { + t.Fatalf("updated content did not persist: %q", updated.Content) + } + + if err := deletePrompt(ctx, created.ID); err != nil { + t.Fatalf("deletePrompt() err = %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "greeting.prompt")); !os.IsNotExist(err) { + t.Fatalf("deleted prompt stat err = %v, want not exist", err) + } +} + +func TestRenderPromptAppliesRuntimeSpec(t *testing.T) { + isolateCaptainConfig(t) + + dir := t.TempDir() + cwd := t.TempDir() + t.Chdir(cwd) + ctx := ContextWithPromptDirs(context.Background(), []string{dir}) + content := `--- +name: Runtime Spec +model: claude-sonnet-4-6 +--- +{{role "user"}} +Hello {{name}} +` + created, err := createPrompt(ctx, map[string]any{ + "name": "Runtime Spec", + "content": content, + }) + if err != nil { + t.Fatalf("createPrompt() err = %v", err) + } + + temp := 0.2 + rendered, err := renderPrompt(ctx, created.ID, PromptRenderRequest{ + Variables: map[string]any{"name": "Ada"}, + Runtime: PromptRuntimeOptions{ + Spec: &api.Spec{ + Model: api.Model{ + Name: "gpt-4o", + ID: "openai/gpt-4o", + Backend: api.BackendOpenAI, + Temperature: &temp, + Effort: api.EffortLow, + }, + Prompt: api.Prompt{ + System: "runtime system", + AppendSystem: "runtime append", + Source: "runtime-source", + Metadata: map[string]string{"surface": "prompt-ui"}, + }, + Budget: api.Budget{Cost: 0.5, MaxTokens: 1234}, + Permissions: api.Permissions{ + Mode: api.PermissionAcceptEdits, + Presets: []api.Preset{api.PresetEdit}, + Tools: api.Tools{ + Allow: []string{"Read"}, + Deny: []string{"Bash"}, + Modes: map[string]api.ToolMode{"Bash": api.ToolModeDisabled}, + }, + MCP: api.MCP{Disabled: true, Servers: []string{"filesystem"}}, + Plugins: []string{"/plugins"}, + }, + Memory: api.Memory{ + Skills: []string{"/skills"}, + SkipUser: true, + SkipMemory: true, + Bare: true, + }, + Context: api.Context{ + Dir: "workspace", + Files: []string{"a.go"}, + Git: &api.Git{Repo: "/repo", SHA: "abc123", PR: "42"}, + Worktree: &api.Worktree{ + Branch: "runtime-branch", + KeepOnExit: true, + }, + Env: map[string]string{"CAPTAIN_UI": "1"}, + }, + SessionID: "sess-runtime", + MaxTurns: 4, + }, + }, + }) + if err != nil { + t.Fatalf("renderPrompt() err = %v", err) + } + if rendered.ValidationError != "" { + t.Fatalf("render validation error = %q", rendered.ValidationError) + } + if rendered.Model != "gpt-4o" || rendered.Backend != "openai" { + t.Fatalf("rendered model/backend = %s/%s, want gpt-4o/openai", rendered.Model, rendered.Backend) + } + if rendered.Config.Model.ID != "openai/gpt-4o" { + t.Fatalf("config model ID = %q, want openai/gpt-4o", rendered.Config.Model.ID) + } + if rendered.Input.Temperature == nil || *rendered.Input.Temperature != temp { + t.Fatalf("temperature = %v, want %v", rendered.Input.Temperature, temp) + } + if rendered.Input.Budget.Cost != 0.5 || rendered.Input.Budget.MaxTokens != 1234 { + t.Fatalf("budget = %+v, want cost/maxTokens override", rendered.Input.Budget) + } + if rendered.Input.Prompt.System != "runtime system" || rendered.Input.Prompt.AppendSystem != "runtime append" { + t.Fatalf("prompt system fields = %+v, want runtime overrides", rendered.Input.Prompt) + } + if rendered.Input.Prompt.Source != "runtime-source" || rendered.Input.Prompt.Metadata["surface"] != "prompt-ui" { + t.Fatalf("prompt source/metadata = %+v, want runtime overrides", rendered.Input.Prompt) + } + if rendered.Input.Permissions.Mode != api.PermissionAcceptEdits || + rendered.Input.Permissions.Tools.Modes["Bash"] != api.ToolModeDisabled || + !rendered.Input.Permissions.MCP.Disabled { + t.Fatalf("permissions = %+v, want runtime overrides", rendered.Input.Permissions) + } + if !rendered.Input.Memory.SkipUser || !rendered.Input.Memory.SkipMemory || !rendered.Input.Memory.Bare { + t.Fatalf("memory = %+v, want runtime overrides", rendered.Input.Memory) + } + if rendered.Input.Context.Dir != filepath.Join(cwd, "workspace") { + t.Fatalf("context dir = %q, want cwd-relative runtime dir", rendered.Input.Context.Dir) + } + if rendered.Input.Context.Git == nil || rendered.Input.Context.Git.SHA != "abc123" { + t.Fatalf("git context = %+v, want runtime git overlay", rendered.Input.Context.Git) + } + if rendered.Input.Context.Worktree == nil || !rendered.Input.Context.Worktree.KeepOnExit { + t.Fatalf("worktree context = %+v, want runtime worktree overlay", rendered.Input.Context.Worktree) + } + if rendered.Input.Context.Env["CAPTAIN_UI"] != "1" || rendered.Input.SessionID != "sess-runtime" || rendered.Input.MaxTurns != 4 { + t.Fatalf("runtime tail fields = input=%+v context=%+v", rendered.Input, rendered.Input.Context) + } +} + +func isolateCaptainConfig(t *testing.T) { + t.Helper() + path := filepath.Join(t.TempDir(), ".captain.yaml") + captainconfig.SetPathForTesting(path) + t.Cleanup(func() { captainconfig.SetPathForTesting("") }) +} diff --git a/pkg/cli/serve.go b/pkg/cli/serve.go index 9fab916..3e15dc2 100644 --- a/pkg/cli/serve.go +++ b/pkg/cli/serve.go @@ -38,6 +38,7 @@ type ServeOptions struct { UIPort int Open bool ThreadsFile string + PromptDirs []string } func NewServeCommand(version string) *cobra.Command { @@ -74,6 +75,7 @@ proxies /api back to this Go process.`, cmd.Flags().IntVar(&opts.UIPort, "ui-port", opts.UIPort, "Port for the Vite dev server when --dev is set") cmd.Flags().BoolVar(&opts.Open, "open", false, "Open the web UI in the default browser") cmd.Flags().StringVar(&opts.ThreadsFile, "threads-file", opts.ThreadsFile, "Path to persisted chat thread JSON") + cmd.Flags().StringArrayVar(&opts.PromptDirs, "prompt-dir", nil, "Additional local directory containing .prompt files (repeatable)") return cmd } @@ -91,6 +93,9 @@ func (o ServeOptions) validate() error { if strings.TrimSpace(o.ThreadsFile) == "" { return fmt.Errorf("threads file cannot be empty") } + if err := ValidatePromptDirs(o.PromptDirs); err != nil { + return err + } return nil } @@ -157,7 +162,7 @@ func RunServe(ctx context.Context, rootCmd *cobra.Command, opts ServeOptions, ve addr := fmt.Sprintf("%s:%d", opts.Host, opts.Port) httpSrv := &http.Server{ Addr: addr, - Handler: mux, + Handler: PromptDirsMiddleware(mux, opts.PromptDirs), ReadTimeout: 30 * time.Second, // /api/chat streams SSE; a fixed write timeout truncates long turns. IdleTimeout: 60 * time.Second, diff --git a/pkg/cli/webapp/src/App.tsx b/pkg/cli/webapp/src/App.tsx index ed07676..471d82e 100644 --- a/pkg/cli/webapp/src/App.tsx +++ b/pkg/cli/webapp/src/App.tsx @@ -16,6 +16,7 @@ import { apiClient } from "./api"; import { AgentLauncher } from "./AgentLauncher"; import { ChatLayer } from "./ChatLayer"; import { ChatRoute } from "./ChatRoute"; +import { PromptWorkbench } from "./PromptWorkbench"; import { SessionBrowser } from "./SessionBrowser"; export function App() { @@ -40,6 +41,13 @@ export function App() { nav={} actions={} /> + ) : route.kind === "prompts" ? ( + } + actions={} + /> ) : ( Sessions + + )} + {detail?.writable && ( + + )} + + + } + bodySplit={30} + contentClassName="p-0 overflow-hidden" + > + setTab(next as DetailTab)} + draft={draft} + onDraftChange={setDraft} + variables={variables} + variablesText={variablesText} + onVariablesChange={(next) => { + setVariables(next); + setVariablesText(JSON.stringify(next, null, 2)); + }} + onVariablesTextChange={(next) => { + setVariablesText(next); + const parsed = parseJsonObject(next); + if (parsed.ok) setVariables(parsed.value); + }} + runtime={runtime} + onRuntimeChange={setRuntime} + models={models} + modelsLoading={modelsQuery.isLoading} + modelsError={modelsQuery.error} + tools={AGENT_TOOLS} + renderResult={renderResult} + runResult={runResult} + onRender={() => void renderPrompt()} + onRun={() => void runPrompt()} + renderLoading={actionLoading === "render"} + runLoading={actionLoading === "run"} + renderEnabled={Boolean(promptOps.render && detail)} + runEnabled={Boolean(promptOps.run && detail)} + /> + setCreateOpen(false)} + sources={writableSources} + createOp={promptOps.create} + seedContent={detail?.content} + onCreated={(prompt) => { + setCreateOpen(false); + void listQuery.refetch(); + onNavigate(`/prompts/${encodeURIComponent(prompt.id)}`); + }} + /> + + ); +} + +function PromptSidebar({ + source, + onSourceChange, + query, + onQueryChange, + prompts, + selectedId, + loading, + error, + onSelect, + onRefresh, + onCreate, +}: { + source: SourceFilter; + onSourceChange: (source: SourceFilter) => void; + query: string; + onQueryChange: (query: string) => void; + prompts: PromptSummary[]; + selectedId?: string; + loading: boolean; + error: unknown; + onSelect: (prompt: PromptSummary) => void; + onRefresh: () => void; + onCreate: () => void; +}) { + return ( +
+
+
+
Prompts
+
+ + +
+
+ + +
+ {loading ? "Loading..." : `${prompts.length} prompts`} +
+
+ +
+ {error ? ( +
{errorMessage(error)}
+ ) : prompts.length === 0 && !loading ? ( +
No prompts found.
+ ) : ( +
+ {prompts.map((prompt) => { + const active = prompt.id === selectedId; + return ( + + ); + })} +
+ )} +
+
+ ); +} + +function PromptHeader({ + prompt, + loading, + ready, +}: { + prompt?: PromptSummary; + loading: boolean; + ready: boolean; +}) { + if (!ready) { + return ( +
+
Prompt Workbench
+
Loading prompt operations...
+
+ ); + } + if (loading && !prompt) { + return
Loading prompt...
; + } + if (!prompt) { + return ( +
+
Prompt Workbench
+
Select or create a prompt.
+
+ ); + } + return ( +
+
+
{prompt.name}
+ + {prompt.sourceKind} + + {prompt.writable && ( + + editable + + )} +
+
+ {prompt.model && {prompt.model}} + {prompt.backend && {prompt.backend}} + {prompt.path} +
+
+ ); +} + +function PromptDetailPane({ + detail, + hasSelection, + loading, + error, + tab, + onTabChange, + draft, + onDraftChange, + variables, + variablesText, + onVariablesChange, + onVariablesTextChange, + runtime, + onRuntimeChange, + models, + modelsLoading, + modelsError, + tools, + renderResult, + runResult, + onRender, + onRun, + renderLoading, + runLoading, + renderEnabled, + runEnabled, +}: { + detail?: PromptDetail; + hasSelection: boolean; + loading: boolean; + error: unknown; + tab: DetailTab; + onTabChange: (tab: string) => void; + draft: string; + onDraftChange: (value: string) => void; + variables: Record; + variablesText: string; + onVariablesChange: (value: Record) => void; + onVariablesTextChange: (value: string) => void; + runtime: RuntimeForm; + onRuntimeChange: (value: RuntimeForm) => void; + models: ChatModel[]; + modelsLoading: boolean; + modelsError: unknown; + tools: ToolMeta[]; + renderResult?: PromptRenderResult; + runResult?: PromptRunResult; + onRender: () => void; + onRun: () => void; + renderLoading: boolean; + runLoading: boolean; + renderEnabled: boolean; + runEnabled: boolean; +}) { + const variablesFormId = useId(); + + if (!hasSelection) { + return ( +
+ Select a prompt. +
+ ); + } + if (loading && !detail) { + return ( +
+ Loading prompt... +
+ ); + } + if (!detail) { + return ( +
+ {error ? errorMessage(error) : "Prompt not found."} +
+ ); + } + + const schema = normalizeObjectSchema(detail.inputSchema); + const variablesParse = parseJsonObject(variablesText); + + return ( +
+
+ +
+
+ {Boolean(error) && ( +
{errorMessage(error)}
+ )} + {tab === "source" ? ( + + ) : ( +
+
+
+
+ + Variables +
+ {schema ? ( +
+ +
+ ) : ( +
+