Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/server/routers/system_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ async def discovery():
"browser_login": True,
"api_key_auth": True,
"remote_config": True,
"agent_list": True,
"agent_show": True,
"kb_upload": knowledge_enabled,
"kb_list": knowledge_enabled,
"kb_files": knowledge_enabled,
Expand Down
2 changes: 2 additions & 0 deletions backend/test/integration/api/test_system_router_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ async def test_discovery_declares_cli_knowledge_capabilities(test_client):
expected = knowledge_capability_enabled()
assert capabilities["features"]["knowledge"] is expected
cli_capabilities = capabilities["cli"]
assert cli_capabilities["agent_list"] is True
assert cli_capabilities["agent_show"] is True
for capability in ("kb_list", "kb_files", "kb_query", "kb_open", "kb_find"):
assert cli_capabilities.get(capability) is expected, capability
assert "kb_parse" not in cli_capabilities
Expand Down
2 changes: 2 additions & 0 deletions backend/test/unit/routers/test_system_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ def test_discovery_endpoint_is_public(monkeypatch):
assert payload["capabilities"]["features"]["knowledge"] is True
assert payload["capabilities"]["cli"]["browser_login"] is True
assert payload["capabilities"]["cli"]["api_key_auth"] is True
assert payload["capabilities"]["cli"]["agent_list"] is True
assert payload["capabilities"]["cli"]["agent_show"] is True
assert payload["capabilities"]["cli"]["kb_upload"] is True
assert payload["endpoints"]["cli_auth_sessions"] == "/api/auth/cli/sessions"
assert payload["endpoints"]["readiness"] == "/api/system/ready"
Expand Down
1 change: 1 addition & 0 deletions docs/develop-guides/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ beta2 延续 beta1 的存储与数据库迁移边界。从 v0.7.1 或更早版
- Dashboard、用户管理分页、知识库 OCR 配置和数据库 schema 迁移入口完成收敛,减少大数据量和升级场景中的不一致。
- pnpm 升级到 11.24.0、uv 升级到 0.12.6,并刷新 Web、docs、backend 与 CLI 锁文件;生产依赖审计无已知漏洞。
- Vue 图标依赖从已废弃的 `lucide-vue-next` 迁移到官方 `@lucide/vue` 1.34.0,现有图标名称、尺寸和样式保持兼容。
- CLI 新增 `yuxi agent list` 与 `yuxi agent show <slug>`:登录用户可以列出服务端授权可见的主 Agent、描述和默认标识,并查看指定 Agent 的模型、Skills、系统提示词、工具等角色过滤后的配置;命令支持 remote 选择和 JSON 输出,服务端 discovery 同步声明对应能力。

## v0.7.2.beta1 (2026-08-23)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# CLI 查看可用 Agent

状态:implemented
类型:feature
Owner:packages/yuxi-cli/src/yuxi_cli/agent.py

## 问题

CLI 登录用户可以按已知 slug 调用 Agent,却无法先发现当前账号可调用的 Agent,也无法在终端核对指定 Agent 的配置。Agent 可见性与角色配置过滤已经由 `GET /api/agent`、`GET /api/agent/{slug}` 和 `AgentRepository` 拥有,CLI 不能复制权限判断或绕过服务端返回内容。

## 决策

新增 `yuxi agent list` 与 `yuxi agent show <slug>`。CLI 通过现有认证 API 读取当前用户可见的主 Agent:列表展示默认标识、名称、slug 和描述;详情展示基础信息,以及 `config_json.context` 中的模型、Skills、系统提示词、工具和其余配置。两个命令支持与知识库查询命令一致的 `--remote` 和无 ANSI 原始 `--json` 输出,并通过 discovery 中的专用能力声明拒绝不支持该契约的旧服务端。

CLI 不推测运行时资源解析结果。`tools`、`knowledges`、`mcps` 和 `skills` 未配置时展示为使用全部可用资源,显式空列表展示为不启用;`subagents` 未配置或为空列表时均展示为使用全部可见子 Agent。详情接口仍允许按既有授权读取可见子 Agent,列表保持服务端现有的主 Agent 范围。

## 替代方案

