Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ zero exec --output-format stream-json < turns.jsonl
## Why Zero

- **Use the model you want.** Bring OpenAI, Anthropic, Gemini, Groq, OpenRouter,
DeepSeek, Mistral, xAI, Qwen, Kimi, GitHub Models, Ollama, LM Studio, or any
DeepSeek, Mistral, xAI, Qwen, Kimi, GitHub Models, Ollama, LM Studio, Atomic Chat, or any
OpenAI-/Anthropic-compatible endpoint.
- **Stay in control.** File writes, shell commands, network access, and
out-of-workspace writes go through Zero's permission and sandbox policy.
Expand Down Expand Up @@ -182,8 +182,12 @@ zero providers add custom-openai-compatible \
--set-active
```

For local models, run Ollama or LM Studio and then use `zero setup` or
`zero providers detect`.
For local models, run Ollama, LM Studio, or the [Atomic Chat](https://atomic.chat)
desktop app, then use `zero setup` or `zero providers detect`. For Atomic Chat,
load a model and enable its local OpenAI-compatible API (default
`http://127.0.0.1:1337/v1`). Choose `atomic-chat-local`; detection includes the
loaded model ID in the add command. If no usable ID is discovered, load a model
and retry. Model IDs requiring shell-specific quoting use interactive setup.

## Daily Use

Expand Down
8 changes: 6 additions & 2 deletions README_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ zero exec --output-format stream-json < turns.jsonl

## 为什么选择 Zero

- **使用你想要的模型。** 支持 OpenAI、Anthropic、Gemini、Groq、OpenRouter、DeepSeek、Mistral、xAI、Qwen、Kimi、GitHub Models、Ollama、LM Studio,或任何 OpenAI/Anthropic 兼容端点。
- **使用你想要的模型。** 支持 OpenAI、Anthropic、Gemini、Groq、OpenRouter、DeepSeek、Mistral、xAI、Qwen、Kimi、GitHub Models、Ollama、LM Studio、Atomic Chat,或任何 OpenAI/Anthropic 兼容端点。
- **保持控制权。** 文件写入、Shell 命令、网络访问和工作区外写入都经过 Zero 的权限和沙箱策略。
- **在终端中工作。** TUI 具有模型/提供商选择器、图片输入、斜杠命令、实时计划/工具渲染、回滚滚动、主题以及恢复/分叉支持。
- **无 TUI 也能工作。** `zero exec` 可脚本化,支持文本/JSON/stream-JSON I/O、隔离的工作树、规范优先运行,以及用于 CI 的有意义的退出码。
Expand Down Expand Up @@ -114,7 +114,11 @@ export LONGCAT_API_KEY=...
zero providers setup longcat --set-active
```

对于本地模型,运行 Ollama 或 LM Studio,然后使用 `zero setup` 或 `zero providers detect`。
对于本地模型,运行 Ollama、LM Studio 或 [Atomic Chat](https://atomic.chat) 桌面应用,
然后使用 `zero setup` 或 `zero providers detect`。使用 Atomic Chat 时,请先加载模型并启用
本地 OpenAI 兼容 API(默认地址为 `http://127.0.0.1:1337/v1`),再选择 `atomic-chat-local`。
检测生成的添加命令会包含已加载的模型 ID;如果未发现可用的 ID,请加载模型后重试。
对于需要特定 Shell 转义的模型 ID,请使用交互式设置。

## 日常使用

