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

commit a486b0bd7e6a8c6c31469dcb4dca588af5fddd5b
Author: Yang Luo <[email protected]>
AuthorDate: Fri Sep 4 14:30:32 2026 +0800

    feat(agents): support Cline, Qwen Code, iFlow, Roo Code and Copilot CLI
---
 README.md                                   |   5 +
 README_zh.md                                |   5 +
 agent/fingerprint_load_test.go              |   7 +-
 agent/fingerprints/cline.json               |   9 +
 agent/fingerprints/copilot-cli.json         |   8 +
 agent/fingerprints/iflow-cli.json           |   8 +
 agent/fingerprints/qwen-code.json           |   8 +
 agent/fingerprints/roo-code.json            |   7 +
 agentconfig/layout.go                       |  28 +++
 agentenv/agentenv.go                        |   7 +
 agenthistory/history.go                     |   7 +-
 agenthook/hook.go                           |   1 +
 agenthook/qwen.go                           | 106 ++++++++++
 agentpatch/gemini.go                        | 196 +-----------------
 agentpatch/qwen.go                          |  28 +++
 agentpatch/{gemini.go => settings_hooks.go} | 116 ++++++-----
 agentprovider/cline.go                      | 309 ++++++++++++++++++++++++++++
 agentprovider/iflow.go                      | 189 +++++++++++++++++
 agentprovider/qwen.go                       | 254 +++++++++++++++++++++++
 web/src/components/AgentIcon.tsx            |   5 +
 web/src/lib/agents.ts                       |   6 +
 web/src/locales/en/data.json                |  28 +++
 web/src/locales/zh/data.json                |  28 +++
 23 files changed, 1116 insertions(+), 249 deletions(-)

diff --git a/README.md b/README.md
index ef6170b..78d1f1f 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,11 @@
   <a href="https://gemini.google.com"; title="Gemini CLI"><img 
src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64"; 
width="30" height="30" alt="Gemini CLI"></a>
   <a href="https://cursor.com"; title="Cursor / Cursor Agent"><img 
src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64"; width="30" 
height="30" alt="Cursor"></a>
   <a href="https://windsurf.com"; title="Windsurf"><img 
src="https://www.google.com/s2/favicons?domain=windsurf.com&sz=64"; width="30" 
height="30" alt="Windsurf"></a>
+  <a href="https://cline.bot"; title="Cline"><img 
src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64"; width="30" 
height="30" alt="Cline"></a>
+  <a href="https://github.com/QwenLM/qwen-code"; title="Qwen Code"><img 
src="https://www.google.com/s2/favicons?domain=qwen.ai&sz=64"; width="30" 
height="30" alt="Qwen Code"></a>
+  <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://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://opencode.ai"; title="opencode / opencode Desktop"><img 
src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64"; width="30" 
height="30" alt="opencode"></a>
   <a href="https://openagentai.org"; title="OpenAgent"><img 
src="https://www.google.com/s2/favicons?domain=openagentai.org&sz=64"; 
width="30" height="30" alt="OpenAgent"></a>
   <a href="https://openclaw.ai"; title="OpenClaw"><img 
src="https://www.google.com/s2/favicons?domain=openclaw.ai&sz=64"; width="30" 
height="30" alt="OpenClaw"></a>
diff --git a/README_zh.md b/README_zh.md
index 7ac8c5f..697844d 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -24,6 +24,11 @@
   <a href="https://gemini.google.com"; title="Gemini CLI"><img 
src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64"; 
width="30" height="30" alt="Gemini CLI"></a>
   <a href="https://cursor.com"; title="Cursor / Cursor Agent"><img 
src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64"; width="30" 
height="30" alt="Cursor"></a>
   <a href="https://windsurf.com"; title="Windsurf"><img 
src="https://www.google.com/s2/favicons?domain=windsurf.com&sz=64"; width="30" 
height="30" alt="Windsurf"></a>
+  <a href="https://cline.bot"; title="Cline"><img 
src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64"; width="30" 
height="30" alt="Cline"></a>
+  <a href="https://github.com/QwenLM/qwen-code"; title="Qwen Code"><img 
src="https://www.google.com/s2/favicons?domain=qwen.ai&sz=64"; width="30" 
height="30" alt="Qwen Code"></a>
+  <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://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://opencode.ai"; title="opencode / opencode Desktop"><img 
src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64"; width="30" 
height="30" alt="opencode"></a>
   <a href="https://openagentai.org"; title="OpenAgent"><img 
src="https://www.google.com/s2/favicons?domain=openagentai.org&sz=64"; 
width="30" height="30" alt="OpenAgent"></a>
   <a href="https://openclaw.ai"; title="OpenClaw"><img 
