diff --git a/Makefile b/Makefile index 026d1db3..9b37f984 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Image configuration REGISTRY ?= gjkim42 VERSION ?= latest -IMAGE_DIRS ?= cmd/axon-controller cmd/axon-spawner cmd/axon-token-refresher claude-code codex gemini opencode +IMAGE_DIRS ?= cmd/axon-controller cmd/axon-spawner cmd/axon-token-refresher claude-code codex gemini opencode cursor # Version injection for the axon CLI – only set ldflags when an explicit # version is given so that dev builds fall through to runtime/debug info. diff --git a/README.md b/README.md index 06daabe7..9b7e4405 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Point Axon at a GitHub issue and get a PR back — fully autonomous, running in Kubernetes. Each agent runs in an isolated, ephemeral Pod with a freshly cloned git workspace. Fan out across repositories, chain tasks into pipelines, and react to events automatically. -Supports **Claude Code**, **OpenAI Codex**, **Google Gemini**, **OpenCode**, and [custom agent images](docs/agent-image-interface.md). +Supports **Claude Code**, **OpenAI Codex**, **Google Gemini**, **OpenCode**, **Cursor**, and [custom agent images](docs/agent-image-interface.md). ## Demo @@ -47,7 +47,7 @@ AI coding agents are evolving from interactive CLI tools into autonomous backgro - **Orchestration, not just execution** — Don't just run an agent; manage its entire lifecycle. Chain tasks with `dependsOn` and pass results (branch names, PR URLs, token usage) between pipeline stages. Use `TaskSpawner` to build event-driven workers that react to GitHub issues, PRs, or schedules. - **Host-isolated autonomy** — Each task runs in an isolated, ephemeral Pod with a freshly cloned git workspace. Agents have no access to your host machine — use [scoped tokens and branch protection](#security-considerations) to control repository access. -- **Standardized interface** — Plug in any agent (Claude, Codex, Gemini, OpenCode, or your own) using a simple [container interface](docs/agent-image-interface.md). Axon handles credential injection, workspace management, and Kubernetes plumbing. +- **Standardized interface** — Plug in any agent (Claude, Codex, Gemini, OpenCode, Cursor, or your own) using a simple [container interface](docs/agent-image-interface.md). Axon handles credential injection, workspace management, and Kubernetes plumbing. - **Scalable parallelism** — Fan out agents across multiple repositories. Kubernetes handles scheduling, resource management, and queueing — scale is limited by your cluster capacity and API provider quotas. - **Observable & CI-native** — Every agent run is a first-class Kubernetes resource with deterministic outputs (branch names, PR URLs, commit SHAs, token usage) captured into status. Monitor via `kubectl`, manage via the `axon` CLI or declarative YAML (GitOps-ready), and integrate with ArgoCD or GitHub Actions. @@ -511,7 +511,7 @@ axon resume taskspawner my-spawner
What agents does Axon support? -Axon supports **Claude Code**, **OpenAI Codex**, **Google Gemini**, and **OpenCode** out of the box. You can also bring your own agent image using the [container interface](docs/agent-image-interface.md). +Axon supports **Claude Code**, **OpenAI Codex**, **Google Gemini**, **OpenCode**, and **Cursor** out of the box. You can also bring your own agent image using the [container interface](docs/agent-image-interface.md).
diff --git a/api/v1alpha1/task_types.go b/api/v1alpha1/task_types.go index f1063d6c..bef107c2 100644 --- a/api/v1alpha1/task_types.go +++ b/api/v1alpha1/task_types.go @@ -75,7 +75,7 @@ type PodOverrides struct { type TaskSpec struct { // Type specifies the agent type (e.g., claude-code). // +kubebuilder:validation:Required - // +kubebuilder:validation:Enum=claude-code;codex;gemini;opencode + // +kubebuilder:validation:Enum=claude-code;codex;gemini;opencode;cursor Type string `json:"type"` // Prompt is the task prompt to send to the agent. diff --git a/api/v1alpha1/taskspawner_types.go b/api/v1alpha1/taskspawner_types.go index 11e12547..987733a8 100644 --- a/api/v1alpha1/taskspawner_types.go +++ b/api/v1alpha1/taskspawner_types.go @@ -111,7 +111,7 @@ type Jira struct { type TaskTemplate struct { // Type specifies the agent type (e.g., claude-code). // +kubebuilder:validation:Required - // +kubebuilder:validation:Enum=claude-code;codex;gemini;opencode + // +kubebuilder:validation:Enum=claude-code;codex;gemini;opencode;cursor Type string `json:"type"` // Credentials specifies how to authenticate with the agent. diff --git a/cmd/axon-controller/main.go b/cmd/axon-controller/main.go index 36a09687..99b66069 100644 --- a/cmd/axon-controller/main.go +++ b/cmd/axon-controller/main.go @@ -40,6 +40,8 @@ func main() { var geminiImagePullPolicy string var openCodeImage string var openCodeImagePullPolicy string + var cursorImage string + var cursorImagePullPolicy string var spawnerImage string var spawnerImagePullPolicy string var tokenRefresherImage string @@ -58,6 +60,8 @@ func main() { flag.StringVar(&geminiImagePullPolicy, "gemini-image-pull-policy", "", "The image pull policy for Gemini CLI agent containers (e.g., Always, Never, IfNotPresent).") flag.StringVar(&openCodeImage, "opencode-image", controller.OpenCodeImage, "The image to use for OpenCode agent containers.") flag.StringVar(&openCodeImagePullPolicy, "opencode-image-pull-policy", "", "The image pull policy for OpenCode agent containers (e.g., Always, Never, IfNotPresent).") + flag.StringVar(&cursorImage, "cursor-image", controller.CursorImage, "The image to use for Cursor CLI agent containers.") + flag.StringVar(&cursorImagePullPolicy, "cursor-image-pull-policy", "", "The image pull policy for Cursor CLI agent containers (e.g., Always, Never, IfNotPresent).") flag.StringVar(&spawnerImage, "spawner-image", controller.DefaultSpawnerImage, "The image to use for spawner Deployments.") flag.StringVar(&spawnerImagePullPolicy, "spawner-image-pull-policy", "", "The image pull policy for spawner Deployments (e.g., Always, Never, IfNotPresent).") flag.StringVar(&tokenRefresherImage, "token-refresher-image", controller.DefaultTokenRefresherImage, "The image to use for the token refresher sidecar.") @@ -97,6 +101,8 @@ func main() { jobBuilder.GeminiImagePullPolicy = corev1.PullPolicy(geminiImagePullPolicy) jobBuilder.OpenCodeImage = openCodeImage jobBuilder.OpenCodeImagePullPolicy = corev1.PullPolicy(openCodeImagePullPolicy) + jobBuilder.CursorImage = cursorImage + jobBuilder.CursorImagePullPolicy = corev1.PullPolicy(cursorImagePullPolicy) if err = (&controller.TaskReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), diff --git a/cursor/Dockerfile b/cursor/Dockerfile new file mode 100644 index 00000000..f49770b9 --- /dev/null +++ b/cursor/Dockerfile @@ -0,0 +1,48 @@ +FROM ubuntu:24.04 + +ARG GO_VERSION=1.25.0 + +RUN apt-get update && apt-get install -y \ + make \ + curl \ + ca-certificates \ + git \ + && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y nodejs \ + && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + -o /usr/share/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list \ + && apt-get update \ + && apt-get install -y gh \ + && rm -rf /var/lib/apt/lists/* + +RUN ARCH=$(dpkg --print-architecture) \ + && TARBALL="go${GO_VERSION}.linux-${ARCH}.tar.gz" \ + && curl -fsSL -o "/tmp/${TARBALL}" "https://dl.google.com/go/${TARBALL}" \ + && curl -fsSL -o "/tmp/${TARBALL}.sha256" "https://dl.google.com/go/${TARBALL}.sha256" \ + && echo "$(cat /tmp/${TARBALL}.sha256) /tmp/${TARBALL}" | sha256sum -c - \ + && tar -C /usr/local -xzf "/tmp/${TARBALL}" \ + && rm "/tmp/${TARBALL}" "/tmp/${TARBALL}.sha256" + +ENV PATH="/usr/local/go/bin:${PATH}" + +COPY cursor/axon_entrypoint.sh /axon_entrypoint.sh +RUN chmod +x /axon_entrypoint.sh + +COPY bin/axon-capture /axon/axon-capture + +RUN useradd -u 61100 -m -s /bin/bash agent +RUN mkdir -p /home/agent/.cursor && chown -R agent:agent /home/agent + +USER agent + +# Cursor CLI installs to ~/.local/bin +RUN curl -fsSL https://cursor.com/install | bash + +ENV GOPATH="/home/agent/go" +ENV PATH="/home/agent/.local/bin:${GOPATH}/bin:${PATH}" + +WORKDIR /workspace + +ENTRYPOINT ["agent"] diff --git a/cursor/axon_entrypoint.sh b/cursor/axon_entrypoint.sh new file mode 100644 index 00000000..b1888948 --- /dev/null +++ b/cursor/axon_entrypoint.sh @@ -0,0 +1,77 @@ +#!/bin/bash +# axon_entrypoint.sh — Axon agent image interface implementation for +# Cursor CLI. +# +# Interface contract: +# - First argument ($1): the task prompt +# - CURSOR_API_KEY env var: API key for authentication +# - AXON_MODEL env var: model name (optional) +# - UID 61100: shared between git-clone init container and agent +# - Working directory: /workspace/repo when a workspace is configured + +set -uo pipefail + +PROMPT="${1:?Prompt argument is required}" + +ARGS=( + "-p" + "--force" + "--trust" + "--sandbox" "disabled" + "--output-format" "stream-json" + "$PROMPT" +) + +if [ -n "${AXON_MODEL:-}" ]; then + ARGS=("--model" "$AXON_MODEL" "${ARGS[@]}") +fi + +# Write user-level instructions to both user config and workspace root. +# Cursor CLI may read AGENTS.md from the working directory. +if [ -n "${AXON_AGENTS_MD:-}" ]; then + mkdir -p ~/.cursor + printf '%s' "$AXON_AGENTS_MD" >~/.cursor/AGENTS.md + printf '%s' "$AXON_AGENTS_MD" >/workspace/AGENTS.md +fi + +# Install each plugin's skills into Cursor's .cursor/skills/ directory +# in the workspace so the CLI discovers them at runtime. +if [ -n "${AXON_PLUGIN_DIR:-}" ] && [ -d "${AXON_PLUGIN_DIR}" ]; then + for plugindir in "${AXON_PLUGIN_DIR}"/*/; do + [ -d "$plugindir" ] || continue + if [ -d "${plugindir}skills" ]; then + for skilldir in "${plugindir}skills"/*/; do + [ -d "$skilldir" ] || continue + skillname=$(basename "$skilldir") + pluginname=$(basename "$plugindir") + targetdir="/workspace/.cursor/skills/${pluginname}-${skillname}" + mkdir -p "$targetdir" + if [ -f "${skilldir}SKILL.md" ]; then + cp "${skilldir}SKILL.md" "$targetdir/SKILL.md" + fi + done + fi + done +fi + +# Write MCP server configuration to user-scoped ~/.cursor/mcp.json. +# The AXON_MCP_SERVERS JSON format matches Cursor's native format directly. +if [ -n "${AXON_MCP_SERVERS:-}" ]; then + mkdir -p ~/.cursor + node -e ' +const fs = require("fs"); +const cfgPath = require("os").homedir() + "/.cursor/mcp.json"; +let existing = {}; +try { existing = JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch {} +const mcp = JSON.parse(process.env.AXON_MCP_SERVERS); +existing.mcpServers = Object.assign(existing.mcpServers || {}, mcp.mcpServers || {}); +fs.writeFileSync(cfgPath, JSON.stringify(existing, null, 2)); +' +fi + +agent "${ARGS[@]}" | tee /tmp/agent-output.jsonl +AGENT_EXIT_CODE=${PIPESTATUS[0]} + +/axon/axon-capture + +exit $AGENT_EXIT_CODE diff --git a/docs/agent-image-interface.md b/docs/agent-image-interface.md index e3ecea4b..02a89673 100644 --- a/docs/agent-image-interface.md +++ b/docs/agent-image-interface.md @@ -34,12 +34,13 @@ Axon sets the following reserved environment variables on agent containers: | `CODEX_AUTH_JSON` | Contents of `~/.codex/auth.json` (`codex` agent, `oauth` credential type) | When credential type is `oauth` and agent type is `codex` | | `GEMINI_API_KEY` | API key for Google Gemini (`gemini` agent, api-key or oauth credential type) | When agent type is `gemini` | | `OPENCODE_API_KEY` | API key for OpenCode (`opencode` agent, api-key or oauth credential type) | When agent type is `opencode` | +| `CURSOR_API_KEY` | API key for Cursor CLI (`cursor` agent, api-key or oauth credential type) | When agent type is `cursor` | | `CLAUDE_CODE_OAUTH_TOKEN` | OAuth token (`claude-code` agent, oauth credential type) | When credential type is `oauth` and agent type is `claude-code` | | `GITHUB_TOKEN` | GitHub token for workspace access | When workspace has a `secretRef` | | `GH_TOKEN` | GitHub token for `gh` CLI (github.com) | When workspace has a `secretRef` and repo is on github.com | | `GH_ENTERPRISE_TOKEN` | GitHub token for `gh` CLI (GitHub Enterprise) | When workspace has a `secretRef` and repo is on a GitHub Enterprise host | | `GH_HOST` | Hostname for GitHub Enterprise | When repo is on a GitHub Enterprise host | -| `AXON_AGENT_TYPE` | The agent type (`claude-code`, `codex`, `gemini`, `opencode`) | Always | +| `AXON_AGENT_TYPE` | The agent type (`claude-code`, `codex`, `gemini`, `opencode`, `cursor`) | Always | | `AXON_BASE_BRANCH` | The base branch (workspace `ref`) for the task | When workspace has a non-empty `ref` | | `AXON_AGENTS_MD` | User-level instructions from AgentConfig | When `agentConfigRef` is set and `agentsMD` is non-empty | | `AXON_PLUGIN_DIR` | Path to plugin directory containing skills and agents | When `agentConfigRef` is set and `plugins` is non-empty | @@ -130,3 +131,4 @@ the agent exits non-zero. - `codex/axon_entrypoint.sh` — wraps the `codex` CLI (OpenAI Codex). - `gemini/axon_entrypoint.sh` — wraps the `gemini` CLI (Google Gemini). - `opencode/axon_entrypoint.sh` — wraps the `opencode` CLI (OpenCode). +- `cursor/axon_entrypoint.sh` — wraps the `agent` CLI (Cursor). diff --git a/install-crd.yaml b/install-crd.yaml index a219b46a..aad26851 100644 --- a/install-crd.yaml +++ b/install-crd.yaml @@ -532,6 +532,7 @@ spec: - codex - gemini - opencode + - cursor type: string workspaceRef: description: WorkspaceRef optionally references a Workspace resource @@ -1011,6 +1012,7 @@ spec: - codex - gemini - opencode + - cursor type: string workspaceRef: description: |- diff --git a/install.yaml b/install.yaml index 4e0f2cd1..9ee4df64 100644 --- a/install.yaml +++ b/install.yaml @@ -259,6 +259,7 @@ spec: - --codex-image=gjkim42/codex:latest - --gemini-image=gjkim42/gemini:latest - --opencode-image=gjkim42/opencode:latest + - --cursor-image=gjkim42/cursor:latest - --spawner-image=gjkim42/axon-spawner:latest - --token-refresher-image=gjkim42/axon-token-refresher:latest securityContext: diff --git a/internal/capture/usage.go b/internal/capture/usage.go index 469e9880..36bc29ff 100644 --- a/internal/capture/usage.go +++ b/internal/capture/usage.go @@ -39,6 +39,8 @@ func ParseUsage(agentType, filePath string) map[string]string { return parseGemini(lines) case "opencode": return parseOpencode(lines) + case "cursor": + return parseClaudeCode(lines) default: return nil } diff --git a/internal/capture/usage_test.go b/internal/capture/usage_test.go index 1013fb9e..0fd98512 100644 --- a/internal/capture/usage_test.go +++ b/internal/capture/usage_test.go @@ -80,6 +80,18 @@ func TestParseUsage(t *testing.T) { "output-tokens": "300", }, }, + { + name: "cursor reuses claude-code parser", + agentType: "cursor", + content: `{"type":"assistant","message":"thinking..."} +{"type":"result","total_cost_usd":0.0532,"usage":{"input_tokens":15230,"output_tokens":4821}} +`, + want: map[string]string{ + "cost-usd": "0.0532", + "input-tokens": "15230", + "output-tokens": "4821", + }, + }, { name: "unknown agent type returns nil", agentType: "unknown-agent", diff --git a/internal/cli/run.go b/internal/cli/run.go index e0b3b278..5fc122a9 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -305,7 +305,7 @@ func newRunCommand(cfg *ClientConfig) *cobra.Command { cmd.MarkFlagRequired("prompt") _ = cmd.RegisterFlagCompletionFunc("credential-type", cobra.FixedCompletions([]string{"api-key", "oauth"}, cobra.ShellCompDirectiveNoFileComp)) - _ = cmd.RegisterFlagCompletionFunc("type", cobra.FixedCompletions([]string{"claude-code", "codex", "gemini", "opencode"}, cobra.ShellCompDirectiveNoFileComp)) + _ = cmd.RegisterFlagCompletionFunc("type", cobra.FixedCompletions([]string{"claude-code", "codex", "gemini", "opencode", "cursor"}, cobra.ShellCompDirectiveNoFileComp)) return cmd } @@ -341,6 +341,8 @@ func apiKeySecretKey(agentType string) string { return "GEMINI_API_KEY" case "opencode": return "OPENCODE_API_KEY" + case "cursor": + return "CURSOR_API_KEY" default: return "ANTHROPIC_API_KEY" } @@ -356,6 +358,8 @@ func oauthSecretKey(agentType string) string { return "GEMINI_API_KEY" case "opencode": return "OPENCODE_API_KEY" + case "cursor": + return "CURSOR_API_KEY" default: return "CLAUDE_CODE_OAUTH_TOKEN" } diff --git a/internal/controller/job_builder.go b/internal/controller/job_builder.go index aba212be..707c9daa 100644 --- a/internal/controller/job_builder.go +++ b/internal/controller/job_builder.go @@ -27,6 +27,9 @@ const ( // OpenCodeImage is the default image for OpenCode agent. OpenCodeImage = "gjkim42/opencode:latest" + // CursorImage is the default image for Cursor CLI agent. + CursorImage = "gjkim42/cursor:latest" + // AgentTypeClaudeCode is the agent type for Claude Code. AgentTypeClaudeCode = "claude-code" @@ -39,6 +42,9 @@ const ( // AgentTypeOpenCode is the agent type for OpenCode. AgentTypeOpenCode = "opencode" + // AgentTypeCursor is the agent type for Cursor CLI. + AgentTypeCursor = "cursor" + // GitCloneImage is the image used for cloning git repositories. GitCloneImage = "alpine/git:v2.47.2" @@ -74,6 +80,8 @@ type JobBuilder struct { GeminiImagePullPolicy corev1.PullPolicy OpenCodeImage string OpenCodeImagePullPolicy corev1.PullPolicy + CursorImage string + CursorImagePullPolicy corev1.PullPolicy } // NewJobBuilder creates a new JobBuilder. @@ -83,6 +91,7 @@ func NewJobBuilder() *JobBuilder { CodexImage: CodexImage, GeminiImage: GeminiImage, OpenCodeImage: OpenCodeImage, + CursorImage: CursorImage, } } @@ -98,6 +107,8 @@ func (b *JobBuilder) Build(task *axonv1alpha1.Task, workspace *axonv1alpha1.Work return b.buildAgentJob(task, workspace, agentConfig, b.GeminiImage, b.GeminiImagePullPolicy, prompt) case AgentTypeOpenCode: return b.buildAgentJob(task, workspace, agentConfig, b.OpenCodeImage, b.OpenCodeImagePullPolicy, prompt) + case AgentTypeCursor: + return b.buildAgentJob(task, workspace, agentConfig, b.CursorImage, b.CursorImagePullPolicy, prompt) default: return nil, fmt.Errorf("unsupported agent type: %s", task.Spec.Type) } @@ -119,6 +130,8 @@ func apiKeyEnvVar(agentType string) string { // OPENCODE_API_KEY is the environment variable that the opencode // entrypoint reads for API key authentication. return "OPENCODE_API_KEY" + case AgentTypeCursor: + return "CURSOR_API_KEY" default: return "ANTHROPIC_API_KEY" } @@ -134,6 +147,8 @@ func oauthEnvVar(agentType string) string { return "GEMINI_API_KEY" case AgentTypeOpenCode: return "OPENCODE_API_KEY" + case AgentTypeCursor: + return "CURSOR_API_KEY" default: return "CLAUDE_CODE_OAUTH_TOKEN" } diff --git a/internal/controller/job_builder_test.go b/internal/controller/job_builder_test.go index e1a32738..190853e3 100644 --- a/internal/controller/job_builder_test.go +++ b/internal/controller/job_builder_test.go @@ -1339,6 +1339,231 @@ func TestBuildOpenCodeJob_OAuthCredentials(t *testing.T) { } } +func TestBuildCursorJob_DefaultImage(t *testing.T) { + builder := NewJobBuilder() + task := &axonv1alpha1.Task{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cursor", + Namespace: "default", + }, + Spec: axonv1alpha1.TaskSpec{ + Type: AgentTypeCursor, + Prompt: "Fix the bug", + Credentials: axonv1alpha1.Credentials{ + Type: axonv1alpha1.CredentialTypeAPIKey, + SecretRef: axonv1alpha1.SecretReference{Name: "cursor-secret"}, + }, + Model: "claude-sonnet-4-20250514", + }, + } + + job, err := builder.Build(task, nil, nil, task.Spec.Prompt) + if err != nil { + t.Fatalf("Build() returned error: %v", err) + } + + container := job.Spec.Template.Spec.Containers[0] + + if container.Image != CursorImage { + t.Errorf("Expected image %q, got %q", CursorImage, container.Image) + } + + if container.Name != AgentTypeCursor { + t.Errorf("Expected container name %q, got %q", AgentTypeCursor, container.Name) + } + + if len(container.Command) != 1 || container.Command[0] != "/axon_entrypoint.sh" { + t.Errorf("Expected command [/axon_entrypoint.sh], got %v", container.Command) + } + + if len(container.Args) != 1 || container.Args[0] != "Fix the bug" { + t.Errorf("Expected args [Fix the bug], got %v", container.Args) + } + + foundAxonModel := false + foundCursorKey := false + for _, env := range container.Env { + if env.Name == "AXON_MODEL" { + foundAxonModel = true + if env.Value != "claude-sonnet-4-20250514" { + t.Errorf("AXON_MODEL value: expected %q, got %q", "claude-sonnet-4-20250514", env.Value) + } + } + if env.Name == "CURSOR_API_KEY" { + foundCursorKey = true + if env.ValueFrom == nil || env.ValueFrom.SecretKeyRef == nil { + t.Error("Expected CURSOR_API_KEY to reference a secret") + } else { + if env.ValueFrom.SecretKeyRef.Name != "cursor-secret" { + t.Errorf("Expected secret name %q, got %q", "cursor-secret", env.ValueFrom.SecretKeyRef.Name) + } + if env.ValueFrom.SecretKeyRef.Key != "CURSOR_API_KEY" { + t.Errorf("Expected secret key %q, got %q", "CURSOR_API_KEY", env.ValueFrom.SecretKeyRef.Key) + } + } + } + if env.Name == "ANTHROPIC_API_KEY" { + t.Error("ANTHROPIC_API_KEY should not be set for cursor agent type") + } + if env.Name == "CODEX_API_KEY" { + t.Error("CODEX_API_KEY should not be set for cursor agent type") + } + } + if !foundAxonModel { + t.Error("Expected AXON_MODEL env var to be set") + } + if !foundCursorKey { + t.Error("Expected CURSOR_API_KEY env var to be set") + } +} + +func TestBuildCursorJob_CustomImage(t *testing.T) { + builder := NewJobBuilder() + task := &axonv1alpha1.Task{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cursor-custom", + Namespace: "default", + }, + Spec: axonv1alpha1.TaskSpec{ + Type: AgentTypeCursor, + Prompt: "Refactor the module", + Credentials: axonv1alpha1.Credentials{ + Type: axonv1alpha1.CredentialTypeAPIKey, + SecretRef: axonv1alpha1.SecretReference{Name: "cursor-secret"}, + }, + Image: "my-cursor:v2", + }, + } + + job, err := builder.Build(task, nil, nil, task.Spec.Prompt) + if err != nil { + t.Fatalf("Build() returned error: %v", err) + } + + container := job.Spec.Template.Spec.Containers[0] + + if container.Image != "my-cursor:v2" { + t.Errorf("Expected image %q, got %q", "my-cursor:v2", container.Image) + } + + if len(container.Command) != 1 || container.Command[0] != "/axon_entrypoint.sh" { + t.Errorf("Expected command [/axon_entrypoint.sh], got %v", container.Command) + } +} + +func TestBuildCursorJob_OAuthCredentials(t *testing.T) { + builder := NewJobBuilder() + task := &axonv1alpha1.Task{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cursor-oauth", + Namespace: "default", + }, + Spec: axonv1alpha1.TaskSpec{ + Type: AgentTypeCursor, + Prompt: "Review the code", + Credentials: axonv1alpha1.Credentials{ + Type: axonv1alpha1.CredentialTypeOAuth, + SecretRef: axonv1alpha1.SecretReference{Name: "cursor-oauth"}, + }, + }, + } + + job, err := builder.Build(task, nil, nil, task.Spec.Prompt) + if err != nil { + t.Fatalf("Build() returned error: %v", err) + } + + container := job.Spec.Template.Spec.Containers[0] + + foundCursorKey := false + for _, env := range container.Env { + if env.Name == "CURSOR_API_KEY" { + foundCursorKey = true + if env.ValueFrom == nil || env.ValueFrom.SecretKeyRef == nil { + t.Error("Expected CURSOR_API_KEY to reference a secret") + } else { + if env.ValueFrom.SecretKeyRef.Name != "cursor-oauth" { + t.Errorf("Expected secret name %q, got %q", "cursor-oauth", env.ValueFrom.SecretKeyRef.Name) + } + if env.ValueFrom.SecretKeyRef.Key != "CURSOR_API_KEY" { + t.Errorf("Expected secret key %q, got %q", "CURSOR_API_KEY", env.ValueFrom.SecretKeyRef.Key) + } + } + } + if env.Name == "CLAUDE_CODE_OAUTH_TOKEN" { + t.Error("CLAUDE_CODE_OAUTH_TOKEN should not be set for cursor agent type") + } + } + if !foundCursorKey { + t.Error("Expected CURSOR_API_KEY env var to be set") + } +} + +func TestBuildJob_AgentConfigCursor(t *testing.T) { + builder := NewJobBuilder() + task := &axonv1alpha1.Task{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cursor-agentconfig", + Namespace: "default", + }, + Spec: axonv1alpha1.TaskSpec{ + Type: AgentTypeCursor, + Prompt: "Fix issue", + Credentials: axonv1alpha1.Credentials{ + Type: axonv1alpha1.CredentialTypeAPIKey, + SecretRef: axonv1alpha1.SecretReference{Name: "my-secret"}, + }, + }, + } + + agentConfig := &axonv1alpha1.AgentConfigSpec{ + AgentsMD: "Follow coding standards.", + Plugins: []axonv1alpha1.PluginSpec{ + { + Name: "dev-tools", + Skills: []axonv1alpha1.SkillDefinition{ + {Name: "test", Content: "Run unit tests first"}, + }, + Agents: []axonv1alpha1.AgentDefinition{ + {Name: "linter", Content: "You are a code linter"}, + }, + }, + }, + } + + job, err := builder.Build(task, nil, agentConfig, task.Spec.Prompt) + if err != nil { + t.Fatalf("Build() returned error: %v", err) + } + + container := job.Spec.Template.Spec.Containers[0] + envMap := map[string]string{} + for _, env := range container.Env { + if env.Value != "" { + envMap[env.Name] = env.Value + } + } + + if envMap["AXON_AGENTS_MD"] != "Follow coding standards." { + t.Errorf("Expected AXON_AGENTS_MD=%q, got %q", "Follow coding standards.", envMap["AXON_AGENTS_MD"]) + } + + if envMap["AXON_PLUGIN_DIR"] != PluginMountPath { + t.Errorf("Expected AXON_PLUGIN_DIR=%q, got %q", PluginMountPath, envMap["AXON_PLUGIN_DIR"]) + } + + if len(job.Spec.Template.Spec.Volumes) != 1 { + t.Errorf("Expected 1 volume, got %d", len(job.Spec.Template.Spec.Volumes)) + } + if len(job.Spec.Template.Spec.InitContainers) != 1 { + t.Errorf("Expected 1 init container, got %d", len(job.Spec.Template.Spec.InitContainers)) + } + + if container.Name != AgentTypeCursor { + t.Errorf("Expected container name %q, got %q", AgentTypeCursor, container.Name) + } +} + func TestBuildClaudeCodeJob_UnsupportedType(t *testing.T) { builder := NewJobBuilder() task := &axonv1alpha1.Task{ diff --git a/internal/manifests/install-crd.yaml b/internal/manifests/install-crd.yaml index a219b46a..aad26851 100644 --- a/internal/manifests/install-crd.yaml +++ b/internal/manifests/install-crd.yaml @@ -532,6 +532,7 @@ spec: - codex - gemini - opencode + - cursor type: string workspaceRef: description: WorkspaceRef optionally references a Workspace resource @@ -1011,6 +1012,7 @@ spec: - codex - gemini - opencode + - cursor type: string workspaceRef: description: |- diff --git a/internal/manifests/install.yaml b/internal/manifests/install.yaml index 4e0f2cd1..9ee4df64 100644 --- a/internal/manifests/install.yaml +++ b/internal/manifests/install.yaml @@ -259,6 +259,7 @@ spec: - --codex-image=gjkim42/codex:latest - --gemini-image=gjkim42/gemini:latest - --opencode-image=gjkim42/opencode:latest + - --cursor-image=gjkim42/cursor:latest - --spawner-image=gjkim42/axon-spawner:latest - --token-refresher-image=gjkim42/axon-token-refresher:latest securityContext: