This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
The following commit(s) were added to refs/heads/master by this push:
new ef71358 feat(agents): support Grok Build
ef71358 is described below
commit ef71358cf5ee689e1b2dd82cb8bb5d35c3c555c0
Author: Yang Luo <[email protected]>
AuthorDate: Sun Sep 27 21:18:06 2026 +0800
feat(agents): support Grok Build
---
README.md | 2 +
README_zh.md | 2 +
agent/fingerprint_load_test.go | 4 +-
agent/fingerprints/grok-build.json | 38 ++++++
agent/headless.go | 4 +-
agentconfig/layout.go | 11 ++
agentenv/agentenv.go | 6 +
agentprovider/grok.go | 233 +++++++++++++++++++++++++++++++++++++
agentprovider/toml.go | 109 +++++++++++++++--
agentsession/command_unix.go | 6 +
agentsession/command_windows.go | 20 +++-
agentsession/runner.go | 11 +-
object/import_link.go | 19 +--
web/src/components/AgentIcon.tsx | 2 +
web/src/lib/agents.ts | 2 +
web/src/locales/en/data.json | 1 +
web/src/locales/zh/data.json | 1 +
17 files changed, 440 insertions(+), 31 deletions(-)
diff --git a/README.md b/README.md
index 708196a..db276da 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,7 @@
<a href="https://platform.iflow.cn/en/cli" title="iFlow CLI"><img
src="https://www.google.com/s2/favicons?domain=iflow.cn&sz=64" width="30"
height="30" alt="iFlow CLI"></a>
<a href="https://moonshotai.github.io/kimi-code" title="Kimi Code CLI"><img
src="https://www.google.com/s2/favicons?domain=kimi.com&sz=64" width="30"
height="30" alt="Kimi Code CLI"></a>
<a href="https://www.codebuddy.ai" title="CodeBuddy Code"><img
src="https://www.google.com/s2/favicons?domain=codebuddy.ai&sz=64" width="30"
height="30" alt="CodeBuddy Code"></a>
+ <a href="https://x.ai/cli" title="Grok Build"><img
src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="30"
height="30" alt="Grok Build"></a>
<a href="https://roocode.com" title="Roo Code"><img
src="https://www.google.com/s2/favicons?domain=roocode.com&sz=64" width="30"
height="30" alt="Roo Code"></a>
<a href="https://github.com/github/copilot-cli" title="Copilot CLI"><img
src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="30"
height="30" alt="Copilot CLI"></a>
<a href="https://continue.dev" title="Continue"><img
src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="30"
height="30" alt="Continue"></a>
@@ -168,6 +169,7 @@ The password behind that account is `admin` / `123`, and it
only matters if you
| <img src="https://www.google.com/s2/favicons?domain=iflow.cn&sz=64"
width="16" height="16" alt=""> **iFlow CLI** | — | ✅ OpenAI | ✅ | — | ✅ | — |
npm |
| <img src="https://www.google.com/s2/favicons?domain=kimi.com&sz=64"
width="16" height="16" alt=""> **Kimi Code CLI** | — | ✅ OpenAI | ✅ | ✅ | ✅ | —
| npm · winget |
| <img src="https://www.google.com/s2/favicons?domain=codebuddy.ai&sz=64"
width="16" height="16" alt=""> **CodeBuddy Code** | — | ✅ OpenAI | ✅ | ✅ | ✅ |
— | npm |
+| <img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16"
height="16" alt=""> **Grok Build** | — | ✅ OpenAI | ✅ | ✅ | ✅ | — | npm ·
script · self |
| <img src="https://www.google.com/s2/favicons?domain=roocode.com&sz=64"
width="16" height="16" alt=""> **Roo Code** | — | — | — | — | — | — | — |
| <img src="https://www.google.com/s2/favicons?domain=github.com&sz=64"
width="16" height="16" alt=""> **Copilot CLI** | — | — | — | — | — | — | npm |
| <img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64"
width="16" height="16" alt=""> **Continue** | — | ✅ OpenAI | — | — | — | — |
npm |
diff --git a/README_zh.md b/README_zh.md
index de167fc..7e300bd 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -30,6 +30,7 @@
<a href="https://platform.iflow.cn/en/cli" title="iFlow CLI"><img
src="https://www.google.com/s2/favicons?domain=iflow.cn&sz=64" width="30"
height="30" alt="iFlow CLI"></a>
<a href="https://moonshotai.github.io/kimi-code" title="Kimi Code CLI"><img
src="https://www.google.com/s2/favicons?domain=kimi.com&sz=64" width="30"
height="30" alt="Kimi Code CLI"></a>
<a href="https://www.codebuddy.ai" title="CodeBuddy Code"><img
src="https://www.google.com/s2/favicons?domain=codebuddy.ai&sz=64" width="30"
height="30" alt="CodeBuddy Code"></a>
+ <a href="https://x.ai/cli" title="Grok Build"><img
src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="30"
height="30" alt="Grok Build"></a>
<a href="https://roocode.com" title="Roo Code"><img
src="https://www.google.com/s2/favicons?domain=roocode.com&sz=64" width="30"
height="30" alt="Roo Code"></a>
<a href="https://github.com/github/copilot-cli" title="Copilot CLI"><img
src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="30"
height="30" alt="Copilot CLI"></a>
<a href="https://continue.dev" title="Continue"><img
src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="30"
height="30" alt="Continue"></a>
@@ -166,6 +167,7 @@ Gateway 会在自己的窗口里打开 —— 不用登录:它只服务本机
| <img src="https://www.google.com/s2/favicons?domain=iflow.cn&sz=64"
width="16" height="16" alt=""> **iFlow CLI** | — | ✅ OpenAI | ✅ | — | ✅ | — |
npm |
| <img src="https://www.google.com/s2/favicons?domain=kimi.com&sz=64"
width="16" height="16" alt=""> **Kimi Code CLI** | — | ✅ OpenAI | ✅ | ✅ | ✅ | —
| npm · winget |
| <img src="https://www.google.com/s2/favicons?domain=codebuddy.ai&sz=64"
width="16" height="16" alt=""> **CodeBuddy Code** | — | ✅ OpenAI | ✅ | ✅ | ✅ |
— | npm |
+| <img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16"
height="16" alt=""> **Grok Build** | — | ✅ OpenAI | ✅ | ✅ | ✅ | — | npm ·
script · self |
| <img src="https://www.google.com/s2/favicons?domain=roocode.com&sz=64"
width="16" height="16" alt=""> **Roo Code** | — | — | — | — | — | — | — |
| <img src="https://www.google.com/s2/favicons?domain=github.com&sz=64"
width="16" height="16" alt=""> **Copilot CLI** | — | — | — | — | — | — | npm |
| <img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64"
width="16" height="16" alt=""> **Continue** | — | ✅ OpenAI | — | — | — | — |
npm |
diff --git a/agent/fingerprint_load_test.go b/agent/fingerprint_load_test.go
index e116f05..305d485 100644
--- a/agent/fingerprint_load_test.go
+++ b/agent/fingerprint_load_test.go
@@ -24,9 +24,9 @@ func TestFingerprintsLoad(t *testing.T) {
want := []string{
"aider", "claude-code", "claude-desktop", "cline", "codebuddy",
"codex-cli", "codex", "continue", "copilot-cli", "crush",
"cursor-agent",
- "cursor", "droid", "dsh", "gemini-cli", "goose", "hermes-agent",
+ "cursor", "droid", "dsh", "gemini-cli", "goose", "grok-build",
"hermes-agent",
"iflow-cli", "kimi-code", "openagent", "openclaw",
"opencode-desktop",
- "opencode", "pi", "qwen-code", "roo-code", "trae", "windsurf",
"zed",
+ "opencode", "pi", "qwen-code", "roo-code", "trae", "windsurf",
"zcode", "zed",
}
loaded, err := loadFingerprints(fingerprintFS)
diff --git a/agent/fingerprints/grok-build.json
b/agent/fingerprints/grok-build.json
new file mode 100644
index 0000000..edb79ea
--- /dev/null
+++ b/agent/fingerprints/grok-build.json
@@ -0,0 +1,38 @@
+{
+ "id": "grok-build",
+ "displayName": "Grok Build",
+ "installUrl": "https://x.ai/cli",
+ "execName": "grok",
+ "instanceEnv": "GROK_HOME",
+ "stateDir": "grok",
+ "npmPackage": "@xai-official/grok",
+ "homeDirs": [".grok/bin"],
+ "versionFile": "../.metadata_version",
+ "updateArgs": ["update"],
+ "installScript": {
+ "windows": "irm https://x.ai/cli/install.ps1 | iex",
+ "darwin": "curl -fsSL https://x.ai/cli/install.sh | bash",
+ "linux": "curl -fsSL https://x.ai/cli/install.sh | bash"
+ },
+ "headless": {
+ "args": [
+ "--single={prompt}",
+ "--output-format",
+ "streaming-messages-json",
+ "-s",
+ "{session}"
+ ],
+ "resumeArgs": [
+ "--single={prompt}",
+ "--output-format",
+ "streaming-messages-json",
+ "-r",
+ "{session}"
+ ],
+ "modelArgs": [
+ "-m",
+ "{model}"
+ ],
+ "format": "claude-stream-json"
+ }
+}
diff --git a/agent/headless.go b/agent/headless.go
index 2fee7a0..47db8a2 100644
--- a/agent/headless.go
+++ b/agent/headless.go
@@ -16,7 +16,9 @@ package agent
// Placeholders one of Headless's command lines carries. Each is replaced
whole:
// a placeholder is always an argument of its own, never part of one, so
nothing
-// here has to be quoted or escaped.
+// here has to be quoted or escaped. The one exception is a prompt written as
+// "--flag={prompt}", for an agent that would take a prompt starting with a
dash
+// for a flag of its own.
const (
PromptPlaceholder = "{prompt}"
SessionPlaceholder = "{session}"
diff --git a/agentconfig/layout.go b/agentconfig/layout.go
index 7b6523e..27f1ae1 100644
--- a/agentconfig/layout.go
+++ b/agentconfig/layout.go
@@ -289,6 +289,16 @@ var layouts = map[string]layout{
mcp: &mcpLayout{file: under(".codebuddy", ".mcp.json"),
store: &jsonStore{paths: [][]string{{"mcpServers"}}}},
prompt: promptFile("CODEBUDDY.md", ".codebuddy"),
},
+ // Grok Build reads its home-level AGENTS.md straight from GROK_HOME,
and
+ // the shared ~/.agents skills beside its own.
+ "grok-build": {
+ skills: &skillLayout{sources: []skillSource{
+ userSkills("skills").within(grokDir),
+ userSkills(".agents", "skills"),
+ }},
+ mcp: &mcpLayout{file: in(grokDir, "config.toml"), store:
&tomlStore{table: "mcp_servers"}},
+ prompt: promptFile("AGENTS.md").within(grokDir),
+ },
}
var codexLayout = layout{
@@ -417,6 +427,7 @@ var (
codexDir = movedBy("CODEX_HOME", ".codex")
dshDir = movedBy("DSH_HOME", ".dsh")
kimiDir = movedBy("KIMI_CODE_HOME", ".kimi-code")
+ grokDir = movedBy("GROK_HOME", ".grok")
)
// opencodeConfig is the config file to edit. opencode reads config.json,
diff --git a/agentenv/agentenv.go b/agentenv/agentenv.go
index 1800b0d..32ff42f 100644
--- a/agentenv/agentenv.go
+++ b/agentenv/agentenv.go
@@ -114,6 +114,12 @@ var agentVars = map[string][]variable{
"codebuddy": {
{key: "CODEBUDDY_MODEL"},
},
+ // Grok Build reads the starting model from the environment before
+ // config.toml. Each model a switch writes carries its own base_url and
+ // key, which GROK_MODELS_BASE_URL and XAI_API_KEY do not reach.
+ "grok-build": {
+ {key: "GROK_DEFAULT_MODEL"},
+ },
}
// value is one variable found set, with where it was found.
diff --git a/agentprovider/grok.go b/agentprovider/grok.go
new file mode 100644
index 0000000..371c29f
--- /dev/null
+++ b/agentprovider/grok.go
@@ -0,0 +1,233 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agentprovider
+
+import (
+ "errors"
+ "fmt"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "github.com/BurntSushi/toml"
+ "github.com/apache/casbin-gateway/agenthome"
+)
+
+const (
+ // grokAliasPrefix opens the [model.*] tables Gateway writes, which is
what
+ // tells them apart from the ones Grok ships or someone added by hand.
+ grokAliasPrefix = "casbin-gateway/"
+ // grokModels is the table naming which model does what.
+ grokModels = "models"
+ // grokBackend is the wire format each table asks for.
+ grokBackend = "chat_completions"
+ // grokLocalKey stands in for a key the endpoint does not have. Without
one
+ // Grok sends its xAI session token instead.
+ grokLocalKey = "casbin-gateway"
+ // grokContextWindow drives auto-compact. The endpoint carries no size,
so
+ // this is the smaller one in use, for the reason kimiContextSize is.
+ grokContextWindow = 128000
+ // grokBuiltin is what Grok talks to with nothing bound: the xAI
account.
+ grokBuiltin = "Grok"
+)
+
+// grokRoles are the [models] keys a switch points at the bound model: the one
+// sessions start on, and the one titles are written with, which otherwise asks
+// the session's endpoint for an xAI model it does not serve.
+var grokRoles = []string{"default", "session_summary"}
+
+var errGrokNoModel = errors.New("Grok Build needs a model name, so bind a
provider that lists at least one model")
+
+type grokWriter struct{}
+
+func init() {
+ register(grokWriter{})
+}
+
+func (grokWriter) AgentId() string { return "grok-build" }
+
+func (grokWriter) Protocol() string { return "openai" }
+
+func (w grokWriter) Plan(target Target, endpoint Endpoint) ([]File, error) {
+ if endpoint.Model == "" {
+ return nil, errGrokNoModel
+ }
+ path, err := w.configPath(target)
+ if err != nil {
+ return nil, err
+ }
+
+ preview := endpoint
+ preview.ApiKey = maskSecret(emptyAs(endpoint.ApiKey, grokLocalKey))
+ config := ""
+ for _, role := range grokRoles {
+ config = tomlSetTableKey(config, grokModels, role,
grokAlias(endpoint.Model))
+ }
+ config = tomlTidy(tomlAppend(config, w.tables(preview)))
+ return []File{{Path: path, Format: "toml", Preview: config}}, nil
+}
+
+func (w grokWriter) Apply(target Target, endpoint Endpoint)
(map[string]string, error) {
+ if endpoint.Model == "" {
+ return nil, errGrokNoModel
+ }
+ path, err := w.configPath(target)
+ if err != nil {
+ return nil, err
+ }
+ data, _, _, err := readFile(path)
+ if err != nil {
+ return nil, err
+ }
+ document, err := w.decode(path, data)
+ if err != nil {
+ return nil, err
+ }
+
+ previous := map[string]string{}
+ models := tableOf(document, grokModels)
+ for _, role := range grokRoles {
+ // A model Gateway already wrote is not what a restore should
put back.
+ if value := stringOf(models[role]); value != "" &&
!strings.HasPrefix(value, grokAliasPrefix) {
+ previous[role] = value
+ }
+ }
+
+ text := w.cut(string(data))
+ for _, role := range grokRoles {
+ text = tomlSetTableKey(text, grokModels, role,
grokAlias(endpoint.Model))
+ }
+ text = tomlAppend(text, w.tables(endpoint))
+ return previous, w.save(path, text)
+}
+
+func (w grokWriter) Restore(target Target, previous map[string]string) error {
+ path, err := w.configPath(target)
+ if err != nil {
+ return err
+ }
+ data, _, _, err := readFile(path)
+ if err != nil {
+ return err
+ }
+
+ text := w.cut(string(data))
+ for _, role := range grokRoles {
+ if value, ok := previous[role]; ok {
+ text = tomlSetTableKey(text, grokModels, role, value)
+ } else {
+ text = tomlDeleteTableKey(text, grokModels, role)
+ }
+ }
+ return w.save(path, text)
+}
+
+// Current is the base URL the starting model is reached at. One of Grok's own
+// models names none and goes to xAI, which is no provider of this machine's.
+func (w grokWriter) Current(target Target) (string, error) {
+ document, err := w.document(target)
+ if err != nil {
+ return "", err
+ }
+
+ selected := stringOf(tableOf(document, grokModels)["default"])
+ if baseUrl := stringOf(tableOf(tableOf(document, "model"),
selected)["base_url"]); baseUrl != "" {
+ return baseUrl, nil
+ }
+ return stringOf(tableOf(document, "endpoints")["models_base_url"]), nil
+}
+
+func (w grokWriter) Builtin(target Target, previous map[string]string) string {
+ if previous != nil {
+ return emptyAs(previous["default"], grokBuiltin)
+ }
+
+ document, err := w.document(target)
+ if err != nil {
+ return grokBuiltin
+ }
+ selected := stringOf(tableOf(document, grokModels)["default"])
+ if strings.HasPrefix(selected, grokAliasPrefix) {
+ return grokBuiltin
+ }
+ return emptyAs(selected, grokBuiltin)
+}
+
+// tables are one [model.*] table per model the endpoint serves, so Grok's own
+// picker switches between them without Gateway writing the file again. Each
+// carries its own key, which keeps the xAI session token from being sent here.
+func (grokWriter) tables(endpoint Endpoint) string {
+ blocks := []string{}
+ for _, model := range endpoint.catalog() {
+ blocks = append(blocks, tomlRawTable([]string{"model",
grokAlias(model)},
+ []string{"model", "base_url", "api_key", "api_backend",
"context_window"},
+ map[string]string{
+ "model": strconv.Quote(model),
+ "base_url":
strconv.Quote(endpoint.BaseUrl),
+ "api_key":
strconv.Quote(emptyAs(endpoint.ApiKey, grokLocalKey)),
+ "api_backend": strconv.Quote(grokBackend),
+ "context_window":
strconv.Itoa(grokContextWindow),
+ }))
+ }
+ return strings.Join(blocks, "\n")
+}
+
+func (grokWriter) cut(text string) string {
+ return tomlCutTablesUnder(text, "model", grokAliasPrefix)
+}
+
+func grokAlias(model string) string {
+ return grokAliasPrefix + model
+}
+
+func (grokWriter) save(path string, text string) error {
+ changes := &txn{}
+ if err := changes.write(path, []byte(tomlTidy(text))); err != nil {
+ changes.abort()
+ return err
+ }
+ return changes.commit()
+}
+
+func (w grokWriter) document(target Target) (map[string]any, error) {
+ path, err := w.configPath(target)
+ if err != nil {
+ return nil, err
+ }
+ data, _, exists, err := readFile(path)
+ if err != nil || !exists {
+ return map[string]any{}, err
+ }
+ return w.decode(path, data)
+}
+
+func (grokWriter) decode(path string, data []byte) (map[string]any, error) {
+ document := map[string]any{}
+ if len(strings.TrimSpace(string(data))) == 0 {
+ return document, nil
+ }
+ if err := toml.Unmarshal(data, &document); err != nil {
+ return nil, fmt.Errorf("parse %s: %w", path, err)
+ }
+ return document, nil
+}
+
+func (grokWriter) configPath(target Target) (string, error) {
+ home, err := agenthome.Resolve(target.Owner)
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(home, ".grok", "config.toml"), nil
+}
diff --git a/agentprovider/toml.go b/agentprovider/toml.go
index 3f0c470..4bc22fd 100644
--- a/agentprovider/toml.go
+++ b/agentprovider/toml.go
@@ -30,20 +30,37 @@ func tomlHeader(line string) ([]string, bool) {
if !strings.HasPrefix(text, "[") || strings.HasPrefix(text, "[[") {
return nil, false
}
- end := strings.Index(text, "]")
- if end < 0 {
- return nil, false
- }
- parts := strings.Split(text[1:end], ".")
- for index, part := range parts {
- part = strings.TrimSpace(part)
- if unquoted, err := strconv.Unquote(part); err == nil {
- part = unquoted
+ // A quoted part may hold a "." or a "]" of its own - a model id such as
+ // "gpt-5.5" does - so the header is scanned rather than split.
+ parts := []string{}
+ part := strings.Builder{}
+ var quote byte
+ for index := 1; index < len(text); index++ {
+ character := text[index]
+ switch {
+ case quote != 0:
+ if character == '\\' && quote == '"' && index+1 <
len(text) {
+ index++
+ part.WriteByte(text[index])
+ } else if character == quote {
+ quote = 0
+ } else {
+ part.WriteByte(character)
+ }
+ case character == '"' || character == '\'':
+ quote = character
+ case character == '.' || character == ']':
+ parts = append(parts, strings.TrimSpace(part.String()))
+ part.Reset()
+ if character == ']' {
+ return parts, true
+ }
+ default:
+ part.WriteByte(character)
}
- parts[index] = part
}
- return parts, true
+ return nil, false
}
// tomlCutTable removes one table and its sub-tables, leaving every other line,
@@ -131,6 +148,76 @@ func tomlDeleteRootKey(text string, key string) string {
return strings.Join(kept, "\n")
}
+// tomlSetTableKey writes a key of one top-level table, appending the table
when
+// the file has none. An existing assignment is replaced in place.
+func tomlSetTableKey(text string, table string, key string, value string)
string {
+ assignment := fmt.Sprintf("%s = %s", key, strconv.Quote(value))
+ lines := strings.Split(text, "\n")
+ start, end := tomlTableSpan(lines, table)
+ if start < 0 {
+ return tomlAppend(text, fmt.Sprintf("[%s]\n%s\n",
tomlKey(table), assignment))
+ }
+
+ last := start
+ for index := start + 1; index < end; index++ {
+ if tomlAssigns(lines[index], key) {
+ lines[index] = assignment
+ return strings.Join(lines, "\n")
+ }
+ if strings.TrimSpace(lines[index]) != "" {
+ last = index
+ }
+ }
+ rest := append([]string{assignment}, lines[last+1:]...)
+ return strings.Join(append(lines[:last+1:last+1], rest...), "\n")
+}
+
+// tomlDeleteTableKey removes a key of one top-level table, and the table with
+// it when nothing else is left in it.
+func tomlDeleteTableKey(text string, table string, key string) string {
+ lines := strings.Split(text, "\n")
+ start, end := tomlTableSpan(lines, table)
+ if start < 0 {
+ return text
+ }
+
+ kept := []string{}
+ empty := true
+ for index := start + 1; index < end; index++ {
+ if tomlAssigns(lines[index], key) {
+ continue
+ }
+ if strings.TrimSpace(lines[index]) != "" {
+ empty = false
+ }
+ kept = append(kept, lines[index])
+ }
+ if empty {
+ return strings.Join(append(lines[:start:start],
lines[end:]...), "\n")
+ }
+ rest := append(kept, lines[end:]...)
+ return strings.Join(append(lines[:start+1:start+1], rest...), "\n")
+}
+
+// tomlTableSpan is the header line of one top-level table and the line its
+// body ends before, -1 when the file has no such table.
+func tomlTableSpan(lines []string, table string) (int, int) {
+ start := -1
+ for index, line := range lines {
+ header, ok := tomlHeader(line)
+ if !ok && !strings.HasPrefix(strings.TrimSpace(line), "[[") {
+ continue
+ }
+ if start >= 0 {
+ return start, index
+ }
+ if ok && len(header) == 1 && header[0] == table {
+ start = index
+ }
+ }
+ return start, len(lines)
+}
+
func tomlAssigns(line string, key string) bool {
text := strings.TrimSpace(line)
if !strings.HasPrefix(text, key) {
diff --git a/agentsession/command_unix.go b/agentsession/command_unix.go
index aee4490..350a732 100644
--- a/agentsession/command_unix.go
+++ b/agentsession/command_unix.go
@@ -30,6 +30,12 @@ func newCommand(ctx context.Context, executable string, args
[]string) *exec.Cmd
return cmd
}
+// viaShell reports whether a launcher's command line is parsed again by a
+// shell, which no launcher here is.
+func viaShell(executable string) bool {
+ return false
+}
+
// killTree ends the launcher and everything it started. Every one of these
// agents is a shim in front of a runtime, so killing the shim alone would
leave
// the agent running.
diff --git a/agentsession/command_windows.go b/agentsession/command_windows.go
index 55b2a59..41faca7 100644
--- a/agentsession/command_windows.go
+++ b/agentsession/command_windows.go
@@ -29,16 +29,24 @@ import (
// own. A package manager's shim is a batch file, which Windows cannot start
// directly, so those go through cmd.exe.
func newCommand(ctx context.Context, executable string, args []string)
*exec.Cmd {
- switch strings.ToLower(filepath.Ext(executable)) {
- case ".cmd", ".bat":
+ if viaShell(executable) {
cmd := exec.Command("cmd.exe")
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true,
CmdLine: batchLine(executable, args)}
return cmd
- default:
- cmd := exec.Command(executable, args...)
- cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
- return cmd
}
+ cmd := exec.Command(executable, args...)
+ cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+ return cmd
+}
+
+// viaShell reports whether a launcher's command line is parsed again by
+// cmd.exe, which is what a batch file needs to run at all.
+func viaShell(executable string) bool {
+ switch strings.ToLower(filepath.Ext(executable)) {
+ case ".cmd", ".bat":
+ return true
+ }
+ return false
}
// batchLine writes the command line for a shim run through cmd.exe, which
parses
diff --git a/agentsession/runner.go b/agentsession/runner.go
index 5529e9e..8802dea 100644
--- a/agentsession/runner.go
+++ b/agentsession/runner.go
@@ -52,8 +52,9 @@ func run(ctx context.Context, headless *agent.Headless,
session Session, prompt
// A command line cannot carry a newline through a package manager's
Windows
// shim - cmd.exe ends the argument there - and a message typed in a
chat
// window routinely has one. An agent whose prompt goes on the command
line
- // is therefore held to a single line rather than quietly losing the
rest.
- if !headless.PromptStdin && strings.ContainsAny(prompt, "\r\n") {
+ // of such a shim is therefore held to a single line rather than quietly
+ // losing the rest.
+ if !headless.PromptStdin && viaShell(launch.Executable) &&
strings.ContainsAny(prompt, "\r\n") {
return errors.New("this agent takes its prompt on the command
line, which cannot carry more than one line")
}
@@ -164,6 +165,12 @@ func fill(template []string, session Session, prompt
string, promptOnStdin bool)
case agent.ModelPlaceholder:
args = append(args, session.Model)
default:
+ if flag, ok := strings.CutSuffix(arg,
"="+agent.PromptPlaceholder); ok {
+ if !promptOnStdin {
+ args = append(args, flag+"="+prompt)
+ }
+ continue
+ }
args = append(args, arg)
}
}
diff --git a/object/import_link.go b/object/import_link.go
index 525ebfb..0966fab 100644
--- a/object/import_link.go
+++ b/object/import_link.go
@@ -50,15 +50,16 @@ const (
// and its desktop app share ~/.config/opencode — because the listing keeps one
// entry per location, under whichever of the two was found first.
var importLinkAgents = map[string][]string{
- "claude": {"claude-code"},
- "codex": {"codex-cli", "codex"},
- "cursor": {"cursor", "cursor-agent"},
- "gemini": {"gemini-cli"},
- "hermes": {"hermes-agent"},
- "iflow": {"iflow-cli"},
- "kimi": {"kimi-code"},
- "opencode": {"opencode", "opencode-desktop"},
- "qwen": {"qwen-code"},
+ "claude": {"claude-code"},
+ "codex": {"codex-cli", "codex"},
+ "cursor": {"cursor", "cursor-agent"},
+ "gemini": {"gemini-cli"},
+ "grokbuild": {"grok-build"},
+ "hermes": {"hermes-agent"},
+ "iflow": {"iflow-cli"},
+ "kimi": {"kimi-code"},
+ "opencode": {"opencode", "opencode-desktop"},
+ "qwen": {"qwen-code"},
}
// ImportLink is what one link carries, read and handed to the page. Nothing in
diff --git a/web/src/components/AgentIcon.tsx b/web/src/components/AgentIcon.tsx
index b21a04c..51026a6 100644
--- a/web/src/components/AgentIcon.tsx
+++ b/web/src/components/AgentIcon.tsx
@@ -45,6 +45,7 @@ const agentSites: Record<string, string> = {
Pi: "pi.dev",
"Kimi Code CLI": "kimi.com",
"CodeBuddy Code": "codebuddy.ai",
+ "Grok Build": "x.ai",
ZCode: "z.ai",
};
@@ -52,6 +53,7 @@ const agentSites: Record<string, string> = {
const agentIdSites: Record<string, string> = {
codebuddy: "codebuddy.ai",
dsh: "deepseek.com",
+ "grok-build": "x.ai",
"kimi-code": "kimi.com",
};
diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts
index aff7b8d..84e13a8 100644
--- a/web/src/lib/agents.ts
+++ b/web/src/lib/agents.ts
@@ -131,6 +131,8 @@ export function agentSetupNoteKey(agentId: string) {
return "agent:Kimi Code config hint";
case "codebuddy":
return "agent:CodeBuddy config hint";
+ case "grok-build":
+ return "agent:Grok Build config hint";
default:
return "";
}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index c913680..3e8c941 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -160,6 +160,7 @@
"Gateway mode hint": "The agent is pointed at the local proxy, so
switching to another provider takes effect on the next request without
restarting the agent.",
"Gateway only hint": "Codex speaks only the OpenAI Responses API. Gateway
serves it by translating to the chat completions this provider answers, so
Codex cannot be pointed at it directly.",
"Gemini CLI config hint": "The Gemini CLI reads no base URL from a
settings file: the endpoint is GOOGLE_GEMINI_BASE_URL in ~/.gemini/.env, with
GEMINI_API_KEY beside it and \"security.auth.selectedType\": \"gemini-api-key\"
in ~/.gemini/settings.json. A project of its own .env is read first, and that
one wins.",
+ "Grok Build config hint": "Grok Build keeps the endpoint in
~/.grok/config.toml: one [model.\"casbin-gateway/*\"] table per model, each
with its own base_url, api_key and api_backend \"chat_completions\", and
[models] default and session_summary naming the one it starts on. Grok's own
models keep going to xAI on its sign-in. GROK_DEFAULT_MODEL beats that default,
and GROK_HOME moves the whole directory.",
"Gemini gateway only hint": "The Gemini CLI speaks only Google's
generateContent API, which no provider here serves. Gateway serves it by
translating to the API the provider answers, so the CLI cannot be pointed at a
provider directly.",
"Getting started": "Getting started",
"Home folder": "Home",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 3d3b418..0bbd244 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -160,6 +160,7 @@
"Gateway mode hint": "把Agent指向本地代理,切换供应商在下一次请求即生效,无需重启Agent。",
"Gateway only hint": "Codex只会说OpenAI Responses
API。Gateway会把它翻译成该供应商应答的chat completions,所以Codex无法直连这个供应商。",
"Gemini CLI config hint": "Gemini CLI不从配置文件读取Base
URL:端点是~/.gemini/.env中的GOOGLE_GEMINI_BASE_URL,旁边是GEMINI_API_KEY,同时~/.gemini/settings.json中写入\"security.auth.selectedType\":
\"gemini-api-key\"。项目自带的.env会被优先读取并覆盖它。",
+ "Grok Build config hint": "Grok
Build的端点写在~/.grok/config.toml里:每个模型一个[model.\"casbin-gateway/*\"]表,各自带base_url、api_key和api_backend
\"chat_completions\",再由[models]下的default和session_summary选中启动用的那个。Grok自带的模型仍凭它的登录直连xAI。GROK_DEFAULT_MODEL会覆盖这个默认值,GROK_HOME会把整个目录挪到别处。",
"Gemini gateway only hint": "Gemini CLI只会说Google的generateContent
API,这里没有供应商提供该API。Gateway会把它翻译成供应商应答的API,所以Gemini CLI无法直连供应商。",
"Getting started": "快速上手",
"Home folder": "主目录",
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]