Expand Down
8 changes: 6 additions & 2 deletions internal/cli/command_center.go
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,11 @@ func formatProviderCatalogLine(provider providerCatalogSummary) string {
provider.RuntimeSupported,
))
if provider.RuntimeSupported {
lines = append(lines, " setup: zero providers setup "+displayCLIValue(provider.ID, "unknown")+" --set-active")
setup := " setup: zero providers setup " + displayCLIValue(provider.ID, "unknown") + " --set-active"
if provider.ID == "atomic-chat-local" {
setup = " setup: run zero setup to select a loaded model, or zero providers detect to get an add command"
}
lines = append(lines, setup)
} else {
lines = append(lines, " unsupported: "+displayCLIValue(provider.RuntimeUnsupportedReason, "unknown"))
}
Expand Down Expand Up @@ -502,7 +506,7 @@ func writeProvidersHelp(w io.Writer) error {
zero providers models [name] [flags]

Inspects resolved provider profiles and provider catalog descriptors without printing secrets.
Detect probes for running local runtimes (Ollama, LM Studio) and prints adopt commands plus per-provider next steps.
Detect probes for running local runtimes (Ollama, LM Studio, Atomic Chat) and prints adopt commands plus per-provider next steps.
Models probes a provider's live model-listing endpoint (e.g. an OpenAI-compatible /v1/models) and lists the models it serves — including custom OpenAI-/Anthropic-compatible endpoints — so a self-hosted provider needs no per-model config.

Flags:
Expand Down
7 changes: 7 additions & 0 deletions internal/cli/command_center_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -967,3 +967,10 @@ func TestProvidersListMarksOAuthLoginProviders(t *testing.T) {
t.Fatalf("list should render the oauth login state, got:\n%s", rendered)
}
}

func TestAtomicCatalogSetupRequiresLoadedModel(t *testing.T) {
out := formatProviderCatalogLine(providerCatalogSummary{ID: "atomic-chat-local", RuntimeSupported: true})
if strings.Contains(out, "zero providers setup atomic-chat-local --set-active") || !strings.Contains(out, "zero setup") {
t.Fatalf("catalog advertises a failing setup command: %s", out)
}
}
4 changes: 3 additions & 1 deletion internal/cli/provider_detect.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ type providerDetectReport struct {
}

// runProvidersDetect probes the machine for running local, OpenAI-compatible
// model runtimes (Ollama, LM Studio) and prints a no-key adopt command for each
// model runtimes (Ollama, LM Studio, Atomic Chat) and prints a no-key adopt command for each
// one it finds, followed by the next-step actions for every already-configured
// provider. It is the onboarding-advice surface — "what can I do right now?" —
// and never errors on a machine with nothing running locally (it just reports an
Expand Down Expand Up @@ -146,6 +146,8 @@ func formatProviderDetectReport(report providerDetectReport) string {
}
if command := strings.TrimSpace(runtime.Action.Command); command != "" {
lines = append(lines, " "+runtime.Action.Label+": "+command)
} else if detail := strings.TrimSpace(runtime.Action.Detail); detail != "" {
lines = append(lines, " "+runtime.Action.Label+": "+detail)
}
}

Expand Down
10 changes: 10 additions & 0 deletions internal/cli/provider_detect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,13 @@ func TestRunProvidersDetectJSONNoRuntimesActiveProvider(t *testing.T) {
t.Fatalf("expected only a Check action for an active keyed provider, got %#v", payload.Providers[0].Actions)
}
}

func TestProviderDetectShowsGuidanceWithoutCommand(t *testing.T) {
for _, models := range [][]string{nil, {"local-model"}, {"x&calc"}} {
runtime := provideronboarding.DetectedLocalRuntime{LocalRuntime: provideronboarding.LocalRuntime{CatalogID: "atomic-chat-local", Name: "Atomic Chat Local", DefaultModel: "local-model"}, Models: models}
out := formatProviderDetectReport(buildProviderDetectReport(config.ResolvedConfig{}, []provideronboarding.DetectedLocalRuntime{runtime}))
if strings.Contains(out, "providers add") || !strings.Contains(out, runtime.SetupAction().Detail) {
t.Fatalf("missing actionable guidance: %s", out)
}
}
}
8 changes: 8 additions & 0 deletions internal/cli/provider_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,14 @@ func providerProfileForAdd(options providerAddOptions) (config.ProviderProfile,
catalogHeaders = aimlapi.WithResolvedPartnerHeader(catalogHeaders)
}
}
// Discovery fallback can pass the nonempty catalog placeholder through
// either CLI setup path. Never persist it as an Atomic Chat model.
if descriptor.ID == "atomic-chat-local" {
model := strings.TrimSpace(options.model)
if model == "" || model == "local-model" {
return config.ProviderProfile{}, fmt.Errorf("provider %q serves a locally loaded model; pass --model <id> (run `zero providers detect` to see the served model)", descriptor.ID)
}
}
profile := config.ProviderProfile{
Name: name,
ProviderKind: providerKindForDescriptor(descriptor),
Expand Down
58 changes: 58 additions & 0 deletions internal/cli/provider_setup_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package cli

import (
"strings"
"testing"

"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/provideronboarding"
"mvdan.cc/sh/v3/shell"
)

// Regression for issue #555's follow-up: `zero providers check` must not
Expand Down Expand Up @@ -66,3 +69,58 @@ func TestValidateProviderRuntimeReadyCustomEndpoint(t *testing.T) {
})
}
}