- 新增 CLI 专用后端路由:会复制现有 Agent 序列化与授权边界,没有新的服务端语义,拒绝。
- CLI 拉取管理接口或本地配置后自行判断可见性:会绕过 repository 权限 Owner,拒绝。
- 只打印完整 JSON:不满足终端用户快速识别默认 Agent 与关键配置的需求;保留为显式选项。

## 后果

Agent 可见性、详情过滤和不存在或无权访问时的 404 继续由现有后端 Owner 决定。CLI 把 slug 编码为单个 URL 路径段,仅展示服务端授权后的响应,并在所有输出模式下对畸形响应、未登录状态和缺失能力声明显式失败。人类可读输出移除服务端文本中的终端控制字符,避免远端字段改变本地终端状态。

人类可读输出只摘要稳定关键字段,其余字段保留在“其他配置”和 `--json` 中,避免维护平行 schema。后端扩展 Agent 查询契约时,需要同步 discovery 能力、CLI 展示与负向测试。

## 验证

- `cd packages/yuxi-cli && UV_PYTHON=3.13 uv run --group test pytest -q`:111 passed。
- `uvx ruff format/check`(Agent 新实现与测试):通过。
- `cd backend && UV_PYTHON=3.13 uv run --group test pytest test/unit -m "not slow"`:1590 passed。
- API 容器 unit fallback `uv run --no-sync --group test pytest test/unit -m "not slow" -q`:1569 passed,40 skipped;常规同步命令因容器内 editable 文件权限失败。
- 真实 integration 权限过滤测试:1 passed;隐藏 Agent 的真实 HTTP 请求返回 404。
- `python3 scripts/verify_engineering_contracts.py` 与 `python3 -m unittest scripts.test_verify_engineering_contracts`:通过,后者 61 passed。
- `cd docs && pnpm run build`:通过;保留既有 VitePress/Rolldown 警告。
- 独立 Reviewer 已复核功能修复与最新 `main` 的最终 diff,无新增代码问题。
16 changes: 16 additions & 0 deletions docs/intro/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,22 @@ yuxi chat --remote production --no-open

关闭终端中的进程后,本地页面也会停止。当前页面支持纯文本对话、新建会话、`/state` 查看线程状态和 `/approve` 继续工具审批;附件和 `ask_user_question` 仍需使用正式 Web 界面。

## 查看可用 Agent

列出当前账号有权调用的主 Agent:

```bash
yuxi agent list
```

列表中的 `*` 表示默认 Agent。使用 slug 查看服务端已授权返回的详细配置,包括绑定的模型、Skills、系统提示词、工具、MCP、知识库和子 Agent:

```bash
yuxi agent show default-chatbot
```

未显式绑定的资源显示为“默认(全部可用)”。`tools`、`knowledges`、`mcps` 和 `skills` 的显式空列表显示为“无”;`subagents` 的空列表仍按服务端契约显示为“默认(全部可用)”。这两条命令都支持 `--remote <name>` 切换实例,以及 `--json` 输出完整服务端响应。

## 上传知识库文件

上传需要当前账号可以管理知识库。省略 `--kb-id` 时,CLI 会列出当前实例中支持文档上传的知识库供选择:
Expand Down
1 change: 1 addition & 0 deletions packages/yuxi-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ First-stage scope:
- `whoami`, `status`, and `logout`
- server discovery and compatibility check for Yuxi `>=0.7.1`
- `yuxi chat` for a temporary local browser chat with streamed Agent output; `/state` reads thread state and `/approve` resumes a pending tool approval
- `yuxi agent list` and `yuxi agent show <slug>` for inspecting agents visible to the logged-in user
- `yuxi kb upload` for knowledge base file uploads
- `yuxi agent eval` for running existing Langfuse dataset experiments with a logged-in remote
202 changes: 202 additions & 0 deletions packages/yuxi-cli/src/yuxi_cli/agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
from __future__ import annotations

import json
from typing import Any

from rich.console import Console
from rich.table import Table
from rich.text import Text

from yuxi_cli.client import YuxiClient
from yuxi_cli.config import ConfigStore, Remote
from yuxi_cli.discovery import ServerCompatibilityError, ensure_server_compatible


class AgentError(Exception):
"""Agent 查询命令错误。"""