src="https://www.google.com/s2/favicons?domain=openclaw.ai&sz=64"; width="30" 
height="30" alt="OpenClaw"></a>
diff --git a/agent/fingerprint_load_test.go b/agent/fingerprint_load_test.go
index a016a5a..e15bb52 100644
--- a/agent/fingerprint_load_test.go
+++ b/agent/fingerprint_load_test.go
@@ -22,9 +22,10 @@ import (
 
 func TestFingerprintsLoad(t *testing.T) {
        want := []string{
-               "claude-code", "claude-desktop", "codex-cli", "codex", 
"cursor-agent",
-               "cursor", "dsh", "gemini-cli", "hermes-agent", "openagent", 
"openclaw",
-               "opencode-desktop", "opencode",
+               "claude-code", "claude-desktop", "cline", "codex-cli", "codex",
+               "copilot-cli", "cursor-agent", "cursor", "dsh", "gemini-cli",
+               "hermes-agent", "iflow-cli", "openagent", "openclaw",
+               "opencode-desktop", "opencode", "qwen-code", "roo-code",
                "windsurf",
        }
 
diff --git a/agent/fingerprints/cline.json b/agent/fingerprints/cline.json
new file mode 100644
index 0000000..c5ac220
--- /dev/null
+++ b/agent/fingerprints/cline.json
@@ -0,0 +1,9 @@
+{
+  "id": "cline",
+  "displayName": "Cline",
+  "installUrl": "https://docs.cline.bot/getting-started/installing-cline";,
+  "execName": "cline",
+  "instanceEnv": "CLINE_DIR",
+  "stateDir": "cline",
+  "npmPackage": "cline"
+}
diff --git a/agent/fingerprints/copilot-cli.json 
b/agent/fingerprints/copilot-cli.json
new file mode 100644
index 0000000..82239dc
--- /dev/null
+++ b/agent/fingerprints/copilot-cli.json
@@ -0,0 +1,8 @@
+{
+  "id": "copilot-cli",
+  "displayName": "Copilot CLI",
+  "installUrl": 
"https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli";,
+  "execName": "copilot",
+  "stateDir": "copilot",
+  "npmPackage": "@github/copilot"
+}
diff --git a/agent/fingerprints/iflow-cli.json 
b/agent/fingerprints/iflow-cli.json
new file mode 100644
index 0000000..158a036
--- /dev/null
+++ b/agent/fingerprints/iflow-cli.json
@@ -0,0 +1,8 @@
+{
+  "id": "iflow-cli",
+  "displayName": "iFlow CLI",
+  "installUrl": "https://platform.iflow.cn/en/cli";,
+  "execName": "iflow",
+  "stateDir": "iflow",
+  "npmPackage": "@iflow-ai/iflow-cli"
+}
diff --git a/agent/fingerprints/qwen-code.json 
b/agent/fingerprints/qwen-code.json
new file mode 100644
index 0000000..9e4b838
--- /dev/null
+++ b/agent/fingerprints/qwen-code.json
@@ -0,0 +1,8 @@
+{
+  "id": "qwen-code",
+  "displayName": "Qwen Code",
+  "installUrl": "https://github.com/QwenLM/qwen-code";,
+  "execName": "qwen",
+  "stateDir": "qwen",
+  "npmPackage": "@qwen-code/qwen-code"
+}
diff --git a/agent/fingerprints/roo-code.json b/agent/fingerprints/roo-code.json
new file mode 100644
index 0000000..65bbef9
--- /dev/null
+++ b/agent/fingerprints/roo-code.json
@@ -0,0 +1,7 @@
+{
+  "id": "roo-code",
+  "displayName": "Roo Code",
+  "installUrl": "https://docs.roocode.com/getting-started/installing";,
+  "execName": "roo",
+  "stateDir": "roo"
+}
diff --git a/agentconfig/layout.go b/agentconfig/layout.go
index 4327240..1ba0465 100644
--- a/agentconfig/layout.go
+++ b/agentconfig/layout.go
@@ -181,6 +181,34 @@ var layouts = map[string]layout{
                mcp:    &mcpLayout{file: under(".gemini", "settings.json"), 
store: &jsonStore{paths: [][]string{{"mcpServers"}}}},
                prompt: promptFile("GEMINI.md", ".gemini"),
        },
+       // Qwen Code is a Gemini CLI fork and keeps the same layout under its 
own
+       // directory, the shared ~/.agents skills included.
+       "qwen-code": {
+               skills: &skillLayout{sources: []skillSource{
+                       userSkills(".qwen", "skills"),
+                       userSkills(".agents", "skills"),
+                       pluginSkills(".qwen", "extensions"),
+               }},
+               mcp:    &mcpLayout{file: under(".qwen", "settings.json"), 
store: &jsonStore{paths: [][]string{{"mcpServers"}}}},
+               prompt: promptFile("QWEN.md", ".qwen"),
+       },
+       "iflow-cli": {
+               mcp:    &mcpLayout{file: under(".iflow", "settings.json"), 
store: &jsonStore{paths: [][]string{{"mcpServers"}}}},
+               prompt: promptFile("IFLOW.md", ".iflow"),
+       },
+       // Cline's IDE extension, CLI and SDK share one ~/.cline: the skills sit
+       // directly under it, the MCP servers with the rest of the app state.
+       "cline": {
+               skills: &skillLayout{sources: []skillSource{
+                       userSkills(".cline", "skills"),
+                       userSkills(".agents", "skills"),
+                       pluginSkills(".cline", "plugins"),
+               }},
+               mcp: &mcpLayout{
+                       file:  under(".cline", "data", "settings", 
"cline_mcp_settings.json"),
+                       store: &jsonStore{paths: [][]string{{"mcpServers"}}},
+               },
+       },
        "windsurf": {
                mcp: &mcpLayout{
                        file:  under(".codeium", "windsurf", "mcp_config.json"),
diff --git a/agentenv/agentenv.go b/agentenv/agentenv.go
index 3071d53..6acceb3 100644
--- a/agentenv/agentenv.go
+++ b/agentenv/agentenv.go
@@ -80,6 +80,13 @@ var agentVars = map[string][]variable{
                {key: "GEMINI_API_KEY", secret: true},
                {key: "GEMINI_MODEL"},
        },
+       // Qwen Code is a Gemini CLI fork and reads ~/.qwen/.env the same way, 
which
+       // never replaces a variable the shell already exports.
+       "qwen-code": {
+               {key: "OPENAI_BASE_URL", endpoint: true},
+               {key: "OPENAI_API_KEY", secret: true},
+               {key: "OPENAI_MODEL"},
+       },
        // dsh resolves the route's key by name, and the environment answers 
before
        // the credentials file does.
        "dsh": {
diff --git a/agenthistory/history.go b/agenthistory/history.go
index 942a978..c46b432 100644
--- a/agenthistory/history.go
+++ b/agenthistory/history.go
@@ -75,10 +75,11 @@ var transcriptDirs = []struct {
 }{
        {agent: "claude-code", parts: []string{".claude", "projects"}},
        {agent: "codex-cli", parts: []string{".codex", "sessions"}},
-       // The Gemini CLI keeps one directory per project hash, and OpenClaw 
one per
-       // agent id; both hold the sessions a level or two further down, which 
is
-       // where the walk finds them.
+       // The Gemini CLI and its Qwen Code fork keep one directory per project 
hash,
+       // and OpenClaw one per agent id; all hold the sessions a level or two
+       // further down, which is where the walk finds them.
        {agent: "gemini-cli", parts: []string{".gemini", "tmp"}},
+       {agent: "qwen-code", parts: []string{".qwen", "tmp"}},
        {agent: "openclaw", parts: []string{".openclaw", "agents"}},
        // Claude Desktop writes one directory per Cowork session, named by the
        // session id, with the transcript inside it under a fixed name.
diff --git a/agenthook/hook.go b/agenthook/hook.go
index 3174394..c6bccb0 100644
--- a/agenthook/hook.go
+++ b/agenthook/hook.go
@@ -107,6 +107,7 @@ var normalizers = map[string]func(map[string]any, string, 
time.Time) *agentmonit
        "claude-code": Normalize,
        "cursor":      NormalizeCursor,
        "gemini-cli":  NormalizeGemini,
+       "qwen-code":   NormalizeQwen,
        "windsurf":    NormalizeWindsurf,
 }
 
diff --git a/agenthook/qwen.go b/agenthook/qwen.go
new file mode 100644
index 0000000..9142511
--- /dev/null
+++ b/agenthook/qwen.go
@@ -0,0 +1,106 @@
+// 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 agenthook
+
+import (
+       "time"
+
+       "github.com/apache/casbin-gateway/agentmonitor"
+       "github.com/apache/casbin-gateway/auditutil"
+)
+
+// QwenEvents are the hook events Gateway installs. Qwen Code is a Gemini CLI
+// fork that took Claude Code's event names, so these read like the latter's.
+// MessageDisplay is left out: it fires on every chunk of a streaming reply and
+// says nothing about what the agent did.
+var QwenEvents = []string{
+       "SessionStart", "SessionEnd", "UserPromptSubmit", "Stop",
+       "PreToolUse", "PostToolUse", "PostToolUseFailure",
+       "SubagentStart", "SubagentStop", "Notification",
+       "PreCompact", "PostCompact",
+}
+
+// NormalizeQwen maps Qwen Code's hook event schema to Gateway's agent
+// monitoring record. Unknown events are not monitoring events and are ignored.
+func NormalizeQwen(event map[string]any, agentPath string, now time.Time) 
*agentmonitor.Record {
+       record := &agentmonitor.Record{
+               Agent:       "qwen-code",
+               AgentPath:   agentPath,
+               CreatedTime: now.Format(time.RFC3339Nano),
+               SessionKey:  stringValue(event["session_id"]),
+               ToolUseId:   stringValue(event["tool_use_id"]),
+               ToolName:    stringValue(event["tool_name"]),
+               Model:       stringValue(event["model"]),
+       }
+
+       switch stringValue(event["hook_event_name"]) {
+       case "SessionStart":
+               record.EventType, record.Action = "session", "start"
+       case "SessionEnd":
+               record.EventType, record.Action = "session", "end"
+               record.Detail = 
auditutil.SanitizeString(stringValue(event["reason"]))
+       case "Stop":
+               record.EventType, record.Action, record.Outcome = "session", 
"stop", "success"
+       case "UserPromptSubmit":
+               record.EventType, record.Action, record.Outcome = "prompt", 
"submitted", "attempted"
+               record.Title = 
auditutil.SanitizeString(stringValue(event["prompt"]))
+       case "PreToolUse":
+               record.EventType, record.Action, record.Outcome = "tool", 
"call", "attempted"
+       case "PostToolUse":
+               record.EventType, record.Action, record.Outcome = "tool", 
"call", "success"
+       case "PostToolUseFailure":
+               record.EventType, record.Action, record.Outcome = "tool", 
"call", "failure"
+               record.Detail = 
auditutil.SanitizeString(stringValue(event["error"]))
+       case "SubagentStart":
+               record.EventType, record.Action = "subagent", "start"
+       case "SubagentStop":
+               record.EventType, record.Action, record.Outcome = "subagent", 
"stop", "success"
+       case "Notification":
+               record.EventType, record.Action = "notification", 
stringValue(event["notification_type"])
+               record.Detail = 
auditutil.SanitizeString(stringValue(event["message"]))
+       case "PreCompact":
+               record.EventType, record.Action, record.Outcome = "compact", 
"before", "attempted"
+       case "PostCompact":
+               record.EventType, record.Action, record.Outcome = "compact", 
"after", "success"
+       default:
+               return nil
+       }
+
+       if record.Action == "" {
+               record.Action = "observed"
+       }
+       record.Object = auditutil.EncodeBoundedJSON(qwenPayload(event, 
record.ToolName), auditutil.MaxPayloadBytes)
+       return record
+}
+
+// qwenPayload drops what a record cannot carry: the transcript is a path into
+// the agent's own storage, and a tool response or an assistant turn is the
+// content itself rather than a description of the action.
+func qwenPayload(event map[string]any, toolName string) map[string]any {
+       payload := make(map[string]any, len(event))
+       for key, value := range event {
+               switch key {
+               case "transcript_path", "tool_response":
+                       continue
+               case "last_assistant_message", "compact_summary":
+                       payload[key+"_length"] = len(stringValue(value))
+               case "tool_input":
+                       payload[key] = auditutil.SanitizeToolInput(toolName, 
value)
+               default:
+                       payload[key] = auditutil.SanitizeValue(key, value)
+               }
+       }
+       return payload
+}
diff --git a/agentpatch/gemini.go b/agentpatch/gemini.go
index 21d58cb..b96b2ea 100644
--- a/agentpatch/gemini.go
+++ b/agentpatch/gemini.go
@@ -14,195 +14,13 @@
 
 package agentpatch
 
-import (
-       "fmt"
-       "path/filepath"
-       "strings"
-
-       "github.com/apache/casbin-gateway/agenthome"
-       "github.com/apache/casbin-gateway/agenthook"
-)
-
-// geminiHookTimeoutMs is what the Gemini CLI waits for the hook, in
-// milliseconds. Reporting a record is one loopback request, and a hook that
-// hangs must not hold up the session.
-const geminiHookTimeoutMs = 5000
-
-type geminiPatcher struct{}
+import "github.com/apache/casbin-gateway/agenthook"
 
 func init() {
-       register(geminiPatcher{})
-}
-
-func (geminiPatcher) AgentId() string { return "gemini-cli" }
-
-func (geminiPatcher) Supported() bool { return true }
-
-func (p geminiPatcher) Patch(target Target) error {
-       stateMutex.Lock()
-       defer stateMutex.Unlock()
-
-       path, err := p.settingsPath(target)
-       if err != nil {
-               return err
-       }
-       config, mode, _, err := readJSONConfig(path)
-       if err != nil {
-               return err
-       }
-       command, err := geminiHookCommand(target)
-       if err != nil {
-               return err
-       }
-
-       hooks, err := geminiHooks(config)
-       if err != nil {
-               return err
-       }
-       for _, event := range agenthook.GeminiEvents {
-               groups, err := withoutHooks(hooks[event], isGeminiHook)
-               if err != nil {
-                       return fmt.Errorf("hooks.%s: %w", event, err)
-               }
-               hooks[event] = append(groups, map[string]any{"hooks": 
[]any{newGeminiHook(command)}})
-       }
-       return writeJSONConfig(path, config, mode)
-}
-
-func (p geminiPatcher) Unpatch(target Target) error {
-       stateMutex.Lock()
-       defer stateMutex.Unlock()
-
-       path, err := p.settingsPath(target)
-       if err != nil {
-               return err
-       }
-       config, mode, exists, err := readJSONConfig(path)
-       if err != nil {
-               return err
-       }
-       // The credential is revoked on every unpatch path, including the ones 
that
-       // change no file, so a stale hook can never keep reporting.
-       if exists && removeGeminiHooks(config) {
-               if err := writeJSONConfig(path, config, mode); err != nil {
-                       return err
-               }
-       }
-       return RevokeIngestToken(monitorTarget(target))
-}
-
-func (p geminiPatcher) Status(target Target) (Status, error) {
-       path, err := p.settingsPath(target)
-       if err != nil {
-               return Status{}, err
-       }
-       config, _, exists, err := readJSONConfig(path)
-       if err != nil {
-               return Status{}, err
-       }
-       if !exists {
-               return Status{Detail: "Gemini CLI hooks are not installed"}, nil
-       }
-       hooks, ok := objectAt(config["hooks"])
-       if !ok {
-               return Status{Detail: "Gemini CLI hooks are not installed"}, nil
-       }
-       for _, event := range agenthook.GeminiEvents {
-               if !hasHook(hooks[event], isGeminiHook) {
-                       return Status{Detail: "Gemini CLI hooks need refresh"}, 
nil
-               }
-       }
-       return Status{Patched: true, Detail: "Gemini CLI hooks active"}, nil
-}
-
-func (geminiPatcher) PatchNotice(patched bool) (string, string) {
-       if patched {
-               return "Removes Gateway's audit-only Gemini CLI hooks.", 
"Restart any Gemini CLI session that is already running."
-       }
-       return "Installs audit-only Gemini CLI hooks. Hooks observe events and 
never block an action.",
-               "Restart any Gemini CLI session that is already running."
-}
-
-func (geminiPatcher) settingsPath(target Target) (string, error) {
-       home, err := agenthome.Resolve(target.Owner)
-       if err != nil {
-               return "", err
-       }
-       return filepath.Join(home, ".gemini", "settings.json"), nil
-}
-
-// geminiHookCommand is the whole command line, since the Gemini CLI takes a
-// command string rather than a program and its arguments.
-func geminiHookCommand(target Target) (string, error) {
-       executable, err := gatewayExecutable()
-       if err != nil {
-               return "", err
-       }
-       args, err := hookArgs("gemini-cli", target)
-       if err != nil {
-               return "", err
-       }
-       return hookCommandLine(executable, args), nil
-}
-
-func geminiHooks(config map[string]any) (map[string]any, error) {
-       value, exists := config["hooks"]
-       if !exists {
-               hooks := map[string]any{}
-               config["hooks"] = hooks
-               return hooks, nil
-       }
-       hooks, ok := objectAt(value)
-       if !ok {
-               return nil, fmt.Errorf("hooks must be a JSON object")
-       }
-       return hooks, nil
-}
-
-func newGeminiHook(command string) map[string]any {
-       return map[string]any{
-               "type":    "command",
-               "command": command,
-               "name":    gatewayEntryName,
-               "timeout": geminiHookTimeoutMs,
-       }
-}
-
-func removeGeminiHooks(config map[string]any) bool {
-       hooks, ok := objectAt(config["hooks"])
-       if !ok {
-               return false
-       }
-       changed := false
-       for _, event := range agenthook.GeminiEvents {
-               if !hasHook(hooks[event], isGeminiHook) {
-                       continue
-               }
-               groups, err := withoutHooks(hooks[event], isGeminiHook)
-               if err != nil {
-                       continue
-               }
-               changed = true
-               if len(groups) == 0 {
-                       delete(hooks, event)
-               } else {
-                       hooks[event] = groups
-               }
-       }
-       if changed && len(hooks) == 0 {
-               delete(config, "hooks")
-       }
-       return changed
-}
-
-// isGeminiHook recognizes Gateway's own handler by the command it runs, which
-// is the only thing a Gemini CLI hook entry carries.
-func isGeminiHook(handler map[string]any) bool {
-       if handlerType, _ := handler["type"].(string); handlerType != "command" 
{
-               return false
-       }
-       command, _ := handler["command"].(string)
-       return strings.Contains(command, agenthook.OwnershipFlag) &&
-               strings.Contains(command, "--agent gemini-cli") &&
-               strings.Contains(command, "--records-url")
+       register(settingsHookPatcher{
+               agentId: "gemini-cli",
+               name:    "Gemini CLI",
+               dir:     ".gemini",
+               events:  agenthook.GeminiEvents,
+       })
 }
diff --git a/agentpatch/qwen.go b/agentpatch/qwen.go
new file mode 100644
index 0000000..d59198e
--- /dev/null
+++ b/agentpatch/qwen.go
@@ -0,0 +1,28 @@
+// 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 agentpatch
+
+import "github.com/apache/casbin-gateway/agenthook"
+
+// Qwen Code is a Gemini CLI fork: the same settings file under its own
+// directory, with Claude Code's event names in it.
+func init() {
+       register(settingsHookPatcher{
+               agentId: "qwen-code",
+               name:    "Qwen Code",
+               dir:     ".qwen",
+               events:  agenthook.QwenEvents,
+       })
+}
diff --git a/agentpatch/gemini.go b/agentpatch/settings_hooks.go
similarity index 51%
copy from agentpatch/gemini.go
copy to agentpatch/settings_hooks.go
index 21d58cb..f7ccc7e 100644
--- a/agentpatch/gemini.go
+++ b/agentpatch/settings_hooks.go
@@ -23,22 +23,30 @@ import (
        "github.com/apache/casbin-gateway/agenthook"
 )
 
-// geminiHookTimeoutMs is what the Gemini CLI waits for the hook, in
-// milliseconds. Reporting a record is one loopback request, and a hook that
-// hangs must not hold up the session.
-const geminiHookTimeoutMs = 5000
-
-type geminiPatcher struct{}
-
-func init() {
-       register(geminiPatcher{})
+// hookTimeoutMs is what these CLIs wait for the hook, in milliseconds.
+// Reporting a record is one loopback request, and a hook that hangs must not
+// hold up the session.
+const hookTimeoutMs = 5000
+
+// settingsHookPatcher installs Gateway's audit-only command hooks into one
+// agent's settings.json. The Gemini CLI and the forks that took its
+// configuration all spell hooks the same way — events keyed by name, each an
+// array of groups holding command handlers — and differ only in where the file
+// sits and which events they fire.
+type settingsHookPatcher struct {
+       agentId string
+       // name is how the agent is called in the status line and the patch 
notice.
+       name string
+       // dir is the agent's state directory under the home, ".gemini" and the 
like.
+       dir    string
+       events []string
 }
 
-func (geminiPatcher) AgentId() string { return "gemini-cli" }
+func (p settingsHookPatcher) AgentId() string { return p.agentId }
 
-func (geminiPatcher) Supported() bool { return true }
+func (settingsHookPatcher) Supported() bool { return true }
 
-func (p geminiPatcher) Patch(target Target) error {
+func (p settingsHookPatcher) Patch(target Target) error {
        stateMutex.Lock()
        defer stateMutex.Unlock()
 
@@ -50,26 +58,26 @@ func (p geminiPatcher) Patch(target Target) error {
        if err != nil {
                return err
        }
-       command, err := geminiHookCommand(target)
+       command, err := p.command(target)
        if err != nil {
                return err
        }
 
-       hooks, err := geminiHooks(config)
+       hooks, err := p.hooksOf(config)
        if err != nil {
                return err
        }
-       for _, event := range agenthook.GeminiEvents {
-               groups, err := withoutHooks(hooks[event], isGeminiHook)
+       for _, event := range p.events {
+               groups, err := withoutHooks(hooks[event], p.owns)
                if err != nil {
                        return fmt.Errorf("hooks.%s: %w", event, err)
                }
-               hooks[event] = append(groups, map[string]any{"hooks": 
[]any{newGeminiHook(command)}})
+               hooks[event] = append(groups, map[string]any{"hooks": 
[]any{newSettingsHook(command)}})
        }
        return writeJSONConfig(path, config, mode)
 }
 
-func (p geminiPatcher) Unpatch(target Target) error {
+func (p settingsHookPatcher) Unpatch(target Target) error {
        stateMutex.Lock()
        defer stateMutex.Unlock()
 
@@ -83,7 +91,7 @@ func (p geminiPatcher) Unpatch(target Target) error {
        }
        // The credential is revoked on every unpatch path, including the ones 
that
        // change no file, so a stale hook can never keep reporting.
-       if exists && removeGeminiHooks(config) {
+       if exists && p.remove(config) {
                if err := writeJSONConfig(path, config, mode); err != nil {
                        return err
                }
@@ -91,7 +99,7 @@ func (p geminiPatcher) Unpatch(target Target) error {
        return RevokeIngestToken(monitorTarget(target))
 }
 
-func (p geminiPatcher) Status(target Target) (Status, error) {
+func (p settingsHookPatcher) Status(target Target) (Status, error) {
        path, err := p.settingsPath(target)
        if err != nil {
                return Status{}, err
@@ -101,51 +109,51 @@ func (p geminiPatcher) Status(target Target) (Status, 
error) {
                return Status{}, err
        }
        if !exists {
-               return Status{Detail: "Gemini CLI hooks are not installed"}, nil
+               return Status{Detail: p.name + " hooks are not installed"}, nil
        }
        hooks, ok := objectAt(config["hooks"])
        if !ok {
-               return Status{Detail: "Gemini CLI hooks are not installed"}, nil
+               return Status{Detail: p.name + " hooks are not installed"}, nil
        }
-       for _, event := range agenthook.GeminiEvents {
-               if !hasHook(hooks[event], isGeminiHook) {
-                       return Status{Detail: "Gemini CLI hooks need refresh"}, 
nil
+       for _, event := range p.events {
+               if !hasHook(hooks[event], p.owns) {
+                       return Status{Detail: p.name + " hooks need refresh"}, 
nil
                }
        }
-       return Status{Patched: true, Detail: "Gemini CLI hooks active"}, nil
+       return Status{Patched: true, Detail: p.name + " hooks active"}, nil
 }
 
-func (geminiPatcher) PatchNotice(patched bool) (string, string) {
+func (p settingsHookPatcher) PatchNotice(patched bool) (string, string) {
+       restart := "Restart any " + p.name + " session that is already running."
        if patched {
-               return "Removes Gateway's audit-only Gemini CLI hooks.", 
"Restart any Gemini CLI session that is already running."
+               return "Removes Gateway's audit-only " + p.name + " hooks.", 
restart
        }
-       return "Installs audit-only Gemini CLI hooks. Hooks observe events and 
never block an action.",
-               "Restart any Gemini CLI session that is already running."
+       return "Installs audit-only " + p.name + " hooks. Hooks observe events 
and never block an action.", restart
 }
 
-func (geminiPatcher) settingsPath(target Target) (string, error) {
+func (p settingsHookPatcher) settingsPath(target Target) (string, error) {
        home, err := agenthome.Resolve(target.Owner)
        if err != nil {
                return "", err
        }
-       return filepath.Join(home, ".gemini", "settings.json"), nil
+       return filepath.Join(home, p.dir, "settings.json"), nil
 }
 
-// geminiHookCommand is the whole command line, since the Gemini CLI takes a
-// command string rather than a program and its arguments.
-func geminiHookCommand(target Target) (string, error) {
+// command is the whole command line, since these CLIs take a command string
+// rather than a program and its arguments.
+func (p settingsHookPatcher) command(target Target) (string, error) {
        executable, err := gatewayExecutable()
        if err != nil {
                return "", err
        }
-       args, err := hookArgs("gemini-cli", target)
+       args, err := hookArgs(p.agentId, target)
        if err != nil {
                return "", err
        }
        return hookCommandLine(executable, args), nil
 }
 
-func geminiHooks(config map[string]any) (map[string]any, error) {
+func (settingsHookPatcher) hooksOf(config map[string]any) (map[string]any, 
error) {
        value, exists := config["hooks"]
        if !exists {
                hooks := map[string]any{}
@@ -159,26 +167,17 @@ func geminiHooks(config map[string]any) (map[string]any, 
error) {
        return hooks, nil
 }
 
-func newGeminiHook(command string) map[string]any {
-       return map[string]any{
-               "type":    "command",
-               "command": command,
-               "name":    gatewayEntryName,
-               "timeout": geminiHookTimeoutMs,
-       }
-}
-
-func removeGeminiHooks(config map[string]any) bool {
+func (p settingsHookPatcher) remove(config map[string]any) bool {
        hooks, ok := objectAt(config["hooks"])
        if !ok {
                return false
        }
        changed := false
-       for _, event := range agenthook.GeminiEvents {
-               if !hasHook(hooks[event], isGeminiHook) {
+       for _, event := range p.events {
+               if !hasHook(hooks[event], p.owns) {
                        continue
                }
-               groups, err := withoutHooks(hooks[event], isGeminiHook)
+               groups, err := withoutHooks(hooks[event], p.owns)
                if err != nil {
                        continue
                }
@@ -195,14 +194,23 @@ func removeGeminiHooks(config map[string]any) bool {
        return changed
 }
 
-// isGeminiHook recognizes Gateway's own handler by the command it runs, which
-// is the only thing a Gemini CLI hook entry carries.
-func isGeminiHook(handler map[string]any) bool {
+// owns recognizes Gateway's own handler by the command it runs, which is the
+// only thing one of these hook entries carries.
+func (p settingsHookPatcher) owns(handler map[string]any) bool {
        if handlerType, _ := handler["type"].(string); handlerType != "command" 
{
                return false
        }
        command, _ := handler["command"].(string)
        return strings.Contains(command, agenthook.OwnershipFlag) &&
-               strings.Contains(command, "--agent gemini-cli") &&
+               strings.Contains(command, "--agent "+p.agentId) &&
                strings.Contains(command, "--records-url")
 }
+
+func newSettingsHook(command string) map[string]any {
+       return map[string]any{
+               "type":    "command",
+               "command": command,
+               "name":    gatewayEntryName,
+               "timeout": hookTimeoutMs,
+       }
+}
diff --git a/agentprovider/cline.go b/agentprovider/cline.go
new file mode 100644
index 0000000..683fc86
--- /dev/null
+++ b/agentprovider/cline.go
@@ -0,0 +1,309 @@
+// 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"
+       "os"
+       "path/filepath"
+       "strings"
+       "time"
+
+       "github.com/apache/casbin-gateway/agenthome"
+)
+
+const (
+       // clineProvider is the provider entry Gateway owns in providers.json. 
Every
+       // other provider in the file is left alone.
+       clineProvider = "casbin-gateway"
+       // clineProviderName is what Cline's own picker shows the entry as.
+       clineProviderName = "Casbin Gateway"
+       // clineClient and clineProtocol pick the OpenAI-compatible path through
+       // Cline's runtime, which is what an endpoint it does not ship needs.
+       clineClient   = "openai-compatible"
+       clineProtocol = "openai-chat"
+       // clineSelectedKey is the root key naming the provider Cline starts 
on, and
+       // how its previous value is remembered.
+       clineSelectedKey = "lastUsedProvider"
+       // clineLocalKey stands in for a key the endpoint does not have. The
+       // OpenAI-compatible client sends one either way, and the gateway takes 
any
+       // credential from this host.
+       clineLocalKey = "casbin-gateway"
+       // clineFileVersion is the schema version both settings files carry. 
Cline
+       // refuses a file whose version it does not know.
+       clineFileVersion = 1
+)
+
+// errClineNoModel rejects a provider entry Cline could not use: an endpoint it
+// does not know has no catalog of its own, and it needs a name to start on.
+var errClineNoModel = errors.New("Cline needs a model name, so bind a provider 
that lists at least one model")
+
+type clineWriter struct{}
+
+func init() {
+       register(clineWriter{})
+}
+
+func (clineWriter) AgentId() string { return "cline" }
+
+func (clineWriter) Protocol() string { return "openai" }
+
+func (w clineWriter) Plan(target Target, endpoint Endpoint) ([]File, error) {
+       if endpoint.Model == "" {
+               return nil, errClineNoModel
+       }
+       providersPath, modelsPath, err := w.paths(target)
+       if err != nil {
+               return nil, err
+       }
+
+       preview := endpoint
+       preview.ApiKey = maskSecret(emptyAs(endpoint.ApiKey, clineLocalKey))
+       providers, err := encodeJSON(map[string]any{
+               clineSelectedKey: clineProvider,
+               "providers":      map[string]any{clineProvider: 
w.entry(preview)},
+       })
+       if err != nil {
+               return nil, err
+       }
+       models, err := encodeJSON(map[string]any{
+               "providers": map[string]any{clineProvider: w.catalog(endpoint)},
+       })
+       if err != nil {
+               return nil, err
+       }
+       return []File{
+               {Path: providersPath, Format: "json", Preview: 
string(providers)},
+               {Path: modelsPath, Format: "json", Preview: string(models)},
+       }, nil
+}
+
+func (w clineWriter) Apply(target Target, endpoint Endpoint) 
(map[string]string, error) {
+       if endpoint.Model == "" {
+               return nil, errClineNoModel
+       }
+       providersPath, modelsPath, err := w.paths(target)
+       if err != nil {
+               return nil, err
+       }
+       settings, _, err := readJSON(providersPath)
+       if err != nil {
+               return nil, err
+       }
+       models, _, err := readJSON(modelsPath)
+       if err != nil {
+               return nil, err
+       }
+
+       previous := map[string]string{}
+       if value := stringAt(settings, clineSelectedKey); value != "" {
+               previous[clineSelectedKey] = value
+       }
+
+       ensureNested(settings, "providers")[clineProvider] = w.entry(endpoint)
+       settings[clineSelectedKey] = clineProvider
+       ensureNested(models, "providers")[clineProvider] = w.catalog(endpoint)
+
+       changes := &txn{}
+       if err := w.stage(changes, providersPath, settings); err != nil {
+               changes.abort()
+               return nil, err
+       }
+       if err := w.stage(changes, modelsPath, models); err != nil {
+               changes.abort()
+               return nil, err
+       }
+       if err := changes.commit(); err != nil {
+               return nil, err
+       }
+       return previous, nil
+}
+
+func (w clineWriter) Restore(target Target, previous map[string]string) error {
+       providersPath, modelsPath, err := w.paths(target)
+       if err != nil {
+               return err
+       }
+       settings, _, err := readJSON(providersPath)
+       if err != nil {
+               return err
+       }
+       models, _, err := readJSON(modelsPath)
+       if err != nil {
+               return err
+       }
+
+       w.dropProvider(settings)
+       w.dropProvider(models)
+       if value, ok := previous[clineSelectedKey]; ok {
+               settings[clineSelectedKey] = value
+       } else {
+               delete(settings, clineSelectedKey)
+       }
+
+       changes := &txn{}
+       if err := w.stage(changes, providersPath, settings); err != nil {
+               changes.abort()
+               return err
+       }
+       if err := w.stage(changes, modelsPath, models); err != nil {
+               changes.abort()
+               return err
+       }
+       return changes.commit()
+}
+
+// Current is the endpoint behind the provider Cline starts on. A provider
+// without a base URL is one Cline knows itself, which is still worth naming: 
it
+// is not the endpoint Gateway wrote.
+func (w clineWriter) Current(target Target) (string, error) {
+       settings, err := w.settingsOf(target)
+       if err != nil {
+               return "", err
+       }
+       selected := stringAt(settings, clineSelectedKey)
+       if selected == "" {
+               return "", nil
+       }
+       if baseUrl := stringAt(w.settingsFor(settings, selected), "baseUrl"); 
baseUrl != "" {
+               return baseUrl, nil
+       }
+       return selected, nil
+}
+
+// Builtin is the model Cline starts on without Gateway. Cline ships no 
provider
+// of its own, so a file that selects none has no name to show.
+func (w clineWriter) Builtin(target Target, previous map[string]string) string 
{
+       settings, err := w.settingsOf(target)
+       if err != nil {
+               return ""
+       }
+
+       selected := stringAt(settings, clineSelectedKey)
+       if previous != nil {
+               selected = previous[clineSelectedKey]
+       } else if selected == clineProvider {
+               return ""
+       }
+       if selected == "" {
+               return ""
+       }
+       return stringAt(w.settingsFor(settings, selected), "model")
+}
+
+// entry is one provider as providers.json stores it: the settings Cline hands
+// its runtime, wrapped in the bookkeeping the file keeps beside them.
+func (clineWriter) entry(endpoint Endpoint) map[string]any {
+       return map[string]any{
+               "settings": map[string]any{
+                       "provider": clineProvider,
+                       "apiKey":   emptyAs(endpoint.ApiKey, clineLocalKey),
+                       "baseUrl":  endpoint.BaseUrl,
+                       "model":    endpoint.Model,
+                       "client":   clineClient,
+                       "protocol": clineProtocol,
+               },
+               "updatedAt":   time.Now().UTC().Format(time.RFC3339),
+               "tokenSource": "manual",
+       }
+}
+
+// catalog is the models.json entry. Cline ships no catalog for an endpoint it
+// does not know, so the entry carries the whole of it and its own picker can
+// switch between the models without Gateway writing the file again.
+func (clineWriter) catalog(endpoint Endpoint) map[string]any {
+       models := map[string]any{}
+       for _, model := range endpoint.catalog() {
+               models[model] = map[string]any{"id": model, "name": model}
+       }
+       return map[string]any{
+               "provider": map[string]any{
+                       "name":           clineProviderName,
+                       "baseUrl":        endpoint.BaseUrl,
+                       "defaultModelId": endpoint.Model,
+                       "client":         clineClient,
+                       "protocol":       clineProtocol,
+               },
+               "models": models,
+       }
+}
+
+func (clineWriter) dropProvider(config map[string]any) {
+       if providers := objectAt(config, "providers"); providers != nil {
+               delete(providers, clineProvider)
+       }
+}
+
+// stage queues one file, removing it instead once Gateway's entry was the only
+// thing in it: an empty settings file is not what Cline had before.
+func (w clineWriter) stage(changes *txn, path string, config map[string]any) 
error {
+       pruneEmpty(config, "providers")
+       pruneEmpty(config, "modes")
+       if w.isEmpty(config) {
+               if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+                       return err
+               }
+               return nil
+       }
+
+       config["version"] = clineFileVersion
+       // providers.json carries a modes block, which Cline reads before it 
reaches
+       // the providers beside it.
+       if strings.HasSuffix(path, "providers.json") && objectAt(config, 
"modes") == nil {
+               config["modes"] = map[string]any{}
+       }
+
+       data, err := encodeJSON(config)
+       if err != nil {
+               return err
+       }
+       return changes.write(path, data)
+}
+
+// isEmpty reports a file holding nothing but the schema version, which is the
+// one key Gateway writes on its own account.
+func (clineWriter) isEmpty(config map[string]any) bool {
+       for key := range config {
+               if key != "version" {
+                       return false
+               }
+       }
+       return true
+}
+
+func (w clineWriter) settingsOf(target Target) (map[string]any, error) {
+       providersPath, _, err := w.paths(target)
+       if err != nil {
+               return nil, err
+       }
+       settings, _, err := readJSON(providersPath)
+       return settings, err
+}
+
+// settingsFor is the settings block of one provider entry.
+func (clineWriter) settingsFor(config map[string]any, name string) 
map[string]any {
+       return nestedObject(config, "providers", name, "settings")
+}
+
+// paths are the two files a switch writes, both under Cline's own data
+// directory: the provider settings and the model catalog beside them.
+func (clineWriter) paths(target Target) (string, string, error) {
+       home, err := agenthome.Resolve(target.Owner)
+       if err != nil {
+               return "", "", err
+       }
+       dir := filepath.Join(home, ".cline", "data", "settings")
+       return filepath.Join(dir, "providers.json"), filepath.Join(dir, 
"models.json"), nil
+}
diff --git a/agentprovider/iflow.go b/agentprovider/iflow.go
new file mode 100644
index 0000000..75eb9e2
--- /dev/null
+++ b/agentprovider/iflow.go
@@ -0,0 +1,189 @@
+// 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 (
+       "path/filepath"
+       "strings"
+
+       "github.com/apache/casbin-gateway/agenthome"
+)
+
+// iflowBuiltin is what iFlow CLI talks to with no endpoint of its own: the
+// iFlow account, whose model the CLI picks by itself.
+const iflowBuiltin = "iFlow"
+
+// iflowOpenaiAuth is the auth method that reads the baseUrl beside it. The CLI
+// stores the method it last signed in with and that stored value wins, so a
+// switch has to write it or an account already signed in to iFlow would keep
+// going there.
+const iflowOpenaiAuth = "openai-compatible"
+
+// iflowLocalKey stands in for a key the endpoint does not have. The CLI 
refuses
+// to start without one, while the gateway takes any credential from this host.
+const iflowLocalKey = "casbin-gateway"
+
+// The settings keys Gateway owns in ~/.iflow/settings.json. iFlow keeps them 
at
+// the root of the file, beside everything else it stores there.
+const (
+       iflowAuthKey  = "selectedAuthType"
+       iflowUrlKey   = "baseUrl"
+       iflowKeyKey   = "apiKey"
+       iflowModelKey = "modelName"
+)
+
+var iflowKeys = []string{iflowAuthKey, iflowUrlKey, iflowKeyKey, iflowModelKey}
+
+type iflowWriter struct{}
+
+func init() {
+       register(iflowWriter{})
+}
+
+func (iflowWriter) AgentId() string { return "iflow-cli" }
+
+func (iflowWriter) Protocol() string { return "openai" }
+
+func (w iflowWriter) Plan(target Target, endpoint Endpoint) ([]File, error) {
+       path, err := w.configPath(target)
+       if err != nil {
+               return nil, err
+       }
+
+       preview := endpoint
+       preview.ApiKey = maskSecret(emptyAs(endpoint.ApiKey, iflowLocalKey))
+       data, err := encodeJSON(w.settings(preview))
+       if err != nil {
+               return nil, err
+       }
+       return []File{{Path: path, Format: "json", Preview: string(data)}}, nil
+}
+
+func (w iflowWriter) Apply(target Target, endpoint Endpoint) 
(map[string]string, error) {
+       path, err := w.configPath(target)
+       if err != nil {
+               return nil, err
+       }
+       settings, _, err := readJSON(path)
+       if err != nil {
+               return nil, err
+       }
+
+       previous := map[string]string{}
+       for _, key := range iflowKeys {
+               if value := stringAt(settings, key); value != "" {
+                       previous[key] = value
+               }
+       }
+       for key, value := range w.settings(endpoint) {
+               settings[key] = value
+       }
+       // The model is what the CLI starts on, and a bound provider that names 
none
+       // leaves it to pick from the endpoint itself.
+       if endpoint.Model == "" {
+               delete(settings, iflowModelKey)
+       }
+
+       return previous, w.save(path, settings)
+}
+
+func (w iflowWriter) Restore(target Target, previous map[string]string) error {
+       path, err := w.configPath(target)
+       if err != nil {
+               return err
+       }
+       settings, _, err := readJSON(path)
+       if err != nil {
+               return err
+       }
+
+       for _, key := range iflowKeys {
+               if value, ok := previous[key]; ok {
+                       settings[key] = value
+               } else {
+                       delete(settings, key)
+               }
+       }
+       return w.save(path, settings)
+}
+
+func (w iflowWriter) Current(target Target) (string, error) {
+       path, err := w.configPath(target)
+       if err != nil {
+               return "", err
+       }
+       settings, _, err := readJSON(path)
+       if err != nil {
+               return "", err
+       }
+       return stringAt(settings, iflowUrlKey), nil
+}
+
+func (w iflowWriter) Builtin(target Target, previous map[string]string) string 
{
+       if previous != nil {
+               return emptyAs(previous[iflowModelKey], iflowBuiltin)
+       }
+
+       path, err := w.configPath(target)
+       if err != nil {
+               return iflowBuiltin
+       }
+       settings, _, err := readJSON(path)
+       if err != nil {
+               return iflowBuiltin
+       }
+       // A model beside an endpoint Gateway wrote is Gateway's, not the one 
the
+       // CLI would pick for itself.
+       if strings.Contains(stringAt(settings, iflowUrlKey), "/v1/agents/") {
+               return iflowBuiltin
+       }
+       return emptyAs(stringAt(settings, iflowModelKey), iflowBuiltin)
+}
+
+// settings is what a switch writes at the root of the file. The auth type is
+// what makes the CLI read the three keys below it rather than sign in.
+func (iflowWriter) settings(endpoint Endpoint) map[string]any {
+       settings := map[string]any{
+               iflowAuthKey: iflowOpenaiAuth,
+               iflowUrlKey:  endpoint.BaseUrl,
+               iflowKeyKey:  emptyAs(endpoint.ApiKey, iflowLocalKey),
+       }
+       if endpoint.Model != "" {
+               settings[iflowModelKey] = endpoint.Model
+       }
+       return settings
+}
+
+func (iflowWriter) save(path string, settings map[string]any) error {
+       data, err := encodeJSON(settings)
+       if err != nil {
+               return err
+       }
+
+       changes := &txn{}
+       if err := changes.write(path, data); err != nil {
+               changes.abort()
+               return err
+       }
+       return changes.commit()
+}
+
+func (iflowWriter) configPath(target Target) (string, error) {
+       home, err := agenthome.Resolve(target.Owner)
+       if err != nil {
+               return "", err
+       }
+       return filepath.Join(home, ".iflow", "settings.json"), nil
+}
diff --git a/agentprovider/qwen.go b/agentprovider/qwen.go
new file mode 100644
index 0000000..78d8871
--- /dev/null
+++ b/agentprovider/qwen.go
@@ -0,0 +1,254 @@
+// 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 (
+       "path/filepath"
+       "strings"
+
+       "github.com/apache/casbin-gateway/agenthome"
+)
+
+// qwenBuiltin is what Qwen Code talks to with no endpoint of its own: the Qwen
+// sign-in, whose model the CLI picks by itself.
+const qwenBuiltin = "Qwen"
+
+// qwenOpenaiAuth is the auth method that reads the OPENAI_* variables. Qwen
+// Code stores the method it last signed in with and that stored value wins, so
+// a switch has to write it or an account already signed in to Qwen would keep
+// going there.
+const qwenOpenaiAuth = "openai"
+
+// qwenAuthKey is where that method is stored, and how the previous value is
+// kept beside the environment variables a switch owns.
+const qwenAuthKey = "security.auth.selectedType"
+
+// qwenLocalKey stands in for a key the endpoint does not have. The CLI refuses
+// to start without one, while the gateway takes any credential from this host.
+const qwenLocalKey = "casbin-gateway"
+
+// The environment Gateway owns in ~/.qwen/.env.
+var qwenKeys = []string{
+       "OPENAI_BASE_URL",
+       "OPENAI_API_KEY",
+       "OPENAI_MODEL",
+}
+
+type qwenWriter struct{}
+
+func init() {
+       register(qwenWriter{})
+}
+
+func (qwenWriter) AgentId() string { return "qwen-code" }
+
+func (qwenWriter) Protocol() string { return "openai" }
+
+func (w qwenWriter) Plan(target Target, endpoint Endpoint) ([]File, error) {
+       envPath, settingsPath, err := w.paths(target)
+       if err != nil {
+               return nil, err
+       }
+
+       preview := &envFile{}
+       masked := w.env(endpoint, true)
+       for _, key := range qwenKeys {
+               if value, ok := masked[key]; ok {
+                       preview.set(key, value)
+               }
+       }
+       auth, err := encodeJSON(map[string]any{
+               "security": map[string]any{"auth": 
map[string]any{"selectedType": qwenOpenaiAuth}},
+       })
+       if err != nil {
+               return nil, err
+       }
+       return []File{
+               {Path: envPath, Format: "env", Preview: 
string(preview.bytes())},
+               {Path: settingsPath, Format: "json", Preview: string(auth)},
+       }, nil
+}
+
+func (w qwenWriter) Apply(target Target, endpoint Endpoint) 
(map[string]string, error) {
+       envPath, settingsPath, err := w.paths(target)
+       if err != nil {
+               return nil, err
+       }
+       env, err := readEnvFile(envPath)
+       if err != nil {
+               return nil, err
+       }
+       settings, _, err := readJSON(settingsPath)
+       if err != nil {
+               return nil, err
+       }
+
+       previous := map[string]string{}
+       for _, key := range qwenKeys {
+               if value, ok := env.get(key); ok {
+                       previous[key] = value
+               }
+               env.remove(key)
+       }
+       for key, value := range w.env(endpoint, false) {
+               env.set(key, value)
+       }
+
+       auth := ensureNested(settings, "security", "auth")
+       if value, ok := auth["selectedType"].(string); ok {
+               previous[qwenAuthKey] = value
+       }
+       auth["selectedType"] = qwenOpenaiAuth
+
+       data, err := encodeJSON(settings)
+       if err != nil {
+               return nil, err
+       }
+
+       changes := &txn{}
+       if err := changes.write(envPath, env.bytes()); err != nil {
+               changes.abort()
+               return nil, err
+       }
+       if err := changes.write(settingsPath, data); err != nil {
+               changes.abort()
+               return nil, err
+       }
+       if err := changes.commit(); err != nil {
+               return nil, err
+       }
+       return previous, nil
+}
+
+func (w qwenWriter) Restore(target Target, previous map[string]string) error {
+       envPath, settingsPath, err := w.paths(target)
+       if err != nil {
+               return err
+       }
+       env, err := readEnvFile(envPath)
+       if err != nil {
+               return err
+       }
+       settings, _, err := readJSON(settingsPath)
+       if err != nil {
+               return err
+       }
+
+       for _, key := range qwenKeys {
+               if value, ok := previous[key]; ok {
+                       env.set(key, value)
+               } else {
+                       env.remove(key)
+               }
+       }
+
+       if auth := objectAt(objectAt(settings, "security"), "auth"); auth != 
nil {
+               if value, ok := previous[qwenAuthKey]; ok {
+                       auth["selectedType"] = value
+               } else {
+                       delete(auth, "selectedType")
+               }
+       }
+
+       data, err := encodeJSON(settings)
+       if err != nil {
+               return err
+       }
+
+       changes := &txn{}
+       if err := changes.write(envPath, env.bytes()); err != nil {
+               changes.abort()
+               return err
+       }
+       if err := changes.write(settingsPath, data); err != nil {
+               changes.abort()
+               return err
+       }
+       return changes.commit()
+}
+
+func (w qwenWriter) Current(target Target) (string, error) {
+       env, err := w.envOf(target)
+       if err != nil {
+               return "", err
+       }
+       baseUrl, _ := env.get("OPENAI_BASE_URL")
+       return baseUrl, nil
+}
+
+func (w qwenWriter) Builtin(target Target, previous map[string]string) string {
+       if previous != nil {
+               return emptyAs(previous["OPENAI_MODEL"], qwenBuiltin)
+       }
+
+       env, err := w.envOf(target)
+       if err != nil {
+               return qwenBuiltin
+       }
+       // A model beside an endpoint Gateway wrote is Gateway's, not the one 
the
+       // CLI would pick for itself.
+       if baseUrl, _ := env.get("OPENAI_BASE_URL"); strings.Contains(baseUrl, 
"/v1/agents/") {
+               return qwenBuiltin
+       }
+       if model, _ := env.get("OPENAI_MODEL"); model != "" {
+               return model
+       }
+
+       _, settingsPath, err := w.paths(target)
+       if err != nil {
+               return qwenBuiltin
+       }
+       settings, _, err := readJSON(settingsPath)
+       if err != nil {
+               return qwenBuiltin
+       }
+       return emptyAs(stringAt(objectAt(settings, "model"), "name"), 
qwenBuiltin)
+}
+
+// env is what a switch writes into ~/.qwen/.env. The base URL selects the
+// endpoint; the key is only there because the CLI insists on one.
+func (qwenWriter) env(endpoint Endpoint, masked bool) map[string]string {
+       key := emptyAs(endpoint.ApiKey, qwenLocalKey)
+       if masked {
+               key = maskSecret(key)
+       }
+
+       env := map[string]string{
+               "OPENAI_BASE_URL": endpoint.BaseUrl,
+               "OPENAI_API_KEY":  key,
+       }
+       if endpoint.Model != "" {
+               env["OPENAI_MODEL"] = endpoint.Model
+       }
+       return env
+}
+
+func (qwenWriter) envOf(target Target) (*envFile, error) {
+       home, err := agenthome.Resolve(target.Owner)
+       if err != nil {
+               return nil, err
+       }
+       return readEnvFile(filepath.Join(home, ".qwen", ".env"))
+}
+
+// paths are the two files a switch writes: the environment the CLI loads, and
+// the settings that say which sign-in it loads it for.
+func (qwenWriter) paths(target Target) (string, string, error) {
+       home, err := agenthome.Resolve(target.Owner)
+       if err != nil {
+               return "", "", err
+       }
+       return filepath.Join(home, ".qwen", ".env"), filepath.Join(home, 
".qwen", "settings.json"), nil
+}
diff --git a/web/src/components/AgentIcon.tsx b/web/src/components/AgentIcon.tsx
index 7c5463a..6fd54d6 100644
--- a/web/src/components/AgentIcon.tsx
+++ b/web/src/components/AgentIcon.tsx
@@ -30,6 +30,11 @@ const agentSites: Record<string, string> = {
   "opencode Desktop": "opencode.ai",
   "DeepSeek Harness": "deepseek.com",
   "Gemini CLI": "gemini.google.com",
+  "Qwen Code": "qwen.ai",
+  "iFlow CLI": "iflow.cn",
+  Cline: "cline.bot",
+  "Roo Code": "roocode.com",
+  "Copilot CLI": "github.com",
 };
 
 const agentKey = (agent: unknown) =>
diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts
index a3d28a7..ae743d3 100644
--- a/web/src/lib/agents.ts
+++ b/web/src/lib/agents.ts
@@ -99,6 +99,12 @@ export function agentSetupNoteKey(agentId: string) {
     return "agent:opencode config hint";
   case "gemini-cli":
     return "agent:Gemini CLI config hint";
+  case "qwen-code":
+    return "agent:Qwen Code config hint";
+  case "iflow-cli":
+    return "agent:iFlow CLI config hint";
+  case "cline":
+    return "agent:Cline config hint";
   default:
     return "";
   }
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index ff6269c..2cf39db 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -68,6 +68,7 @@
     "Claude Desktop sandbox hint": "Claude Desktop runs its Cowork and Code 
sessions in a sandbox of its own, where 127.0.0.1 is that sandbox rather than 
this host. The URL above names this machine on its own network instead, and 
Gateway answers there as well as on loopback.",
     "Clear it with": "Clear it with",
     "Client auth token hint": "This provider forwards the agent's own 
credentials, so no token is set: the agent keeps the sign-in it already has.",
+    "Cline config hint": "Cline reads no base URL from the environment: the 
endpoint is a provider entry in ~/.cline/data/settings/providers.json, started 
on through lastUsedProvider, with its models listed in models.json beside it. 
The IDE extension, the CLI and the SDK all read that one directory.",
     "Codex CLI config hint": "The base URL can also be a model provider in 
~/.codex/config.toml, selected with model_provider. Its wire_api has to be 
\"responses\", the only wire format Codex still speaks.",
     "Configuration preview": "Configuration preview",
     "Configuration restored": "Configuration restored",
@@ -194,6 +195,7 @@
     "Provider enabled": "Provider enabled",
     "Provider saved": "Provider saved",
     "Provider speaks another API": "This agent speaks the {protocol} API, 
which the bound provider does not. A direct binding needs both to speak the 
same one: pick another provider, or route this agent through the gateway, which 
translates.",
+    "Qwen Code config hint": "Qwen Code reads no base URL from a settings 
file: the endpoint is OPENAI_BASE_URL in ~/.qwen/.env, with OPENAI_API_KEY and 
OPENAI_MODEL beside it and \"security.auth.selectedType\": \"openai\" in 
~/.qwen/settings.json. A shell that exports the same variables is read first, 
and wins over the file.",
     "Read a file": "Read a file",
     "Read a notebook": "Read a notebook",
     "Read a running command's output": "Read a running command's output",
@@ -281,6 +283,7 @@
     "Written by Gateway": "Written by Gateway",
     "allowed": "allowed",
     "blocked": "blocked",
+    "iFlow CLI config hint": "iFlow CLI keeps the endpoint in 
~/.iflow/settings.json itself: baseUrl, apiKey and modelName at the root of the 
file, with \"selectedAuthType\": \"openai-compatible\" beside them.",
     "opencode config hint": "opencode reads no base URL from the environment: 
the endpoint is a \"provider\" entry in ~/.config/opencode/opencode.json, 
loaded through @ai-sdk/openai-compatible and selected with \"model\": 
\"<provider>/<model>\".",
     "switches": "switches",
     "{count} from transcripts": "{count} from transcripts",
@@ -807,6 +810,31 @@
     "Version": "Version",
     "{total} in total": "{total} in total"
   },
+  "link": {
+    "A place to install skills from": "A place to install skills from",
+    "Branch": "Branch",
+    "Discard": "Discard",
+    "Done": "Done",
+    "Folder": "Folder",
+    "Import": "Import",
+    "Import finished with failures": "Some of this could not be imported",
+    "Import from a link": "Import from a link",
+    "Import hint": "What a link carries is shown here first. Nothing is 
written until you say so, because a link like this comes from someone else.",
+    "Imported": "Imported",
+    "Instructions hint": "The instructions this link carries, to be read by an 
agent before every session.",
+    "MCP servers from this link": "MCP servers from this link",
+    "MCP servers hint": "A server runs as a program on this machine with the 
arguments below. Read them before adding it.",
+    "Paste a link": "Paste a link",
+    "Paste a link hint": "A vendor’s “add this” link, for a provider, an MCP 
server, a set of instructions or a place to install skills from.",
+    "Read link": "Read link",
+    "Repository": "Repository",
+    "Skill source hint": "Skills are downloaded from this repository when you 
install one from it. Nothing is installed by adding it.",
+    "Some apps are not managed here": "Part of this link is for other apps",
+    "Some apps are not managed here hint": "The link also names {apps}, which 
Gateway does not manage. The rest of it can still be imported.",
+    "This link cannot be read": "This link cannot be read",
+    "This replaces the instructions": "This replaces the instructions the 
agent has",
+    "This replaces the instructions hint": "Each agent reads one such file. 
Importing writes over whatever is in it now."
+  },
   "llm": {
     "All time": "All time",
     "Any outcome": "Any outcome",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index f083e84..3b2d477 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -68,6 +68,7 @@
     "Claude Desktop sandbox hint": "Claude Desktop 的 Cowork 和 Code 
会话跑在它自己的沙箱里,那里的 127.0.0.1 是沙箱本身,不是这台主机。所以上面的地址用的是本机在局域网上的地址,Gateway 
除回环外也会在该地址上监听。",
     "Clear it with": "清除命令",
     "Client auth token hint": "该供应商转发 Agent 自己的凭据,所以不设置 token,Agent 
继续用它已有的登录。",
+    "Cline config hint": "Cline 不从环境变量读取 base URL:端点是 
~/.cline/data/settings/providers.json 中的一个 provider 条目,由 lastUsedProvider 
选中,模型列在旁边的 models.json 里。IDE 插件、CLI 和 SDK 读的都是这一个目录。",
     "Codex CLI config hint": "base URL 也可以写成 ~/.codex/config.toml 里的一个 model 
provider,再用 model_provider 选中它。它的 wire_api 必须是 \"responses\",这是 Codex 
现在唯一还支持的线格式。",
     "Configuration preview": "配置预览",
     "Configuration restored": "配置已还原",
@@ -194,6 +195,7 @@
     "Provider enabled": "已启用供应商",
     "Provider saved": "供应商已保存",
     "Provider speaks another API": "该 Agent 使用 {protocol} 
API,而绑定的供应商不支持。直连模式要求两者使用同一种 API:请改选其他供应商,或将该 Agent 改为经网关转发(网关会自动转换)。",
+    "Qwen Code config hint": "Qwen Code 不从配置文件读取 Base URL:端点是 ~/.qwen/.env 中的 
OPENAI_BASE_URL,旁边是 OPENAI_API_KEY 和 OPENAI_MODEL,同时 ~/.qwen/settings.json 中写入 
\"security.auth.selectedType\": \"openai\"。shell 中导出的同名变量会被优先读取,覆盖文件里的值。",
     "Read a file": "读取文件",
     "Read a notebook": "读取 Notebook",
     "Read a running command's output": "读取运行中命令的输出",
@@ -281,6 +283,7 @@
     "Written by Gateway": "已由 Gateway 写入",
     "allowed": "项已允许",
     "blocked": "项被拦截",
+    "iFlow CLI config hint": "iFlow CLI 的端点就写在 ~/.iflow/settings.json 里:文件根部的 
baseUrl、apiKey 和 modelName,旁边是 \"selectedAuthType\": \"openai-compatible\"。",
     "opencode config hint": "opencode 不从环境变量读取 base URL:端点是 
~/.config/opencode/opencode.json 里的一个 \"provider\" 条目,用 
@ai-sdk/openai-compatible 加载,再用 \"model\": \"<provider>/<model>\" 选中它。",
     "switches": "个开关",
     "{count} from transcripts": "{count} 个来自会话记录",
@@ -879,6 +882,31 @@
     "Version": "版本",
     "{total} in total": "共 {total} 条"
   },
+  "link": {
+    "A place to install skills from": "技能来源",
+    "Branch": "分支",
+    "Discard": "丢弃",
+    "Done": "完成",
+    "Folder": "子目录",
+    "Import": "导入",
+    "Import finished with failures": "部分内容未能导入",
+    "Import from a link": "从链接导入",
+    "Import hint": "这里先展示链接携带的内容。在你确认之前不会写入任何东西——这样的链接来自别人。",
+    "Imported": "已导入",
+    "Instructions hint": "该链接携带的指令,Agent 会在每次会话前读取。",
+    "MCP servers from this link": "链接中的 MCP 服务器",
+    "MCP servers hint": "MCP 服务器会以下面的参数在本机上运行。添加前请先看清楚。",
+    "Paste a link": "粘贴链接",
+    "Paste a link hint": "厂商的“一键添加”链接,可以是服务商、MCP 服务器、指令,或技能来源。",
+    "Read link": "读取链接",
+    "Repository": "仓库",
+    "Skill source hint": "从该来源安装技能时才会下载仓库内容。仅添加来源不会安装任何技能。",
+    "Some apps are not managed here": "链接中有部分内容属于其他应用",
+    "Some apps are not managed here hint": "链接还指定了 {apps},Gateway 
不管理它们。其余部分仍可导入。",
+    "This link cannot be read": "无法读取该链接",
+    "This replaces the instructions": "这会覆盖该 Agent 现有的指令",
+    "This replaces the instructions hint": "每个 Agent 只读取一个指令文件。导入会覆盖其中现有的内容。"
+  },
   "llm": {
     "All time": "全部时间",
     "Any outcome": "全部结果",


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to