From 03617d1296f3e45ad934ffa7f1e82fceb8d6912d Mon Sep 17 00:00:00 2001 From: huqi Date: Wed, 15 Jul 2026 17:41:40 +0800 Subject: [PATCH] feat(daemon): add AtomCode CLI as an agent runtime (#5654) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(daemon): add AtomCode CLI as an agent runtime Add a declarative RuntimeAgentDef for AtomCode CLI (open-source, Rust, MIT, v4.26.0), making it invocable from both the web UI and the `od` CLI alongside the existing 26 agent adapters. AtomCode's headless mode (`atomcode -p "..."`) streams the assistant reply on stdout — Claude Code `-p` style. The adapter reuses the same `promptViaFile: true` + `streamFormat: 'plain'` infrastructure that grok-build already exercises: the daemon stages the composed prompt in a temp file and passes it via `--prompt-file`, staying clear of argv length limits. `-y` auto-approves tool calls in the non-interactive headless context. Authentication is CLI-owned (`atomcode login` / `~/.atomcode/config.toml`), matching the grok-build / qoder pattern. Closes nexu-io/open-design#5653 * fix(daemon): address AtomCode adapter review feedback --- apps/daemon/src/runtimes/defs/atomcode.ts | 70 +++++++++++++++++++++ apps/daemon/src/runtimes/metadata.ts | 4 ++ apps/daemon/src/runtimes/registry.ts | 2 + apps/daemon/tests/runtimes/atomcode.test.ts | 69 ++++++++++++++++++++ 4 files changed, 145 insertions(+) create mode 100644 apps/daemon/src/runtimes/defs/atomcode.ts create mode 100644 apps/daemon/tests/runtimes/atomcode.test.ts diff --git a/apps/daemon/src/runtimes/defs/atomcode.ts b/apps/daemon/src/runtimes/defs/atomcode.ts new file mode 100644 index 0000000000..d394b92b64 --- /dev/null +++ b/apps/daemon/src/runtimes/defs/atomcode.ts @@ -0,0 +1,70 @@ +import { DEFAULT_MODEL_OPTION } from './shared.js'; +import type { RuntimeAgentDef } from '../types.js'; + +// AtomCode — open-source terminal AI coding agent written in Rust. +// Repo: https://atomgit.com/atomgit_atomcode/atomcode +// Docs: https://atomcode.atomgit.com/docs/en/index.html +// Install: `cargo install --path crates/atomcode-cli --locked` (from source) +// or `npm install -g @atomgit.com/atomcode` / `brew install --cask atomcode` +// +// Headless mode with `--prompt-file ` runs a single prompt +// non-interactively and streams the assistant reply on stdout. OD composes the +// prompt in a temp file to stay clear of argv length limits, exactly like +// grok-build. +// +// AtomCode owns its own authentication: users run `atomcode login` (AtomGit +// OAuth, auto-persisted under `~/.atomcode/`) or set up an API key in +// `~/.atomcode/config.toml` before OD detects the binary. The daemon does not +// inject credentials. Any OpenAI-compatible provider works (Anthropic, OpenAI, +// DeepSeek, Zhipu/GLM, Qwen, SiliconFlow, Ollama, …). +// +// Output: AtomCode headless has no structured stream-json mode yet — it emits +// plain-text assistant replies on stdout and verbose diagnostics (tool calls, +// token usage) on stderr with `-v`. We therefore use `streamFormat: 'plain'` +// (single-turn text reply, no tool_use streaming). Upgrading to a structured +// parser is follow-up work once AtomCode ships a stable JSON event stream. +// +// Safety: OD passes AtomCode's documented `-y` flag so the non-interactive run +// can proceed without waiting for an approval prompt it cannot surface. +export const atomcodeAgentDef = { + id: 'atomcode', + name: 'AtomCode CLI', + bin: 'atomcode', + versionArgs: ['--version'], + fallbackModels: [ + DEFAULT_MODEL_OPTION, + { id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' }, + { id: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5' }, + { id: 'gpt-5.2', label: 'GPT-5.2' }, + { id: 'glm-5.2', label: 'GLM-5.2' }, + { id: 'deepseek-v4', label: 'DeepSeek V4' }, + ], + // `--prompt-file ` avoids argv length limits for long OD prompts. + // `-y` auto-approves tool calls in headless mode (no interactive TTY). + // `--model ` selects the upstream provider/model when non-default. + buildArgs: ( + _prompt, + _imagePaths, + _extraAllowedDirs = [], + options = {}, + runtimeContext = {}, + ) => { + if (!runtimeContext.promptFilePath) { + throw new Error('atomcode requires runtimeContext.promptFilePath'); + } + const args = [ + '--prompt-file', + runtimeContext.promptFilePath, + '-y', + ]; + if (options.model && options.model !== DEFAULT_MODEL_OPTION.id) { + args.push('--model', options.model); + } + return args; + }, + promptViaFile: true, + promptViaStdin: false, + streamFormat: 'plain', + installUrl: 'https://atomcode.atomgit.com/docs/en/quickstart.html', + docsUrl: 'https://atomcode.atomgit.com/docs/en/index.html', +} satisfies RuntimeAgentDef; diff --git a/apps/daemon/src/runtimes/metadata.ts b/apps/daemon/src/runtimes/metadata.ts index bb313ab283..3d5b774f95 100644 --- a/apps/daemon/src/runtimes/metadata.ts +++ b/apps/daemon/src/runtimes/metadata.ts @@ -83,6 +83,10 @@ const AGENT_INSTALL_LINKS: Record< installUrl: 'https://www.codebuddy.cn', docsUrl: 'https://www.codebuddy.cn/docs/workbuddy/Overview', }, + atomcode: { + installUrl: 'https://atomcode.atomgit.com/docs/en/quickstart.html', + docsUrl: 'https://atomcode.atomgit.com/docs/en/index.html', + }, }; function sanitizeHttpsUrl(value: string | undefined): string | undefined { diff --git a/apps/daemon/src/runtimes/registry.ts b/apps/daemon/src/runtimes/registry.ts index 8469e6246d..cbf9a39d61 100644 --- a/apps/daemon/src/runtimes/registry.ts +++ b/apps/daemon/src/runtimes/registry.ts @@ -23,6 +23,7 @@ import { antigravityAgentDef } from './defs/antigravity.js'; import { codebuddyAgentDef } from './defs/codebuddy.js'; import { reasonixAgentDef } from './defs/reasonix.js'; import { mimoAgentDef } from './defs/mimo.js'; +import { atomcodeAgentDef } from './defs/atomcode.js'; import { readLocalAgentProfileDefs as readLocalAgentProfileDefsFromFile } from './local-profiles.js'; import type { RuntimeAgentDef } from './types.js'; @@ -52,6 +53,7 @@ const BASE_AGENT_DEFS: RuntimeAgentDef[] = [ reasonixAgentDef, codebuddyAgentDef, mimoAgentDef, + atomcodeAgentDef, ]; export function readLocalAgentProfileDefs( diff --git a/apps/daemon/tests/runtimes/atomcode.test.ts b/apps/daemon/tests/runtimes/atomcode.test.ts new file mode 100644 index 0000000000..8f4c8f1f8c --- /dev/null +++ b/apps/daemon/tests/runtimes/atomcode.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { atomcodeAgentDef } from '../../src/runtimes/defs/atomcode.js'; +import { DEFAULT_MODEL_OPTION } from '../../src/runtimes/defs/shared.js'; + +const promptFileContext = { promptFilePath: '/tmp/open-design/atomcode-prompt.md' }; + +describe('atomcode buildArgs', () => { + it('requires a staged prompt file', () => { + expect(() => atomcodeAgentDef.buildArgs('prompt', [], [], {}, {})).toThrow( + 'atomcode requires runtimeContext.promptFilePath', + ); + }); + + it('emits the exact base argv without embedding the prompt', () => { + const prompt = 'do not put this prompt in argv'; + const args = atomcodeAgentDef.buildArgs(prompt, [], [], {}, promptFileContext); + + expect(args).toEqual([ + '--prompt-file', + promptFileContext.promptFilePath, + '-y', + ]); + expect(args).not.toContain(prompt); + }); + + it('appends --model for a non-default model', () => { + const args = atomcodeAgentDef.buildArgs( + 'prompt', + [], + [], + { model: 'claude-sonnet-4-6' }, + promptFileContext, + ); + + expect(args).toEqual([ + '--prompt-file', + promptFileContext.promptFilePath, + '-y', + '--model', + 'claude-sonnet-4-6', + ]); + }); + + it('omits --model for the default sentinel', () => { + const args = atomcodeAgentDef.buildArgs( + 'prompt', + [], + [], + { model: DEFAULT_MODEL_OPTION.id }, + promptFileContext, + ); + + expect(args).not.toContain('--model'); + }); +}); + +describe('atomcode definition metadata', () => { + it('declares the runtime identity', () => { + expect(atomcodeAgentDef.id).toBe('atomcode'); + expect(atomcodeAgentDef.name).toBe('AtomCode CLI'); + expect(atomcodeAgentDef.bin).toBe('atomcode'); + }); + + it('uses prompt-file transport and plain output', () => { + expect(atomcodeAgentDef.promptViaFile).toBe(true); + expect(atomcodeAgentDef.promptViaStdin).toBe(false); + expect(atomcodeAgentDef.streamFormat).toBe('plain'); + }); +});