_TERMINAL_CONTROL_TRANSLATION = {
codepoint: None
for codepoint in (*range(32), 127, *range(128, 160))
if codepoint not in (9, 10)
}


def run_agent_list(
store: ConfigStore,
remote_name: str | None,
console: Console,
*,
as_json: bool = False,
client_factory: type[YuxiClient] = YuxiClient,
) -> dict:
"""列出当前用户可调用的主 Agent。"""
remote = _require_remote(store, remote_name)
with client_factory(remote) as client:
_ensure_capability(client, "cli.agent_list")
data = client.list_agents()
_render_agent_list(data, console, as_json=as_json)
return data


def run_agent_show(
store: ConfigStore,
remote_name: str | None,
agent_slug: str,
console: Console,
*,
as_json: bool = False,
client_factory: type[YuxiClient] = YuxiClient,
) -> dict:
"""展示当前用户可见的指定 Agent 配置。"""
remote = _require_remote(store, remote_name)
with client_factory(remote) as client:
_ensure_capability(client, "cli.agent_show")
data = client.get_agent(agent_slug)
_render_agent_detail(data, console, as_json=as_json)
return data


def _require_remote(store: ConfigStore, remote_name: str | None) -> Remote:
"""返回已登录的 remote。"""
remote = store.load().get_remote(remote_name)
if not remote.api_key:
raise AgentError(f"remote 尚未登录: {remote.name}")
return remote


def _ensure_capability(client: YuxiClient, capability: str) -> None:
"""确认服务端声明了命令所需能力。"""
try:
ensure_server_compatible(client.discovery(), capability)
except ServerCompatibilityError as exc:
raise AgentError(str(exc)) from exc


def _render_agent_list(data: dict, console: Console, *, as_json: bool) -> None:
"""渲染 Agent 列表或原始 JSON。"""
agents = _validate_agent_list(data)
if as_json:
_print_json(data, console)
return

if not agents:
console.print("没有可调用的 Agent")
return

table = Table(show_header=True, header_style="bold")
table.add_column("Default", width=7, justify="center")
table.add_column("Name")
table.add_column("Slug")
table.add_column("Description")
for agent in agents:
table.add_row(
"*" if agent.get("is_default") else "",
_text(agent.get("name")),
_text(agent.get("slug") or agent.get("agent_id")),
_text(agent.get("description")),
)
console.print(table)


def _render_agent_detail(data: dict, console: Console, *, as_json: bool) -> None:
"""渲染 Agent 详情或原始 JSON。"""
agent, config_json, context = _validate_agent_detail(data)
if as_json:
_print_json(data, console)
return

details = Table(show_header=False, box=None, pad_edge=False)
details.add_column(style="bold", no_wrap=True)
details.add_column()
details.add_row("Name", _text(agent.get("name")))
details.add_row("Slug", _text(agent.get("slug") or agent.get("agent_id")))
details.add_row("Description", _text(agent.get("description")))
details.add_row("Default", "yes" if agent.get("is_default") else "no")
details.add_row("Backend", _text(agent.get("backend_id")))
details.add_row("Model", _text(context.get("model"), default="系统默认"))
details.add_row("Skills", _selection(context.get("skills")))
details.add_row("Tools", _selection(context.get("tools")))
details.add_row("MCP servers", _selection(context.get("mcps")))
details.add_row("Knowledge bases", _selection(context.get("knowledges")))
details.add_row(
"Subagents",
_selection(context.get("subagents"), empty_means_default=True),
)
console.print(details)

console.print("\n[bold]System prompt[/bold]")
console.print(_text(context.get("system_prompt")))

known_fields = {
"model",
"skills",
"tools",
"mcps",
"knowledges",
"subagents",
"system_prompt",
}
other_context = {
key: value for key, value in context.items() if key not in known_fields
}
other_config = {
key: value for key, value in config_json.items() if key != "context"
}
if other_context or other_config:
console.print("\n[bold]Other configuration[/bold]")
_print_json({"context": other_context, **other_config}, console)


def _selection(value: Any, *, empty_means_default: bool = False) -> Text:
"""区分默认资源范围、显式空列表与具体选择。"""
if value is None or (empty_means_default and value == []):
return Text("默认(全部可用)")
if isinstance(value, list):
value = ", ".join(str(item) for item in value) if value else "无"
return Text(_safe_terminal_text(str(value)))


