From 213e43496bd9a0cc658adfa817543ee21c2e6025 Mon Sep 17 00:00:00 2001 From: Ted Tomlinson Date: Mon, 20 Jul 2026 19:34:29 -0700 Subject: [PATCH 1/6] Add experimental genie-cli command Add `databricks experimental genie-cli`: an out-of-the-box interactive AI coding agent preconfigured for a Databricks workspace. It authenticates through the workspace and routes model requests through the Databricks AI Gateway (no separate model API keys), and it preloads the Databricks skills plugin so the agent works effectively against Databricks. The command orchestrates an existing launcher (ucode) driving Codex, but that runtime is an intentional implementation detail: users only interact with `genie-cli`. On each run it: 1. ensures the launcher runtime is installed (installing it via uv, and erroring with actionable instructions when uv is absent), 2. configures the agent against the resolved workspace non-interactively, 3. installs the Databricks skills plugin for the agent if not already recorded, then refreshes it (refresh failures are non-fatal so the agent still starts offline), and 4. hands off to the interactive agent session via process replacement. Co-authored-by: Isaac --- .nextchanges/cli/experimental-genie-cli.md | 1 + cmd/experimental/experimental.go | 2 + experimental/geniecli/cmd/geniecli.go | 71 ++++++++++++ experimental/geniecli/cmd/plugin.go | 90 ++++++++++++++ experimental/geniecli/cmd/plugin_test.go | 41 +++++++ experimental/geniecli/cmd/ucode.go | 129 +++++++++++++++++++++ experimental/geniecli/cmd/ucode_test.go | 118 +++++++++++++++++++ 7 files changed, 452 insertions(+) create mode 100644 .nextchanges/cli/experimental-genie-cli.md create mode 100644 experimental/geniecli/cmd/geniecli.go create mode 100644 experimental/geniecli/cmd/plugin.go create mode 100644 experimental/geniecli/cmd/plugin_test.go create mode 100644 experimental/geniecli/cmd/ucode.go create mode 100644 experimental/geniecli/cmd/ucode_test.go diff --git a/.nextchanges/cli/experimental-genie-cli.md b/.nextchanges/cli/experimental-genie-cli.md new file mode 100644 index 00000000000..4a7c19fdc16 --- /dev/null +++ b/.nextchanges/cli/experimental-genie-cli.md @@ -0,0 +1 @@ +Added the experimental `databricks experimental genie-cli` command, which starts an interactive AI coding agent preconfigured for your Databricks workspace (routing model requests through the Databricks AI Gateway and preloading the Databricks skills plugin). diff --git a/cmd/experimental/experimental.go b/cmd/experimental/experimental.go index d87c893abc5..d49399c2b07 100644 --- a/cmd/experimental/experimental.go +++ b/cmd/experimental/experimental.go @@ -4,6 +4,7 @@ import ( aircmd "github.com/databricks/cli/experimental/air/cmd" aitoolscmd "github.com/databricks/cli/experimental/aitools/cmd" geniecmd "github.com/databricks/cli/experimental/genie/cmd" + genieclicmd "github.com/databricks/cli/experimental/geniecli/cmd" postgrescmd "github.com/databricks/cli/experimental/postgres/cmd" "github.com/spf13/cobra" ) @@ -26,6 +27,7 @@ development. They may change or be removed in future versions without notice.`, cmd.AddCommand(aircmd.New()) cmd.AddCommand(aitoolscmd.NewAitoolsCmd()) cmd.AddCommand(geniecmd.NewGenieCmd()) + cmd.AddCommand(genieclicmd.New()) cmd.AddCommand(postgrescmd.New()) cmd.AddCommand(newWorkspaceOpenCommand()) diff --git a/experimental/geniecli/cmd/geniecli.go b/experimental/geniecli/cmd/geniecli.go new file mode 100644 index 00000000000..75e4d5cbc55 --- /dev/null +++ b/experimental/geniecli/cmd/geniecli.go @@ -0,0 +1,71 @@ +package genieclicmd + +import ( + "github.com/databricks/cli/cmd/root" + "github.com/databricks/cli/libs/cmdctx" + "github.com/databricks/cli/libs/cmdio" + "github.com/spf13/cobra" +) + +// New returns the `genie-cli` command: an out-of-the-box interactive AI coding +// agent preconfigured for Databricks. It routes model traffic through the +// workspace's Databricks AI Gateway (no separate API keys) and preloads the +// Databricks skills plugin so the agent works effectively against Databricks. +// +// The agent runtime is an implementation detail intentionally kept out of the +// user-facing surface: the user only ever interacts with `genie-cli`. +func New() *cobra.Command { + cmd := &cobra.Command{ + Use: "genie-cli [-- AGENT_ARGS...]", + Short: "Start an interactive AI coding agent configured for Databricks", + Long: `Start an interactive AI coding agent that is preconfigured for your +Databricks workspace. + +The agent authenticates through your Databricks workspace and routes model +requests through the Databricks AI Gateway, so no separate model API keys are +needed. Databricks coding skills are installed and kept up to date on every +launch so the agent works effectively against Databricks. + +Any arguments after "--" are forwarded to the underlying agent, e.g.: + + databricks experimental genie-cli -- --full-auto`, + // The agent takes over the terminal, so no bundle is loaded. + PreRunE: func(cmd *cobra.Command, args []string) error { + cmd.SetContext(root.SkipLoadBundle(cmd.Context())) + return root.MustWorkspaceClient(cmd, args) + }, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + // Use the identity from the authenticated workspace client, which + // reflects the profile/host the invocation resolved (--profile, + // DATABRICKS_CONFIG_PROFILE, the default profile, or env-var auth). + cfg := cmdctx.WorkspaceClient(ctx).Config + + // Ensure the launcher runtime is present, installing it if needed. + ucodePath, err := ensureUcode(ctx) + if err != nil { + return err + } + + // Configure the agent against this workspace. This also installs the + // agent binary and the Databricks CLI the runtime depends on, so it + // must run before the plugin step (which drives the agent's own CLI). + if err := configureAgent(ctx, ucodePath, cfg); err != nil { + return err + } + + // Install the Databricks skills plugin for the agent, then refresh it. + // Refresh failures (offline, GitHub unreachable) are non-fatal: a stale + // plugin still works, so we warn and launch anyway. + if err := ensureDatabricksPlugin(ctx); err != nil { + return err + } + refreshDatabricksPlugin(ctx) + + cmdio.LogString(ctx, "Starting the Databricks agent...") + return launchAgent(ctx, ucodePath, args) + }, + } + + return cmd +} diff --git a/experimental/geniecli/cmd/plugin.go b/experimental/geniecli/cmd/plugin.go new file mode 100644 index 00000000000..ba4b6d0039e --- /dev/null +++ b/experimental/geniecli/cmd/plugin.go @@ -0,0 +1,90 @@ +package genieclicmd + +import ( + "context" + "fmt" + + "github.com/databricks/cli/libs/aitools/agents" + "github.com/databricks/cli/libs/aitools/installer" + "github.com/databricks/cli/libs/log" +) + +// pluginScope is the CLI install scope for the Databricks plugin. genie-cli is +// a global, workspace-wide agent, so it installs at global scope. +const pluginScope = installer.ScopeGlobal + +// nativeScope is the agent-native plugin scope Codex installs into. Codex +// ignores --scope, but the installer records it, so "user" matches how a +// global CLI install maps to an agent's user-level scope. +const nativeScope = "user" + +// ensureDatabricksPlugin installs the Databricks skills plugin for the agent if +// the CLI has not already recorded installing it. Codex must already be on PATH +// here (the configure step installs it) because the plugin installs through +// Codex's own CLI. Codex exposes no plugin manifest the CLI can read back, so +// prior installs are detected from the CLI's own install state rather than from +// the agent (unlike Claude Code). +func ensureDatabricksPlugin(ctx context.Context) error { + agent := agents.ByName(agentName) + if agent == nil { + return fmt.Errorf("unknown agent %q", agentName) + } + + installed, err := pluginRecorded(ctx, agent.Name) + if err != nil { + return err + } + if installed { + return nil + } + + ref, _, err := installer.GetSkillsRef(ctx) + if err != nil { + return fmt.Errorf("failed to resolve the Databricks skills version: %w", err) + } + + rec, err := installer.InstallPluginForAgent(ctx, agent, nativeScope, ref) + if err != nil { + return fmt.Errorf("failed to install the Databricks skills plugin: %w", err) + } + + // Record the install so the refresh path (and `databricks aitools`) can act + // on exactly where we installed. + records := map[string]installer.PluginRecord{agent.Name: rec} + if err := installer.RecordPluginInstalls(ctx, pluginScope, records, ref); err != nil { + return fmt.Errorf("failed to record the Databricks skills plugin install: %w", err) + } + return nil +} + +// pluginRecorded reports whether the CLI's install state already records the +// Databricks plugin for the named agent at the global scope. +func pluginRecorded(ctx context.Context, name string) (bool, error) { + dir, err := installer.GlobalSkillsDir(ctx) + if err != nil { + return false, err + } + state, err := installer.LoadState(dir) + if err != nil { + return false, fmt.Errorf("failed to read the Databricks skills install state: %w", err) + } + if state == nil { + return false, nil + } + _, ok := state.Plugins[name] + return ok, nil +} + +// refreshDatabricksPlugin updates the Databricks skills plugin to the latest +// release before the agent starts. Failures are non-fatal: a stale plugin still +// works, so an offline or unreachable-GitHub run warns and launches anyway. +func refreshDatabricksPlugin(ctx context.Context) { + ref, _, err := installer.GetSkillsRef(ctx) + if err != nil { + log.Warnf(ctx, "Skipping Databricks skills update: %v", err) + return + } + if _, err := installer.UpdateInstalledPlugins(ctx, pluginScope, ref); err != nil { + log.Warnf(ctx, "Skipping Databricks skills update: %v", err) + } +} diff --git a/experimental/geniecli/cmd/plugin_test.go b/experimental/geniecli/cmd/plugin_test.go new file mode 100644 index 00000000000..663aa233047 --- /dev/null +++ b/experimental/geniecli/cmd/plugin_test.go @@ -0,0 +1,41 @@ +package genieclicmd + +import ( + "testing" + + "github.com/databricks/cli/libs/aitools/installer" + "github.com/databricks/cli/libs/env" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPluginRecorded(t *testing.T) { + home := t.TempDir() + ctx := env.Set(t.Context(), "HOME", home) + // On Windows env.UserHomeDir reads USERPROFILE. + ctx = env.Set(ctx, "USERPROFILE", home) + + // No state yet. + got, err := pluginRecorded(ctx, agentName) + require.NoError(t, err) + assert.False(t, got) + + dir, err := installer.GlobalSkillsDir(ctx) + require.NoError(t, err) + + // State without the agent's plugin. + require.NoError(t, installer.SaveState(dir, &installer.InstallState{ + Plugins: map[string]installer.PluginRecord{"claude-code": {Plugin: "databricks"}}, + })) + got, err = pluginRecorded(ctx, agentName) + require.NoError(t, err) + assert.False(t, got) + + // State with the agent's plugin recorded. + require.NoError(t, installer.SaveState(dir, &installer.InstallState{ + Plugins: map[string]installer.PluginRecord{agentName: {Plugin: "databricks"}}, + })) + got, err = pluginRecorded(ctx, agentName) + require.NoError(t, err) + assert.True(t, got) +} diff --git a/experimental/geniecli/cmd/ucode.go b/experimental/geniecli/cmd/ucode.go new file mode 100644 index 00000000000..78628533180 --- /dev/null +++ b/experimental/geniecli/cmd/ucode.go @@ -0,0 +1,129 @@ +package genieclicmd + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + + "github.com/databricks/cli/libs/auth" + "github.com/databricks/cli/libs/env" + "github.com/databricks/cli/libs/execv" + "github.com/databricks/cli/libs/log" + "github.com/databricks/cli/libs/process" + "github.com/databricks/databricks-sdk-go/config" +) + +const ( + // ucodeBin is the launcher that wires a coding agent to the Databricks AI + // Gateway. It, the agent binary, and the underlying model routing are + // implementation details hidden behind the genie-cli command. + ucodeBin = "ucode" + // uvBin installs ucode; it is the only supported installer for the launcher + // (a Python tool published as a uv tool). See ucodeInstallSpec. + uvBin = "uv" + // ucodeInstallSpec is the source uv installs the launcher from. + // https://github.com/databricks/ucode + ucodeInstallSpec = "git+https://github.com/databricks/ucode" + + // agentName is the coding agent genie-cli drives. It matches the agent + // registry name in libs/aitools/agents so the Databricks plugin is installed + // for the same agent that gets launched. + agentName = "codex" +) + +// lookPath is a package-level seam so tests can resolve binaries without +// depending on the host PATH. +var lookPath = exec.LookPath + +// launch replaces the current process with the given command; a package-level +// seam so tests can assert the launch argv without exec'ing. +var launch = execv.Execv + +// ensureUcode makes sure the launcher is installed, installing it through uv if +// it is missing. It returns the absolute path to the launcher binary. +func ensureUcode(ctx context.Context) (string, error) { + if path, err := lookPath(ucodeBin); err == nil { + return path, nil + } + + // The launcher is only distributed as a uv tool, so uv is a hard prerequisite + // rather than something we can work around. + if _, err := lookPath(uvBin); err != nil { + return "", fmt.Errorf("genie-cli requires uv to install its runtime, but uv was not found on PATH\n"+ + "Install uv (https://docs.astral.sh/uv/getting-started/installation) and re-run, "+ + "or install the runtime manually with: uv tool install %s", ucodeInstallSpec) + } + + log.Infof(ctx, "Installing the genie-cli runtime with uv") + if err := process.Forwarded(ctx, []string{uvBin, "tool", "install", ucodeInstallSpec}, os.Stdin, os.Stdout, os.Stderr); err != nil { + return "", fmt.Errorf("failed to install the genie-cli runtime: %w", err) + } + + // uv installs into its tool bin dir (e.g. ~/.local/bin), which may not be on + // the current process's PATH even after a successful install. + path, err := lookPath(ucodeBin) + if err != nil { + return "", fmt.Errorf("the genie-cli runtime was installed but %q is not on PATH\n"+ + "Add uv's tool bin directory to PATH (run: uv tool update-shell) and re-run", ucodeBin) + } + return path, nil +} + +// configureArgs builds the launcher's non-interactive configure argv for the +// resolved workspace config. The launcher needs an explicit workspace identity +// or it prompts interactively, which would break the non-interactive setup. +func configureArgs(ucodePath string, cfg *config.Config) ([]string, error) { + args := []string{ucodePath, "configure", "--agents", agentName, "--skip-validate", "--skip-upgrade"} + + switch { + case cfg.Profile != "": + args = append(args, "--profiles", cfg.Profile) + // --use-pat reuses the profile's stored token with no interactive login; + // it is only valid for PAT profiles and requires --profiles. + if cfg.AuthType == auth.AuthTypePat { + args = append(args, "--use-pat") + } + case cfg.Host != "": + args = append(args, "--workspaces", cfg.Host) + default: + return nil, errors.New("no Databricks profile or host resolved; run `databricks auth login` first") + } + return args, nil +} + +// configureAgent points the agent at this workspace's AI Gateway. This also +// installs the agent binary and the Databricks CLI the launcher relies on. +func configureAgent(ctx context.Context, ucodePath string, cfg *config.Config) error { + args, err := configureArgs(ucodePath, cfg) + if err != nil { + return err + } + if err := process.Forwarded(ctx, args, os.Stdin, os.Stdout, os.Stderr); err != nil { + return fmt.Errorf("failed to configure the Databricks agent: %w", err) + } + return nil +} + +// launchAgent replaces the current process with the agent session. Extra args +// are forwarded to the underlying agent. --skip-preflight trusts the configure +// step we just ran and skips a redundant auth + gateway re-validation. +func launchAgent(ctx context.Context, ucodePath string, extraArgs []string) error { + args := append([]string{ucodePath, agentName, "--skip-preflight"}, extraArgs...) + return launch(execv.Options{ + Args: args, + Env: envSlice(ctx), + }) +} + +// envSlice returns the process environment as a "KEY=value" slice, pulled +// through libs/env so tests can override it per context. +func envSlice(ctx context.Context) []string { + all := env.All(ctx) + out := make([]string, 0, len(all)) + for k, v := range all { + out = append(out, k+"="+v) + } + return out +} diff --git a/experimental/geniecli/cmd/ucode_test.go b/experimental/geniecli/cmd/ucode_test.go new file mode 100644 index 00000000000..c4b81a9f6f2 --- /dev/null +++ b/experimental/geniecli/cmd/ucode_test.go @@ -0,0 +1,118 @@ +package genieclicmd + +import ( + "testing" + + "github.com/databricks/cli/libs/auth" + "github.com/databricks/cli/libs/execv" + "github.com/databricks/cli/libs/process" + "github.com/databricks/databricks-sdk-go/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConfigureArgs(t *testing.T) { + tests := []struct { + name string + cfg *config.Config + want []string + }{ + { + name: "pat profile uses --use-pat", + cfg: &config.Config{Profile: "prod", AuthType: auth.AuthTypePat}, + want: []string{"ucode", "configure", "--agents", "codex", "--skip-validate", "--skip-upgrade", "--profiles", "prod", "--use-pat"}, + }, + { + name: "oauth profile omits --use-pat", + cfg: &config.Config{Profile: "prod", AuthType: "databricks-cli"}, + want: []string{"ucode", "configure", "--agents", "codex", "--skip-validate", "--skip-upgrade", "--profiles", "prod"}, + }, + { + name: "host without profile falls back to --workspaces", + cfg: &config.Config{Host: "https://myworkspace.databricks.com"}, + want: []string{"ucode", "configure", "--agents", "codex", "--skip-validate", "--skip-upgrade", "--workspaces", "https://myworkspace.databricks.com"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := configureArgs("ucode", tt.cfg) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestConfigureArgsNoIdentity(t *testing.T) { + _, err := configureArgs("ucode", &config.Config{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "databricks auth login") +} + +func TestEnsureUcodeAlreadyOnPath(t *testing.T) { + restore := lookPath + t.Cleanup(func() { lookPath = restore }) + lookPath = func(file string) (string, error) { + assert.Equal(t, ucodeBin, file) + return "/usr/local/bin/ucode", nil + } + + path, err := ensureUcode(t.Context()) + require.NoError(t, err) + assert.Equal(t, "/usr/local/bin/ucode", path) +} + +func TestEnsureUcodeMissingUvErrors(t *testing.T) { + restore := lookPath + t.Cleanup(func() { lookPath = restore }) + lookPath = func(file string) (string, error) { + return "", assert.AnError + } + + _, err := ensureUcode(t.Context()) + require.Error(t, err) + assert.Contains(t, err.Error(), "uv") +} + +func TestEnsureUcodeInstallsWhenMissing(t *testing.T) { + restore := lookPath + t.Cleanup(func() { lookPath = restore }) + // ucode absent on the first probe, uv present, ucode present after install. + seen := map[string]int{} + lookPath = func(file string) (string, error) { + seen[file]++ + switch file { + case uvBin: + return "/usr/local/bin/uv", nil + case ucodeBin: + if seen[ucodeBin] == 1 { + return "", assert.AnError + } + return "/home/user/.local/bin/ucode", nil + } + return "", assert.AnError + } + + ctx, stub := process.WithStub(t.Context()) + stub.WithStdoutFor("uv tool install", "installed") + + path, err := ensureUcode(ctx) + require.NoError(t, err) + assert.Equal(t, "/home/user/.local/bin/ucode", path) + assert.Len(t, stub.Commands(), 1) + assert.Contains(t, stub.Commands()[0], "uv tool install") +} + +func TestLaunchAgentPassesThroughArgs(t *testing.T) { + restore := launch + t.Cleanup(func() { launch = restore }) + var gotArgs []string + launch = func(opts execv.Options) error { + gotArgs = opts.Args + return nil + } + + err := launchAgent(t.Context(), "/usr/local/bin/ucode", []string{"--full-auto"}) + require.NoError(t, err) + assert.Equal(t, []string{"/usr/local/bin/ucode", "codex", "--skip-preflight", "--full-auto"}, gotArgs) +} From d53debb421daa937b7fd76e5f52777edf4aca21c Mon Sep 17 00:00:00 2001 From: Ted Tomlinson Date: Mon, 20 Jul 2026 20:34:28 -0700 Subject: [PATCH 2/6] Prime genie-cli with a Databricks system prompt and MCP tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the agent operate as a Databricks CLI assistant out of the box: - Inject a default system prompt (adapted from the Genie Code agent, retargeted to the real `databricks` CLI, the Databricks skills, and the Databricks MCP servers). It is injected per-session only, via the agent's `-c developer_instructions=` override — an additive developer-role message, so nothing is written to the user's agent config and their own instructions are untouched. `--no-system-prompt` opts out. - Embed the resolved workspace host and CLI profile in the prompt so the agent acts against the same workspace and profile genie-cli authenticated with. - Register Databricks MCP services from system.ai for the session (best-effort: a workspace without those services warns and still launches). Co-authored-by: Isaac --- .nextchanges/cli/experimental-genie-cli.md | 2 +- experimental/geniecli/cmd/geniecli.go | 20 ++++- experimental/geniecli/cmd/prompt.go | 93 ++++++++++++++++++++++ experimental/geniecli/cmd/prompt_test.go | 39 +++++++++ experimental/geniecli/cmd/ucode.go | 40 ++++++++-- experimental/geniecli/cmd/ucode_test.go | 47 ++++++++--- 6 files changed, 223 insertions(+), 18 deletions(-) create mode 100644 experimental/geniecli/cmd/prompt.go create mode 100644 experimental/geniecli/cmd/prompt_test.go diff --git a/.nextchanges/cli/experimental-genie-cli.md b/.nextchanges/cli/experimental-genie-cli.md index 4a7c19fdc16..f89e40c33c4 100644 --- a/.nextchanges/cli/experimental-genie-cli.md +++ b/.nextchanges/cli/experimental-genie-cli.md @@ -1 +1 @@ -Added the experimental `databricks experimental genie-cli` command, which starts an interactive AI coding agent preconfigured for your Databricks workspace (routing model requests through the Databricks AI Gateway and preloading the Databricks skills plugin). +Added the experimental `databricks experimental genie-cli` command, which starts an interactive AI coding agent preconfigured for your Databricks workspace: it routes model requests through the Databricks AI Gateway, preloads the Databricks skills plugin, registers Databricks MCP tools, and primes the agent with a Databricks-aware system prompt scoped to the resolved workspace host and profile. diff --git a/experimental/geniecli/cmd/geniecli.go b/experimental/geniecli/cmd/geniecli.go index 75e4d5cbc55..a3b26f6e5f5 100644 --- a/experimental/geniecli/cmd/geniecli.go +++ b/experimental/geniecli/cmd/geniecli.go @@ -15,6 +15,8 @@ import ( // The agent runtime is an implementation detail intentionally kept out of the // user-facing surface: the user only ever interacts with `genie-cli`. func New() *cobra.Command { + var noSystemPrompt bool + cmd := &cobra.Command{ Use: "genie-cli [-- AGENT_ARGS...]", Short: "Start an interactive AI coding agent configured for Databricks", @@ -24,7 +26,8 @@ Databricks workspace. The agent authenticates through your Databricks workspace and routes model requests through the Databricks AI Gateway, so no separate model API keys are needed. Databricks coding skills are installed and kept up to date on every -launch so the agent works effectively against Databricks. +launch, Databricks MCP tools are registered, and the agent is primed with a +system prompt so it works effectively against Databricks out of the box. Any arguments after "--" are forwarded to the underlying agent, e.g.: @@ -62,10 +65,23 @@ Any arguments after "--" are forwarded to the underlying agent, e.g.: } refreshDatabricksPlugin(ctx) + // Register Databricks MCP tools for the session (best-effort). + registerMCP(ctx, ucodePath) + + // Prime the agent to operate as a Databricks CLI assistant, injected + // per-session only (no config file is written). Carries the resolved + // host and profile so the agent acts against this workspace. + var systemPrompt string + if !noSystemPrompt { + systemPrompt = developerInstructionsOverride(cfg.Host, cfg.Profile) + } + cmdio.LogString(ctx, "Starting the Databricks agent...") - return launchAgent(ctx, ucodePath, args) + return launchAgent(ctx, ucodePath, systemPrompt, args) }, } + cmd.Flags().BoolVar(&noSystemPrompt, "no-system-prompt", false, "Do not prime the agent with the default Databricks system prompt") + return cmd } diff --git a/experimental/geniecli/cmd/prompt.go b/experimental/geniecli/cmd/prompt.go new file mode 100644 index 00000000000..87fba565463 --- /dev/null +++ b/experimental/geniecli/cmd/prompt.go @@ -0,0 +1,93 @@ +package genieclicmd + +import ( + "fmt" + "strings" +) + +// systemPromptTemplate is the default developer instruction genie-cli injects +// into the agent so it operates as a Databricks-native CLI assistant out of the +// box. It is adapted for a terminal coding agent (Codex) that has the real +// `databricks` CLI, the Databricks skills, and the Databricks MCP servers +// available, rather than the web/notebook harness the original Genie Code agent +// targets. +// +// The %s placeholders are the resolved workspace host and CLI profile so the +// agent acts against the same workspace the genie-cli command authenticated as. +const systemPromptTemplate = `You are the Databricks CLI assistant: a coding agent that helps users get work done on Databricks directly from the terminal. + +# Workspace +You are operating against Databricks workspace %s. +%s +Use this workspace and profile for every Databricks operation. Do not switch workspaces or profiles unless the user explicitly asks. + +# Primary objective +Analyze the user's request and act on it against their Databricks workspace: +- For data and analytics requests, find the relevant assets, run queries, and give actionable results. +- For workspace operations (jobs, pipelines, clusters, apps, Lakebase, Unity Catalog), use the Databricks CLI to inspect and manage resources. +- For general help, gather what you need with tools before answering; do not run destructive or expensive operations just to explain something. + +# Tools available to you +- **The ` + "`databricks`" + ` CLI** is installed and pre-authenticated for this workspace. Prefer it for workspace resource management (jobs, pipelines, clusters, apps, secrets, Unity Catalog, Lakebase, filesystem). Always target the resolved profile above. Run ` + "`databricks --help`" + ` to discover commands rather than guessing. +- **Databricks skills** are installed for this session under the Databricks plugin. Consult the relevant skill before a non-trivial Databricks task (for example the DABs, jobs, pipelines, or SQL skills) so you follow current best practices. +- **Databricks MCP servers** may be registered (for example Unity Catalog functions and Genie spaces). When an MCP tool fits the task, prefer it over shelling out. + +# Core principles +1. **Discover before concluding.** Assume the data or resource exists in the workspace until you have actively searched and found nothing. Never claim you "don't have access" or that something is "unavailable" without first searching with the CLI or MCP tools. +2. **Act with sensible defaults.** When the user directs a concrete operation, attempt it immediately with reasonable assumptions. Only ask a clarifying question if the attempt fails or a critical detail is genuinely ambiguous. +3. **Read before modifying.** Always read a resource's current definition before editing it. +4. **Plan multi-step work.** For tasks with dependencies (investigate -> change -> verify), track steps explicitly and finish them before ending your turn. Skip the ceremony for simple single-action fixes. +5. **Stay simple.** Solve what was asked. Do not add extra features or speculative complexity. +6. **Be persistent.** Keep working autonomously until the request is resolved. If one approach is blocked, try another or report precisely what is blocking you. +7. **Be safe.** When generating SQL or code, avoid injection and other vulnerabilities. Confirm before running anything destructive (dropping data, deleting resources, force-deploying over existing state). + +# Response format +- Keep answers concise: a few short paragraphs. +- Incorporate tool results into your analysis; do not restate raw command output or code verbatim. +- Use standard Markdown tables for structured or comparative data. +` + +// buildSystemPrompt renders the default developer instruction for the resolved +// workspace. host is the workspace URL; profile is the resolved CLI profile +// name, which may be empty when auth came from environment variables rather +// than a named profile. +func buildSystemPrompt(host, profile string) string { + hostText := host + if hostText == "" { + hostText = "(the workspace resolved by the Databricks CLI)" + } + + var profileLine string + if profile != "" { + profileLine = fmt.Sprintf("Authenticate with the Databricks CLI profile %q; pass `--profile %s` to every `databricks` command.", profile, profile) + } else { + // No named profile: auth resolved from environment/host, which the CLI + // already picks up, so the agent must not invent a --profile flag. + profileLine = "Authentication is resolved from the environment; run `databricks` commands without a --profile flag." + } + + return fmt.Sprintf(systemPromptTemplate, hostText, profileLine) +} + +// developerInstructionsOverride returns the Codex `-c key=value` override that +// injects the system prompt for this session only. Codex loads +// developer_instructions as an additive developer-role message, so this primes +// the agent without replacing its base instructions or writing any config file. +func developerInstructionsOverride(host, profile string) string { + prompt := buildSystemPrompt(host, profile) + // Codex parses the value as TOML, so the string must be quoted and escaped. + return "developer_instructions=" + tomlQuote(prompt) +} + +// tomlQuote renders s as a TOML basic string: wrapped in double quotes with +// backslashes, double quotes, and newlines escaped. +func tomlQuote(s string) string { + r := strings.NewReplacer( + `\`, `\\`, + `"`, `\"`, + "\n", `\n`, + "\t", `\t`, + "\r", `\r`, + ) + return `"` + r.Replace(s) + `"` +} diff --git a/experimental/geniecli/cmd/prompt_test.go b/experimental/geniecli/cmd/prompt_test.go new file mode 100644 index 00000000000..93fc5dbb224 --- /dev/null +++ b/experimental/geniecli/cmd/prompt_test.go @@ -0,0 +1,39 @@ +package genieclicmd + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBuildSystemPromptWithProfile(t *testing.T) { + got := buildSystemPrompt("https://myworkspace.databricks.com", "prod") + assert.Contains(t, got, "https://myworkspace.databricks.com") + assert.Contains(t, got, `--profile prod`) + assert.Contains(t, got, "Databricks CLI assistant") +} + +func TestBuildSystemPromptWithoutProfile(t *testing.T) { + got := buildSystemPrompt("https://myworkspace.databricks.com", "") + assert.Contains(t, got, "https://myworkspace.databricks.com") + // With no named profile the agent is told auth comes from the environment + // and must not be told to pass a specific `--profile `. + assert.Contains(t, got, "resolved from the environment") + assert.NotContains(t, got, `--profile "`) +} + +func TestDeveloperInstructionsOverrideIsQuotedTOML(t *testing.T) { + got := developerInstructionsOverride("https://myworkspace.databricks.com", "prod") + assert.True(t, strings.HasPrefix(got, `developer_instructions="`)) + assert.True(t, strings.HasSuffix(got, `"`)) + // The multi-line prompt must be escaped to a single TOML basic string. + value := strings.TrimPrefix(got, "developer_instructions=") + assert.NotContains(t, value[1:len(value)-1], "\n") + assert.Contains(t, value, `\n`) +} + +func TestTomlQuoteEscapes(t *testing.T) { + got := tomlQuote("a\"b\nc\\d") + assert.Equal(t, `"a\"b\nc\\d"`, got) +} diff --git a/experimental/geniecli/cmd/ucode.go b/experimental/geniecli/cmd/ucode.go index 78628533180..8dcf18a469e 100644 --- a/experimental/geniecli/cmd/ucode.go +++ b/experimental/geniecli/cmd/ucode.go @@ -31,6 +31,10 @@ const ( // registry name in libs/aitools/agents so the Databricks plugin is installed // for the same agent that gets launched. agentName = "codex" + + // mcpLocation is the Unity Catalog schema whose MCP services are registered + // for the agent. system.ai hosts the built-in Databricks MCP services. + mcpLocation = "system.ai" ) // lookPath is a package-level seam so tests can resolve binaries without @@ -106,11 +110,37 @@ func configureAgent(ctx context.Context, ucodePath string, cfg *config.Config) e return nil } -// launchAgent replaces the current process with the agent session. Extra args -// are forwarded to the underlying agent. --skip-preflight trusts the configure -// step we just ran and skips a redundant auth + gateway re-validation. -func launchAgent(ctx context.Context, ucodePath string, extraArgs []string) error { - args := append([]string{ucodePath, agentName, "--skip-preflight"}, extraArgs...) +// registerMCP registers the Databricks MCP services from the configured Unity +// Catalog location for the agent, so the session has Databricks MCP tools. It +// is best-effort: a workspace without MCP services in that schema (or an +// unreachable one) should not block the agent, so failures are logged and +// swallowed, matching the plugin-refresh policy. +func registerMCP(ctx context.Context, ucodePath string) { + args := []string{ucodePath, "configure", "mcp", "--location", mcpLocation} + if err := process.Forwarded(ctx, args, os.Stdin, os.Stdout, os.Stderr); err != nil { + log.Warnf(ctx, "Skipping Databricks MCP setup: %v", err) + } +} + +// launchAgent replaces the current process with the agent session. +// --skip-preflight trusts the configure step we just ran and skips a redundant +// auth + gateway re-validation. When systemPrompt is non-empty it is injected +// for this session only via the agent's `-c developer_instructions=` override +// (an additive developer-role message; no config file is written). Any userArgs +// are forwarded to the underlying agent after ours. +func launchAgent(ctx context.Context, ucodePath, systemPrompt string, userArgs []string) error { + // Everything after "--" is forwarded verbatim to the underlying agent. + var forwarded []string + if systemPrompt != "" { + forwarded = append(forwarded, "-c", systemPrompt) + } + forwarded = append(forwarded, userArgs...) + + args := []string{ucodePath, agentName, "--skip-preflight"} + if len(forwarded) > 0 { + args = append(args, "--") + args = append(args, forwarded...) + } return launch(execv.Options{ Args: args, Env: envSlice(ctx), diff --git a/experimental/geniecli/cmd/ucode_test.go b/experimental/geniecli/cmd/ucode_test.go index c4b81a9f6f2..6690b6113e3 100644 --- a/experimental/geniecli/cmd/ucode_test.go +++ b/experimental/geniecli/cmd/ucode_test.go @@ -103,16 +103,43 @@ func TestEnsureUcodeInstallsWhenMissing(t *testing.T) { assert.Contains(t, stub.Commands()[0], "uv tool install") } -func TestLaunchAgentPassesThroughArgs(t *testing.T) { - restore := launch - t.Cleanup(func() { launch = restore }) - var gotArgs []string - launch = func(opts execv.Options) error { - gotArgs = opts.Args - return nil +func TestLaunchAgent(t *testing.T) { + tests := []struct { + name string + systemPrompt string + userArgs []string + want []string + }{ + { + name: "no prompt, no args", + want: []string{"/usr/local/bin/ucode", "codex", "--skip-preflight"}, + }, + { + name: "user args only", + userArgs: []string{"--full-auto"}, + want: []string{"/usr/local/bin/ucode", "codex", "--skip-preflight", "--", "--full-auto"}, + }, + { + name: "system prompt injected before user args", + systemPrompt: `developer_instructions="hi"`, + userArgs: []string{"--full-auto"}, + want: []string{"/usr/local/bin/ucode", "codex", "--skip-preflight", "--", "-c", `developer_instructions="hi"`, "--full-auto"}, + }, } - err := launchAgent(t.Context(), "/usr/local/bin/ucode", []string{"--full-auto"}) - require.NoError(t, err) - assert.Equal(t, []string{"/usr/local/bin/ucode", "codex", "--skip-preflight", "--full-auto"}, gotArgs) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + restore := launch + t.Cleanup(func() { launch = restore }) + var gotArgs []string + launch = func(opts execv.Options) error { + gotArgs = opts.Args + return nil + } + + err := launchAgent(t.Context(), "/usr/local/bin/ucode", tt.systemPrompt, tt.userArgs) + require.NoError(t, err) + assert.Equal(t, tt.want, gotArgs) + }) + } } From 31684a3e54d3aa4f5947025f4de895d86c52e383 Mon Sep 17 00:00:00 2001 From: Ted Tomlinson Date: Mon, 20 Jul 2026 20:45:18 -0700 Subject: [PATCH 3/6] Hide the genie-cli command Mark genie-cli Hidden like the other in-progress experimental commands (genie, aitools). It stays fully runnable; it just doesn't list in `databricks experimental --help`. Co-authored-by: Isaac --- experimental/geniecli/cmd/geniecli.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/experimental/geniecli/cmd/geniecli.go b/experimental/geniecli/cmd/geniecli.go index a3b26f6e5f5..7244f704ebb 100644 --- a/experimental/geniecli/cmd/geniecli.go +++ b/experimental/geniecli/cmd/geniecli.go @@ -18,8 +18,9 @@ func New() *cobra.Command { var noSystemPrompt bool cmd := &cobra.Command{ - Use: "genie-cli [-- AGENT_ARGS...]", - Short: "Start an interactive AI coding agent configured for Databricks", + Use: "genie-cli [-- AGENT_ARGS...]", + Hidden: true, + Short: "Start an interactive AI coding agent configured for Databricks", Long: `Start an interactive AI coding agent that is preconfigured for your Databricks workspace. From ab1ff6c083ff0ce798c5ead2464fab85ef321531 Mon Sep 17 00:00:00 2001 From: Ted Tomlinson Date: Mon, 20 Jul 2026 20:59:55 -0700 Subject: [PATCH 4/6] Route data questions to hosted Genie in the genie-cli prompt Guide the agent to answer data discovery and data-science / analytics questions with `databricks experimental genie ask ""` instead of hand-writing SQL, falling back to SQL only when Genie can't answer or the user is authoring a query/pipeline. Co-authored-by: Isaac --- experimental/geniecli/cmd/prompt.go | 20 ++++++++++++++++---- experimental/geniecli/cmd/prompt_test.go | 2 ++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/experimental/geniecli/cmd/prompt.go b/experimental/geniecli/cmd/prompt.go index 87fba565463..27158ddebb2 100644 --- a/experimental/geniecli/cmd/prompt.go +++ b/experimental/geniecli/cmd/prompt.go @@ -23,17 +23,29 @@ Use this workspace and profile for every Databricks operation. Do not switch wor # Primary objective Analyze the user's request and act on it against their Databricks workspace: -- For data and analytics requests, find the relevant assets, run queries, and give actionable results. +- For data discovery, analytics, and data-science question answering ("what is our revenue by region?", "which tables track orders?", "show the trend of X"), ask hosted Genie first (see below) instead of hand-writing SQL. - For workspace operations (jobs, pipelines, clusters, apps, Lakebase, Unity Catalog), use the Databricks CLI to inspect and manage resources. - For general help, gather what you need with tools before answering; do not run destructive or expensive operations just to explain something. -# Tools available to you -- **The ` + "`databricks`" + ` CLI** is installed and pre-authenticated for this workspace. Prefer it for workspace resource management (jobs, pipelines, clusters, apps, secrets, Unity Catalog, Lakebase, filesystem). Always target the resolved profile above. Run ` + "`databricks --help`" + ` to discover commands rather than guessing. +# Answering data questions: prefer hosted Genie +For most data discovery and data-science / analytics question answering, ask hosted Databricks Genie rather than writing and running SQL yourself: + + databricks experimental genie ask "" + +Genie is grounded in this workspace's data, so it finds the right tables, writes the SQL, runs it, and returns an answer. Reach for it whenever the user asks a question *about their data* — metrics, aggregations, trends, "what/which/how many" questions, or "what data do I have about X". + +- Pass the question in natural language; let Genie find the tables. Add ` + "`--warehouse-id `" + ` only if the user names a specific warehouse. +- Continue a line of questioning in one conversation with ` + "`-s `" + `, e.g. ` + "`genie ask -s sales \"break that down by region\"`" + `. +- Use ` + "`--output json`" + ` when you need to parse the result programmatically. +- Fall back to writing SQL yourself (via the CLI or a SQL skill) only when Genie can't answer, the user explicitly wants hand-written SQL, or the task is about authoring/editing a query or pipeline rather than getting an answer. + +# Other tools available to you +- **The ` + "`databricks`" + ` CLI** is installed and pre-authenticated for this workspace. Prefer it for workspace resource management (jobs, pipelines, clusters, apps, secrets, Unity Catalog, Lakebase, filesystem) and for hosted Genie (above). Always target the resolved profile above. Run ` + "`databricks --help`" + ` to discover commands rather than guessing. - **Databricks skills** are installed for this session under the Databricks plugin. Consult the relevant skill before a non-trivial Databricks task (for example the DABs, jobs, pipelines, or SQL skills) so you follow current best practices. - **Databricks MCP servers** may be registered (for example Unity Catalog functions and Genie spaces). When an MCP tool fits the task, prefer it over shelling out. # Core principles -1. **Discover before concluding.** Assume the data or resource exists in the workspace until you have actively searched and found nothing. Never claim you "don't have access" or that something is "unavailable" without first searching with the CLI or MCP tools. +1. **Discover before concluding.** Assume the data or resource exists in the workspace until you have actively looked and found nothing. Never claim you "don't have access" or that something is "unavailable" without first asking hosted Genie or searching with the CLI or MCP tools. 2. **Act with sensible defaults.** When the user directs a concrete operation, attempt it immediately with reasonable assumptions. Only ask a clarifying question if the attempt fails or a critical detail is genuinely ambiguous. 3. **Read before modifying.** Always read a resource's current definition before editing it. 4. **Plan multi-step work.** For tasks with dependencies (investigate -> change -> verify), track steps explicitly and finish them before ending your turn. Skip the ceremony for simple single-action fixes. diff --git a/experimental/geniecli/cmd/prompt_test.go b/experimental/geniecli/cmd/prompt_test.go index 93fc5dbb224..588955c9122 100644 --- a/experimental/geniecli/cmd/prompt_test.go +++ b/experimental/geniecli/cmd/prompt_test.go @@ -12,6 +12,8 @@ func TestBuildSystemPromptWithProfile(t *testing.T) { assert.Contains(t, got, "https://myworkspace.databricks.com") assert.Contains(t, got, `--profile prod`) assert.Contains(t, got, "Databricks CLI assistant") + // Data questions should route to hosted Genie. + assert.Contains(t, got, "databricks experimental genie ask") } func TestBuildSystemPromptWithoutProfile(t *testing.T) { From 3bd62fc33e9c234891224418850b8ec57dfe18a5 Mon Sep 17 00:00:00 2001 From: Ted Tomlinson Date: Tue, 21 Jul 2026 07:24:45 -0700 Subject: [PATCH 5/6] Default genie-cli data tasks to hosted Genie end-to-end Sharpen the prompt around one decision: if a task does not depend on the user's local filesystem, do the whole thing through `genie ask` rather than using Genie only to find tables and then hand-writing SQL locally. Genie gives a better, workspace-grounded, shareable result and still returns the SQL and tables it used (via --include-sql / --output json) so the agent can explore further locally when the task genuinely needs it. Co-authored-by: Isaac --- experimental/geniecli/cmd/prompt.go | 42 ++++++++++++++++++----------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/experimental/geniecli/cmd/prompt.go b/experimental/geniecli/cmd/prompt.go index 27158ddebb2..0a6fe7deaa2 100644 --- a/experimental/geniecli/cmd/prompt.go +++ b/experimental/geniecli/cmd/prompt.go @@ -22,22 +22,34 @@ You are operating against Databricks workspace %s. Use this workspace and profile for every Databricks operation. Do not switch workspaces or profiles unless the user explicitly asks. # Primary objective -Analyze the user's request and act on it against their Databricks workspace: -- For data discovery, analytics, and data-science question answering ("what is our revenue by region?", "which tables track orders?", "show the trend of X"), ask hosted Genie first (see below) instead of hand-writing SQL. -- For workspace operations (jobs, pipelines, clusters, apps, Lakebase, Unity Catalog), use the Databricks CLI to inspect and manage resources. -- For general help, gather what you need with tools before answering; do not run destructive or expensive operations just to explain something. - -# Answering data questions: prefer hosted Genie -For most data discovery and data-science / analytics question answering, ask hosted Databricks Genie rather than writing and running SQL yourself: - - databricks experimental genie ask "" - -Genie is grounded in this workspace's data, so it finds the right tables, writes the SQL, runs it, and returns an answer. Reach for it whenever the user asks a question *about their data* — metrics, aggregations, trends, "what/which/how many" questions, or "what data do I have about X". - -- Pass the question in natural language; let Genie find the tables. Add ` + "`--warehouse-id `" + ` only if the user names a specific warehouse. +Analyze the user's request and decide where the work belongs. The single most +important decision: **does the task depend on the user's local filesystem +(their code, local data files, a repo, a bundle, notebooks on disk)?** + +- **No local dependency** (a question or task purely about workspace data — metrics, analytics, data science, "what data do I have", exploring tables, computing a result): **do the whole task through hosted Genie** with ` + "`genie ask`" + ` (see below). Do NOT just use Genie to find tables and then hand-write and run SQL locally — send Genie the actual task and let it produce the answer. +- **Depends on the local filesystem** (editing code, a DAB/bundle, files in the repo, local data, or generating artifacts on disk): do it locally with your own tools and the ` + "`databricks`" + ` CLI, pulling data in via Genie when you need workspace facts. +- **Workspace operations** (jobs, pipelines, clusters, apps, Lakebase, Unity Catalog management): use the Databricks CLI. +- For general help, gather what you need before answering; do not run destructive or expensive operations just to explain something. + +# Answering data questions: do the work in hosted Genie +Hosted Databricks Genie is grounded in this workspace's data. For any data +discovery, analytics, or data-science task that does not depend on the user's +local files, send the whole task to Genie rather than writing and running SQL +yourself: + + databricks experimental genie ask "" + +Prefer Genie for the complete task, not just table discovery, because: +- It produces a better, workspace-grounded answer than locally hand-written SQL. +- The result is easily shareable — it lives in a Genie conversation the user can open, re-run, and share. +- It still returns the SQL it ran and the tables it used, so you can read those back and explore further locally when the task genuinely needs it. + +Guidance: +- Send the real task in natural language (e.g. ` + "`genie ask \"what is monthly active revenue by region for the last 6 months?\"`" + `), not a narrow "find me a table" query. Let Genie find the tables and write the SQL. +- Use ` + "`--include-sql`" + ` to see the queries Genie ran, and ` + "`--output json`" + ` when you need to parse the answer, SQL, or table references programmatically for follow-up work. - Continue a line of questioning in one conversation with ` + "`-s `" + `, e.g. ` + "`genie ask -s sales \"break that down by region\"`" + `. -- Use ` + "`--output json`" + ` when you need to parse the result programmatically. -- Fall back to writing SQL yourself (via the CLI or a SQL skill) only when Genie can't answer, the user explicitly wants hand-written SQL, or the task is about authoring/editing a query or pipeline rather than getting an answer. +- Add ` + "`--warehouse-id `" + ` only if the user names a specific warehouse. +- Only fall back to hand-writing and running SQL yourself when Genie cannot answer, the user explicitly asks for hand-written SQL, or the task is about authoring/editing a query or pipeline on disk rather than getting an answer. # Other tools available to you - **The ` + "`databricks`" + ` CLI** is installed and pre-authenticated for this workspace. Prefer it for workspace resource management (jobs, pipelines, clusters, apps, secrets, Unity Catalog, Lakebase, filesystem) and for hosted Genie (above). Always target the resolved profile above. Run ` + "`databricks --help`" + ` to discover commands rather than guessing. From f3649badce01615dfee080f27c3c4f39a29d6471 Mon Sep 17 00:00:00 2001 From: Ted Tomlinson Date: Tue, 21 Jul 2026 11:27:28 -0700 Subject: [PATCH 6/6] Support selectable harness (codex/opencode) in genie-cli Add a --harness flag (default codex) so the user can choose which coding agent ucode launches, with pass-through for any ucode agent name. Make system-prompt injection harness-aware, since agents differ: - codex: inline `-c developer_instructions=` (unchanged). - opencode: no inline flag, so the prompt is written to a genie-cli-managed file and referenced via OPENCODE_CONFIG_CONTENT={"instructions":[...]}, whose entries opencode appends to the model context. Still session-scoped (env var only) and non-destructive to the user's own opencode config. - any other harness: launched with no prompt injection. Skip the aitools plugin step for harnesses without a headless plugin (e.g. opencode, which is skills-only): those rely on the injected system prompt. Co-authored-by: Isaac --- .nextchanges/cli/experimental-genie-cli.md | 2 +- experimental/geniecli/cmd/geniecli.go | 29 ++++--- experimental/geniecli/cmd/harness.go | 92 ++++++++++++++++++++++ experimental/geniecli/cmd/harness_test.go | 63 +++++++++++++++ experimental/geniecli/cmd/plugin.go | 25 +++--- experimental/geniecli/cmd/plugin_test.go | 19 ++++- experimental/geniecli/cmd/prompt.go | 10 --- experimental/geniecli/cmd/prompt_test.go | 11 --- experimental/geniecli/cmd/ucode.go | 40 ++++------ experimental/geniecli/cmd/ucode_test.go | 83 ++++++++++++------- 10 files changed, 275 insertions(+), 99 deletions(-) create mode 100644 experimental/geniecli/cmd/harness.go create mode 100644 experimental/geniecli/cmd/harness_test.go diff --git a/.nextchanges/cli/experimental-genie-cli.md b/.nextchanges/cli/experimental-genie-cli.md index f89e40c33c4..5449e2936bf 100644 --- a/.nextchanges/cli/experimental-genie-cli.md +++ b/.nextchanges/cli/experimental-genie-cli.md @@ -1 +1 @@ -Added the experimental `databricks experimental genie-cli` command, which starts an interactive AI coding agent preconfigured for your Databricks workspace: it routes model requests through the Databricks AI Gateway, preloads the Databricks skills plugin, registers Databricks MCP tools, and primes the agent with a Databricks-aware system prompt scoped to the resolved workspace host and profile. +Added the experimental `databricks experimental genie-cli` command, which starts an interactive AI coding agent preconfigured for your Databricks workspace: it routes model requests through the Databricks AI Gateway, preloads the Databricks skills plugin, registers Databricks MCP tools, and primes the agent with a Databricks-aware system prompt scoped to the resolved workspace host and profile. Use `--harness` to choose the coding agent (default `codex`; `opencode` is also primed with the Databricks system prompt). diff --git a/experimental/geniecli/cmd/geniecli.go b/experimental/geniecli/cmd/geniecli.go index 7244f704ebb..f9fe6e95a2c 100644 --- a/experimental/geniecli/cmd/geniecli.go +++ b/experimental/geniecli/cmd/geniecli.go @@ -16,6 +16,7 @@ import ( // user-facing surface: the user only ever interacts with `genie-cli`. func New() *cobra.Command { var noSystemPrompt bool + var harness string cmd := &cobra.Command{ Use: "genie-cli [-- AGENT_ARGS...]", @@ -30,9 +31,12 @@ needed. Databricks coding skills are installed and kept up to date on every launch, Databricks MCP tools are registered, and the agent is primed with a system prompt so it works effectively against Databricks out of the box. -Any arguments after "--" are forwarded to the underlying agent, e.g.: +Use --harness to pick which coding agent runs (default codex; opencode is also +primed with the Databricks system prompt). Any arguments after "--" are +forwarded to the underlying agent, e.g.: - databricks experimental genie-cli -- --full-auto`, + databricks experimental genie-cli -- --full-auto + databricks experimental genie-cli --harness opencode`, // The agent takes over the terminal, so no bundle is loaded. PreRunE: func(cmd *cobra.Command, args []string) error { cmd.SetContext(root.SkipLoadBundle(cmd.Context())) @@ -51,17 +55,18 @@ Any arguments after "--" are forwarded to the underlying agent, e.g.: return err } - // Configure the agent against this workspace. This also installs the + // Configure the harness against this workspace. This also installs the // agent binary and the Databricks CLI the runtime depends on, so it // must run before the plugin step (which drives the agent's own CLI). - if err := configureAgent(ctx, ucodePath, cfg); err != nil { + if err := configureAgent(ctx, ucodePath, harness, cfg); err != nil { return err } - // Install the Databricks skills plugin for the agent, then refresh it. + // Install the Databricks skills plugin for the harness, then refresh + // it. No-op for harnesses without a headless plugin (e.g. opencode). // Refresh failures (offline, GitHub unreachable) are non-fatal: a stale // plugin still works, so we warn and launch anyway. - if err := ensureDatabricksPlugin(ctx); err != nil { + if err := ensureDatabricksPlugin(ctx, harness); err != nil { return err } refreshDatabricksPlugin(ctx) @@ -70,18 +75,22 @@ Any arguments after "--" are forwarded to the underlying agent, e.g.: registerMCP(ctx, ucodePath) // Prime the agent to operate as a Databricks CLI assistant, injected - // per-session only (no config file is written). Carries the resolved + // per-session only (no user config is written). Carries the resolved // host and profile so the agent acts against this workspace. - var systemPrompt string + var inj injection if !noSystemPrompt { - systemPrompt = developerInstructionsOverride(cfg.Host, cfg.Profile) + inj, err = buildInjection(ctx, harness, cfg.Host, cfg.Profile) + if err != nil { + return err + } } cmdio.LogString(ctx, "Starting the Databricks agent...") - return launchAgent(ctx, ucodePath, systemPrompt, args) + return launchAgent(ctx, ucodePath, harness, inj, args) }, } + cmd.Flags().StringVar(&harness, "harness", defaultHarness, "Coding agent to run (e.g. codex, opencode)") cmd.Flags().BoolVar(&noSystemPrompt, "no-system-prompt", false, "Do not prime the agent with the default Databricks system prompt") return cmd diff --git a/experimental/geniecli/cmd/harness.go b/experimental/geniecli/cmd/harness.go new file mode 100644 index 00000000000..5941e93fd6d --- /dev/null +++ b/experimental/geniecli/cmd/harness.go @@ -0,0 +1,92 @@ +package genieclicmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/databricks/cli/libs/env" +) + +// defaultHarness is the coding agent genie-cli drives unless the user selects +// another with --harness. It matches an agent name ucode understands and (for +// codex) the agent registry name in libs/aitools/agents. +const defaultHarness = "codex" + +// harnessCodex and harnessOpenCode are the harnesses genie-cli primes with the +// Databricks system prompt. Any other ucode agent name is still accepted and +// launched, just without prompt injection (see buildInjection). +const ( + harnessCodex = "codex" + harnessOpenCode = "opencode" +) + +// injection describes how the Databricks system prompt is delivered to a +// harness for one session. Delivery differs per agent: Codex takes an inline +// `-c` override, OpenCode reads an instructions file referenced through an +// environment variable. Fields are empty when a harness gets no prompt. +type injection struct { + // forwardArgs are appended after the agent's "--" separator (Codex). + forwardArgs []string + // env are extra KEY=value entries for the launched process (OpenCode). + env []string +} + +// buildInjection renders the system prompt for the harness and returns how to +// deliver it for this session, without writing to the user's agent config. +// +// - codex: inline `-c developer_instructions=` (additive developer-role +// message). +// - opencode: the prompt is written to a genie-cli-managed file and referenced +// via OPENCODE_CONFIG_CONTENT={"instructions":[""]}, whose entries +// OpenCode appends to the model context. Uses an env var (not a flag) +// because OpenCode has no inline system-prompt flag. +// - any other harness: no injection (returns a zero injection, nil error). +func buildInjection(ctx context.Context, harness, host, profile string) (injection, error) { + prompt := buildSystemPrompt(host, profile) + + switch harness { + case harnessCodex: + return injection{forwardArgs: []string{"-c", "developer_instructions=" + tomlQuote(prompt)}}, nil + case harnessOpenCode: + path, err := writeOpenCodePromptFile(ctx, prompt) + if err != nil { + return injection{}, err + } + content, err := json.Marshal(map[string]any{"instructions": []string{path}}) + if err != nil { + return injection{}, fmt.Errorf("failed to encode OpenCode config: %w", err) + } + return injection{env: []string{"OPENCODE_CONFIG_CONTENT=" + string(content)}}, nil + default: + return injection{}, nil + } +} + +// openCodePromptPath returns the genie-cli-managed file that holds the OpenCode +// instructions. It lives under the user's home in a stable, overwritten +// location so there is no temp file to clean up after the exec handoff. +func openCodePromptPath(ctx context.Context) (string, error) { + home, err := env.UserHomeDir(ctx) + if err != nil { + return "", err + } + return filepath.Join(home, ".databricks", "genie-cli", "opencode-instructions.md"), nil +} + +// writeOpenCodePromptFile writes the prompt to the managed path and returns it. +func writeOpenCodePromptFile(ctx context.Context, prompt string) (string, error) { + path, err := openCodePromptPath(ctx) + if err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return "", fmt.Errorf("failed to create genie-cli directory: %w", err) + } + if err := os.WriteFile(path, []byte(prompt), 0o644); err != nil { + return "", fmt.Errorf("failed to write the OpenCode system prompt: %w", err) + } + return path, nil +} diff --git a/experimental/geniecli/cmd/harness_test.go b/experimental/geniecli/cmd/harness_test.go new file mode 100644 index 00000000000..dac303afa4f --- /dev/null +++ b/experimental/geniecli/cmd/harness_test.go @@ -0,0 +1,63 @@ +package genieclicmd + +import ( + "encoding/json" + "os" + "strings" + "testing" + + "github.com/databricks/cli/libs/env" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildInjectionCodex(t *testing.T) { + inj, err := buildInjection(t.Context(), harnessCodex, "https://myworkspace.databricks.com", "prod") + require.NoError(t, err) + assert.Empty(t, inj.env) + require.Len(t, inj.forwardArgs, 2) + assert.Equal(t, "-c", inj.forwardArgs[0]) + + // The prompt is delivered as a single quoted TOML basic string. + value := inj.forwardArgs[1] + assert.True(t, strings.HasPrefix(value, `developer_instructions="`)) + assert.True(t, strings.HasSuffix(value, `"`)) + body := strings.TrimPrefix(value, "developer_instructions=") + assert.NotContains(t, body[1:len(body)-1], "\n") + assert.Contains(t, body, `\n`) + assert.Contains(t, value, "myworkspace.databricks.com") +} + +func TestBuildInjectionOpenCode(t *testing.T) { + home := t.TempDir() + ctx := env.Set(t.Context(), "HOME", home) + ctx = env.Set(ctx, "USERPROFILE", home) + + inj, err := buildInjection(ctx, harnessOpenCode, "https://myworkspace.databricks.com", "prod") + require.NoError(t, err) + assert.Empty(t, inj.forwardArgs) + require.Len(t, inj.env, 1) + + // The env var carries JSON pointing at the managed prompt file, which must + // exist and contain the rendered prompt. + content := strings.TrimPrefix(inj.env[0], "OPENCODE_CONFIG_CONTENT=") + assert.NotEqual(t, inj.env[0], content) // prefix was present + + var cfg struct { + Instructions []string `json:"instructions"` + } + require.NoError(t, json.Unmarshal([]byte(content), &cfg)) + require.Len(t, cfg.Instructions, 1) + + data, err := os.ReadFile(cfg.Instructions[0]) + require.NoError(t, err) + assert.Contains(t, string(data), "myworkspace.databricks.com") + assert.Contains(t, string(data), "databricks experimental genie ask") +} + +func TestBuildInjectionUnknownHarnessIsEmpty(t *testing.T) { + inj, err := buildInjection(t.Context(), "gemini", "https://myworkspace.databricks.com", "prod") + require.NoError(t, err) + assert.Empty(t, inj.forwardArgs) + assert.Empty(t, inj.env) +} diff --git a/experimental/geniecli/cmd/plugin.go b/experimental/geniecli/cmd/plugin.go index ba4b6d0039e..24feb6158bb 100644 --- a/experimental/geniecli/cmd/plugin.go +++ b/experimental/geniecli/cmd/plugin.go @@ -18,16 +18,21 @@ const pluginScope = installer.ScopeGlobal // global CLI install maps to an agent's user-level scope. const nativeScope = "user" -// ensureDatabricksPlugin installs the Databricks skills plugin for the agent if -// the CLI has not already recorded installing it. Codex must already be on PATH -// here (the configure step installs it) because the plugin installs through -// Codex's own CLI. Codex exposes no plugin manifest the CLI can read back, so -// prior installs are detected from the CLI's own install state rather than from -// the agent (unlike Claude Code). -func ensureDatabricksPlugin(ctx context.Context) error { - agent := agents.ByName(agentName) - if agent == nil { - return fmt.Errorf("unknown agent %q", agentName) +// ensureDatabricksPlugin installs the Databricks skills plugin for the harness +// if the CLI has not already recorded installing it. The harness binary must +// already be on PATH here (the configure step installs it) because the plugin +// installs through the agent's own CLI. +// +// It is a no-op for a harness that has no headless plugin (e.g. OpenCode, which +// is skills-only) or that the aitools registry doesn't know: those harnesses +// rely on the injected system prompt for Databricks priming instead. Codex +// exposes no plugin manifest the CLI can read back, so prior installs are +// detected from the CLI's own install state rather than from the agent (unlike +// Claude Code). +func ensureDatabricksPlugin(ctx context.Context, harness string) error { + agent := agents.ByName(harness) + if agent == nil || agent.Plugin == nil { + return nil } installed, err := pluginRecorded(ctx, agent.Name) diff --git a/experimental/geniecli/cmd/plugin_test.go b/experimental/geniecli/cmd/plugin_test.go index 663aa233047..78d21aee555 100644 --- a/experimental/geniecli/cmd/plugin_test.go +++ b/experimental/geniecli/cmd/plugin_test.go @@ -16,7 +16,7 @@ func TestPluginRecorded(t *testing.T) { ctx = env.Set(ctx, "USERPROFILE", home) // No state yet. - got, err := pluginRecorded(ctx, agentName) + got, err := pluginRecorded(ctx, "codex") require.NoError(t, err) assert.False(t, got) @@ -27,15 +27,26 @@ func TestPluginRecorded(t *testing.T) { require.NoError(t, installer.SaveState(dir, &installer.InstallState{ Plugins: map[string]installer.PluginRecord{"claude-code": {Plugin: "databricks"}}, })) - got, err = pluginRecorded(ctx, agentName) + got, err = pluginRecorded(ctx, "codex") require.NoError(t, err) assert.False(t, got) // State with the agent's plugin recorded. require.NoError(t, installer.SaveState(dir, &installer.InstallState{ - Plugins: map[string]installer.PluginRecord{agentName: {Plugin: "databricks"}}, + Plugins: map[string]installer.PluginRecord{"codex": {Plugin: "databricks"}}, })) - got, err = pluginRecorded(ctx, agentName) + got, err = pluginRecorded(ctx, "codex") require.NoError(t, err) assert.True(t, got) } + +func TestEnsureDatabricksPluginSkipsNonPluginHarness(t *testing.T) { + home := t.TempDir() + ctx := env.Set(t.Context(), "HOME", home) + ctx = env.Set(ctx, "USERPROFILE", home) + + // opencode is skills-only (no headless plugin) and gemini is unknown to the + // aitools registry; both must be no-ops rather than errors or installs. + require.NoError(t, ensureDatabricksPlugin(ctx, "opencode")) + require.NoError(t, ensureDatabricksPlugin(ctx, "gemini")) +} diff --git a/experimental/geniecli/cmd/prompt.go b/experimental/geniecli/cmd/prompt.go index 0a6fe7deaa2..348e26b00b5 100644 --- a/experimental/geniecli/cmd/prompt.go +++ b/experimental/geniecli/cmd/prompt.go @@ -93,16 +93,6 @@ func buildSystemPrompt(host, profile string) string { return fmt.Sprintf(systemPromptTemplate, hostText, profileLine) } -// developerInstructionsOverride returns the Codex `-c key=value` override that -// injects the system prompt for this session only. Codex loads -// developer_instructions as an additive developer-role message, so this primes -// the agent without replacing its base instructions or writing any config file. -func developerInstructionsOverride(host, profile string) string { - prompt := buildSystemPrompt(host, profile) - // Codex parses the value as TOML, so the string must be quoted and escaped. - return "developer_instructions=" + tomlQuote(prompt) -} - // tomlQuote renders s as a TOML basic string: wrapped in double quotes with // backslashes, double quotes, and newlines escaped. func tomlQuote(s string) string { diff --git a/experimental/geniecli/cmd/prompt_test.go b/experimental/geniecli/cmd/prompt_test.go index 588955c9122..fefdbc03880 100644 --- a/experimental/geniecli/cmd/prompt_test.go +++ b/experimental/geniecli/cmd/prompt_test.go @@ -1,7 +1,6 @@ package genieclicmd import ( - "strings" "testing" "github.com/stretchr/testify/assert" @@ -25,16 +24,6 @@ func TestBuildSystemPromptWithoutProfile(t *testing.T) { assert.NotContains(t, got, `--profile "`) } -func TestDeveloperInstructionsOverrideIsQuotedTOML(t *testing.T) { - got := developerInstructionsOverride("https://myworkspace.databricks.com", "prod") - assert.True(t, strings.HasPrefix(got, `developer_instructions="`)) - assert.True(t, strings.HasSuffix(got, `"`)) - // The multi-line prompt must be escaped to a single TOML basic string. - value := strings.TrimPrefix(got, "developer_instructions=") - assert.NotContains(t, value[1:len(value)-1], "\n") - assert.Contains(t, value, `\n`) -} - func TestTomlQuoteEscapes(t *testing.T) { got := tomlQuote("a\"b\nc\\d") assert.Equal(t, `"a\"b\nc\\d"`, got) diff --git a/experimental/geniecli/cmd/ucode.go b/experimental/geniecli/cmd/ucode.go index 8dcf18a469e..642caa03fa5 100644 --- a/experimental/geniecli/cmd/ucode.go +++ b/experimental/geniecli/cmd/ucode.go @@ -27,11 +27,6 @@ const ( // https://github.com/databricks/ucode ucodeInstallSpec = "git+https://github.com/databricks/ucode" - // agentName is the coding agent genie-cli drives. It matches the agent - // registry name in libs/aitools/agents so the Databricks plugin is installed - // for the same agent that gets launched. - agentName = "codex" - // mcpLocation is the Unity Catalog schema whose MCP services are registered // for the agent. system.ai hosts the built-in Databricks MCP services. mcpLocation = "system.ai" @@ -76,10 +71,11 @@ func ensureUcode(ctx context.Context) (string, error) { } // configureArgs builds the launcher's non-interactive configure argv for the -// resolved workspace config. The launcher needs an explicit workspace identity -// or it prompts interactively, which would break the non-interactive setup. -func configureArgs(ucodePath string, cfg *config.Config) ([]string, error) { - args := []string{ucodePath, "configure", "--agents", agentName, "--skip-validate", "--skip-upgrade"} +// chosen harness and resolved workspace config. The launcher needs an explicit +// workspace identity or it prompts interactively, which would break the +// non-interactive setup. +func configureArgs(ucodePath, harness string, cfg *config.Config) ([]string, error) { + args := []string{ucodePath, "configure", "--agents", harness, "--skip-validate", "--skip-upgrade"} switch { case cfg.Profile != "": @@ -97,10 +93,10 @@ func configureArgs(ucodePath string, cfg *config.Config) ([]string, error) { return args, nil } -// configureAgent points the agent at this workspace's AI Gateway. This also +// configureAgent points the harness at this workspace's AI Gateway. This also // installs the agent binary and the Databricks CLI the launcher relies on. -func configureAgent(ctx context.Context, ucodePath string, cfg *config.Config) error { - args, err := configureArgs(ucodePath, cfg) +func configureAgent(ctx context.Context, ucodePath, harness string, cfg *config.Config) error { + args, err := configureArgs(ucodePath, harness, cfg) if err != nil { return err } @@ -122,28 +118,24 @@ func registerMCP(ctx context.Context, ucodePath string) { } } -// launchAgent replaces the current process with the agent session. +// launchAgent replaces the current process with the harness session. // --skip-preflight trusts the configure step we just ran and skips a redundant -// auth + gateway re-validation. When systemPrompt is non-empty it is injected -// for this session only via the agent's `-c developer_instructions=` override -// (an additive developer-role message; no config file is written). Any userArgs -// are forwarded to the underlying agent after ours. -func launchAgent(ctx context.Context, ucodePath, systemPrompt string, userArgs []string) error { +// auth + gateway re-validation. The system prompt is delivered per the harness's +// injection (forwarded args and/or extra env); userArgs are forwarded to the +// underlying agent after ours. +func launchAgent(ctx context.Context, ucodePath, harness string, inj injection, userArgs []string) error { // Everything after "--" is forwarded verbatim to the underlying agent. - var forwarded []string - if systemPrompt != "" { - forwarded = append(forwarded, "-c", systemPrompt) - } + forwarded := append([]string{}, inj.forwardArgs...) forwarded = append(forwarded, userArgs...) - args := []string{ucodePath, agentName, "--skip-preflight"} + args := []string{ucodePath, harness, "--skip-preflight"} if len(forwarded) > 0 { args = append(args, "--") args = append(args, forwarded...) } return launch(execv.Options{ Args: args, - Env: envSlice(ctx), + Env: append(envSlice(ctx), inj.env...), }) } diff --git a/experimental/geniecli/cmd/ucode_test.go b/experimental/geniecli/cmd/ucode_test.go index 6690b6113e3..fc8e163332c 100644 --- a/experimental/geniecli/cmd/ucode_test.go +++ b/experimental/geniecli/cmd/ucode_test.go @@ -13,30 +13,40 @@ import ( func TestConfigureArgs(t *testing.T) { tests := []struct { - name string - cfg *config.Config - want []string + name string + harness string + cfg *config.Config + want []string }{ { - name: "pat profile uses --use-pat", - cfg: &config.Config{Profile: "prod", AuthType: auth.AuthTypePat}, - want: []string{"ucode", "configure", "--agents", "codex", "--skip-validate", "--skip-upgrade", "--profiles", "prod", "--use-pat"}, + name: "pat profile uses --use-pat", + harness: "codex", + cfg: &config.Config{Profile: "prod", AuthType: auth.AuthTypePat}, + want: []string{"ucode", "configure", "--agents", "codex", "--skip-validate", "--skip-upgrade", "--profiles", "prod", "--use-pat"}, }, { - name: "oauth profile omits --use-pat", - cfg: &config.Config{Profile: "prod", AuthType: "databricks-cli"}, - want: []string{"ucode", "configure", "--agents", "codex", "--skip-validate", "--skip-upgrade", "--profiles", "prod"}, + name: "oauth profile omits --use-pat", + harness: "codex", + cfg: &config.Config{Profile: "prod", AuthType: "databricks-cli"}, + want: []string{"ucode", "configure", "--agents", "codex", "--skip-validate", "--skip-upgrade", "--profiles", "prod"}, }, { - name: "host without profile falls back to --workspaces", - cfg: &config.Config{Host: "https://myworkspace.databricks.com"}, - want: []string{"ucode", "configure", "--agents", "codex", "--skip-validate", "--skip-upgrade", "--workspaces", "https://myworkspace.databricks.com"}, + name: "host without profile falls back to --workspaces", + harness: "codex", + cfg: &config.Config{Host: "https://myworkspace.databricks.com"}, + want: []string{"ucode", "configure", "--agents", "codex", "--skip-validate", "--skip-upgrade", "--workspaces", "https://myworkspace.databricks.com"}, + }, + { + name: "harness is forwarded to --agents", + harness: "opencode", + cfg: &config.Config{Profile: "prod", AuthType: "databricks-cli"}, + want: []string{"ucode", "configure", "--agents", "opencode", "--skip-validate", "--skip-upgrade", "--profiles", "prod"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := configureArgs("ucode", tt.cfg) + got, err := configureArgs("ucode", tt.harness, tt.cfg) require.NoError(t, err) assert.Equal(t, tt.want, got) }) @@ -44,7 +54,7 @@ func TestConfigureArgs(t *testing.T) { } func TestConfigureArgsNoIdentity(t *testing.T) { - _, err := configureArgs("ucode", &config.Config{}) + _, err := configureArgs("ucode", "codex", &config.Config{}) require.Error(t, err) assert.Contains(t, err.Error(), "databricks auth login") } @@ -105,25 +115,37 @@ func TestEnsureUcodeInstallsWhenMissing(t *testing.T) { func TestLaunchAgent(t *testing.T) { tests := []struct { - name string - systemPrompt string - userArgs []string - want []string + name string + harness string + inj injection + userArgs []string + wantArgs []string + wantEnv []string // env entries that must be present }{ { - name: "no prompt, no args", - want: []string{"/usr/local/bin/ucode", "codex", "--skip-preflight"}, + name: "no prompt, no args", + harness: "codex", + wantArgs: []string{"/usr/local/bin/ucode", "codex", "--skip-preflight"}, }, { name: "user args only", + harness: "codex", + userArgs: []string{"--full-auto"}, + wantArgs: []string{"/usr/local/bin/ucode", "codex", "--skip-preflight", "--", "--full-auto"}, + }, + { + name: "codex injection forwards -c before user args", + harness: "codex", + inj: injection{forwardArgs: []string{"-c", `developer_instructions="hi"`}}, userArgs: []string{"--full-auto"}, - want: []string{"/usr/local/bin/ucode", "codex", "--skip-preflight", "--", "--full-auto"}, + wantArgs: []string{"/usr/local/bin/ucode", "codex", "--skip-preflight", "--", "-c", `developer_instructions="hi"`, "--full-auto"}, }, { - name: "system prompt injected before user args", - systemPrompt: `developer_instructions="hi"`, - userArgs: []string{"--full-auto"}, - want: []string{"/usr/local/bin/ucode", "codex", "--skip-preflight", "--", "-c", `developer_instructions="hi"`, "--full-auto"}, + name: "opencode injection sets env, no forwarded args", + harness: "opencode", + inj: injection{env: []string{`OPENCODE_CONFIG_CONTENT={"instructions":["/x"]}`}}, + wantArgs: []string{"/usr/local/bin/ucode", "opencode", "--skip-preflight"}, + wantEnv: []string{`OPENCODE_CONFIG_CONTENT={"instructions":["/x"]}`}, }, } @@ -131,15 +153,18 @@ func TestLaunchAgent(t *testing.T) { t.Run(tt.name, func(t *testing.T) { restore := launch t.Cleanup(func() { launch = restore }) - var gotArgs []string + var got execv.Options launch = func(opts execv.Options) error { - gotArgs = opts.Args + got = opts return nil } - err := launchAgent(t.Context(), "/usr/local/bin/ucode", tt.systemPrompt, tt.userArgs) + err := launchAgent(t.Context(), "/usr/local/bin/ucode", tt.harness, tt.inj, tt.userArgs) require.NoError(t, err) - assert.Equal(t, tt.want, gotArgs) + assert.Equal(t, tt.wantArgs, got.Args) + for _, e := range tt.wantEnv { + assert.Contains(t, got.Env, e) + } }) } }