// atomic-chat-local without --model would persist the catalog placeholder
// "local-model", which the Atomic Chat server never serves, so the first
// completion fails. Adding it must require a real model instead.
func TestProviderProfileForAddRequiresModelForAtomicChatLocal(t *testing.T) {
if _, err := providerProfileForAdd(providerAddOptions{catalogID: "atomic-chat-local"}); err == nil {
t.Fatalf("providerProfileForAdd(atomic-chat-local, no --model) = nil error, want a require-model error")
} else if !strings.Contains(err.Error(), "--model") {
t.Fatalf("error should tell the user to pass --model, got %v", err)
}

// The interactive wizards and the no-id detect fallback resolve the model to
// the catalog DefaultModel and pass it through as a non-empty value, so the
// placeholder itself must be rejected, not just an empty --model.
if _, err := providerProfileForAdd(providerAddOptions{catalogID: "atomic-chat-local", model: "local-model"}); err == nil {
t.Fatalf("providerProfileForAdd(atomic-chat-local, --model local-model) = nil error, want reject of the catalog placeholder")
}

profile, err := providerProfileForAdd(providerAddOptions{catalogID: "atomic-chat-local", model: "unsloth/gemma-4-E2B-it-GGUF"})
if err != nil {
t.Fatalf("providerProfileForAdd(atomic-chat-local, --model) returned error: %v", err)
}
if profile.Model != "unsloth/gemma-4-E2B-it-GGUF" {
t.Fatalf("profile.Model = %q, want the explicit model", profile.Model)
}
if profile.Model == "local-model" {
t.Fatalf("profile persisted the catalog placeholder")
}
}

func TestDetectedModelActionSurvivesAddParser(t *testing.T) {
for _, catalogID := range []string{"atomic-chat-local", "lmstudio", "ollama"} {
for _, modelID := range []string{"-loaded-model", "--set-active", "--model", "-loaded model", "ordinary/model", "model with spaces"} {
t.Run(catalogID+"/"+modelID, func(t *testing.T) {
detected := provideronboarding.DetectedLocalRuntime{
LocalRuntime: provideronboarding.LocalRuntime{CatalogID: catalogID, Name: "Local Runtime", DefaultModel: "local-model"},
Models: []string{modelID},
}
command := detected.SetupAction().Command
args, err := shell.Fields(command, func(string) string { return "" })
if err != nil || len(args) < 4 {
t.Fatalf("invalid adoption command %q: %v", command, err)
}
options, help, err := parseProviderAddArgs(args[3:])
if err != nil || help {
t.Fatalf("generated adoption command rejected by add parser: %q: %v", command, err)
}
profile, err := providerProfileForAdd(options)
if err != nil || profile.Model != modelID || !options.setActive || profile.Name != "Local Runtime" {
t.Fatalf("adoption changed model or options: model=%q name=%q active=%v err=%v", profile.Model, profile.Name, options.setActive, err)
}
})
}
}
}
11 changes: 11 additions & 0 deletions internal/cli/setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,3 +367,14 @@ func TestVerifySetupProviderDistinguishesMissingFromRejectedKey(t *testing.T) {
t.Fatal("a keyless local provider should still be probed")
}
}

