Merge remote-tracking branch 'origin/main' into refactor/cmd
This commit is contained in:
commit
7a0a9ac6bb
27 changed files with 303 additions and 155 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Stage 1: Build the picoclaw binary
|
# Stage 1: Build the picoclaw binary
|
||||||
# ============================================================
|
# ============================================================
|
||||||
FROM golang:1.26.0-alpine AS builder
|
FROM golang:1.25-alpine AS builder
|
||||||
|
|
||||||
RUN apk add --no-cache git make
|
RUN apk add --no-cache git make
|
||||||
|
|
||||||
|
|
|
||||||
7
Makefile
7
Makefile
|
|
@ -25,6 +25,7 @@ GOLANGCI_LINT?=golangci-lint
|
||||||
INSTALL_PREFIX?=$(HOME)/.local
|
INSTALL_PREFIX?=$(HOME)/.local
|
||||||
INSTALL_BIN_DIR=$(INSTALL_PREFIX)/bin
|
INSTALL_BIN_DIR=$(INSTALL_PREFIX)/bin
|
||||||
INSTALL_MAN_DIR=$(INSTALL_PREFIX)/share/man/man1
|
INSTALL_MAN_DIR=$(INSTALL_PREFIX)/share/man/man1
|
||||||
|
INSTALL_TMP_SUFFIX=.new
|
||||||
|
|
||||||
# Workspace and Skills
|
# Workspace and Skills
|
||||||
PICOCLAW_HOME?=$(HOME)/.picoclaw
|
PICOCLAW_HOME?=$(HOME)/.picoclaw
|
||||||
|
|
@ -100,8 +101,10 @@ build-all: generate
|
||||||
install: build
|
install: build
|
||||||
@echo "Installing $(BINARY_NAME)..."
|
@echo "Installing $(BINARY_NAME)..."
|
||||||
@mkdir -p $(INSTALL_BIN_DIR)
|
@mkdir -p $(INSTALL_BIN_DIR)
|
||||||
@cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_BIN_DIR)/$(BINARY_NAME)
|
# Copy binary with temporary suffix to ensure atomic update
|
||||||
@chmod +x $(INSTALL_BIN_DIR)/$(BINARY_NAME)
|
@cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX)
|
||||||
|
@chmod +x $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX)
|
||||||
|
@mv -f $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX) $(INSTALL_BIN_DIR)/$(BINARY_NAME)
|
||||||
@echo "Installed binary to $(INSTALL_BIN_DIR)/$(BINARY_NAME)"
|
@echo "Installed binary to $(INSTALL_BIN_DIR)/$(BINARY_NAME)"
|
||||||
@echo "Installation complete!"
|
@echo "Installation complete!"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,10 @@ vim config/config.json # Configurez DISCORD_BOT_TOKEN, clés API, etc.
|
||||||
# 3. Compiler & Démarrer
|
# 3. Compiler & Démarrer
|
||||||
docker compose --profile gateway up -d
|
docker compose --profile gateway up -d
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> **Utilisateurs Docker** : Par défaut, le Gateway écoute sur `127.0.0.1`, ce qui n'est pas accessible depuis l'hôte. Si vous avez besoin d'accéder aux endpoints de santé ou d'exposer des ports, définissez `PICOCLAW_GATEWAY_HOST=0.0.0.0` dans votre environnement ou mettez à jour `config.json`.
|
||||||
|
|
||||||
|
|
||||||
# 4. Voir les logs
|
# 4. Voir les logs
|
||||||
docker compose logs -f picoclaw-gateway
|
docker compose logs -f picoclaw-gateway
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,10 @@ vim config/config.json # DISCORD_BOT_TOKEN, プロバイダーの API キ
|
||||||
# 3. ビルドと起動
|
# 3. ビルドと起動
|
||||||
docker compose --profile gateway up -d
|
docker compose --profile gateway up -d
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> **Docker ユーザー**: デフォルトでは、Gateway は `127.0.0.1` でリッスンしており、ホストからアクセスできません。ヘルスチェックエンドポイントにアクセスしたり、ポートを公開したりする必要がある場合は、環境変数で `PICOCLAW_GATEWAY_HOST=0.0.0.0` を設定するか、`config.json` を更新してください。
|
||||||
|
|
||||||
|
|
||||||
# 4. ログ確認
|
# 4. ログ確認
|
||||||
docker compose logs -f picoclaw-gateway
|
docker compose logs -f picoclaw-gateway
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,10 @@ vim config/config.json # Set DISCORD_BOT_TOKEN, API keys, etc.
|
||||||
# 3. Build & Start
|
# 3. Build & Start
|
||||||
docker compose --profile gateway up -d
|
docker compose --profile gateway up -d
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`.
|
||||||
|
|
||||||
|
|
||||||
# 4. Check logs
|
# 4. Check logs
|
||||||
docker compose logs -f picoclaw-gateway
|
docker compose logs -f picoclaw-gateway
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,10 @@ vim config/config.json # Configure DISCORD_BOT_TOKEN, API keys, etc.
|
||||||
# 3. Build & Iniciar
|
# 3. Build & Iniciar
|
||||||
docker compose --profile gateway up -d
|
docker compose --profile gateway up -d
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> **Usuários Docker**: Por padrão, o Gateway ouve em `127.0.0.1`, o que não é acessível a partir do host. Se você precisar acessar os endpoints de integridade ou expor portas, defina `PICOCLAW_GATEWAY_HOST=0.0.0.0` em seu ambiente ou atualize o `config.json`.
|
||||||
|
|
||||||
|
|
||||||
# 4. Ver logs
|
# 4. Ver logs
|
||||||
docker compose logs -f picoclaw-gateway
|
docker compose logs -f picoclaw-gateway
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,10 @@ vim config/config.json # Thiết lập DISCORD_BOT_TOKEN, API keys, v.v.
|
||||||
# 3. Build & Khởi động
|
# 3. Build & Khởi động
|
||||||
docker compose --profile gateway up -d
|
docker compose --profile gateway up -d
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> **Người dùng Docker**: Theo mặc định, Gateway lắng nghe trên `127.0.0.1`, không thể truy cập từ máy chủ. Nếu bạn cần truy cập các endpoint kiểm tra sức khỏe hoặc mở cổng, hãy đặt `PICOCLAW_GATEWAY_HOST=0.0.0.0` trong môi trường của bạn hoặc cập nhật `config.json`.
|
||||||
|
|
||||||
|
|
||||||
# 4. Xem logs
|
# 4. Xem logs
|
||||||
docker compose logs -f picoclaw-gateway
|
docker compose logs -f picoclaw-gateway
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -173,6 +173,9 @@ vim config/config.json # 设置 DISCORD_BOT_TOKEN, API keys 等
|
||||||
# 3. 构建并启动
|
# 3. 构建并启动
|
||||||
docker compose --profile gateway up -d
|
docker compose --profile gateway up -d
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
**Docker 用户**: 默认情况下, Gateway监听 `127.0.0.1`,这使得这个端口未暴露到容器外。如果你需要通过端口映射访问健康检查接口, 请在环境变量中设置 `PICOCLAW_GATEWAY_HOST=0.0.0.0` 或修改 `config.json`。
|
||||||
|
|
||||||
# 4. 查看日志
|
# 4. 查看日志
|
||||||
docker compose logs -f picoclaw-gateway
|
docker compose logs -f picoclaw-gateway
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ func NewSkillsCommand() *cobra.Command {
|
||||||
workspace := cfg.WorkspacePath()
|
workspace := cfg.WorkspacePath()
|
||||||
installer := skills.NewSkillInstaller(workspace)
|
installer := skills.NewSkillInstaller(workspace)
|
||||||
|
|
||||||
|
// get global config directory and builtin skills directory
|
||||||
globalDir := filepath.Dir(internal2.GetConfigPath())
|
globalDir := filepath.Dir(internal2.GetConfigPath())
|
||||||
globalSkillsDir := filepath.Join(globalDir, "skills")
|
globalSkillsDir := filepath.Join(globalDir, "skills")
|
||||||
builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills")
|
builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills")
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,10 @@
|
||||||
"volcengine": {
|
"volcengine": {
|
||||||
"api_key": "",
|
"api_key": "",
|
||||||
"api_base": ""
|
"api_base": ""
|
||||||
|
},
|
||||||
|
"mistral": {
|
||||||
|
"api_key": "",
|
||||||
|
"api_base": "https://api.mistral.ai/v1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
|
|
@ -243,7 +247,7 @@
|
||||||
"monitor_usb": true
|
"monitor_usb": true
|
||||||
},
|
},
|
||||||
"gateway": {
|
"gateway": {
|
||||||
"host": "0.0.0.0",
|
"host": "127.0.0.1",
|
||||||
"port": 18790
|
"port": 18790
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,31 +47,31 @@ func (c *QQChannel) Start(ctx context.Context) error {
|
||||||
|
|
||||||
logger.InfoC("qq", "Starting QQ bot (WebSocket mode)")
|
logger.InfoC("qq", "Starting QQ bot (WebSocket mode)")
|
||||||
|
|
||||||
// 创建 token source
|
// create token source
|
||||||
credentials := &token.QQBotCredentials{
|
credentials := &token.QQBotCredentials{
|
||||||
AppID: c.config.AppID,
|
AppID: c.config.AppID,
|
||||||
AppSecret: c.config.AppSecret,
|
AppSecret: c.config.AppSecret,
|
||||||
}
|
}
|
||||||
c.tokenSource = token.NewQQBotTokenSource(credentials)
|
c.tokenSource = token.NewQQBotTokenSource(credentials)
|
||||||
|
|
||||||
// 创建子 context
|
// create child context
|
||||||
c.ctx, c.cancel = context.WithCancel(ctx)
|
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||||
|
|
||||||
// 启动自动刷新 token 协程
|
// start auto-refresh token goroutine
|
||||||
if err := token.StartRefreshAccessToken(c.ctx, c.tokenSource); err != nil {
|
if err := token.StartRefreshAccessToken(c.ctx, c.tokenSource); err != nil {
|
||||||
return fmt.Errorf("failed to start token refresh: %w", err)
|
return fmt.Errorf("failed to start token refresh: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化 OpenAPI 客户端
|
// initialize OpenAPI client
|
||||||
c.api = botgo.NewOpenAPI(c.config.AppID, c.tokenSource).WithTimeout(5 * time.Second)
|
c.api = botgo.NewOpenAPI(c.config.AppID, c.tokenSource).WithTimeout(5 * time.Second)
|
||||||
|
|
||||||
// 注册事件处理器
|
// register event handlers
|
||||||
intent := event.RegisterHandlers(
|
intent := event.RegisterHandlers(
|
||||||
c.handleC2CMessage(),
|
c.handleC2CMessage(),
|
||||||
c.handleGroupATMessage(),
|
c.handleGroupATMessage(),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 获取 WebSocket 接入点
|
// get WebSocket endpoint
|
||||||
wsInfo, err := c.api.WS(c.ctx, nil, "")
|
wsInfo, err := c.api.WS(c.ctx, nil, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get websocket info: %w", err)
|
return fmt.Errorf("failed to get websocket info: %w", err)
|
||||||
|
|
@ -81,10 +81,10 @@ func (c *QQChannel) Start(ctx context.Context) error {
|
||||||
"shards": wsInfo.Shards,
|
"shards": wsInfo.Shards,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 创建并保存 sessionManager
|
// create and save sessionManager
|
||||||
c.sessionManager = botgo.NewSessionManager()
|
c.sessionManager = botgo.NewSessionManager()
|
||||||
|
|
||||||
// 在 goroutine 中启动 WebSocket 连接,避免阻塞
|
// start WebSocket connection in goroutine to avoid blocking
|
||||||
go func() {
|
go func() {
|
||||||
if err := c.sessionManager.Start(wsInfo, c.tokenSource, &intent); err != nil {
|
if err := c.sessionManager.Start(wsInfo, c.tokenSource, &intent); err != nil {
|
||||||
logger.ErrorCF("qq", "WebSocket session error", map[string]any{
|
logger.ErrorCF("qq", "WebSocket session error", map[string]any{
|
||||||
|
|
@ -116,12 +116,12 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
return fmt.Errorf("QQ bot not running")
|
return fmt.Errorf("QQ bot not running")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构造消息
|
// construct message
|
||||||
msgToCreate := &dto.MessageToCreate{
|
msgToCreate := &dto.MessageToCreate{
|
||||||
Content: msg.Content,
|
Content: msg.Content,
|
||||||
}
|
}
|
||||||
|
|
||||||
// C2C 消息发送
|
// send C2C message
|
||||||
_, err := c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate)
|
_, err := c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("qq", "Failed to send C2C message", map[string]any{
|
logger.ErrorCF("qq", "Failed to send C2C message", map[string]any{
|
||||||
|
|
@ -133,15 +133,15 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleC2CMessage 处理 QQ 私聊消息
|
// handleC2CMessage handles QQ private messages
|
||||||
func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
|
func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
|
||||||
return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error {
|
return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error {
|
||||||
// 去重检查
|
// deduplication check
|
||||||
if c.isDuplicate(data.ID) {
|
if c.isDuplicate(data.ID) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提取用户信息
|
// extract user info
|
||||||
var senderID string
|
var senderID string
|
||||||
if data.Author != nil && data.Author.ID != "" {
|
if data.Author != nil && data.Author.ID != "" {
|
||||||
senderID = data.Author.ID
|
senderID = data.Author.ID
|
||||||
|
|
@ -150,7 +150,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提取消息内容
|
// extract message content
|
||||||
content := data.Content
|
content := data.Content
|
||||||
if content == "" {
|
if content == "" {
|
||||||
logger.DebugC("qq", "Received empty message, ignoring")
|
logger.DebugC("qq", "Received empty message, ignoring")
|
||||||
|
|
@ -162,7 +162,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
|
||||||
"length": len(content),
|
"length": len(content),
|
||||||
})
|
})
|
||||||
|
|
||||||
// 转发到消息总线
|
// forward to message bus
|
||||||
metadata := map[string]string{
|
metadata := map[string]string{
|
||||||
"message_id": data.ID,
|
"message_id": data.ID,
|
||||||
"peer_kind": "direct",
|
"peer_kind": "direct",
|
||||||
|
|
@ -175,15 +175,15 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleGroupATMessage 处理群@消息
|
// handleGroupATMessage handles group @messages
|
||||||
func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
||||||
return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error {
|
return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error {
|
||||||
// 去重检查
|
// deduplication check
|
||||||
if c.isDuplicate(data.ID) {
|
if c.isDuplicate(data.ID) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提取用户信息
|
// extract user info
|
||||||
var senderID string
|
var senderID string
|
||||||
if data.Author != nil && data.Author.ID != "" {
|
if data.Author != nil && data.Author.ID != "" {
|
||||||
senderID = data.Author.ID
|
senderID = data.Author.ID
|
||||||
|
|
@ -192,7 +192,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提取消息内容(去掉 @ 机器人部分)
|
// extract message content (remove @bot part)
|
||||||
content := data.Content
|
content := data.Content
|
||||||
if content == "" {
|
if content == "" {
|
||||||
logger.DebugC("qq", "Received empty group message, ignoring")
|
logger.DebugC("qq", "Received empty group message, ignoring")
|
||||||
|
|
@ -205,7 +205,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
||||||
"length": len(content),
|
"length": len(content),
|
||||||
})
|
})
|
||||||
|
|
||||||
// 转发到消息总线(使用 GroupID 作为 ChatID)
|
// forward to message bus (use GroupID as ChatID)
|
||||||
metadata := map[string]string{
|
metadata := map[string]string{
|
||||||
"message_id": data.ID,
|
"message_id": data.ID,
|
||||||
"group_id": data.GroupID,
|
"group_id": data.GroupID,
|
||||||
|
|
@ -219,7 +219,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// isDuplicate 检查消息是否重复
|
// isDuplicate checks if message is duplicate
|
||||||
func (c *QQChannel) isDuplicate(messageID string) bool {
|
func (c *QQChannel) isDuplicate(messageID string) bool {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
@ -230,9 +230,9 @@ func (c *QQChannel) isDuplicate(messageID string) bool {
|
||||||
|
|
||||||
c.processedIDs[messageID] = true
|
c.processedIDs[messageID] = true
|
||||||
|
|
||||||
// 简单清理:限制 map 大小
|
// simple cleanup: limit map size
|
||||||
if len(c.processedIDs) > 10000 {
|
if len(c.processedIDs) > 10000 {
|
||||||
// 清空一半
|
// clear half
|
||||||
count := 0
|
count := 0
|
||||||
for id := range c.processedIDs {
|
for id := range c.processedIDs {
|
||||||
if count >= 5000 {
|
if count >= 5000 {
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查白名单,避免为被拒绝的用户下载附件
|
// check allowlist to avoid downloading attachments for rejected users
|
||||||
if !c.IsAllowed(ev.User) {
|
if !c.IsAllowed(ev.User) {
|
||||||
logger.DebugCF("slack", "Message rejected by allowlist", map[string]any{
|
logger.DebugCF("slack", "Message rejected by allowlist", map[string]any{
|
||||||
"user_id": ev.User,
|
"user_id": ev.User,
|
||||||
|
|
@ -232,9 +232,9 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
||||||
content = c.stripBotMention(content)
|
content = c.stripBotMention(content)
|
||||||
|
|
||||||
var mediaPaths []string
|
var mediaPaths []string
|
||||||
localFiles := []string{} // 跟踪需要清理的本地文件
|
localFiles := []string{} // track local files that need cleanup
|
||||||
|
|
||||||
// 确保临时文件在函数返回时被清理
|
// ensure temp files are cleaned up when function returns
|
||||||
defer func() {
|
defer func() {
|
||||||
for _, file := range localFiles {
|
for _, file := range localFiles {
|
||||||
if err := os.Remove(file); err != nil {
|
if err := os.Remove(file); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -208,7 +208,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
||||||
senderID = fmt.Sprintf("%d|%s", user.ID, user.Username)
|
senderID = fmt.Sprintf("%d|%s", user.ID, user.Username)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查白名单,避免为被拒绝的用户下载附件
|
// check allowlist to avoid downloading attachments for rejected users
|
||||||
if !c.IsAllowed(senderID) {
|
if !c.IsAllowed(senderID) {
|
||||||
logger.DebugCF("telegram", "Message rejected by allowlist", map[string]any{
|
logger.DebugCF("telegram", "Message rejected by allowlist", map[string]any{
|
||||||
"user_id": senderID,
|
"user_id": senderID,
|
||||||
|
|
@ -221,9 +221,9 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
||||||
|
|
||||||
content := ""
|
content := ""
|
||||||
mediaPaths := []string{}
|
mediaPaths := []string{}
|
||||||
localFiles := []string{} // 跟踪需要清理的本地文件
|
localFiles := []string{} // track local files that need cleanup
|
||||||
|
|
||||||
// 确保临时文件在函数返回时被清理
|
// ensure temp files are cleaned up when function returns
|
||||||
defer func() {
|
defer func() {
|
||||||
for _, file := range localFiles {
|
for _, file := range localFiles {
|
||||||
if err := os.Remove(file); err != nil {
|
if err := os.Remove(file); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -324,6 +324,7 @@ type ProvidersConfig struct {
|
||||||
GitHubCopilot ProviderConfig `json:"github_copilot"`
|
GitHubCopilot ProviderConfig `json:"github_copilot"`
|
||||||
Antigravity ProviderConfig `json:"antigravity"`
|
Antigravity ProviderConfig `json:"antigravity"`
|
||||||
Qwen ProviderConfig `json:"qwen"`
|
Qwen ProviderConfig `json:"qwen"`
|
||||||
|
Mistral ProviderConfig `json:"mistral"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
|
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
|
||||||
|
|
@ -345,7 +346,8 @@ func (p ProvidersConfig) IsEmpty() bool {
|
||||||
p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" &&
|
p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" &&
|
||||||
p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
|
p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
|
||||||
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
|
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
|
||||||
p.Qwen.APIKey == "" && p.Qwen.APIBase == ""
|
p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
|
||||||
|
p.Mistral.APIKey == "" && p.Mistral.APIBase == ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
|
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
|
||||||
|
|
@ -644,7 +646,8 @@ func (c *Config) HasProvidersConfig() bool {
|
||||||
v.VolcEngine.APIKey != "" || v.VolcEngine.APIBase != "" ||
|
v.VolcEngine.APIKey != "" || v.VolcEngine.APIBase != "" ||
|
||||||
v.GitHubCopilot.APIKey != "" || v.GitHubCopilot.APIBase != "" ||
|
v.GitHubCopilot.APIKey != "" || v.GitHubCopilot.APIBase != "" ||
|
||||||
v.Antigravity.APIKey != "" || v.Antigravity.APIBase != "" ||
|
v.Antigravity.APIKey != "" || v.Antigravity.APIBase != "" ||
|
||||||
v.Qwen.APIKey != "" || v.Qwen.APIBase != ""
|
v.Qwen.APIKey != "" || v.Qwen.APIBase != "" ||
|
||||||
|
v.Mistral.APIKey != "" || v.Mistral.APIBase != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateModelList validates all ModelConfig entries in the model_list.
|
// ValidateModelList validates all ModelConfig entries in the model_list.
|
||||||
|
|
|
||||||
|
|
@ -246,7 +246,7 @@ func TestDefaultConfig_Temperature(t *testing.T) {
|
||||||
func TestDefaultConfig_Gateway(t *testing.T) {
|
func TestDefaultConfig_Gateway(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
if cfg.Gateway.Host != "0.0.0.0" {
|
if cfg.Gateway.Host != "127.0.0.1" {
|
||||||
t.Error("Gateway host should have default value")
|
t.Error("Gateway host should have default value")
|
||||||
}
|
}
|
||||||
if cfg.Gateway.Port == 0 {
|
if cfg.Gateway.Port == 0 {
|
||||||
|
|
@ -343,7 +343,7 @@ func TestConfig_Complete(t *testing.T) {
|
||||||
if cfg.Agents.Defaults.MaxToolIterations == 0 {
|
if cfg.Agents.Defaults.MaxToolIterations == 0 {
|
||||||
t.Error("MaxToolIterations should not be zero")
|
t.Error("MaxToolIterations should not be zero")
|
||||||
}
|
}
|
||||||
if cfg.Gateway.Host != "0.0.0.0" {
|
if cfg.Gateway.Host != "127.0.0.1" {
|
||||||
t.Error("Gateway host should have default value")
|
t.Error("Gateway host should have default value")
|
||||||
}
|
}
|
||||||
if cfg.Gateway.Port == 0 {
|
if cfg.Gateway.Port == 0 {
|
||||||
|
|
|
||||||
|
|
@ -255,6 +255,14 @@ func DefaultConfig() *Config {
|
||||||
APIKey: "ollama",
|
APIKey: "ollama",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Mistral AI - https://console.mistral.ai/api-keys
|
||||||
|
{
|
||||||
|
ModelName: "mistral-small",
|
||||||
|
Model: "mistral/mistral-small-latest",
|
||||||
|
APIBase: "https://api.mistral.ai/v1",
|
||||||
|
APIKey: "",
|
||||||
|
},
|
||||||
|
|
||||||
// VLLM (local) - http://localhost:8000
|
// VLLM (local) - http://localhost:8000
|
||||||
{
|
{
|
||||||
ModelName: "local-model",
|
ModelName: "local-model",
|
||||||
|
|
@ -264,7 +272,7 @@ func DefaultConfig() *Config {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Gateway: GatewayConfig{
|
Gateway: GatewayConfig{
|
||||||
Host: "0.0.0.0",
|
Host: "127.0.0.1",
|
||||||
Port: 18790,
|
Port: 18790,
|
||||||
},
|
},
|
||||||
Tools: ToolsConfig{
|
Tools: ToolsConfig{
|
||||||
|
|
|
||||||
|
|
@ -324,6 +324,22 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||||
}, true
|
}, true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
providerNames: []string{"mistral"},
|
||||||
|
protocol: "mistral",
|
||||||
|
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||||
|
if p.Mistral.APIKey == "" && p.Mistral.APIBase == "" {
|
||||||
|
return ModelConfig{}, false
|
||||||
|
}
|
||||||
|
return ModelConfig{
|
||||||
|
ModelName: "mistral",
|
||||||
|
Model: "mistral/mistral-small-latest",
|
||||||
|
APIKey: p.Mistral.APIKey,
|
||||||
|
APIBase: p.Mistral.APIBase,
|
||||||
|
Proxy: p.Mistral.Proxy,
|
||||||
|
}, true
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process each provider migration
|
// Process each provider migration
|
||||||
|
|
|
||||||
|
|
@ -131,14 +131,15 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
|
||||||
GitHubCopilot: ProviderConfig{ConnectMode: "grpc"},
|
GitHubCopilot: ProviderConfig{ConnectMode: "grpc"},
|
||||||
Antigravity: ProviderConfig{AuthMethod: "oauth"},
|
Antigravity: ProviderConfig{AuthMethod: "oauth"},
|
||||||
Qwen: ProviderConfig{APIKey: "key17"},
|
Qwen: ProviderConfig{APIKey: "key17"},
|
||||||
|
Mistral: ProviderConfig{APIKey: "key18"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
result := ConvertProvidersToModelList(cfg)
|
result := ConvertProvidersToModelList(cfg)
|
||||||
|
|
||||||
// All 17 providers should be converted
|
// All 18 providers should be converted
|
||||||
if len(result) != 17 {
|
if len(result) != 18 {
|
||||||
t.Errorf("len(result) = %d, want 17", len(result))
|
t.Errorf("len(result) = %d, want 18", len(result))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -119,13 +119,15 @@ func logMessage(level LogLevel, component string, message string, fields map[str
|
||||||
if logger.file != nil {
|
if logger.file != nil {
|
||||||
jsonData, err := json.Marshal(entry)
|
jsonData, err := json.Marshal(entry)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
logger.file.WriteString(string(jsonData) + "\n")
|
logger.file.Write(append(jsonData, '\n'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var fieldStr string
|
var fieldStr string
|
||||||
if len(fields) > 0 {
|
if len(fields) > 0 {
|
||||||
fieldStr = " " + formatFields(fields)
|
fieldStr = " " + formatFields(fields)
|
||||||
|
} else {
|
||||||
|
fieldStr = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
logLine := fmt.Sprintf("[%s] [%s]%s %s%s",
|
logLine := fmt.Sprintf("[%s] [%s]%s %s%s",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ var supportedProviders = map[string]bool{
|
||||||
"qwen": true,
|
"qwen": true,
|
||||||
"deepseek": true,
|
"deepseek": true,
|
||||||
"github_copilot": true,
|
"github_copilot": true,
|
||||||
|
"mistral": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
var supportedChannels = map[string]bool{
|
var supportedChannels = map[string]bool{
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,15 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
|
||||||
sel.model = "deepseek-chat"
|
sel.model = "deepseek-chat"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case "mistral":
|
||||||
|
if cfg.Providers.Mistral.APIKey != "" {
|
||||||
|
sel.apiKey = cfg.Providers.Mistral.APIKey
|
||||||
|
sel.apiBase = cfg.Providers.Mistral.APIBase
|
||||||
|
sel.proxy = cfg.Providers.Mistral.Proxy
|
||||||
|
if sel.apiBase == "" {
|
||||||
|
sel.apiBase = "https://api.mistral.ai/v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
case "github_copilot", "copilot":
|
case "github_copilot", "copilot":
|
||||||
sel.providerType = providerTypeGitHubCopilot
|
sel.providerType = providerTypeGitHubCopilot
|
||||||
if cfg.Providers.GitHubCopilot.APIBase != "" {
|
if cfg.Providers.GitHubCopilot.APIBase != "" {
|
||||||
|
|
@ -275,6 +284,13 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
|
||||||
if sel.apiBase == "" {
|
if sel.apiBase == "" {
|
||||||
sel.apiBase = "http://localhost:11434/v1"
|
sel.apiBase = "http://localhost:11434/v1"
|
||||||
}
|
}
|
||||||
|
case (strings.Contains(lowerModel, "mistral") || strings.HasPrefix(model, "mistral/")) && cfg.Providers.Mistral.APIKey != "":
|
||||||
|
sel.apiKey = cfg.Providers.Mistral.APIKey
|
||||||
|
sel.apiBase = cfg.Providers.Mistral.APIBase
|
||||||
|
sel.proxy = cfg.Providers.Mistral.Proxy
|
||||||
|
if sel.apiBase == "" {
|
||||||
|
sel.apiBase = "https://api.mistral.ai/v1"
|
||||||
|
}
|
||||||
case cfg.Providers.VLLM.APIBase != "":
|
case cfg.Providers.VLLM.APIBase != "":
|
||||||
sel.apiKey = cfg.Providers.VLLM.APIKey
|
sel.apiKey = cfg.Providers.VLLM.APIKey
|
||||||
sel.apiBase = cfg.Providers.VLLM.APIBase
|
sel.apiBase = cfg.Providers.VLLM.APIBase
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
|
|
||||||
case "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
case "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
||||||
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
||||||
"volcengine", "vllm", "qwen":
|
"volcengine", "vllm", "qwen", "mistral":
|
||||||
// All other OpenAI-compatible HTTP providers
|
// All other OpenAI-compatible HTTP providers
|
||||||
if cfg.APIKey == "" && cfg.APIBase == "" {
|
if cfg.APIKey == "" && cfg.APIBase == "" {
|
||||||
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
|
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
|
||||||
|
|
@ -186,6 +186,8 @@ func getDefaultAPIBase(protocol string) string {
|
||||||
return "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
return "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||||
case "vllm":
|
case "vllm":
|
||||||
return "http://localhost:8000/v1"
|
return "http://localhost:8000/v1"
|
||||||
|
case "mistral":
|
||||||
|
return "https://api.mistral.ai/v1"
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -240,7 +240,7 @@ func normalizeModel(model, apiBase string) string {
|
||||||
|
|
||||||
prefix := strings.ToLower(model[:idx])
|
prefix := strings.ToLower(model[:idx])
|
||||||
switch prefix {
|
switch prefix {
|
||||||
case "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu":
|
case "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu", "mistral":
|
||||||
return model[idx+1:]
|
return model[idx+1:]
|
||||||
default:
|
default:
|
||||||
return model
|
return model
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ type ToolCall struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
Function *FunctionCall `json:"function,omitempty"`
|
Function *FunctionCall `json:"function,omitempty"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"-"`
|
||||||
Arguments map[string]any `json:"arguments,omitempty"`
|
Arguments map[string]any `json:"-"`
|
||||||
ThoughtSignature string `json:"-"` // Internal use only
|
ThoughtSignature string `json:"-"` // Internal use only
|
||||||
ExtraContent *ExtraContent `json:"extra_content,omitempty"`
|
ExtraContent *ExtraContent `json:"extra_content,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,9 +55,9 @@ func (info SkillInfo) validate() error {
|
||||||
|
|
||||||
type SkillsLoader struct {
|
type SkillsLoader struct {
|
||||||
workspace string
|
workspace string
|
||||||
workspaceSkills string // workspace skills (项目级别)
|
workspaceSkills string // workspace skills (project-level)
|
||||||
globalSkills string // 全局 skills (~/.picoclaw/skills)
|
globalSkills string // global skills (~/.picoclaw/skills)
|
||||||
builtinSkills string // 内置 skills
|
builtinSkills string // builtin skills
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSkillsLoader(workspace string, globalSkills string, builtinSkills string) *SkillsLoader {
|
func NewSkillsLoader(workspace string, globalSkills string, builtinSkills string) *SkillsLoader {
|
||||||
|
|
@ -71,118 +71,56 @@ func NewSkillsLoader(workspace string, globalSkills string, builtinSkills string
|
||||||
|
|
||||||
func (sl *SkillsLoader) ListSkills() []SkillInfo {
|
func (sl *SkillsLoader) ListSkills() []SkillInfo {
|
||||||
skills := make([]SkillInfo, 0)
|
skills := make([]SkillInfo, 0)
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
if sl.workspaceSkills != "" {
|
addSkills := func(dir, source string) {
|
||||||
if dirs, err := os.ReadDir(sl.workspaceSkills); err == nil {
|
if dir == "" {
|
||||||
for _, dir := range dirs {
|
return
|
||||||
if dir.IsDir() {
|
}
|
||||||
skillFile := filepath.Join(sl.workspaceSkills, dir.Name(), "SKILL.md")
|
dirs, err := os.ReadDir(dir)
|
||||||
if _, err := os.Stat(skillFile); err == nil {
|
if err != nil {
|
||||||
info := SkillInfo{
|
return
|
||||||
Name: dir.Name(),
|
}
|
||||||
Path: skillFile,
|
for _, d := range dirs {
|
||||||
Source: "workspace",
|
if !d.IsDir() {
|
||||||
}
|
continue
|
||||||
metadata := sl.getSkillMetadata(skillFile)
|
|
||||||
if metadata != nil {
|
|
||||||
info.Description = metadata.Description
|
|
||||||
info.Name = metadata.Name
|
|
||||||
}
|
|
||||||
if err := info.validate(); err != nil {
|
|
||||||
slog.Warn("invalid skill from workspace", "name", info.Name, "error", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
skills = append(skills, info)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
skillFile := filepath.Join(dir, d.Name(), "SKILL.md")
|
||||||
|
if _, err := os.Stat(skillFile); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info := SkillInfo{
|
||||||
|
Name: d.Name(),
|
||||||
|
Path: skillFile,
|
||||||
|
Source: source,
|
||||||
|
}
|
||||||
|
metadata := sl.getSkillMetadata(skillFile)
|
||||||
|
if metadata != nil {
|
||||||
|
info.Description = metadata.Description
|
||||||
|
info.Name = metadata.Name
|
||||||
|
}
|
||||||
|
if err := info.validate(); err != nil {
|
||||||
|
slog.Warn("invalid skill from "+source, "name", info.Name, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if seen[info.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[info.Name] = true
|
||||||
|
skills = append(skills, info)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全局 skills (~/.picoclaw/skills) - 被 workspace skills 覆盖
|
// Priority: workspace > global > builtin
|
||||||
if sl.globalSkills != "" {
|
addSkills(sl.workspaceSkills, "workspace")
|
||||||
if dirs, err := os.ReadDir(sl.globalSkills); err == nil {
|
addSkills(sl.globalSkills, "global")
|
||||||
for _, dir := range dirs {
|
addSkills(sl.builtinSkills, "builtin")
|
||||||
if dir.IsDir() {
|
|
||||||
skillFile := filepath.Join(sl.globalSkills, dir.Name(), "SKILL.md")
|
|
||||||
if _, err := os.Stat(skillFile); err == nil {
|
|
||||||
// 检查是否已被 workspace skills 覆盖
|
|
||||||
exists := false
|
|
||||||
for _, s := range skills {
|
|
||||||
if s.Name == dir.Name() && s.Source == "workspace" {
|
|
||||||
exists = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
info := SkillInfo{
|
|
||||||
Name: dir.Name(),
|
|
||||||
Path: skillFile,
|
|
||||||
Source: "global",
|
|
||||||
}
|
|
||||||
metadata := sl.getSkillMetadata(skillFile)
|
|
||||||
if metadata != nil {
|
|
||||||
info.Description = metadata.Description
|
|
||||||
info.Name = metadata.Name
|
|
||||||
}
|
|
||||||
if err := info.validate(); err != nil {
|
|
||||||
slog.Warn("invalid skill from global", "name", info.Name, "error", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
skills = append(skills, info)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if sl.builtinSkills != "" {
|
|
||||||
if dirs, err := os.ReadDir(sl.builtinSkills); err == nil {
|
|
||||||
for _, dir := range dirs {
|
|
||||||
if dir.IsDir() {
|
|
||||||
skillFile := filepath.Join(sl.builtinSkills, dir.Name(), "SKILL.md")
|
|
||||||
if _, err := os.Stat(skillFile); err == nil {
|
|
||||||
// 检查是否已被 workspace 或 global skills 覆盖
|
|
||||||
exists := false
|
|
||||||
for _, s := range skills {
|
|
||||||
if s.Name == dir.Name() && (s.Source == "workspace" || s.Source == "global") {
|
|
||||||
exists = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
info := SkillInfo{
|
|
||||||
Name: dir.Name(),
|
|
||||||
Path: skillFile,
|
|
||||||
Source: "builtin",
|
|
||||||
}
|
|
||||||
metadata := sl.getSkillMetadata(skillFile)
|
|
||||||
if metadata != nil {
|
|
||||||
info.Description = metadata.Description
|
|
||||||
info.Name = metadata.Name
|
|
||||||
}
|
|
||||||
if err := info.validate(); err != nil {
|
|
||||||
slog.Warn("invalid skill from builtin", "name", info.Name, "error", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
skills = append(skills, info)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return skills
|
return skills
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sl *SkillsLoader) LoadSkill(name string) (string, bool) {
|
func (sl *SkillsLoader) LoadSkill(name string) (string, bool) {
|
||||||
// 1. 优先从 workspace skills 加载(项目级别)
|
// 1. load from workspace skills first (project-level)
|
||||||
if sl.workspaceSkills != "" {
|
if sl.workspaceSkills != "" {
|
||||||
skillFile := filepath.Join(sl.workspaceSkills, name, "SKILL.md")
|
skillFile := filepath.Join(sl.workspaceSkills, name, "SKILL.md")
|
||||||
if content, err := os.ReadFile(skillFile); err == nil {
|
if content, err := os.ReadFile(skillFile); err == nil {
|
||||||
|
|
@ -190,7 +128,7 @@ func (sl *SkillsLoader) LoadSkill(name string) (string, bool) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 其次从全局 skills 加载 (~/.picoclaw/skills)
|
// 2. then load from global skills (~/.picoclaw/skills)
|
||||||
if sl.globalSkills != "" {
|
if sl.globalSkills != "" {
|
||||||
skillFile := filepath.Join(sl.globalSkills, name, "SKILL.md")
|
skillFile := filepath.Join(sl.globalSkills, name, "SKILL.md")
|
||||||
if content, err := os.ReadFile(skillFile); err == nil {
|
if content, err := os.ReadFile(skillFile); err == nil {
|
||||||
|
|
@ -198,7 +136,7 @@ func (sl *SkillsLoader) LoadSkill(name string) (string, bool) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 最后从内置 skills 加载
|
// 3. finally load from builtin skills
|
||||||
if sl.builtinSkills != "" {
|
if sl.builtinSkills != "" {
|
||||||
skillFile := filepath.Join(sl.builtinSkills, name, "SKILL.md")
|
skillFile := filepath.Join(sl.builtinSkills, name, "SKILL.md")
|
||||||
if content, err := os.ReadFile(skillFile); err == nil {
|
if content, err := os.ReadFile(skillFile); err == nil {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
package skills
|
package skills
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSkillsInfoValidate(t *testing.T) {
|
func TestSkillsInfoValidate(t *testing.T) {
|
||||||
|
|
@ -135,6 +138,134 @@ func TestExtractFrontmatter(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createSkillDir creates a skill directory with a SKILL.md file containing the given frontmatter.
|
||||||
|
func createSkillDir(t *testing.T, base, dirName, name, description string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := filepath.Join(base, dirName)
|
||||||
|
require.NoError(t, os.MkdirAll(dir, 0o755))
|
||||||
|
content := "---\nname: " + name + "\ndescription: " + description + "\n---\n\n# " + name
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListSkillsWorkspaceOverridesGlobal(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
ws := filepath.Join(tmp, "workspace")
|
||||||
|
global := filepath.Join(tmp, "global")
|
||||||
|
|
||||||
|
createSkillDir(t, filepath.Join(ws, "skills"), "my-skill", "my-skill", "workspace version")
|
||||||
|
createSkillDir(t, global, "my-skill", "my-skill", "global version")
|
||||||
|
|
||||||
|
sl := NewSkillsLoader(ws, global, "")
|
||||||
|
skills := sl.ListSkills()
|
||||||
|
|
||||||
|
assert.Len(t, skills, 1)
|
||||||
|
assert.Equal(t, "workspace", skills[0].Source)
|
||||||
|
assert.Equal(t, "workspace version", skills[0].Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListSkillsGlobalOverridesBuiltin(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
ws := filepath.Join(tmp, "workspace")
|
||||||
|
global := filepath.Join(tmp, "global")
|
||||||
|
builtin := filepath.Join(tmp, "builtin")
|
||||||
|
|
||||||
|
createSkillDir(t, global, "my-skill", "my-skill", "global version")
|
||||||
|
createSkillDir(t, builtin, "my-skill", "my-skill", "builtin version")
|
||||||
|
|
||||||
|
sl := NewSkillsLoader(ws, global, builtin)
|
||||||
|
skills := sl.ListSkills()
|
||||||
|
|
||||||
|
assert.Len(t, skills, 1)
|
||||||
|
assert.Equal(t, "global", skills[0].Source)
|
||||||
|
assert.Equal(t, "global version", skills[0].Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListSkillsMetadataNameDedup(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
ws := filepath.Join(tmp, "workspace")
|
||||||
|
global := filepath.Join(tmp, "global")
|
||||||
|
|
||||||
|
// Different directory names but same metadata name
|
||||||
|
createSkillDir(t, filepath.Join(ws, "skills"), "dir-a", "shared-name", "workspace version")
|
||||||
|
createSkillDir(t, global, "dir-b", "shared-name", "global version")
|
||||||
|
|
||||||
|
sl := NewSkillsLoader(ws, global, "")
|
||||||
|
skills := sl.ListSkills()
|
||||||
|
|
||||||
|
assert.Len(t, skills, 1)
|
||||||
|
assert.Equal(t, "shared-name", skills[0].Name)
|
||||||
|
assert.Equal(t, "workspace", skills[0].Source)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListSkillsMultipleDistinctSkills(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
ws := filepath.Join(tmp, "workspace")
|
||||||
|
global := filepath.Join(tmp, "global")
|
||||||
|
builtin := filepath.Join(tmp, "builtin")
|
||||||
|
|
||||||
|
createSkillDir(t, filepath.Join(ws, "skills"), "skill-a", "skill-a", "desc a")
|
||||||
|
createSkillDir(t, global, "skill-b", "skill-b", "desc b")
|
||||||
|
createSkillDir(t, builtin, "skill-c", "skill-c", "desc c")
|
||||||
|
|
||||||
|
sl := NewSkillsLoader(ws, global, builtin)
|
||||||
|
skills := sl.ListSkills()
|
||||||
|
|
||||||
|
assert.Len(t, skills, 3)
|
||||||
|
names := map[string]string{}
|
||||||
|
for _, s := range skills {
|
||||||
|
names[s.Name] = s.Source
|
||||||
|
}
|
||||||
|
assert.Equal(t, "workspace", names["skill-a"])
|
||||||
|
assert.Equal(t, "global", names["skill-b"])
|
||||||
|
assert.Equal(t, "builtin", names["skill-c"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListSkillsInvalidSkillSkipped(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
ws := filepath.Join(tmp, "workspace")
|
||||||
|
global := filepath.Join(tmp, "global")
|
||||||
|
|
||||||
|
// Invalid name (underscore)
|
||||||
|
createSkillDir(t, filepath.Join(ws, "skills"), "bad_skill", "bad_skill", "desc")
|
||||||
|
// Valid skill
|
||||||
|
createSkillDir(t, global, "good-skill", "good-skill", "desc")
|
||||||
|
|
||||||
|
sl := NewSkillsLoader(ws, global, "")
|
||||||
|
skills := sl.ListSkills()
|
||||||
|
|
||||||
|
assert.Len(t, skills, 1)
|
||||||
|
assert.Equal(t, "good-skill", skills[0].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListSkillsEmptyAndNonexistentDirs(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
ws := filepath.Join(tmp, "workspace")
|
||||||
|
emptyDir := filepath.Join(tmp, "empty")
|
||||||
|
require.NoError(t, os.MkdirAll(emptyDir, 0o755))
|
||||||
|
|
||||||
|
sl := NewSkillsLoader(ws, emptyDir, filepath.Join(tmp, "nonexistent"))
|
||||||
|
skills := sl.ListSkills()
|
||||||
|
|
||||||
|
assert.Empty(t, skills)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListSkillsDirWithoutSkillMD(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
ws := filepath.Join(tmp, "workspace")
|
||||||
|
global := filepath.Join(tmp, "global")
|
||||||
|
|
||||||
|
// Directory exists but has no SKILL.md
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(global, "no-skillmd"), 0o755))
|
||||||
|
// Valid skill alongside
|
||||||
|
createSkillDir(t, global, "real-skill", "real-skill", "desc")
|
||||||
|
|
||||||
|
sl := NewSkillsLoader(ws, global, "")
|
||||||
|
skills := sl.ListSkills()
|
||||||
|
|
||||||
|
assert.Len(t, skills, 1)
|
||||||
|
assert.Equal(t, "real-skill", skills[0].Name)
|
||||||
|
}
|
||||||
|
|
||||||
func TestStripFrontmatter(t *testing.T) {
|
func TestStripFrontmatter(t *testing.T) {
|
||||||
sl := &SkillsLoader{}
|
sl := &SkillsLoader{}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf
|
||||||
execConfig := config.Tools.Exec
|
execConfig := config.Tools.Exec
|
||||||
enableDenyPatterns = execConfig.EnableDenyPatterns
|
enableDenyPatterns = execConfig.EnableDenyPatterns
|
||||||
if enableDenyPatterns {
|
if enableDenyPatterns {
|
||||||
|
denyPatterns = append(denyPatterns, defaultDenyPatterns...)
|
||||||
if len(execConfig.CustomDenyPatterns) > 0 {
|
if len(execConfig.CustomDenyPatterns) > 0 {
|
||||||
fmt.Printf("Using custom deny patterns: %v\n", execConfig.CustomDenyPatterns)
|
fmt.Printf("Using custom deny patterns: %v\n", execConfig.CustomDenyPatterns)
|
||||||
for _, pattern := range execConfig.CustomDenyPatterns {
|
for _, pattern := range execConfig.CustomDenyPatterns {
|
||||||
|
|
@ -91,8 +92,6 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf
|
||||||
}
|
}
|
||||||
denyPatterns = append(denyPatterns, re)
|
denyPatterns = append(denyPatterns, re)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
denyPatterns = append(denyPatterns, defaultDenyPatterns...)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If deny patterns are disabled, we won't add any patterns, allowing all commands.
|
// If deny patterns are disabled, we won't add any patterns, allowing all commands.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue