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
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -511,7 +511,7 @@ axon resume taskspawner my-spawner
<details>
<summary><strong>What agents does Axon support?</strong></summary>

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).

</details>

Expand Down
2 changes: 1 addition & 1 deletion api/v1alpha1/task_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion api/v1alpha1/taskspawner_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions cmd/axon-controller/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.")
Expand Down Expand Up @@ -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(),
Expand Down
48 changes: 48 additions & 0 deletions cursor/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we pin the cursor to a specific version? so that it will not be siliently updated?


ENV GOPATH="/home/agent/go"
ENV PATH="/home/agent/.local/bin:${GOPATH}/bin:${PATH}"

WORKDIR /workspace

ENTRYPOINT ["agent"]
77 changes: 77 additions & 0 deletions cursor/axon_entrypoint.sh
Original file line number Diff line number Diff line change
@@ -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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we have to add /workspace/AGENTS.md? I guess ~/.cursor/AGENTS.md already cover it.

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
Comment on lines +39 to +55
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about the agents?
I guess we have to inject plugin.agents as well.


# 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
4 changes: 3 additions & 1 deletion docs/agent-image-interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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).
2 changes: 2 additions & 0 deletions install-crd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,7 @@ spec:
- codex
- gemini
- opencode
- cursor
type: string
workspaceRef:
description: WorkspaceRef optionally references a Workspace resource
Expand Down Expand Up @@ -1011,6 +1012,7 @@ spec:
- codex
- gemini
- opencode
- cursor
type: string
workspaceRef:
description: |-
Expand Down
1 change: 1 addition & 0 deletions install.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions internal/capture/usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
12 changes: 12 additions & 0 deletions internal/capture/usage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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"
}
Expand All @@ -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"
}
Expand Down
15 changes: 15 additions & 0 deletions internal/controller/job_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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"

Expand Down Expand Up @@ -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.
Expand All @@ -83,6 +91,7 @@ func NewJobBuilder() *JobBuilder {
CodexImage: CodexImage,
GeminiImage: GeminiImage,
OpenCodeImage: OpenCodeImage,
CursorImage: CursorImage,
}
}

Expand All @@ -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)
}
Expand All @@ -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"
}
Expand All @@ -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"
}
Expand Down
Loading
Loading