func TestSaveSetupProviderRejectsAtomicPlaceholderBeforeConfigAccess(t *testing.T) {
for _, model := range []string{"", "local-model", " local-model "} {
accessed := false
deps := appDeps{userConfigPath: func() (string, error) { accessed = true; return filepath.Join(t.TempDir(), "config.json"), nil }}
_, err := saveSetupProvider(deps, tui.SetupSelection{CatalogID: "atomic-chat-local", Model: model}, setupSaveOptions{})
if err == nil || !strings.Contains(err.Error(), "--model") || accessed {
t.Fatalf("invalid model %q reached config access: accessed=%v err=%v", model, accessed, err)
}
}
}
15 changes: 15 additions & 0 deletions internal/config/credentials_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,21 @@ func TestProviderProfileMissingCredentialEnv(t *testing.T) {
profile: ProviderProfile{Name: "local", CatalogID: "ollama"},
want: false,
},
{
// A profile saved against the hosted atomic-chat preset keeps its
// remote base URL and key, so it must still be reported as missing a
// credential. The keyless local runtime is a separate catalog ID
// (atomic-chat-local) precisely so this identity is never repurposed.
name: "hosted atomic-chat profile still requires its key",
profile: ProviderProfile{Name: "atomic-chat", CatalogID: "atomic-chat", BaseURL: "https://api.atomic.chat/v1"},
wantEnv: "ATOMIC_CHAT_API_KEY",
want: true,
},
{
name: "local atomic chat runtime needs no credential",
profile: ProviderProfile{Name: "atomic-local", CatalogID: "atomic-chat-local"},
want: false,
},
{
name: "credential resolved via inline key",
profile: ProviderProfile{Name: "openai", ProviderKind: ProviderKindOpenAI, APIKey: "sk-test"},
Expand Down
1 change: 1 addition & 0 deletions internal/providercatalog/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ var descriptors = []Descriptor{
openAICompat("ollama-cloud", "Ollama Cloud", "https://ollama.com/v1", "qwen3-coder:480b", []string{"OLLAMA_API_KEY"}, "ollama.com", "ollama cloud"),
localOpenAI("ollama", "Ollama Local", "http://localhost:11434/v1", "llama3.1", "ollama local"),
localOpenAI("lmstudio", "LM Studio", "http://localhost:1234/v1", "local-model", "lm-studio", "lm studio"),
localOpenAI("atomic-chat-local", "Atomic Chat Local", "http://127.0.0.1:1337/v1", "local-model", "atomic chat local"),
oauthProvider(openAICompat("openrouter", "OpenRouter", "https://openrouter.ai/api/v1", "openai/gpt-4.1", []string{"OPENROUTER_API_KEY"}), true, false),
// Hugging Face Inference Providers — OpenAI-compatible router at
// https://router.huggingface.co/v1 exposes hundreds of OSS models. OAuth
Expand Down
6 changes: 4 additions & 2 deletions internal/providercatalog/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ var expectedCatalogIDs = []string{
"ollama-cloud",
"ollama",
"lmstudio",
"atomic-chat-local",
"openrouter",
"huggingface",
"chatgpt",
Expand Down Expand Up @@ -259,7 +260,7 @@ func TestRemoteProvidersDeclareAuthOrExplicitPublicAccess(t *testing.T) {
}

func TestLocalProvidersDoNotRequireAuth(t *testing.T) {
for _, id := range []string{"ollama", "lmstudio"} {
for _, id := range []string{"ollama", "lmstudio", "atomic-chat-local"} {
descriptor, err := Require(id)
if err != nil {
t.Fatalf("Require(%q) error = %v", id, err)
Expand Down Expand Up @@ -316,6 +317,7 @@ func TestLookupNormalizesIDsAndAliases(t *testing.T) {
"ollama cloud": "ollama-cloud",
"ollama local": "ollama",
"lm-studio": "lmstudio",
"atomic chat local": "atomic-chat-local",
"mini_max": "minimax",
"Moonshot": "moonshot",
"Atlas Cloud": "atlascloud",
Expand Down Expand Up @@ -369,7 +371,7 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
TransportBedrock: {"bedrock"},
TransportVertex: {"vertex"},
TransportAnthropicCompat: {"minimax", "minimaxi-cn", "opencode-go-anthropic-compatible", "custom-anthropic-compatible"},
TransportOpenAICompat: {"gitlawb-opengateway", "aimlapi", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "fireworks", "dashscope", "moonshot", "atlascloud", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
TransportOpenAICompat: {"gitlawb-opengateway", "aimlapi", "ollama-cloud", "ollama", "lmstudio", "atomic-chat-local", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "fireworks", "dashscope", "moonshot", "atlascloud", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
}

for transport, wantIDs := range cases {
Expand Down
2 changes: 2 additions & 0 deletions internal/providermodelcatalog/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,8 @@ var curatedModels = map[string][]Model{
{ID: "gpt-4.1", Description: "catalog default"},
{ID: "gpt-4o-mini", Description: "fast model"},
},
// atomic-chat-local has no curated list: discover the user-loaded model
// from the runtime's /v1/models endpoint.
"opencode-go-anthropic-compatible": {
{ID: "minimax-m3", Description: "MiniMax M3: default"},
{ID: "minimax-m2.7", Description: "MiniMax M2.7: coding model"},
Expand Down
72 changes: 71 additions & 1 deletion internal/provideronboarding/advice.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,32 @@ func (state ProviderState) Actions() []Action {
}

func SetupCommand(descriptor providercatalog.Descriptor, name string, setActive bool) string {
return setupCommand(descriptor, name, "", setActive)
}

// SetupCommandWithModel is SetupCommand with an explicit --model. A local
// runtime serves whichever model the user loaded, so its catalog DefaultModel is
// only a placeholder: an adopt command that omits --model persists that
// placeholder and the first completion fails with an unknown-model response.
// An empty model falls back to SetupCommand's behaviour. Commands that cannot
// be represented safely across supported shells are omitted.
func SetupCommandWithModel(descriptor providercatalog.Descriptor, name string, model string, setActive bool) string {
return setupCommand(descriptor, name, model, setActive)
}

func setupCommand(descriptor providercatalog.Descriptor, name string, model string, setActive bool) string {
parts := []string{"zero", "providers", "add", strings.TrimSpace(descriptor.ID)}
if name = strings.TrimSpace(name); name != "" {
parts = append(parts, "--name", name)
}
if model = strings.TrimSpace(model); model != "" {
// A separate operand beginning with '-' is rejected as an option.
if strings.HasPrefix(model, "-") {
parts = append(parts, "--model="+model)
} else {
parts = append(parts, "--model", model)
}
}
if descriptor.RequiresAuth && len(descriptor.AuthEnvVars) > 0 {
if env := strings.TrimSpace(descriptor.AuthEnvVars[0]); env != "" {
parts = append(parts, "--api-key-env", env)
Expand All @@ -37,7 +59,7 @@ func SetupCommand(descriptor providercatalog.Descriptor, name string, setActive
if setActive {
parts = append(parts, "--set-active")
}
return joinCommand(parts)
return joinSetupCommand(parts)
}

func UseCommand(name string) string {
Expand Down Expand Up @@ -161,6 +183,54 @@ func firstNonEmpty(values ...string) string {
return ""
}

// joinSetupCommand only emits arguments supported literally by POSIX shells,
// cmd.exe, and PowerShell. Shell-specific quoting cannot safely cover all three.
// Return no command when a value requires it; callers can offer interactive setup.
func joinSetupCommand(parts []string) string {
quoted := make([]string, 0, len(parts))
for _, part := range parts {
if part = strings.TrimSpace(part); part == "" {
continue
}
arg := setupCommandArg(part)
if value, inlineModel := strings.CutPrefix(part, "--model="); inlineModel {
// Validate the untrusted model separately from the fixed separator;
// '=' must not become an allowed character in model IDs.
arg = setupCommandArg(value)
if arg != "" {
arg = "--model=" + arg
}
}
if arg == "" {
return ""
}
quoted = append(quoted, arg)
}
return strings.Join(quoted, " ")
}

func setupCommandArg(value string) string {
for i, r := range value {
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' {
continue
}
switch r {
case '-', '_', '.', '/', ':', ' ':
continue
case '@':
// A leading @ starts splatting in PowerShell.
if i > 0 {
continue
}
}
return ""
}
if value == "" || strings.Contains(value, " ") {
return `"` + value + `"`
}
return value
}

func joinCommand(parts []string) string {
quoted := make([]string, 0, len(parts))
for _, part := range parts {
Expand Down
Loading