def _text(value: Any, *, default: str = "-") -> Text:
"""把服务端字段转为不含终端控制字符的纯文本。"""
text = _safe_terminal_text(str(value)).strip() if value is not None else ""
return Text(text or default)


def _safe_terminal_text(value: str) -> str:
"""移除可改变终端状态的 C0、DEL 与 C1 控制字符。"""
return value.translate(_TERMINAL_CONTROL_TRANSLATION)


def _validate_agent_list(data: dict) -> list[dict]:
"""校验 Agent 列表响应的最小结构。"""
agents = data.get("agents")
if not isinstance(agents, list) or any(
not isinstance(agent, dict) for agent in agents
):
raise AgentError("远程 Agent 列表响应格式无效")
return agents


def _validate_agent_detail(data: dict) -> tuple[dict, dict, dict]:
"""校验 Agent 详情响应并返回渲染所需结构。"""
agent = data.get("agent")
if not isinstance(agent, dict):
raise AgentError("远程 Agent 详情响应格式无效")
config_json = agent.get("config_json")
if config_json is None:
config_json = {}
if not isinstance(config_json, dict):
raise AgentError("远程 Agent 配置响应格式无效")
context = config_json.get("context")
if context is None:
context = {}
if not isinstance(context, dict):
raise AgentError("远程 Agent context 响应格式无效")
return agent, config_json, context


def _print_json(data: dict, console: Console) -> None:
"""输出保留中文的 JSON。"""
console.file.write(json.dumps(data, ensure_ascii=False, default=str) + "\n")
10 changes: 9 additions & 1 deletion packages/yuxi-cli/src/yuxi_cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import urlencode
from urllib.parse import quote, urlencode

import httpx

Expand Down Expand Up @@ -112,6 +112,14 @@ def add_uploaded_documents(self, kb_id: str, items: list[str], params: dict) ->
def list_external_databases(self) -> dict:
return self._request("GET", "/knowledge/databases/external")

def list_agents(self) -> dict:
"""读取当前用户可调用的主 Agent。"""
return self._request("GET", "/agent")

def get_agent(self, agent_slug: str) -> dict:
"""按 slug 读取当前用户可见的 Agent 配置。"""
return self._request("GET", f"/agent/{quote(agent_slug, safe='')}")

def list_external_files(
self,
kb_id: str,
Expand Down
32 changes: 32 additions & 0 deletions packages/yuxi-cli/src/yuxi_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from rich.console import Console

from yuxi_cli import __version__
from yuxi_cli.agent import AgentError, run_agent_list, run_agent_show
from yuxi_cli.agent_eval import AgentEvalError, AgentEvalOptions, run_langfuse_agent_experiment
from yuxi_cli.chat_web import ChatWebError, run_web_chat
from yuxi_cli.client import ClientError
Expand Down Expand Up @@ -370,3 +371,34 @@ def eval_agent(
run_langfuse_agent_experiment(store, remote, options, console)
except (ConfigError, ClientError, AgentEvalError) as exc:
_handle_error(exc)


@agent_app.command("list")
def list_agents(
remote: str | None = typer.Option(None, "--remote", help="Remote name."),
as_json: bool = typer.Option(False, "--json", help="Output raw JSON."),
):
"""List agents visible to the current user."""
store = _store()
try:
if not as_json:
_print_remote_context(store, remote)
run_agent_list(store, remote, console, as_json=as_json)
except (ConfigError, ClientError, AgentError) as exc:
_handle_error(exc)


@agent_app.command("show")
def show_agent(
agent_slug: str = typer.Argument(..., help="Yuxi agent slug."),
remote: str | None = typer.Option(None, "--remote", help="Remote name."),
as_json: bool = typer.Option(False, "--json", help="Output raw JSON."),
):
"""Show one visible agent and its configuration."""
store = _store()
try:
if not as_json:
_print_remote_context(store, remote)
run_agent_show(store, remote, agent_slug, console, as_json=as_json)
except (ConfigError, ClientError, AgentError) as exc:
_handle_error(exc)
Loading