Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .nextchanges/cli/experimental-genie-cli.md
Original file line number Diff line number Diff line change
@@ -0,0 +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. Use `--harness` to choose the coding agent (default `codex`; `opencode` is also primed with the Databricks system prompt).
2 changes: 2 additions & 0 deletions cmd/experimental/experimental.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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())

Expand Down
97 changes: 97 additions & 0 deletions experimental/geniecli/cmd/geniecli.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
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 {
var noSystemPrompt bool
var harness string

cmd := &cobra.Command{
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.

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, Databricks MCP tools are registered, and the agent is primed with a
system prompt so it works effectively against Databricks out of the box.

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 --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()))
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 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, harness, cfg); err != nil {
return err
}

// 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, harness); err != nil {
return err
}
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 user config is written). Carries the resolved
// host and profile so the agent acts against this workspace.
var inj injection
if !noSystemPrompt {
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, 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
}
92 changes: 92 additions & 0 deletions experimental/geniecli/cmd/harness.go
Original file line number Diff line number Diff line change
@@ -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=<toml>` (additive developer-role
// message).
// - opencode: the prompt is written to a genie-cli-managed file and referenced
// via OPENCODE_CONFIG_CONTENT={"instructions":["<file>"]}, 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
}
63 changes: 63 additions & 0 deletions experimental/geniecli/cmd/harness_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
95 changes: 95 additions & 0 deletions experimental/geniecli/cmd/plugin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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 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)
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)
}
}
Loading