Merge pull request #1483 from trheyi/main

Enhance command structure with MCP and Robot functionalities
This commit is contained in:
Max 2026-03-03 09:57:35 +08:00 committed by GitHub
commit 232af96228
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 7227 additions and 8 deletions

View file

@ -1510,6 +1510,7 @@ jobs:
- name: Run Registry Client Tests
env:
YAO_REGISTRY_URL: http://localhost:8080
YAO_TEST_APPLICATION: ${{ github.workspace }}/../app
run: make unit-test-registry
- name: Codecov Report

View file

@ -1126,6 +1126,7 @@ jobs:
- name: Run Registry Client Tests
env:
YAO_REGISTRY_URL: http://localhost:8080
YAO_TEST_APPLICATION: ${{ github.workspace }}/../app
run: make unit-test-registry
- name: Codecov Report

1
.gitignore vendored
View file

@ -73,3 +73,4 @@ tg-session.json
tg-login
tg-send
registry/data/
registry/manager/DESIGN*.md

View file

@ -178,7 +178,7 @@ unit-test-robot:
.PHONY: unit-test-registry
unit-test-registry:
echo "mode: count" > coverage.out
$(GO) test -v -timeout=2m -covermode=count -coverprofile=profile.out ./registry/... > tmp.out; \
$(GO) test -v -p 1 -timeout=5m -covermode=count -coverprofile=profile.out ./registry/... > tmp.out; \
cat tmp.out; \
if grep -q "^--- FAIL" tmp.out; then \
rm tmp.out; \

50
cmd/agent/add.go Normal file
View file

@ -0,0 +1,50 @@
package agent
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/registry"
agentmgr "github.com/yaoapp/yao/registry/manager/agent"
)
var agentAddForce bool
// AddCmd implements "yao agent add @scope/name"
var AddCmd = &cobra.Command{
Use: "add [package]",
Short: L("Install an assistant package from the registry"),
Long: L("Install an assistant package from the registry. Example: yao agent add @yao/keeper"),
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
Boot()
pkgID := args[0]
version, _ := cmd.Flags().GetString("version")
client := registry.New(config.Conf.Registry,
registry.WithAuth(
os.Getenv("YAO_REGISTRY_USER"),
os.Getenv("YAO_REGISTRY_PASS"),
),
)
mgr := agentmgr.New(client, config.Conf.Root, nil)
if err := mgr.Add(pkgID, agentmgr.AddOptions{
Version: version,
Force: agentAddForce,
}); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
},
}
func init() {
AddCmd.Flags().StringP("version", "v", "latest", L("Package version or dist-tag"))
AddCmd.Flags().BoolVarP(&agentAddForce, "force", "", false, L("Force reinstall"))
AddCmd.PersistentFlags().StringVarP(&appPath, "app", "a", "", L("Application directory"))
AddCmd.PersistentFlags().StringVarP(&envFile, "env", "e", "", L("Environment file"))
}

View file

@ -35,6 +35,15 @@ var langs = map[string]string{
"Error: agent (-n) is required when using direct message input and not in an agent directory": "错误: 使用直接消息输入且不在智能体目录时需要指定 -n 参数",
"Hint: Make sure you're in a Yao application directory or specify --app flag": "提示: 确保在 Yao 应用目录中或使用 --app 参数指定",
"Error: invalid timeout format": "错误: 无效的超时格式",
// Registry commands
"Install an assistant package from the registry": "从注册中心安装助手包",
"Update an installed assistant package": "更新已安装的助手包",
"Push an assistant package to the registry": "推送助手包到注册中心",
"Fork an assistant to a local scope": "Fork 一个助手到本地范围",
"Package version or dist-tag": "包版本或 dist-tag",
"Force reinstall": "强制重新安装",
"Package version (required)": "包版本 (必填)",
"Target version or dist-tag": "目标版本或 dist-tag",
// Extract command
"Extract test results to individual files for review": "提取测试结果到单独的文件供审查",
"Extract test results from output JSONL file to individual Markdown or JSON files": "从输出 JSONL 文件中提取测试结果到单独的 Markdown 或 JSON 文件",

48
cmd/agent/fork.go Normal file
View file

@ -0,0 +1,48 @@
package agent
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/registry"
agentmgr "github.com/yaoapp/yao/registry/manager/agent"
)
// ForkCmd implements "yao agent fork @scope/name [@target-scope]"
var ForkCmd = &cobra.Command{
Use: "fork [package] [target-scope]",
Short: L("Fork an assistant to a local scope"),
Long: L("Fork an assistant for local modification. Example: yao agent fork @yao/keeper"),
Args: cobra.RangeArgs(1, 2),
Run: func(cmd *cobra.Command, args []string) {
Boot()
pkgID := args[0]
var targetScope string
if len(args) > 1 {
targetScope = args[1]
}
client := registry.New(config.Conf.Registry,
registry.WithAuth(
os.Getenv("YAO_REGISTRY_USER"),
os.Getenv("YAO_REGISTRY_PASS"),
),
)
mgr := agentmgr.New(client, config.Conf.Root, nil)
if err := mgr.Fork(pkgID, agentmgr.ForkOptions{
TargetScope: targetScope,
}); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
},
}
func init() {
ForkCmd.PersistentFlags().StringVarP(&appPath, "app", "a", "", L("Application directory"))
ForkCmd.PersistentFlags().StringVarP(&envFile, "env", "e", "", L("Environment file"))
}

46
cmd/agent/push.go Normal file
View file

@ -0,0 +1,46 @@
package agent
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/registry"
agentmgr "github.com/yaoapp/yao/registry/manager/agent"
)
// PushCmd implements "yao agent push scope.name --version x.y.z"
var PushCmd = &cobra.Command{
Use: "push [yao-id]",
Short: L("Push an assistant package to the registry"),
Long: L("Package and push an assistant to the registry. Example: yao agent push max.keeper --version 1.0.0"),
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
Boot()
yaoID := args[0]
version, _ := cmd.Flags().GetString("version")
client := registry.New(config.Conf.Registry,
registry.WithAuth(
os.Getenv("YAO_REGISTRY_USER"),
os.Getenv("YAO_REGISTRY_PASS"),
),
)
mgr := agentmgr.New(client, config.Conf.Root, nil)
if err := mgr.Push(yaoID, agentmgr.PushOptions{
Version: version,
}); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
},
}
func init() {
PushCmd.Flags().StringP("version", "v", "", L("Package version (required)"))
PushCmd.PersistentFlags().StringVarP(&appPath, "app", "a", "", L("Application directory"))
PushCmd.PersistentFlags().StringVarP(&envFile, "env", "e", "", L("Environment file"))
}

46
cmd/agent/update.go Normal file
View file

@ -0,0 +1,46 @@
package agent
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/registry"
agentmgr "github.com/yaoapp/yao/registry/manager/agent"
)
// UpdateCmd implements "yao agent update @scope/name"
var UpdateCmd = &cobra.Command{
Use: "update [package]",
Short: L("Update an installed assistant package"),
Long: L("Update an installed assistant to a newer version. Example: yao agent update @yao/keeper"),
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
Boot()
pkgID := args[0]
version, _ := cmd.Flags().GetString("version")
client := registry.New(config.Conf.Registry,
registry.WithAuth(
os.Getenv("YAO_REGISTRY_USER"),
os.Getenv("YAO_REGISTRY_PASS"),
),
)
mgr := agentmgr.New(client, config.Conf.Root, nil)
if err := mgr.Update(pkgID, agentmgr.UpdateOptions{
Version: version,
}); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
},
}
func init() {
UpdateCmd.Flags().StringP("version", "v", "latest", L("Target version or dist-tag"))
UpdateCmd.PersistentFlags().StringVarP(&appPath, "app", "a", "", L("Application directory"))
UpdateCmd.PersistentFlags().StringVarP(&envFile, "env", "e", "", L("Environment file"))
}

49
cmd/mcp/add.go Normal file
View file

@ -0,0 +1,49 @@
package mcp
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/registry"
mcpmgr "github.com/yaoapp/yao/registry/manager/mcp"
)
var mcpAddForce bool
// AddCmd implements "yao mcp add @scope/name"
var AddCmd = &cobra.Command{
Use: "add [package]",
Short: L("Install an MCP package from the registry"),
Long: L("Install an MCP package from the registry. Example: yao mcp add @yao/rag-tools"),
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
Boot()
pkgID := args[0]
version, _ := cmd.Flags().GetString("version")
client := registry.New(config.Conf.Registry,
registry.WithAuth(
os.Getenv("YAO_REGISTRY_USER"),
os.Getenv("YAO_REGISTRY_PASS"),
),
)
mgr := mcpmgr.New(client, config.Conf.Root, nil)
if err := mgr.Add(pkgID, mcpmgr.AddOptions{
Version: version,
Force: mcpAddForce,
}); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
},
}
func init() {
AddCmd.Flags().StringP("version", "v", "latest", L("Package version or dist-tag"))
AddCmd.Flags().BoolVarP(&mcpAddForce, "force", "", false, L("Force reinstall"))
AddCmd.PersistentFlags().StringVarP(&appPath, "app", "a", "", L("Application directory"))
AddCmd.PersistentFlags().StringVarP(&envFile, "env", "e", "", L("Environment file"))
}

47
cmd/mcp/fork.go Normal file
View file

@ -0,0 +1,47 @@
package mcp
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/registry"
mcpmgr "github.com/yaoapp/yao/registry/manager/mcp"
)
// ForkCmd implements "yao mcp fork @scope/name [@target-scope]"
var ForkCmd = &cobra.Command{
Use: "fork [package] [target-scope]",
Short: L("Fork an MCP to a local scope"),
Long: L("Fork an MCP for local modification. Example: yao mcp fork @yao/rag-tools"),
Args: cobra.RangeArgs(1, 2),
Run: func(cmd *cobra.Command, args []string) {
Boot()
pkgID := args[0]
var targetScope string
if len(args) > 1 {
targetScope = args[1]
}
client := registry.New(config.Conf.Registry,
registry.WithAuth(
os.Getenv("YAO_REGISTRY_USER"),
os.Getenv("YAO_REGISTRY_PASS"),
),
)
mgr := mcpmgr.New(client, config.Conf.Root, nil)
if err := mgr.Fork(pkgID, mcpmgr.ForkOptions{
TargetScope: targetScope,
}); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
},
}
func init() {
ForkCmd.PersistentFlags().StringVarP(&appPath, "app", "a", "", L("Application directory"))
ForkCmd.PersistentFlags().StringVarP(&envFile, "env", "e", "", L("Environment file"))
}

57
cmd/mcp/mcp.go Normal file
View file

@ -0,0 +1,57 @@
package mcp
import (
"os"
"path/filepath"
"github.com/yaoapp/kun/exception"
"github.com/yaoapp/yao/config"
)
var appPath string
var envFile string
var langs = map[string]string{
"Install an MCP package from the registry": "从注册中心安装 MCP 包",
"Update an installed MCP package": "更新已安装的 MCP 包",
"Push an MCP package to the registry": "推送 MCP 包到注册中心",
"Fork an MCP to a local scope": "Fork 一个 MCP 到本地范围",
"Package version or dist-tag": "包版本或 dist-tag",
"Force reinstall": "强制重新安装",
"Package version (required)": "包版本 (必填)",
"Target version or dist-tag": "目标版本或 dist-tag",
"Application directory": "应用目录",
"Environment file": "环境变量文件",
}
// L Language switch
func L(words string) string {
var lang = os.Getenv("YAO_LANG")
if lang == "" {
return words
}
if trans, has := langs[words]; has {
return trans
}
return words
}
// Boot sets the configuration
func Boot() {
root := config.Conf.Root
if appPath != "" {
r, err := filepath.Abs(appPath)
if err != nil {
exception.New("Root error %s", 500, err.Error()).Throw()
}
root = r
}
if envFile != "" {
config.Conf = config.LoadFromWithRoot(envFile, root)
} else {
config.Conf = config.LoadFromWithRoot(filepath.Join(root, ".env"), root)
}
config.ApplyMode()
}

45
cmd/mcp/push.go Normal file
View file

@ -0,0 +1,45 @@
package mcp
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/registry"
mcpmgr "github.com/yaoapp/yao/registry/manager/mcp"
)
// PushCmd implements "yao mcp push scope.name --version x.y.z"
var PushCmd = &cobra.Command{
Use: "push [yao-id]",
Short: L("Push an MCP package to the registry"),
Long: L("Package and push an MCP to the registry. Example: yao mcp push max.rag-tools --version 1.0.0"),
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
Boot()
yaoID := args[0]
version, _ := cmd.Flags().GetString("version")
client := registry.New(config.Conf.Registry,
registry.WithAuth(
os.Getenv("YAO_REGISTRY_USER"),
os.Getenv("YAO_REGISTRY_PASS"),
),
)
mgr := mcpmgr.New(client, config.Conf.Root, nil)
if err := mgr.Push(yaoID, mcpmgr.PushOptions{
Version: version,
}); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
},
}
func init() {
PushCmd.Flags().StringP("version", "v", "", L("Package version (required)"))
PushCmd.PersistentFlags().StringVarP(&appPath, "app", "a", "", L("Application directory"))
PushCmd.PersistentFlags().StringVarP(&envFile, "env", "e", "", L("Environment file"))
}

45
cmd/mcp/update.go Normal file
View file

@ -0,0 +1,45 @@
package mcp
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/registry"
mcpmgr "github.com/yaoapp/yao/registry/manager/mcp"
)
// UpdateCmd implements "yao mcp update @scope/name"
var UpdateCmd = &cobra.Command{
Use: "update [package]",
Short: L("Update an installed MCP package"),
Long: L("Update an installed MCP package. Example: yao mcp update @yao/rag-tools"),
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
Boot()
pkgID := args[0]
version, _ := cmd.Flags().GetString("version")
client := registry.New(config.Conf.Registry,
registry.WithAuth(
os.Getenv("YAO_REGISTRY_USER"),
os.Getenv("YAO_REGISTRY_PASS"),
),
)
mgr := mcpmgr.New(client, config.Conf.Root, nil)
if err := mgr.Update(pkgID, mcpmgr.UpdateOptions{
Version: version,
}); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
},
}
func init() {
UpdateCmd.Flags().StringP("version", "v", "latest", L("Target version or dist-tag"))
UpdateCmd.PersistentFlags().StringVarP(&appPath, "app", "a", "", L("Application directory"))
UpdateCmd.PersistentFlags().StringVarP(&envFile, "env", "e", "", L("Environment file"))
}

54
cmd/robot/add.go Normal file
View file

@ -0,0 +1,54 @@
package robot
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/registry"
robotmgr "github.com/yaoapp/yao/registry/manager/robot"
)
// AddCmd implements "yao robot add @scope/name --team TEAM_ID"
var AddCmd = &cobra.Command{
Use: "add [package]",
Short: L("Install a robot package from the registry"),
Long: L("Install a robot and its dependencies. Example: yao robot add @yao/keeper --team team-123"),
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
Boot()
pkgID := args[0]
version, _ := cmd.Flags().GetString("version")
teamID, _ := cmd.Flags().GetString("team")
client := registry.New(config.Conf.Registry,
registry.WithAuth(
os.Getenv("YAO_REGISTRY_USER"),
os.Getenv("YAO_REGISTRY_PASS"),
),
)
mgr := robotmgr.New(client, config.Conf.Root, nil)
robot, err := mgr.Add(pkgID, robotmgr.AddOptions{
Version: version,
TeamID: teamID,
})
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// The actual member record creation requires database access.
// In P0 we print the robot config for the CLI layer to handle.
fmt.Printf("Robot ready: %s (display_name: %s)\n", pkgID, robot.DisplayName)
fmt.Println("Note: Member record must be created via Mission Control or database.")
},
}
func init() {
AddCmd.Flags().StringP("version", "v", "latest", L("Package version or dist-tag"))
AddCmd.Flags().StringP("team", "t", "", L("Team ID (required)"))
AddCmd.PersistentFlags().StringVarP(&appPath, "app", "a", "", L("Application directory"))
AddCmd.PersistentFlags().StringVarP(&envFile, "env", "e", "", L("Environment file"))
}

52
cmd/robot/robot.go Normal file
View file

@ -0,0 +1,52 @@
package robot
import (
"os"
"path/filepath"
"github.com/yaoapp/kun/exception"
"github.com/yaoapp/yao/config"
)
var appPath string
var envFile string
var langs = map[string]string{
"Install a robot package from the registry": "从注册中心安装 Robot 包",
"Team ID (required)": "团队 ID (必填)",
"Package version or dist-tag": "包版本或 dist-tag",
"Application directory": "应用目录",
"Environment file": "环境变量文件",
}
// L Language switch
func L(words string) string {
var lang = os.Getenv("YAO_LANG")
if lang == "" {
return words
}
if trans, has := langs[words]; has {
return trans
}
return words
}
// Boot sets the configuration
func Boot() {
root := config.Conf.Root
if appPath != "" {
r, err := filepath.Abs(appPath)
if err != nil {
exception.New("Root error %s", 500, err.Error()).Throw()
}
root = r
}
if envFile != "" {
config.Conf = config.LoadFromWithRoot(envFile, root)
} else {
config.Conf = config.LoadFromWithRoot(filepath.Join(root, ".env"), root)
}
config.ApplyMode()
}

View file

@ -8,6 +8,8 @@ import (
"github.com/spf13/cobra"
"github.com/yaoapp/kun/exception"
"github.com/yaoapp/yao/cmd/agent"
"github.com/yaoapp/yao/cmd/mcp"
"github.com/yaoapp/yao/cmd/robot"
"github.com/yaoapp/yao/cmd/sui"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/pack"
@ -64,6 +66,10 @@ var langs = map[string]string{
"🎉Successfully updated to version: %s🎉": "🎉成功更新到版本: %s🎉",
"Print all version information": "显示详细版本信息",
"SUI Template Engine": "SUI 模板引擎命令",
"MCP commands": "MCP 包管理命令",
"MCP package management commands": "MCP 包管理命令",
"Robot commands": "Robot 包管理命令",
"Robot package management commands": "Robot 包管理命令",
}
// L Language switch
@ -128,6 +134,30 @@ var agentCmd = &cobra.Command{
},
}
var mcpCmd = &cobra.Command{
Use: "mcp",
Short: L("MCP commands"),
Long: L("MCP package management commands"),
CompletionOptions: cobra.CompletionOptions{
DisableDefaultCmd: true,
},
Run: func(cmd *cobra.Command, args []string) {
cmd.Help()
},
}
var robotCmd = &cobra.Command{
Use: "robot",
Short: L("Robot commands"),
Long: L("Robot package management commands"),
CompletionOptions: cobra.CompletionOptions{
DisableDefaultCmd: true,
},
Run: func(cmd *cobra.Command, args []string) {
cmd.Help()
},
}
// Command initialize
func init() {
@ -139,6 +169,19 @@ func init() {
// Agent
agentCmd.AddCommand(agent.TestCmd)
agentCmd.AddCommand(agent.ExtractCmd)
agentCmd.AddCommand(agent.AddCmd)
agentCmd.AddCommand(agent.UpdateCmd)
agentCmd.AddCommand(agent.PushCmd)
agentCmd.AddCommand(agent.ForkCmd)
// MCP
mcpCmd.AddCommand(mcp.AddCmd)
mcpCmd.AddCommand(mcp.UpdateCmd)
mcpCmd.AddCommand(mcp.PushCmd)
mcpCmd.AddCommand(mcp.ForkCmd)
// Robot
robotCmd.AddCommand(robot.AddCmd)
rootCmd.AddCommand(
versionCmd,
@ -154,6 +197,8 @@ func init() {
// packCmd,
suiCmd,
agentCmd,
mcpCmd,
robotCmd,
// upgradeCmd,
)
// rootCmd.SetHelpCommand(helpCmd)

View file

@ -19,12 +19,13 @@ type Config struct {
LogMaxAage int `json:"log_max_age,omitempty" env:"YAO_LOG_MAX_AGE" envDefault:"7"` // The max log age in day, the default is 7
LogMaxBackups int `json:"log_max_backups" env:"YAO_LOG_MAX_BACKUPS" envDefault:"3"` // The max log backups, the default is 3
LogLocalTime bool `json:"log_local_time" env:"YAO_LOG_LOCAL_TIME" envDefault:"true"`
JWTSecret string `json:"jwt_secret,omitempty" env:"YAO_JWT_SECRET"` // The JWT Secret
DB Database `json:"db,omitempty"` // The database config
AllowFrom []string `json:"allowfrom,omitempty" envSeparator:"|" env:"YAO_ALLOW_FROM"` // Domain list the separator is |
Session Session `json:"session,omitempty"` // Session Config
Runtime Runtime `json:"runtime,omitempty"` // Runtime config
Trace Trace `json:"trace,omitempty"` // Trace config
JWTSecret string `json:"jwt_secret,omitempty" env:"YAO_JWT_SECRET"` // The JWT Secret
DB Database `json:"db,omitempty"` // The database config
AllowFrom []string `json:"allowfrom,omitempty" envSeparator:"|" env:"YAO_ALLOW_FROM"` // Domain list the separator is |
Session Session `json:"session,omitempty"` // Session Config
Runtime Runtime `json:"runtime,omitempty"` // Runtime config
Trace Trace `json:"trace,omitempty"` // Trace config
Registry string `json:"registry,omitempty" env:"YAO_REGISTRY" envDefault:"https://registry.yaoagents.com"` // The package registry server URL
}
// Database 数据库配置

View file

@ -12,7 +12,7 @@ import (
)
const (
testScope = "@test"
testScope = "@yaoagents"
)
func serverURL() string {

View file

@ -0,0 +1,188 @@
package agent
import (
"fmt"
"os"
"strings"
"github.com/yaoapp/yao/registry/manager/common"
mcpmgr "github.com/yaoapp/yao/registry/manager/mcp"
)
// AddOptions configures the Add operation.
type AddOptions struct {
Version string // version or dist-tag, default "latest"
Force bool // force reinstall even if already installed
}
// Add installs an assistant package from the registry.
// Flow per DESIGN-AGENT.md:
// 1. Parse @scope/name
// 2. Check target path conflict
// 3. Pull from registry
// 4. Check and install dependencies (recursive)
// 5. Unpack to assistants/{scope}/{name}/
// 6. Compute file hashes, write registry.yao
// 7. Hot-reload
func (m *Manager) Add(pkgID string, opts AddOptions) error {
if opts.Version == "" {
opts.Version = "latest"
}
scope, name, err := common.ParsePackageID(pkgID)
if err != nil {
return err
}
lf, err := common.LoadLockfile(m.appRoot)
if err != nil {
return err
}
// Check if already installed
if existing, ok := lf.GetPackage(pkgID); ok && !opts.Force {
return fmt.Errorf("package %s is already installed (version %s). Use --force to reinstall", pkgID, existing.Version)
}
// Check directory conflict
destDir := common.PackageDir(common.TypeAssistant, scope, name, m.appRoot)
if _, err := os.Stat(destDir); err == nil {
if _, ok := lf.GetPackage(pkgID); !ok {
return fmt.Errorf("directory %s already exists but is not managed by registry. Please remove or relocate it first", destDir)
}
}
// Pull from registry
regType := common.TypeToRegistryType(common.TypeAssistant)
zipData, digest, err := m.client.Pull(regType, "@"+scope, name, opts.Version)
if err != nil {
return fmt.Errorf("pull %s: %w", pkgID, err)
}
// Read manifest
manifest, err := common.ReadManifest(zipData)
if err != nil {
return fmt.Errorf("read manifest: %w", err)
}
// Install dependencies first
if len(manifest.Dependencies) > 0 {
if err := m.installDependencies(manifest.Dependencies, lf, pkgID, map[string]bool{pkgID: true}); err != nil {
return err
}
// Reload lockfile — dependency managers write their own entries to disk
lf, err = common.LoadLockfile(m.appRoot)
if err != nil {
return err
}
}
// Unpack to destination
if err := os.MkdirAll(destDir, 0755); err != nil {
return err
}
if _, err := common.UnpackTo(zipData, destDir); err != nil {
return fmt.Errorf("unpack: %w", err)
}
// Compute file hashes
relDir := common.PackageDirRel(common.TypeAssistant, scope, name)
files, err := common.HashDir(destDir, relDir)
if err != nil {
return fmt.Errorf("hash files: %w", err)
}
// Update lockfile
info := common.PackageInfo{
Type: common.TypeAssistant,
Version: manifest.Version,
Integrity: digest,
Dependencies: manifest.Dependencies,
Files: files,
}
lf.SetPackage(pkgID, info)
// Update required_by on dependencies
for depID := range manifest.Dependencies {
lf.AddRequiredBy(depID, pkgID)
}
if err := common.SaveLockfile(m.appRoot, lf); err != nil {
return err
}
// Hot-reload: in production this calls assistant.LoadPath().
// For the manager package we keep it as a no-op since LoadPath requires
// the full engine runtime. The CLI layer will handle hot-reload.
fmt.Printf("✓ Installed %s@%s → %s\n", pkgID, manifest.Version, destDir)
return nil
}
// installDependencies recursively installs missing dependencies.
// For MCP-type dependencies, delegates to the MCP manager which handles
// script extraction to the project root correctly.
func (m *Manager) installDependencies(deps map[string]string, lf *common.RegistryYao, parentID string, installing map[string]bool) error {
missing, conflicts, _ := common.CheckDependencies(deps, lf)
// Handle conflicts
for _, c := range conflicts {
msg := fmt.Sprintf(
"⚠ %s is currently %s (required by %s)\n %s requires %s\n",
c.PackageID, c.InstalledVersion, parentID, parentID, c.RequiredVersion,
)
options := []string{
fmt.Sprintf("Upgrade %s (may break other dependents)", c.PackageID),
"Keep current version",
"Abort installation",
}
choice := m.prompter.Choose(msg, options)
switch choice {
case 0:
missing = append(missing, c)
case 1:
continue
default:
return fmt.Errorf("installation aborted by user")
}
}
if len(missing) == 0 {
return nil
}
var summary strings.Builder
summary.WriteString("The following dependencies need to be installed:\n")
for _, dep := range missing {
summary.WriteString(fmt.Sprintf(" %s %s\n", dep.PackageID, dep.RequiredVersion))
}
if !m.prompter.Confirm(summary.String() + "Install?") {
return fmt.Errorf("dependency installation declined, aborting")
}
for _, dep := range missing {
if common.DetectCycle(installing, dep.PackageID) {
continue
}
installing[dep.PackageID] = true
if _, _, err := common.ParsePackageID(dep.PackageID); err != nil {
return err
}
// Try MCP type first (most agent dependencies are MCPs), then assistant.
// Delegate to the appropriate manager so MCP script extraction is handled.
if err := m.mcpMgr.Add(dep.PackageID, mcpmgr.AddOptions{}); err == nil {
fmt.Printf(" ✓ Dependency %s installed (mcp)\n", dep.PackageID)
continue
}
if err := m.Add(dep.PackageID, AddOptions{}); err == nil {
fmt.Printf(" ✓ Dependency %s installed (assistant)\n", dep.PackageID)
continue
}
return fmt.Errorf("failed to install dependency %s: not found in registry", dep.PackageID)
}
return nil
}

View file

@ -0,0 +1,29 @@
// Package agent implements the assistant package manager for the Yao registry.
package agent
import (
"github.com/yaoapp/yao/registry"
"github.com/yaoapp/yao/registry/manager/common"
mcpmgr "github.com/yaoapp/yao/registry/manager/mcp"
)
// Manager handles assistant package operations (add, update, push, fork).
type Manager struct {
client *registry.Client
appRoot string
prompter common.Prompter
mcpMgr *mcpmgr.Manager
}
// New creates an agent Manager.
func New(client *registry.Client, appRoot string, prompter common.Prompter) *Manager {
if prompter == nil {
prompter = &common.StdinPrompter{}
}
return &Manager{
client: client,
appRoot: appRoot,
prompter: prompter,
mcpMgr: mcpmgr.New(client, appRoot, prompter),
}
}

View file

@ -0,0 +1,628 @@
package agent
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/yaoapp/yao/registry"
"github.com/yaoapp/yao/registry/manager/common"
"github.com/yaoapp/yao/registry/testdata"
)
// buildTestZip builds a simple assistant .yao.zip for testing.
func buildTestZip(scope, name, version string, deps []testdata.ManifestDep, files map[string]string) []byte {
zip, err := testdata.BuildZip(&testdata.Manifest{
Type: "assistant",
Scope: scope,
Name: name,
Version: version,
Dependencies: deps,
}, files)
if err != nil {
panic(err)
}
return zip
}
// buildMCPTestZip builds a simple MCP .yao.zip for testing.
func buildMCPTestZip(scope, name, version string) []byte {
zip, err := testdata.BuildZip(&testdata.Manifest{
Type: "mcp",
Scope: scope,
Name: name,
Version: version,
}, map[string]string{
"test.mcp.yao": `{"transport":"process"}`,
})
if err != nil {
panic(err)
}
return zip
}
// mockRegistryServer creates a test HTTP server that serves pre-built zip packages.
func mockRegistryServer(packages map[string][]byte) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// well-known discovery
if r.URL.Path == "/.well-known/yao-registry" {
json.NewEncoder(w).Encode(map[string]interface{}{
"registry": map[string]string{"version": "1.0.0", "api": "/v1"},
"types": []string{"assistants", "mcps", "robots"},
})
return
}
// Pull: GET /v1/{type}/{scope}/{name}/{version}/pull
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/pull") {
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/"), "/")
if len(parts) >= 4 {
key := parts[0] + "/" + parts[1] + "/" + parts[2]
if zipData, ok := packages[key]; ok {
w.Header().Set("X-Digest", "sha256-test")
w.Write(zipData)
return
}
}
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"error": "not found"})
return
}
// Push: PUT /v1/{type}/{scope}/{name}/{version}
if r.Method == http.MethodPut {
w.WriteHeader(http.StatusCreated)
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/"), "/")
result := map[string]string{
"type": parts[0],
"scope": parts[1],
"name": parts[2],
"version": parts[3],
"digest": "sha256-pushed",
}
json.NewEncoder(w).Encode(result)
return
}
w.WriteHeader(http.StatusNotFound)
}))
}
func TestAddBasic(t *testing.T) {
appRoot := t.TempDir()
zip := buildTestZip("@test", "demo-agent", "1.0.0", nil, map[string]string{
"package.yao": `{"name":"demo"}`,
"prompts.yml": "You are a demo.",
})
srv := mockRegistryServer(map[string][]byte{
"assistants/@test/demo-agent": zip,
})
defer srv.Close()
client := registry.New(srv.URL, registry.WithAuth("u", "p"))
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
err := mgr.Add("@test/demo-agent", AddOptions{})
if err != nil {
t.Fatalf("Add failed: %v", err)
}
// Verify directory created
destDir := filepath.Join(appRoot, "assistants", "test", "demo-agent")
if _, err := os.Stat(destDir); err != nil {
t.Fatalf("expected directory %s to exist", destDir)
}
// Verify files
if _, err := os.Stat(filepath.Join(destDir, "package.yao")); err != nil {
t.Error("expected package.yao")
}
if _, err := os.Stat(filepath.Join(destDir, "prompts.yml")); err != nil {
t.Error("expected prompts.yml")
}
// Verify lockfile
lf, err := common.LoadLockfile(appRoot)
if err != nil {
t.Fatal(err)
}
pkg, ok := lf.GetPackage("@test/demo-agent")
if !ok {
t.Fatal("expected @test/demo-agent in lockfile")
}
if pkg.Version != "1.0.0" {
t.Errorf("expected version 1.0.0, got %s", pkg.Version)
}
if pkg.Type != common.TypeAssistant {
t.Errorf("expected type assistant, got %s", pkg.Type)
}
if len(pkg.Files) == 0 {
t.Error("expected non-empty files hash")
}
}
func TestAddAlreadyInstalled(t *testing.T) {
appRoot := t.TempDir()
zip := buildTestZip("@test", "dup", "1.0.0", nil, nil)
srv := mockRegistryServer(map[string][]byte{
"assistants/@test/dup": zip,
})
defer srv.Close()
client := registry.New(srv.URL)
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
if err := mgr.Add("@test/dup", AddOptions{}); err != nil {
t.Fatal(err)
}
// Second add should fail
err := mgr.Add("@test/dup", AddOptions{})
if err == nil {
t.Fatal("expected error for duplicate install")
}
if !strings.Contains(err.Error(), "already installed") {
t.Errorf("expected 'already installed' error, got: %v", err)
}
}
func TestAddDirectoryConflict(t *testing.T) {
appRoot := t.TempDir()
// Create conflicting directory manually (not managed)
os.MkdirAll(filepath.Join(appRoot, "assistants", "test", "conflict"), 0755)
zip := buildTestZip("@test", "conflict", "1.0.0", nil, nil)
srv := mockRegistryServer(map[string][]byte{
"assistants/@test/conflict": zip,
})
defer srv.Close()
client := registry.New(srv.URL)
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
err := mgr.Add("@test/conflict", AddOptions{})
if err == nil {
t.Fatal("expected conflict error")
}
if !strings.Contains(err.Error(), "not managed by registry") {
t.Errorf("expected conflict error, got: %v", err)
}
}
func TestAddWithDependencies(t *testing.T) {
appRoot := t.TempDir()
mcpZip := buildMCPTestZip("@test", "dep-mcp", "1.0.0")
agentZip := buildTestZip("@test", "dep-agent", "1.0.0",
[]testdata.ManifestDep{
{Type: "mcp", Scope: "@test", Name: "dep-mcp", Version: "^1.0.0"},
},
map[string]string{"package.yao": `{"name":"dep-agent"}`},
)
srv := mockRegistryServer(map[string][]byte{
"assistants/@test/dep-agent": agentZip,
"mcps/@test/dep-mcp": mcpZip,
})
defer srv.Close()
client := registry.New(srv.URL)
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
err := mgr.Add("@test/dep-agent", AddOptions{})
if err != nil {
t.Fatalf("Add with deps failed: %v", err)
}
// Verify dependency was installed
lf, _ := common.LoadLockfile(appRoot)
if _, ok := lf.GetPackage("@test/dep-mcp"); !ok {
t.Error("expected dependency @test/dep-mcp to be installed")
}
}
func TestUpdateBasic(t *testing.T) {
appRoot := t.TempDir()
zipV1 := buildTestZip("@test", "updatable", "1.0.0", nil, map[string]string{
"package.yao": `{"name":"updatable"}`,
"prompts.yml": "Original prompt.",
})
zipV2 := buildTestZip("@test", "updatable", "2.0.0", nil, map[string]string{
"package.yao": `{"name":"updatable","version":"2.0.0"}`,
"prompts.yml": "Updated prompt.",
"new-file.md": "New in v2.",
})
srv := mockRegistryServer(map[string][]byte{
"assistants/@test/updatable": zipV2,
})
defer srv.Close()
// First install v1 using the real zip
srvV1 := mockRegistryServer(map[string][]byte{
"assistants/@test/updatable": zipV1,
})
clientV1 := registry.New(srvV1.URL)
mgrV1 := New(clientV1, appRoot, &common.AutoConfirmPrompter{})
if err := mgrV1.Add("@test/updatable", AddOptions{}); err != nil {
t.Fatal(err)
}
srvV1.Close()
// Now update to v2
client := registry.New(srv.URL)
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
err := mgr.Update("@test/updatable", UpdateOptions{})
if err != nil {
t.Fatalf("Update failed: %v", err)
}
// Verify version updated in lockfile
lf, _ := common.LoadLockfile(appRoot)
pkg, _ := lf.GetPackage("@test/updatable")
if pkg.Version != "2.0.0" {
t.Errorf("expected version 2.0.0, got %s", pkg.Version)
}
// Verify new file exists
newFilePath := filepath.Join(appRoot, "assistants", "test", "updatable", "new-file.md")
if _, err := os.Stat(newFilePath); err != nil {
t.Error("expected new-file.md to be added")
}
}
func TestUpdateLocallyModified(t *testing.T) {
appRoot := t.TempDir()
zipV1 := buildTestZip("@test", "modified", "1.0.0", nil, map[string]string{
"package.yao": `{"name":"modified"}`,
"prompts.yml": "Original.",
})
zipV2 := buildTestZip("@test", "modified", "2.0.0", nil, map[string]string{
"package.yao": `{"name":"modified"}`,
"prompts.yml": "Updated.",
})
// Install v1
srvV1 := mockRegistryServer(map[string][]byte{
"assistants/@test/modified": zipV1,
})
clientV1 := registry.New(srvV1.URL)
mgrV1 := New(clientV1, appRoot, &common.AutoConfirmPrompter{})
if err := mgrV1.Add("@test/modified", AddOptions{}); err != nil {
t.Fatal(err)
}
srvV1.Close()
// Modify prompts.yml locally
promptsPath := filepath.Join(appRoot, "assistants", "test", "modified", "prompts.yml")
os.WriteFile(promptsPath, []byte("My custom prompt."), 0644)
// Update to v2
srvV2 := mockRegistryServer(map[string][]byte{
"assistants/@test/modified": zipV2,
})
defer srvV2.Close()
clientV2 := registry.New(srvV2.URL)
mgrV2 := New(clientV2, appRoot, &common.AutoConfirmPrompter{})
err := mgrV2.Update("@test/modified", UpdateOptions{})
if err != nil {
t.Fatalf("Update failed: %v", err)
}
// prompts.yml should be preserved (locally modified)
data, _ := os.ReadFile(promptsPath)
if string(data) != "My custom prompt." {
t.Errorf("expected locally modified prompts.yml preserved, got: %s", data)
}
// New version should be saved as .new
newPath := promptsPath + ".new"
if _, err := os.Stat(newPath); err != nil {
t.Error("expected prompts.yml.new to exist")
}
newData, _ := os.ReadFile(newPath)
if string(newData) != "Updated." {
t.Errorf("expected .new file to contain new version, got: %s", newData)
}
}
func TestUpdateNotInstalled(t *testing.T) {
appRoot := t.TempDir()
srv := mockRegistryServer(nil)
defer srv.Close()
client := registry.New(srv.URL)
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
err := mgr.Update("@test/nonexistent", UpdateOptions{})
if err == nil {
t.Fatal("expected error for not-installed package")
}
if !strings.Contains(err.Error(), "not installed") {
t.Errorf("expected 'not installed' error, got: %v", err)
}
}
func TestUpdateForkedPackage(t *testing.T) {
appRoot := t.TempDir()
// Set up a forked package in lockfile
lf := &common.RegistryYao{
Scope: "@local",
Packages: map[string]common.PackageInfo{
"@local/keeper": {
Type: common.TypeAssistant,
Version: "1.0.0",
ForkedFrom: "@yao/keeper",
Managed: common.BoolPtr(false),
},
},
}
common.SaveLockfile(appRoot, lf)
srv := mockRegistryServer(nil)
defer srv.Close()
client := registry.New(srv.URL)
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
err := mgr.Update("@local/keeper", UpdateOptions{})
if err == nil {
t.Fatal("expected error for forked package")
}
if !strings.Contains(err.Error(), "forked") {
t.Errorf("expected 'forked' error, got: %v", err)
}
}
func TestPushBasic(t *testing.T) {
appRoot := t.TempDir()
// Create assistant directory
assistantDir := filepath.Join(appRoot, "assistants", "max", "my-agent")
os.MkdirAll(assistantDir, 0755)
os.WriteFile(filepath.Join(assistantDir, "package.yao"), []byte(`{"name":"my-agent"}`), 0644)
os.WriteFile(filepath.Join(assistantDir, "prompts.yml"), []byte("test prompt"), 0644)
srv := mockRegistryServer(nil)
defer srv.Close()
client := registry.New(srv.URL, registry.WithAuth("u", "p"))
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
err := mgr.Push("max.my-agent", PushOptions{Version: "1.0.0"})
if err != nil {
t.Fatalf("Push failed: %v", err)
}
}
func TestPushLocalScope(t *testing.T) {
appRoot := t.TempDir()
srv := mockRegistryServer(nil)
defer srv.Close()
client := registry.New(srv.URL, registry.WithAuth("u", "p"))
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
err := mgr.Push("local.my-agent", PushOptions{Version: "1.0.0"})
if err == nil {
t.Fatal("expected error for @local push")
}
if !strings.Contains(err.Error(), "@local") {
t.Errorf("expected @local rejection, got: %v", err)
}
}
func TestPushNoVersion(t *testing.T) {
appRoot := t.TempDir()
srv := mockRegistryServer(nil)
defer srv.Close()
client := registry.New(srv.URL)
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
err := mgr.Push("max.my-agent", PushOptions{})
if err == nil {
t.Fatal("expected error for missing version")
}
}
func TestForkFromInstalled(t *testing.T) {
appRoot := t.TempDir()
// Set up an installed package
assistantDir := filepath.Join(appRoot, "assistants", "yao", "keeper")
os.MkdirAll(assistantDir, 0755)
os.WriteFile(filepath.Join(assistantDir, "package.yao"), []byte(`{"name":"keeper"}`), 0644)
os.WriteFile(filepath.Join(assistantDir, "prompts.yml"), []byte("keeper prompt"), 0644)
lf := &common.RegistryYao{
Scope: "@local",
Packages: map[string]common.PackageInfo{
"@yao/keeper": {
Type: common.TypeAssistant,
Version: "2.0.0",
},
},
}
common.SaveLockfile(appRoot, lf)
srv := mockRegistryServer(nil)
defer srv.Close()
client := registry.New(srv.URL)
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
err := mgr.Fork("@yao/keeper", ForkOptions{})
if err != nil {
t.Fatalf("Fork failed: %v", err)
}
// Verify forked directory
forkDir := filepath.Join(appRoot, "assistants", "local", "keeper")
if _, err := os.Stat(forkDir); err != nil {
t.Fatal("expected forked directory")
}
data, _ := os.ReadFile(filepath.Join(forkDir, "package.yao"))
if string(data) != `{"name":"keeper"}` {
t.Errorf("expected copied content, got: %s", data)
}
// Verify lockfile
lf, _ = common.LoadLockfile(appRoot)
pkg, ok := lf.GetPackage("@local/keeper")
if !ok {
t.Fatal("expected @local/keeper in lockfile")
}
if pkg.ForkedFrom != "@yao/keeper" {
t.Errorf("expected forked_from @yao/keeper, got %s", pkg.ForkedFrom)
}
if pkg.IsManaged() {
t.Error("expected managed=false")
}
if pkg.Version != "2.0.0" {
t.Errorf("expected version 2.0.0, got %s", pkg.Version)
}
}
func TestForkFromRegistry(t *testing.T) {
appRoot := t.TempDir()
zip := buildTestZip("@test", "remote-agent", "3.0.0", nil, map[string]string{
"package.yao": `{"name":"remote-agent"}`,
})
srv := mockRegistryServer(map[string][]byte{
"assistants/@test/remote-agent": zip,
})
defer srv.Close()
client := registry.New(srv.URL)
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
err := mgr.Fork("@test/remote-agent", ForkOptions{})
if err != nil {
t.Fatalf("Fork from registry failed: %v", err)
}
forkDir := filepath.Join(appRoot, "assistants", "local", "remote-agent")
if _, err := os.Stat(forkDir); err != nil {
t.Fatal("expected forked directory")
}
}
func TestForkTargetExists(t *testing.T) {
appRoot := t.TempDir()
// Create target directory
os.MkdirAll(filepath.Join(appRoot, "assistants", "local", "existing"), 0755)
srv := mockRegistryServer(nil)
defer srv.Close()
client := registry.New(srv.URL)
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
err := mgr.Fork("@yao/existing", ForkOptions{})
if err == nil {
t.Fatal("expected error when target exists")
}
if !strings.Contains(err.Error(), "already exists") {
t.Errorf("unexpected error: %v", err)
}
}
func TestForkCustomScope(t *testing.T) {
appRoot := t.TempDir()
zip := buildTestZip("@yao", "keeper", "1.0.0", nil, map[string]string{
"package.yao": `{"name":"keeper"}`,
})
srv := mockRegistryServer(map[string][]byte{
"assistants/@yao/keeper": zip,
})
defer srv.Close()
client := registry.New(srv.URL)
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
err := mgr.Fork("@yao/keeper", ForkOptions{TargetScope: "max"})
if err != nil {
t.Fatalf("Fork to custom scope failed: %v", err)
}
forkDir := filepath.Join(appRoot, "assistants", "max", "keeper")
if _, err := os.Stat(forkDir); err != nil {
t.Fatal("expected directory in max scope")
}
lf, _ := common.LoadLockfile(appRoot)
if _, ok := lf.GetPackage("@max/keeper"); !ok {
t.Error("expected @max/keeper in lockfile")
}
}
func TestScanDependencies(t *testing.T) {
appRoot := t.TempDir()
// Create assistant with MCP dependency
assistantDir := filepath.Join(appRoot, "assistants", "max", "test-scan")
os.MkdirAll(assistantDir, 0755)
os.WriteFile(filepath.Join(assistantDir, "package.yao"), []byte(`{
"name":"test-scan",
"mcp": {
"servers": [
{"server_id": "yao.rag-tools"}
]
}
}`), 0644)
// Create scoped MCP directory so it gets picked up
os.MkdirAll(filepath.Join(appRoot, "mcps", "yao", "rag-tools"), 0755)
deps, err := ScanDependencies(assistantDir, appRoot)
if err != nil {
t.Fatal(err)
}
if _, ok := deps["@yao/rag-tools"]; !ok {
t.Error("expected @yao/rag-tools in scanned dependencies")
}
}
func TestScanDependenciesSkipUnscoped(t *testing.T) {
appRoot := t.TempDir()
assistantDir := filepath.Join(appRoot, "assistants", "max", "test-local")
os.MkdirAll(assistantDir, 0755)
os.WriteFile(filepath.Join(assistantDir, "package.yao"), []byte(`{
"name":"test-local",
"mcp": {
"servers": [
{"server_id": "echo"}
]
}
}`), 0644)
deps, err := ScanDependencies(assistantDir, appRoot)
if err != nil {
t.Fatal(err)
}
// "echo" has no dot, so IDFromYaoID should fail and it should be skipped
if len(deps) != 0 {
t.Errorf("expected no dependencies for unscoped MCP, got %v", deps)
}
}

View file

@ -0,0 +1,141 @@
package agent
import (
"fmt"
"io"
"os"
"path/filepath"
"github.com/yaoapp/yao/registry/manager/common"
)
// ForkOptions configures the Fork operation.
type ForkOptions struct {
TargetScope string // target scope, defaults to lockfile's default scope (usually "local")
}
// Fork copies an assistant to a new scope for local modification.
// Flow per DESIGN.md Fork:
// 1. If locally installed → copy directory
// 2. If not installed → pull from registry
// 3. Place in target scope directory
// 4. Write registry.yao with managed:false
// 5. Hot-reload
func (m *Manager) Fork(pkgID string, opts ForkOptions) error {
scope, name, err := common.ParsePackageID(pkgID)
if err != nil {
return err
}
lf, err := common.LoadLockfile(m.appRoot)
if err != nil {
return err
}
targetScope := opts.TargetScope
if targetScope == "" {
targetScope = lf.DefaultScope()
}
targetPkgID := common.FormatPackageID(targetScope, name)
// Check if target already exists
targetDir := common.PackageDir(common.TypeAssistant, targetScope, name, m.appRoot)
if _, err := os.Stat(targetDir); err == nil {
return fmt.Errorf("target directory %s already exists", targetDir)
}
sourceDir := common.PackageDir(common.TypeAssistant, scope, name, m.appRoot)
if _, ok := lf.GetPackage(pkgID); ok {
// Local copy
if err := copyDir(sourceDir, targetDir); err != nil {
return fmt.Errorf("copy: %w", err)
}
} else {
// Pull from registry
regType := common.TypeToRegistryType(common.TypeAssistant)
zipData, _, err := m.client.Pull(regType, "@"+scope, name, "latest")
if err != nil {
return fmt.Errorf("pull %s: %w", pkgID, err)
}
if err := os.MkdirAll(targetDir, 0755); err != nil {
return err
}
if _, err := common.UnpackTo(zipData, targetDir); err != nil {
return fmt.Errorf("unpack: %w", err)
}
}
// Compute file hashes
relDir := common.PackageDirRel(common.TypeAssistant, targetScope, name)
files, err := common.HashDir(targetDir, relDir)
if err != nil {
return fmt.Errorf("hash files: %w", err)
}
// Write lockfile entry
info := common.PackageInfo{
Type: common.TypeAssistant,
Version: "0.0.0",
ForkedFrom: pkgID,
Managed: common.BoolPtr(false),
Files: files,
}
// Try to get version from source
if existing, ok := lf.GetPackage(pkgID); ok {
info.Version = existing.Version
}
lf.SetPackage(targetPkgID, info)
if err := common.SaveLockfile(m.appRoot, lf); err != nil {
return err
}
yaoID := targetScope + "." + name
fmt.Printf("✓ Forked %s → %s (ID: %s)\n", pkgID, targetDir, yaoID)
fmt.Printf(" Internal references (mcp.servers, uses) still point to original scope.\n")
fmt.Printf(" Edit package.yao if you need to change them.\n")
return nil
}
func copyDir(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if info.IsDir() {
return os.MkdirAll(target, info.Mode())
}
return copyFile(path, target)
})
}
func copyFile(src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
}
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}

View file

@ -0,0 +1,78 @@
package agent
import (
"fmt"
"os"
"path/filepath"
"github.com/yaoapp/yao/registry/manager/common"
)
// PushOptions configures the Push operation.
type PushOptions struct {
Version string // required semver
}
// Push packages and uploads an assistant to the registry.
// Flow per DESIGN-AGENT.md:
// 1. Yao ID → path
// 2. Validate package.yao exists
// 3. Derive scope/name from path
// 4. Reject @local
// 5. Pack directory (including embedded mcps/)
// 6. Scan external dependencies
// 7. Generate pkg.yao manifest
// 8. Push to registry
func (m *Manager) Push(yaoID string, opts PushOptions) error {
if opts.Version == "" {
return fmt.Errorf("--version is required for push")
}
scope, name, err := common.IDFromYaoID(yaoID)
if err != nil {
return fmt.Errorf("invalid assistant ID %q: %w", yaoID, err)
}
if common.IsLocalScope(scope) {
return fmt.Errorf("cannot push @local packages. Fork to your own scope first")
}
assistantDir := common.PackageDir(common.TypeAssistant, scope, name, m.appRoot)
// Validate package.yao exists
pkgYaoPath := filepath.Join(assistantDir, "package.yao")
if _, err := os.Stat(pkgYaoPath); err != nil {
return fmt.Errorf("package.yao not found at %s", pkgYaoPath)
}
// Scan external dependencies
scannedDeps, err := ScanDependencies(assistantDir, m.appRoot)
if err != nil {
fmt.Printf("⚠ Warning: could not scan dependencies: %v\n", err)
scannedDeps = map[string]string{}
}
manifest := &common.PkgManifest{
Type: common.TypeAssistant,
Scope: "@" + scope,
Name: name,
Version: opts.Version,
Dependencies: scannedDeps,
}
// Pack the directory
zipData, err := common.PackDir(assistantDir, manifest, nil)
if err != nil {
return fmt.Errorf("pack: %w", err)
}
// Push to registry
regType := common.TypeToRegistryType(common.TypeAssistant)
result, err := m.client.Push(regType, "@"+scope, name, opts.Version, zipData)
if err != nil {
return fmt.Errorf("push: %w", err)
}
fmt.Printf("✓ Pushed %s@%s (digest: %s)\n", common.FormatPackageID(scope, name), result.Version, result.Digest)
return nil
}

View file

@ -0,0 +1,89 @@
package agent
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
goujson "github.com/yaoapp/gou/json"
"github.com/yaoapp/yao/registry/manager/common"
)
// packageYao represents the assistant's package.yao DSL (subset of fields we care about).
type packageYao struct {
MCP *mcpConfig `json:"mcp,omitempty"`
Agents []string `json:"agents,omitempty"`
Uses json.RawMessage `json:"uses,omitempty"`
}
type mcpConfig struct {
Servers []mcpServerEntry `json:"servers,omitempty"`
}
type mcpServerEntry struct {
ServerID string `json:"server_id,omitempty"`
}
// ScanDependencies scans an assistant directory's package.yao for external dependencies.
// It finds MCP dependencies from mcp.servers and returns them as "@scope/name" → "*" entries.
// Only MCPs with a scope directory (mcps/{scope}/) are included; top-level mcps/ are skipped.
func ScanDependencies(assistantDir, appRoot string) (map[string]string, error) {
pkgPath := filepath.Join(assistantDir, "package.yao")
data, err := os.ReadFile(pkgPath)
if err != nil {
return nil, fmt.Errorf("read package.yao: %w", err)
}
var pkg packageYao
if err := goujson.ParseFile("package.yao", data, &pkg); err != nil {
return nil, fmt.Errorf("parse package.yao: %w", err)
}
deps := map[string]string{}
// Scan MCP servers
if pkg.MCP != nil {
for _, entry := range pkg.MCP.Servers {
serverID := entry.ServerID
if serverID == "" {
continue
}
pkgID, err := resolveMCPDep(serverID, appRoot)
if err != nil {
continue
}
if pkgID != "" {
deps[pkgID] = "*"
}
}
}
return deps, nil
}
// resolveMCPDep resolves an MCP server_id to a package ID if it lives under a scoped directory.
// Returns empty string for non-scoped (local) MCPs.
func resolveMCPDep(serverID, appRoot string) (string, error) {
// server_id like "yao.rag-tools" → scope=yao, name=rag-tools
// Check if mcps/yao/rag-tools/ exists (has scope directory)
scope, name, err := common.IDFromYaoID(serverID)
if err != nil {
return "", nil
}
mcpDir := filepath.Join(appRoot, "mcps", scope, strings.ReplaceAll(name, ".", "/"))
if _, err := os.Stat(mcpDir); err == nil {
return common.FormatPackageID(scope, name), nil
}
// Also check for single-file MCP: mcps/{scope}/{name}.mcp.yao
// This is less common but possible
mcpFile := filepath.Join(appRoot, "mcps", scope, name+".mcp.yao")
if _, err := os.Stat(mcpFile); err == nil {
return common.FormatPackageID(scope, name), nil
}
return "", nil
}

View file

@ -0,0 +1,204 @@
package agent
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/yaoapp/yao/registry/manager/common"
)
// UpdateOptions configures the Update operation.
type UpdateOptions struct {
Version string // target version or dist-tag, default "latest"
}
// Update performs a hash-based safe update per DESIGN.md Update Strategy:
// 1. Confirm installed and managed
// 2. Pull new version
// 3. Check required_by compatibility
// 4. Per-file hash comparison: overwrite unmodified, skip modified (.new), add new, delete removed
// 5. Update registry.yao
// 6. Hot-reload
func (m *Manager) Update(pkgID string, opts UpdateOptions) error {
if opts.Version == "" {
opts.Version = "latest"
}
scope, name, err := common.ParsePackageID(pkgID)
if err != nil {
return err
}
lf, err := common.LoadLockfile(m.appRoot)
if err != nil {
return err
}
existing, ok := lf.GetPackage(pkgID)
if !ok {
return fmt.Errorf("package %s is not installed", pkgID)
}
if !existing.IsManaged() {
return fmt.Errorf("package %s is forked (from %s) and not managed by registry", pkgID, existing.ForkedFrom)
}
// Pull new version
regType := common.TypeToRegistryType(common.TypeAssistant)
zipData, digest, err := m.client.Pull(regType, "@"+scope, name, opts.Version)
if err != nil {
return fmt.Errorf("pull %s: %w", pkgID, err)
}
manifest, err := common.ReadManifest(zipData)
if err != nil {
return fmt.Errorf("read manifest: %w", err)
}
// Check required_by compatibility
if len(existing.RequiredBy) > 0 {
var warnings []string
for _, depID := range existing.RequiredBy {
depPkg, depOK := lf.GetPackage(depID)
if !depOK {
continue
}
if constraint, has := depPkg.Dependencies[pkgID]; has {
if !common.VersionSatisfies(manifest.Version, constraint) {
warnings = append(warnings, fmt.Sprintf(" %s requires %s ← ⚠ incompatible", depID, constraint))
}
}
}
if len(warnings) > 0 {
msg := fmt.Sprintf("%s is depended on by:\n%s\nContinue update?", pkgID, strings.Join(warnings, "\n"))
if !m.prompter.Confirm(msg) {
return fmt.Errorf("update aborted by user")
}
}
}
destDir := common.PackageDir(common.TypeAssistant, scope, name, m.appRoot)
relDir := common.PackageDirRel(common.TypeAssistant, scope, name)
// Get list of new files from zip
newFiles, err := common.ListZipFiles(zipData)
if err != nil {
return err
}
// Build a set of new file relative paths (with full relDir prefix)
newFileSet := map[string]bool{}
for _, f := range newFiles {
newFileSet[relDir+"/"+f] = true
}
newHashes := map[string]string{}
// Per-file comparison
for _, f := range newFiles {
fullRel := relDir + "/" + f
localPath := filepath.Join(destDir, f)
oldHash, wasTracked := existing.Files[fullRel]
// Read new file content from zip
newContent, err := common.ExtractFile(zipData, f)
if err != nil {
return err
}
newHash := common.HashBytes(newContent)
if !wasTracked {
// New file in new version → add
if err := writeFile(localPath, newContent); err != nil {
return err
}
fmt.Printf("+ %s — new file, added\n", f)
newHashes[fullRel] = newHash
continue
}
// Check if local file was modified
localHash, err := common.HashFile(localPath)
if err != nil {
// File might have been deleted locally, just write it
if err := writeFile(localPath, newContent); err != nil {
return err
}
fmt.Printf("✓ %s — restored (was missing locally)\n", f)
newHashes[fullRel] = newHash
continue
}
if localHash == oldHash {
// Unmodified → overwrite
if err := writeFile(localPath, newContent); err != nil {
return err
}
fmt.Printf("✓ %s — unmodified, updated\n", f)
newHashes[fullRel] = newHash
} else {
// Locally modified → skip, save new version as .new
newPath := localPath + ".new"
if err := writeFile(newPath, newContent); err != nil {
return err
}
fmt.Printf("✗ %s — locally modified, skipped (new version → %s.new)\n", f, f)
// Update hash to new version's hash per DESIGN.md
newHashes[fullRel] = newHash
}
}
// Handle deleted files (in old but not in new)
for oldFile, oldHash := range existing.Files {
if newFileSet[oldFile] {
continue
}
localPath := filepath.Join(m.appRoot, filepath.FromSlash(oldFile))
localHash, err := common.HashFile(localPath)
if err != nil {
// Already gone
continue
}
if localHash == oldHash {
// Unmodified → delete
os.Remove(localPath)
fmt.Printf("- %s — removed (deleted in new version)\n", filepath.Base(oldFile))
} else {
fmt.Printf("⚠ %s — locally modified, kept (deleted in new version)\n", filepath.Base(oldFile))
// Keep it, but don't track it anymore
}
}
// Check new version dependencies
if len(manifest.Dependencies) > 0 {
if err := m.installDependencies(manifest.Dependencies, lf, pkgID, map[string]bool{pkgID: true}); err != nil {
return err
}
}
// Update lockfile
existing.Version = manifest.Version
existing.Integrity = digest
existing.Dependencies = manifest.Dependencies
existing.Files = newHashes
lf.SetPackage(pkgID, existing)
for depID := range manifest.Dependencies {
lf.AddRequiredBy(depID, pkgID)
}
if err := common.SaveLockfile(m.appRoot, lf); err != nil {
return err
}
fmt.Printf("✓ Updated %s to %s\n", pkgID, manifest.Version)
return nil
}
func writeFile(path string, content []byte) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
return os.WriteFile(path, content, 0644)
}

View file

@ -0,0 +1,464 @@
package manager_test
import (
"os"
"path/filepath"
"strings"
"testing"
agentmgr "github.com/yaoapp/yao/registry/manager/agent"
"github.com/yaoapp/yao/registry/manager/common"
mcpmgr "github.com/yaoapp/yao/registry/manager/mcp"
)
// =============================================================================
// Agent with 1 MCP dep: Full lifecycle — Push → Add (auto dep) → Update → Fork
// =============================================================================
func TestE2EAgent_SingleDepLifecycle(t *testing.T) {
c := authClient()
devApp := appRoot(t)
defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0")
defer cleanupPkg(c, "assistants", "@"+testScope, "registry-agent", "1.0.0")
defer cleanupPkg(c, "assistants", "@"+testScope, "registry-agent", "2.0.0")
// Push MCP dependency first
mcpMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{})
if err := mcpMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"}); err != nil {
t.Fatalf("Push MCP: %v", err)
}
// Push agent
agentMgr := agentmgr.New(c, devApp, &common.AutoConfirmPrompter{})
if err := agentMgr.Push(testScope+".registry-agent", agentmgr.PushOptions{Version: "1.0.0"}); err != nil {
t.Fatalf("Push agent: %v", err)
}
packument, err := c.GetPackument("assistants", "@"+testScope, "registry-agent")
if err != nil {
t.Fatalf("GetPackument: %v", err)
}
if packument.DistTags["latest"] != "1.0.0" {
t.Errorf("expected latest=1.0.0, got %s", packument.DistTags["latest"])
}
// Add to fresh app — MCP should auto-install
installApp := t.TempDir()
installAgent := agentmgr.New(c, installApp, &common.AutoConfirmPrompter{})
if err := installAgent.Add("@"+testScope+"/registry-agent", agentmgr.AddOptions{}); err != nil {
t.Fatalf("Add agent: %v", err)
}
// Agent on disk
agentDir := filepath.Join(installApp, "assistants", testScope, "registry-agent")
requireFileExists(t, filepath.Join(agentDir, "package.yao"))
requireFileContains(t, filepath.Join(agentDir, "prompts.yml"), "registry E2E testing")
// Lockfile: agent entry
agentPkg := requireLockfileHas(t, installApp, "@"+testScope+"/registry-agent")
if agentPkg.Version != "1.0.0" {
t.Errorf("want version 1.0.0, got %s", agentPkg.Version)
}
if agentPkg.Type != common.TypeAssistant {
t.Errorf("want type assistant, got %s", agentPkg.Type)
}
if len(agentPkg.Files) == 0 {
t.Error("expected file hashes in lockfile")
}
// MCP dependency auto-installed
depPkg := requireLockfileHas(t, installApp, "@"+testScope+"/registry-mcp")
if depPkg.Version != "1.0.0" {
t.Errorf("dependency version: want 1.0.0, got %s", depPkg.Version)
}
found := false
for _, rb := range depPkg.RequiredBy {
if rb == "@"+testScope+"/registry-agent" {
found = true
}
}
if !found {
t.Errorf("expected agent in MCP's required_by, got %v", depPkg.RequiredBy)
}
// Update: push v2, locally modify, then update
v2App := buildV2AgentApp(t)
pushAgentV2 := agentmgr.New(c, v2App, &common.AutoConfirmPrompter{})
if err := pushAgentV2.Push(testScope+".registry-agent", agentmgr.PushOptions{Version: "2.0.0"}); err != nil {
t.Fatalf("Push agent v2: %v", err)
}
customPrompt := "My custom prompt - DO NOT OVERWRITE."
os.WriteFile(filepath.Join(agentDir, "prompts.yml"), []byte(customPrompt), 0644)
if err := installAgent.Update("@"+testScope+"/registry-agent", agentmgr.UpdateOptions{Version: "2.0.0"}); err != nil {
t.Fatalf("Update agent: %v", err)
}
agentPkg = requireLockfileHas(t, installApp, "@"+testScope+"/registry-agent")
if agentPkg.Version != "2.0.0" {
t.Errorf("expected v2.0.0, got %s", agentPkg.Version)
}
// Local modification preserved
preservedData, _ := os.ReadFile(filepath.Join(agentDir, "prompts.yml"))
if string(preservedData) != customPrompt {
t.Errorf("local modification should be preserved, got: %s", preservedData)
}
requireFileExists(t, filepath.Join(agentDir, "prompts.yml.new"))
requireFileContains(t, filepath.Join(agentDir, "prompts.yml.new"), "v2 registry test assistant")
// New file added by v2
requireFileExists(t, filepath.Join(agentDir, "tools.ts"))
// Fork
if err := installAgent.Fork("@"+testScope+"/registry-agent", agentmgr.ForkOptions{TargetScope: "local"}); err != nil {
t.Fatalf("Fork agent: %v", err)
}
forkDir := filepath.Join(installApp, "assistants", "local", "registry-agent")
requireFileExists(t, filepath.Join(forkDir, "package.yao"))
forkedPkg := requireLockfileHas(t, installApp, "@local/registry-agent")
if forkedPkg.ForkedFrom != "@"+testScope+"/registry-agent" {
t.Errorf("expected forked_from=@%s/registry-agent, got %s", testScope, forkedPkg.ForkedFrom)
}
if forkedPkg.IsManaged() {
t.Error("forked package should not be managed")
}
}
// =============================================================================
// Agent with 2 MCP deps: both auto-installed
// =============================================================================
func TestE2EAgent_MultiDepLifecycle(t *testing.T) {
c := authClient()
devApp := appRoot(t)
defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0")
defer cleanupPkg(c, "mcps", "@"+testScope, "data-tools", "1.0.0")
defer cleanupPkg(c, "assistants", "@"+testScope, "analytics", "1.0.0")
// Push both MCP dependencies
mcpMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{})
if err := mcpMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"}); err != nil {
t.Fatalf("Push registry-mcp: %v", err)
}
if err := mcpMgr.Push(testScope+".data-tools", mcpmgr.PushOptions{Version: "1.0.0"}); err != nil {
t.Fatalf("Push data-tools: %v", err)
}
// Push analytics agent
agentMgr := agentmgr.New(c, devApp, &common.AutoConfirmPrompter{})
if err := agentMgr.Push(testScope+".analytics", agentmgr.PushOptions{Version: "1.0.0"}); err != nil {
t.Fatalf("Push analytics: %v", err)
}
// Install to fresh app
installApp := t.TempDir()
installAgent := agentmgr.New(c, installApp, &common.AutoConfirmPrompter{})
if err := installAgent.Add("@"+testScope+"/analytics", agentmgr.AddOptions{}); err != nil {
t.Fatalf("Add analytics: %v", err)
}
// Agent on disk
requireFileExists(t, filepath.Join(installApp, "assistants", testScope, "analytics", "package.yao"))
requireFileContains(t, filepath.Join(installApp, "assistants", testScope, "analytics", "prompts.yml"),
"analytics assistant")
// Both MCP deps auto-installed
requireLockfileHas(t, installApp, "@"+testScope+"/registry-mcp")
requireLockfileHas(t, installApp, "@"+testScope+"/data-tools")
// MCP files on disk
requireFileExists(t, filepath.Join(installApp, "mcps", testScope, "registry-mcp", "server.mcp.yao"))
requireFileExists(t, filepath.Join(installApp, "mcps", testScope, "data-tools", "server.mcp.yao"))
// Scripts from both MCPs
requireFileExists(t, filepath.Join(installApp, "scripts", testScope, "registry_mcp.ts"))
requireFileExists(t, filepath.Join(installApp, "scripts", testScope, "data_tools.ts"))
requireFileExists(t, filepath.Join(installApp, "scripts", testScope, "data_utils.ts"))
// required_by on both MCPs should reference analytics
lf, _ := common.LoadLockfile(installApp)
for _, mcpID := range []string{"@" + testScope + "/registry-mcp", "@" + testScope + "/data-tools"} {
mcpPkg, _ := lf.GetPackage(mcpID)
found := false
for _, rb := range mcpPkg.RequiredBy {
if rb == "@"+testScope+"/analytics" {
found = true
}
}
if !found {
t.Errorf("expected analytics in %s's required_by, got %v", mcpID, mcpPkg.RequiredBy)
}
}
// Analytics lockfile entry
agentPkg := requireLockfileHas(t, installApp, "@"+testScope+"/analytics")
if agentPkg.Version != "1.0.0" {
t.Errorf("want version 1.0.0, got %s", agentPkg.Version)
}
}
// =============================================================================
// Agent with zero deps: standalone push/add/fork
// =============================================================================
func TestE2EAgent_NoDep(t *testing.T) {
c := authClient()
devApp := appRoot(t)
defer cleanupPkg(c, "assistants", "@"+testScope, "simple-greeter", "1.0.0")
agentMgr := agentmgr.New(c, devApp, &common.AutoConfirmPrompter{})
if err := agentMgr.Push(testScope+".simple-greeter", agentmgr.PushOptions{Version: "1.0.0"}); err != nil {
t.Fatalf("Push simple-greeter: %v", err)
}
installApp := t.TempDir()
installAgent := agentmgr.New(c, installApp, &common.AutoConfirmPrompter{})
if err := installAgent.Add("@"+testScope+"/simple-greeter", agentmgr.AddOptions{}); err != nil {
t.Fatalf("Add simple-greeter: %v", err)
}
requireFileExists(t, filepath.Join(installApp, "assistants", testScope, "simple-greeter", "package.yao"))
requireFileContains(t, filepath.Join(installApp, "assistants", testScope, "simple-greeter", "prompts.yml"),
"friendly greeter")
pkg := requireLockfileHas(t, installApp, "@"+testScope+"/simple-greeter")
if pkg.Version != "1.0.0" {
t.Errorf("want version 1.0.0, got %s", pkg.Version)
}
if pkg.Type != common.TypeAssistant {
t.Errorf("want type assistant, got %s", pkg.Type)
}
// No MCP deps should be installed
lf, _ := common.LoadLockfile(installApp)
for id := range lf.Packages {
if id != "@"+testScope+"/simple-greeter" {
t.Errorf("unexpected package in lockfile: %s (standalone agent should have no deps)", id)
}
}
// Fork
if err := installAgent.Fork("@"+testScope+"/simple-greeter", agentmgr.ForkOptions{TargetScope: "local"}); err != nil {
t.Fatalf("Fork simple-greeter: %v", err)
}
requireFileExists(t, filepath.Join(installApp, "assistants", "local", "simple-greeter", "package.yao"))
forkedPkg := requireLockfileHas(t, installApp, "@local/simple-greeter")
if forkedPkg.ForkedFrom != "@"+testScope+"/simple-greeter" {
t.Errorf("expected forked_from, got %s", forkedPkg.ForkedFrom)
}
}
// =============================================================================
// Add already installed — reject without --force
// =============================================================================
func TestE2EAgent_AddAlreadyInstalled(t *testing.T) {
c := authClient()
devApp := appRoot(t)
defer cleanupPkg(c, "assistants", "@"+testScope, "simple-greeter", "1.0.0")
agentMgr := agentmgr.New(c, devApp, &common.AutoConfirmPrompter{})
agentMgr.Push(testScope+".simple-greeter", agentmgr.PushOptions{Version: "1.0.0"})
installApp := t.TempDir()
installAgent := agentmgr.New(c, installApp, &common.AutoConfirmPrompter{})
installAgent.Add("@"+testScope+"/simple-greeter", agentmgr.AddOptions{})
err := installAgent.Add("@"+testScope+"/simple-greeter", agentmgr.AddOptions{})
if err == nil {
t.Fatal("expected already-installed error")
}
if !strings.Contains(err.Error(), "already installed") {
t.Errorf("expected 'already installed' error, got: %v", err)
}
}
// =============================================================================
// Fork to custom scope
// =============================================================================
func TestE2EAgent_ForkToCustomScope(t *testing.T) {
c := authClient()
devApp := appRoot(t)
defer cleanupPkg(c, "assistants", "@"+testScope, "simple-greeter", "1.0.0")
agentMgr := agentmgr.New(c, devApp, &common.AutoConfirmPrompter{})
agentMgr.Push(testScope+".simple-greeter", agentmgr.PushOptions{Version: "1.0.0"})
installApp := t.TempDir()
installAgent := agentmgr.New(c, installApp, &common.AutoConfirmPrompter{})
installAgent.Add("@"+testScope+"/simple-greeter", agentmgr.AddOptions{})
if err := installAgent.Fork("@"+testScope+"/simple-greeter", agentmgr.ForkOptions{TargetScope: "acme"}); err != nil {
t.Fatalf("Fork to custom scope: %v", err)
}
requireFileExists(t, filepath.Join(installApp, "assistants", "acme", "simple-greeter", "package.yao"))
pkg := requireLockfileHas(t, installApp, "@acme/simple-greeter")
if pkg.ForkedFrom != "@"+testScope+"/simple-greeter" {
t.Errorf("expected forked_from, got %s", pkg.ForkedFrom)
}
}
// =============================================================================
// Fork target already exists
// =============================================================================
func TestE2EAgent_ForkTargetExists(t *testing.T) {
c := authClient()
devApp := appRoot(t)
defer cleanupPkg(c, "assistants", "@"+testScope, "simple-greeter", "1.0.0")
agentMgr := agentmgr.New(c, devApp, &common.AutoConfirmPrompter{})
agentMgr.Push(testScope+".simple-greeter", agentmgr.PushOptions{Version: "1.0.0"})
installApp := t.TempDir()
installAgent := agentmgr.New(c, installApp, &common.AutoConfirmPrompter{})
installAgent.Add("@"+testScope+"/simple-greeter", agentmgr.AddOptions{})
// Pre-create target
targetDir := filepath.Join(installApp, "assistants", "local", "simple-greeter")
mustMkdir(t, targetDir)
err := installAgent.Fork("@"+testScope+"/simple-greeter", agentmgr.ForkOptions{TargetScope: "local"})
if err == nil {
t.Fatal("expected fork to fail when target exists")
}
if !strings.Contains(err.Error(), "already exists") {
t.Errorf("expected 'already exists' error, got: %v", err)
}
}
// =============================================================================
// Push @local is rejected
// =============================================================================
func TestE2EAgent_PushLocalRejected(t *testing.T) {
c := authClient()
devApp := t.TempDir()
localDir := filepath.Join(devApp, "assistants", "local", "my-thing")
mustMkdir(t, localDir)
mustWriteFile(t, filepath.Join(localDir, "package.yao"), `{"name":"my-thing"}`)
mgr := agentmgr.New(c, devApp, &common.AutoConfirmPrompter{})
err := mgr.Push("local.my-thing", agentmgr.PushOptions{Version: "1.0.0"})
if err == nil {
t.Fatal("expected push of @local to be rejected")
}
if !strings.Contains(err.Error(), "@local") {
t.Errorf("expected @local rejection error, got: %v", err)
}
}
// =============================================================================
// Shared MCP dep: two agents share same MCP, verify required_by
// =============================================================================
func TestE2EAgent_SharedMCPDep(t *testing.T) {
c := authClient()
devApp := appRoot(t)
defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0")
defer cleanupPkg(c, "assistants", "@"+testScope, "registry-agent", "1.0.0")
defer cleanupPkg(c, "assistants", "@"+testScope, "analytics", "1.0.0")
defer cleanupPkg(c, "mcps", "@"+testScope, "data-tools", "1.0.0")
// Push all dependencies
mcpMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{})
mcpMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"})
mcpMgr.Push(testScope+".data-tools", mcpmgr.PushOptions{Version: "1.0.0"})
agentPushMgr := agentmgr.New(c, devApp, &common.AutoConfirmPrompter{})
agentPushMgr.Push(testScope+".registry-agent", agentmgr.PushOptions{Version: "1.0.0"})
agentPushMgr.Push(testScope+".analytics", agentmgr.PushOptions{Version: "1.0.0"})
// Install agent A (depends on registry-mcp)
installApp := t.TempDir()
installAgent := agentmgr.New(c, installApp, &common.AutoConfirmPrompter{})
if err := installAgent.Add("@"+testScope+"/registry-agent", agentmgr.AddOptions{}); err != nil {
t.Fatalf("Add registry-agent: %v", err)
}
// registry-mcp should be installed with required_by=[registry-agent]
mcpPkg := requireLockfileHas(t, installApp, "@"+testScope+"/registry-mcp")
if mcpPkg.Version != "1.0.0" {
t.Errorf("want MCP version 1.0.0, got %s", mcpPkg.Version)
}
// Install agent B (depends on registry-mcp AND data-tools)
if err := installAgent.Add("@"+testScope+"/analytics", agentmgr.AddOptions{}); err != nil {
t.Fatalf("Add analytics: %v", err)
}
// registry-mcp should NOT be reinstalled, but required_by should include both agents
lf, _ := common.LoadLockfile(installApp)
mcpPkg2, _ := lf.GetPackage("@" + testScope + "/registry-mcp")
requiredBySet := map[string]bool{}
for _, rb := range mcpPkg2.RequiredBy {
requiredBySet[rb] = true
}
if !requiredBySet["@"+testScope+"/registry-agent"] {
t.Error("expected registry-agent in registry-mcp's required_by")
}
if !requiredBySet["@"+testScope+"/analytics"] {
t.Error("expected analytics in registry-mcp's required_by")
}
// data-tools should also be installed
requireLockfileHas(t, installApp, "@"+testScope+"/data-tools")
// Verify disk files are not duplicated — only one copy of each MCP
requireFileExists(t, filepath.Join(installApp, "mcps", testScope, "registry-mcp", "server.mcp.yao"))
requireFileExists(t, filepath.Join(installApp, "mcps", testScope, "data-tools", "server.mcp.yao"))
}
// =============================================================================
// Agent Fork from registry: not installed locally, pull then fork
// =============================================================================
func TestE2EAgent_ForkFromRegistry(t *testing.T) {
c := authClient()
devApp := appRoot(t)
defer cleanupPkg(c, "assistants", "@"+testScope, "simple-greeter", "1.0.0")
agentMgr := agentmgr.New(c, devApp, &common.AutoConfirmPrompter{})
agentMgr.Push(testScope+".simple-greeter", agentmgr.PushOptions{Version: "1.0.0"})
// Fresh app — nothing installed
forkApp := t.TempDir()
forkAgent := agentmgr.New(c, forkApp, &common.AutoConfirmPrompter{})
if err := forkAgent.Fork("@"+testScope+"/simple-greeter", agentmgr.ForkOptions{TargetScope: "local"}); err != nil {
t.Fatalf("Fork from registry: %v", err)
}
requireFileExists(t, filepath.Join(forkApp, "assistants", "local", "simple-greeter", "package.yao"))
requireFileContains(t, filepath.Join(forkApp, "assistants", "local", "simple-greeter", "prompts.yml"),
"friendly greeter")
pkg := requireLockfileHas(t, forkApp, "@local/simple-greeter")
if pkg.ForkedFrom != "@"+testScope+"/simple-greeter" {
t.Errorf("expected forked_from, got %s", pkg.ForkedFrom)
}
}

View file

@ -0,0 +1,148 @@
package common
import (
"fmt"
"strings"
)
// DepStatus represents the status of a dependency check.
type DepStatus int
const (
DepNotInstalled DepStatus = iota // Not installed at all
DepSatisfied // Installed and version satisfies requirement
DepConflict // Installed but version does not satisfy requirement
)
// DepCheckResult holds the result of checking a single dependency.
type DepCheckResult struct {
PackageID string
RequiredVersion string
InstalledVersion string
Status DepStatus
}
// CheckDependencies checks each dependency from the manifest against the lockfile.
// Returns missing, conflicting, and satisfied dependencies.
func CheckDependencies(deps map[string]string, lf *RegistryYao) (missing, conflicts, satisfied []DepCheckResult) {
for pkgID, requiredVer := range deps {
installed, ok := lf.GetPackage(pkgID)
if !ok {
missing = append(missing, DepCheckResult{
PackageID: pkgID,
RequiredVersion: requiredVer,
Status: DepNotInstalled,
})
continue
}
if VersionSatisfies(installed.Version, requiredVer) {
satisfied = append(satisfied, DepCheckResult{
PackageID: pkgID,
RequiredVersion: requiredVer,
InstalledVersion: installed.Version,
Status: DepSatisfied,
})
} else {
conflicts = append(conflicts, DepCheckResult{
PackageID: pkgID,
RequiredVersion: requiredVer,
InstalledVersion: installed.Version,
Status: DepConflict,
})
}
}
return
}
// DetectCycle checks if adding pkgID to the installing set would cause a cycle.
// Returns true if a cycle is detected.
func DetectCycle(installing map[string]bool, pkgID string) bool {
return installing[pkgID]
}
// VersionSatisfies checks if installedVer satisfies the constraint.
// Supports:
// - "^X.Y.Z" — same major, >= minor.patch
// - ">=X.Y.Z" — greater or equal
// - "X.Y.Z" — exact match
// - "*" — any version
func VersionSatisfies(installedVer, constraint string) bool {
constraint = strings.TrimSpace(constraint)
if constraint == "" || constraint == "*" {
return true
}
if strings.HasPrefix(constraint, "^") {
return caretSatisfies(installedVer, constraint[1:])
}
if strings.HasPrefix(constraint, ">=") {
return compareVersions(installedVer, strings.TrimSpace(constraint[2:])) >= 0
}
// Exact match
return installedVer == constraint
}
// caretSatisfies implements ^X.Y.Z: same major version, >= the specified version.
func caretSatisfies(installed, minVer string) bool {
iMajor, iMinor, iPatch, err := parseVersion(installed)
if err != nil {
return false
}
mMajor, mMinor, mPatch, err := parseVersion(minVer)
if err != nil {
return false
}
if iMajor != mMajor {
return false
}
if iMinor > mMinor {
return true
}
if iMinor == mMinor {
return iPatch >= mPatch
}
return false
}
func compareVersions(a, b string) int {
aMaj, aMin, aPat, err1 := parseVersion(a)
bMaj, bMin, bPat, err2 := parseVersion(b)
if err1 != nil || err2 != nil {
if a == b {
return 0
}
if a > b {
return 1
}
return -1
}
if aMaj != bMaj {
return aMaj - bMaj
}
if aMin != bMin {
return aMin - bMin
}
return aPat - bPat
}
func parseVersion(v string) (major, minor, patch int, err error) {
v = strings.TrimSpace(v)
parts := strings.SplitN(v, ".", 3)
if len(parts) != 3 {
return 0, 0, 0, fmt.Errorf("invalid version %q", v)
}
if _, err := fmt.Sscanf(parts[0], "%d", &major); err != nil {
return 0, 0, 0, fmt.Errorf("invalid major in %q", v)
}
if _, err := fmt.Sscanf(parts[1], "%d", &minor); err != nil {
return 0, 0, 0, fmt.Errorf("invalid minor in %q", v)
}
if _, err := fmt.Sscanf(parts[2], "%d", &patch); err != nil {
return 0, 0, 0, fmt.Errorf("invalid patch in %q", v)
}
return major, minor, patch, nil
}

View file

@ -0,0 +1,133 @@
package common
import (
"testing"
)
func TestVersionSatisfies(t *testing.T) {
tests := []struct {
installed string
constraint string
want bool
}{
{"1.0.0", "^1.0.0", true},
{"1.2.3", "^1.0.0", true},
{"1.0.1", "^1.0.0", true},
{"2.0.0", "^1.0.0", false},
{"0.9.0", "^1.0.0", false},
{"1.0.0", ">=1.0.0", true},
{"2.0.0", ">=1.0.0", true},
{"0.9.0", ">=1.0.0", false},
{"1.0.0", "1.0.0", true},
{"1.0.1", "1.0.0", false},
{"1.0.0", "*", true},
{"1.0.0", "", true},
{"1.3.0", "^1.0.0", true},
{"1.0.0", "^1.3.0", false},
}
for _, tt := range tests {
got := VersionSatisfies(tt.installed, tt.constraint)
if got != tt.want {
t.Errorf("VersionSatisfies(%q, %q) = %v, want %v", tt.installed, tt.constraint, got, tt.want)
}
}
}
func TestCheckDependencies(t *testing.T) {
lf := &RegistryYao{
Packages: map[string]PackageInfo{
"@yao/rag-tools": {Type: TypeMCP, Version: "1.3.0"},
"@yao/title-gen": {Type: TypeAssistant, Version: "1.0.0"},
"@yao/old-helper": {Type: TypeAssistant, Version: "0.5.0"},
},
}
deps := map[string]string{
"@yao/rag-tools": "^1.0.0",
"@yao/title-gen": "^2.0.0", // conflict: installed 1.0.0, needs ^2.0.0
"@yao/old-helper": "^0.5.0",
"@yao/new-pkg": "^1.0.0", // missing
}
missing, conflicts, satisfied := CheckDependencies(deps, lf)
if len(missing) != 1 {
t.Fatalf("expected 1 missing, got %d", len(missing))
}
if missing[0].PackageID != "@yao/new-pkg" {
t.Errorf("expected @yao/new-pkg missing, got %s", missing[0].PackageID)
}
if len(conflicts) != 1 {
t.Fatalf("expected 1 conflict, got %d", len(conflicts))
}
if conflicts[0].PackageID != "@yao/title-gen" {
t.Errorf("expected @yao/title-gen conflict, got %s", conflicts[0].PackageID)
}
if conflicts[0].InstalledVersion != "1.0.0" {
t.Errorf("expected installed 1.0.0, got %s", conflicts[0].InstalledVersion)
}
if len(satisfied) != 2 {
t.Fatalf("expected 2 satisfied, got %d", len(satisfied))
}
}
func TestCheckDependenciesEmptyLockfile(t *testing.T) {
lf := &RegistryYao{Packages: map[string]PackageInfo{}}
deps := map[string]string{
"@yao/a": "^1.0.0",
"@yao/b": "^2.0.0",
}
missing, conflicts, satisfied := CheckDependencies(deps, lf)
if len(missing) != 2 {
t.Errorf("expected 2 missing, got %d", len(missing))
}
if len(conflicts) != 0 {
t.Errorf("expected 0 conflicts, got %d", len(conflicts))
}
if len(satisfied) != 0 {
t.Errorf("expected 0 satisfied, got %d", len(satisfied))
}
}
func TestDetectCycle(t *testing.T) {
installing := map[string]bool{
"@yao/keeper": true,
}
if !DetectCycle(installing, "@yao/keeper") {
t.Error("expected cycle detected for @yao/keeper")
}
if DetectCycle(installing, "@yao/other") {
t.Error("expected no cycle for @yao/other")
}
}
func TestCompareVersions(t *testing.T) {
if compareVersions("1.0.0", "1.0.0") != 0 {
t.Error("1.0.0 == 1.0.0")
}
if compareVersions("2.0.0", "1.0.0") <= 0 {
t.Error("2.0.0 > 1.0.0")
}
if compareVersions("1.0.0", "2.0.0") >= 0 {
t.Error("1.0.0 < 2.0.0")
}
if compareVersions("1.1.0", "1.0.0") <= 0 {
t.Error("1.1.0 > 1.0.0")
}
}
func TestParseVersionInvalid(t *testing.T) {
_, _, _, err := parseVersion("bad")
if err == nil {
t.Error("expected error")
}
_, _, _, err = parseVersion("1.2")
if err == nil {
t.Error("expected error for 2-part version")
}
}

View file

@ -0,0 +1,60 @@
package common
import (
"crypto/sha256"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// HashFile computes the SHA-256 hash of a file and returns it as "sha256-<hex>".
func HashFile(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return fmt.Sprintf("sha256-%x", h.Sum(nil)), nil
}
// HashBytes computes the SHA-256 hash of raw bytes and returns "sha256-<hex>".
func HashBytes(data []byte) string {
h := sha256.Sum256(data)
return fmt.Sprintf("sha256-%x", h[:])
}
// HashDir walks a directory and returns a map of relative paths to their SHA-256 hashes.
// The relPrefix is prepended to each relative path (use "" for no prefix).
func HashDir(dir, relPrefix string) (map[string]string, error) {
result := map[string]string{}
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
rel, err := filepath.Rel(dir, path)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if relPrefix != "" {
rel = strings.TrimRight(filepath.ToSlash(relPrefix), "/") + "/" + rel
}
hash, err := HashFile(path)
if err != nil {
return err
}
result[rel] = hash
return nil
})
return result, err
}

View file

@ -0,0 +1,87 @@
package common
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestHashFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.txt")
if err := os.WriteFile(path, []byte("hello world"), 0644); err != nil {
t.Fatal(err)
}
hash, err := HashFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(hash, "sha256-") {
t.Errorf("expected sha256- prefix, got %q", hash)
}
if len(hash) != 7+64 { // "sha256-" + 64 hex chars
t.Errorf("unexpected hash length: %d", len(hash))
}
// Same content should produce same hash
hash2, _ := HashFile(path)
if hash != hash2 {
t.Error("same file should produce same hash")
}
// Non-existent file
_, err = HashFile(filepath.Join(dir, "nope.txt"))
if err == nil {
t.Error("expected error for missing file")
}
}
func TestHashBytes(t *testing.T) {
h := HashBytes([]byte("hello world"))
if !strings.HasPrefix(h, "sha256-") {
t.Errorf("expected sha256- prefix, got %q", h)
}
h2 := HashBytes([]byte("hello world"))
if h != h2 {
t.Error("same bytes should produce same hash")
}
h3 := HashBytes([]byte("different"))
if h == h3 {
t.Error("different bytes should produce different hash")
}
}
func TestHashDir(t *testing.T) {
dir := t.TempDir()
os.MkdirAll(filepath.Join(dir, "sub"), 0755)
os.WriteFile(filepath.Join(dir, "a.txt"), []byte("aaa"), 0644)
os.WriteFile(filepath.Join(dir, "sub", "b.txt"), []byte("bbb"), 0644)
// Without prefix
hashes, err := HashDir(dir, "")
if err != nil {
t.Fatal(err)
}
if len(hashes) != 2 {
t.Errorf("expected 2 files, got %d", len(hashes))
}
if _, ok := hashes["a.txt"]; !ok {
t.Error("expected a.txt in hashes")
}
if _, ok := hashes["sub/b.txt"]; !ok {
t.Error("expected sub/b.txt in hashes")
}
// With prefix
hashes, err = HashDir(dir, "mcps/yao/rag-tools")
if err != nil {
t.Fatal(err)
}
if _, ok := hashes["mcps/yao/rag-tools/a.txt"]; !ok {
t.Error("expected prefixed path")
}
}

View file

@ -0,0 +1,119 @@
package common
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
const lockfileName = "registry.yao"
// LoadLockfile reads registry.yao from appRoot. Returns an empty lockfile if
// the file does not exist.
func LoadLockfile(appRoot string) (*RegistryYao, error) {
path := filepath.Join(appRoot, lockfileName)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &RegistryYao{
Scope: "@local",
Packages: map[string]PackageInfo{},
}, nil
}
return nil, fmt.Errorf("read %s: %w", lockfileName, err)
}
var lf RegistryYao
if err := json.Unmarshal(data, &lf); err != nil {
return nil, fmt.Errorf("parse %s: %w", lockfileName, err)
}
if lf.Packages == nil {
lf.Packages = map[string]PackageInfo{}
}
if lf.Scope == "" {
lf.Scope = "@local"
}
return &lf, nil
}
// SaveLockfile writes registry.yao to appRoot.
func SaveLockfile(appRoot string, lf *RegistryYao) error {
path := filepath.Join(appRoot, lockfileName)
data, err := json.MarshalIndent(lf, "", " ")
if err != nil {
return fmt.Errorf("marshal %s: %w", lockfileName, err)
}
data = append(data, '\n')
return os.WriteFile(path, data, 0644)
}
// GetPackage returns the package info and existence flag for a given ID.
func (lf *RegistryYao) GetPackage(pkgID string) (PackageInfo, bool) {
info, ok := lf.Packages[pkgID]
return info, ok
}
// SetPackage adds or updates a package entry.
func (lf *RegistryYao) SetPackage(pkgID string, info PackageInfo) {
lf.Packages[pkgID] = info
}
// RemovePackage removes a package entry and cleans up required_by references.
func (lf *RegistryYao) RemovePackage(pkgID string) {
pkg, ok := lf.Packages[pkgID]
if !ok {
return
}
// Remove this package from the required_by lists of its dependencies
for depID := range pkg.Dependencies {
if dep, exists := lf.Packages[depID]; exists {
dep.RequiredBy = removeFromSlice(dep.RequiredBy, pkgID)
lf.Packages[depID] = dep
}
}
delete(lf.Packages, pkgID)
}
// AddRequiredBy adds a reverse dependency reference.
func (lf *RegistryYao) AddRequiredBy(depID, requiredByID string) {
dep, ok := lf.Packages[depID]
if !ok {
return
}
for _, id := range dep.RequiredBy {
if id == requiredByID {
return
}
}
dep.RequiredBy = append(dep.RequiredBy, requiredByID)
lf.Packages[depID] = dep
}
// DefaultScope returns the user's default scope (from the "scope" field).
// Returns "local" (without @) for use in directory paths.
func (lf *RegistryYao) DefaultScope() string {
scope := lf.Scope
if scope == "" {
scope = "@local"
}
if len(scope) > 0 && scope[0] == '@' {
return scope[1:]
}
return scope
}
func removeFromSlice(s []string, item string) []string {
result := make([]string, 0, len(s))
for _, v := range s {
if v != item {
result = append(result, v)
}
}
if len(result) == 0 {
return nil
}
return result
}

View file

@ -0,0 +1,178 @@
package common
import (
"os"
"path/filepath"
"testing"
)
func TestLoadLockfileNotExist(t *testing.T) {
dir := t.TempDir()
lf, err := LoadLockfile(dir)
if err != nil {
t.Fatal(err)
}
if lf.Scope != "@local" {
t.Errorf("expected @local scope, got %q", lf.Scope)
}
if len(lf.Packages) != 0 {
t.Errorf("expected empty packages")
}
}
func TestLoadAndSaveLockfile(t *testing.T) {
dir := t.TempDir()
lf := &RegistryYao{
Scope: "@local",
Packages: map[string]PackageInfo{
"@yao/keeper": {
Type: TypeAssistant,
Version: "2.0.0",
Files: map[string]string{"package.yao": "sha256-aaa"},
},
},
}
if err := SaveLockfile(dir, lf); err != nil {
t.Fatal(err)
}
// Verify file exists
data, err := os.ReadFile(filepath.Join(dir, "registry.yao"))
if err != nil {
t.Fatal(err)
}
if len(data) == 0 {
t.Fatal("expected non-empty file")
}
// Reload
lf2, err := LoadLockfile(dir)
if err != nil {
t.Fatal(err)
}
if lf2.Scope != "@local" {
t.Errorf("scope mismatch: %q", lf2.Scope)
}
pkg, ok := lf2.GetPackage("@yao/keeper")
if !ok {
t.Fatal("expected @yao/keeper in lockfile")
}
if pkg.Version != "2.0.0" {
t.Errorf("version mismatch: %q", pkg.Version)
}
if pkg.Files["package.yao"] != "sha256-aaa" {
t.Error("files hash mismatch")
}
}
func TestLoadLockfileInvalid(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "registry.yao"), []byte("not json"), 0644)
_, err := LoadLockfile(dir)
if err == nil {
t.Error("expected parse error")
}
}
func TestSetAndGetPackage(t *testing.T) {
lf := &RegistryYao{Packages: map[string]PackageInfo{}}
lf.SetPackage("@yao/test", PackageInfo{Type: TypeMCP, Version: "1.0.0"})
pkg, ok := lf.GetPackage("@yao/test")
if !ok {
t.Fatal("expected package")
}
if pkg.Type != TypeMCP {
t.Errorf("type mismatch: %q", pkg.Type)
}
}
func TestRemovePackage(t *testing.T) {
lf := &RegistryYao{
Packages: map[string]PackageInfo{
"@yao/keeper": {
Type: TypeAssistant,
Version: "1.0.0",
Dependencies: map[string]string{"@yao/rag-tools": "^1.0.0"},
},
"@yao/rag-tools": {
Type: TypeMCP,
Version: "1.0.0",
RequiredBy: []string{"@yao/keeper"},
},
},
}
lf.RemovePackage("@yao/keeper")
if _, ok := lf.GetPackage("@yao/keeper"); ok {
t.Error("expected @yao/keeper removed")
}
dep, ok := lf.GetPackage("@yao/rag-tools")
if !ok {
t.Fatal("expected @yao/rag-tools still present")
}
if len(dep.RequiredBy) != 0 {
t.Errorf("expected required_by cleaned up, got %v", dep.RequiredBy)
}
}
func TestAddRequiredBy(t *testing.T) {
lf := &RegistryYao{
Packages: map[string]PackageInfo{
"@yao/rag-tools": {Type: TypeMCP, Version: "1.0.0"},
},
}
lf.AddRequiredBy("@yao/rag-tools", "@yao/keeper")
lf.AddRequiredBy("@yao/rag-tools", "@yao/keeper") // duplicate, should not add again
dep, _ := lf.GetPackage("@yao/rag-tools")
if len(dep.RequiredBy) != 1 {
t.Errorf("expected 1 required_by entry, got %d", len(dep.RequiredBy))
}
if dep.RequiredBy[0] != "@yao/keeper" {
t.Errorf("expected @yao/keeper, got %q", dep.RequiredBy[0])
}
// Non-existent package should be a no-op
lf.AddRequiredBy("@nonexistent/pkg", "@yao/keeper")
}
func TestDefaultScope(t *testing.T) {
lf := &RegistryYao{Scope: "@local"}
if lf.DefaultScope() != "local" {
t.Errorf("expected local, got %q", lf.DefaultScope())
}
lf.Scope = "@max"
if lf.DefaultScope() != "max" {
t.Errorf("expected max, got %q", lf.DefaultScope())
}
lf.Scope = ""
if lf.DefaultScope() != "local" {
t.Errorf("expected local for empty scope, got %q", lf.DefaultScope())
}
}
func TestIsManaged(t *testing.T) {
p := PackageInfo{}
if !p.IsManaged() {
t.Error("nil managed should be true")
}
p.Managed = BoolPtr(false)
if p.IsManaged() {
t.Error("false managed should be false")
}
p.Managed = BoolPtr(true)
if !p.IsManaged() {
t.Error("true managed should be true")
}
}

View file

@ -0,0 +1,206 @@
package common
import (
"archive/zip"
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// PackDir creates a .yao.zip from a directory. All files under dir are stored
// under the "package/" prefix in the zip. extraFiles maps additional relative
// paths (under "package/") to their absolute source paths on disk.
// The manifest is written as "package/pkg.yao".
func PackDir(dir string, manifest *PkgManifest, extraFiles map[string]string) ([]byte, error) {
var buf bytes.Buffer
w := zip.NewWriter(&buf)
// Sync Dependencies → RawDependencies before serialization
manifest.PrepareMarshal()
// Write pkg.yao manifest
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return nil, fmt.Errorf("marshal pkg.yao: %w", err)
}
f, err := w.Create("package/pkg.yao")
if err != nil {
return nil, err
}
if _, err := f.Write(data); err != nil {
return nil, err
}
// Walk the main directory
if err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
rel, err := filepath.Rel(dir, path)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
// Skip pkg.yao if it exists in source (we generate our own)
if rel == "pkg.yao" {
return nil
}
return addFileToZip(w, "package/"+rel, path)
}); err != nil {
return nil, fmt.Errorf("walk dir %s: %w", dir, err)
}
// Add extra files (e.g., scripts collected from project root)
for relPath, absPath := range extraFiles {
zipPath := "package/" + filepath.ToSlash(relPath)
if err := addFileToZip(w, zipPath, absPath); err != nil {
return nil, fmt.Errorf("add extra file %s: %w", relPath, err)
}
}
if err := w.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// UnpackTo extracts the "package/" contents from a .yao.zip to destDir.
// Returns a list of extracted file paths relative to destDir.
func UnpackTo(zipData []byte, destDir string) ([]string, error) {
r, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData)))
if err != nil {
return nil, fmt.Errorf("open zip: %w", err)
}
var extracted []string
for _, f := range r.File {
if f.FileInfo().IsDir() {
continue
}
name := f.Name
if !strings.HasPrefix(name, "package/") {
continue
}
rel := strings.TrimPrefix(name, "package/")
if rel == "" || rel == "pkg.yao" {
continue
}
dest := filepath.Join(destDir, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
return nil, err
}
rc, err := f.Open()
if err != nil {
return nil, err
}
out, err := os.Create(dest)
if err != nil {
rc.Close()
return nil, err
}
_, copyErr := io.Copy(out, rc)
rc.Close()
out.Close()
if copyErr != nil {
return nil, copyErr
}
extracted = append(extracted, filepath.ToSlash(rel))
}
return extracted, nil
}
// ReadManifest reads and parses the pkg.yao from a .yao.zip byte slice.
func ReadManifest(zipData []byte) (*PkgManifest, error) {
r, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData)))
if err != nil {
return nil, fmt.Errorf("open zip: %w", err)
}
for _, f := range r.File {
if f.Name == "package/pkg.yao" {
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
var m PkgManifest
if err := json.NewDecoder(rc).Decode(&m); err != nil {
return nil, fmt.Errorf("decode pkg.yao: %w", err)
}
m.NormalizeDependencies()
return &m, nil
}
}
return nil, fmt.Errorf("pkg.yao not found in zip")
}
// ExtractFile reads a single file from the zip under "package/" prefix.
func ExtractFile(zipData []byte, relPath string) ([]byte, error) {
r, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData)))
if err != nil {
return nil, fmt.Errorf("open zip: %w", err)
}
target := "package/" + relPath
for _, f := range r.File {
if f.Name == target {
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
return io.ReadAll(rc)
}
}
return nil, fmt.Errorf("file %q not found in zip", relPath)
}
// ListZipFiles returns all file paths in the zip under "package/" prefix,
// excluding "package/pkg.yao".
func ListZipFiles(zipData []byte) ([]string, error) {
r, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData)))
if err != nil {
return nil, fmt.Errorf("open zip: %w", err)
}
var files []string
for _, f := range r.File {
if f.FileInfo().IsDir() {
continue
}
if !strings.HasPrefix(f.Name, "package/") {
continue
}
rel := strings.TrimPrefix(f.Name, "package/")
if rel == "" || rel == "pkg.yao" {
continue
}
files = append(files, rel)
}
return files, nil
}
func addFileToZip(w *zip.Writer, zipPath, srcPath string) error {
f, err := w.Create(zipPath)
if err != nil {
return err
}
src, err := os.Open(srcPath)
if err != nil {
return err
}
defer src.Close()
_, err = io.Copy(f, src)
return err
}

View file

@ -0,0 +1,158 @@
package common
import (
"os"
"path/filepath"
"sort"
"testing"
)
func TestPackAndUnpack(t *testing.T) {
// Create source directory
srcDir := t.TempDir()
os.WriteFile(filepath.Join(srcDir, "package.yao"), []byte(`{"name":"test"}`), 0644)
os.MkdirAll(filepath.Join(srcDir, "prompts"), 0755)
os.WriteFile(filepath.Join(srcDir, "prompts", "main.md"), []byte("You are a test."), 0644)
manifest := &PkgManifest{
Type: TypeAssistant,
Scope: "test",
Name: "demo",
Version: "1.0.0",
}
zipData, err := PackDir(srcDir, manifest, nil)
if err != nil {
t.Fatalf("PackDir: %v", err)
}
if len(zipData) == 0 {
t.Fatal("expected non-empty zip")
}
// Read manifest from zip
m, err := ReadManifest(zipData)
if err != nil {
t.Fatalf("ReadManifest: %v", err)
}
if m.Type != TypeAssistant || m.Version != "1.0.0" {
t.Errorf("unexpected manifest: %+v", m)
}
// Unpack
destDir := t.TempDir()
files, err := UnpackTo(zipData, destDir)
if err != nil {
t.Fatalf("UnpackTo: %v", err)
}
sort.Strings(files)
if len(files) != 2 {
t.Fatalf("expected 2 files, got %d: %v", len(files), files)
}
if files[0] != "package.yao" || files[1] != "prompts/main.md" {
t.Errorf("unexpected files: %v", files)
}
// Verify content
data, err := os.ReadFile(filepath.Join(destDir, "package.yao"))
if err != nil {
t.Fatal(err)
}
if string(data) != `{"name":"test"}` {
t.Errorf("unexpected content: %s", data)
}
}
func TestPackDirWithExtraFiles(t *testing.T) {
srcDir := t.TempDir()
os.WriteFile(filepath.Join(srcDir, "main.mcp.yao"), []byte("{}"), 0644)
// Create an extra file in a separate location
extraDir := t.TempDir()
os.MkdirAll(filepath.Join(extraDir, "scripts", "yao"), 0755)
scriptPath := filepath.Join(extraDir, "scripts", "yao", "rag.ts")
os.WriteFile(scriptPath, []byte("export function Search() {}"), 0644)
manifest := &PkgManifest{
Type: TypeMCP,
Scope: "yao",
Name: "rag-tools",
Version: "1.0.0",
}
extraFiles := map[string]string{
"scripts/yao/rag.ts": scriptPath,
}
zipData, err := PackDir(srcDir, manifest, extraFiles)
if err != nil {
t.Fatalf("PackDir with extras: %v", err)
}
files, err := ListZipFiles(zipData)
if err != nil {
t.Fatal(err)
}
hasScript := false
hasMCP := false
for _, f := range files {
if f == "scripts/yao/rag.ts" {
hasScript = true
}
if f == "main.mcp.yao" {
hasMCP = true
}
}
if !hasScript {
t.Error("expected scripts/yao/rag.ts in zip")
}
if !hasMCP {
t.Error("expected main.mcp.yao in zip")
}
}
func TestReadManifestMissing(t *testing.T) {
// Create a zip without pkg.yao
srcDir := t.TempDir()
os.WriteFile(filepath.Join(srcDir, "test.txt"), []byte("hello"), 0644)
manifest := &PkgManifest{Type: "test", Version: "1.0.0"}
zipData, err := PackDir(srcDir, manifest, nil)
if err != nil {
t.Fatal(err)
}
// This should succeed because PackDir always writes pkg.yao
m, err := ReadManifest(zipData)
if err != nil {
t.Fatalf("ReadManifest should succeed: %v", err)
}
if m.Type != "test" {
t.Errorf("unexpected type: %s", m.Type)
}
}
func TestExtractFile(t *testing.T) {
srcDir := t.TempDir()
os.WriteFile(filepath.Join(srcDir, "data.json"), []byte(`{"key":"value"}`), 0644)
manifest := &PkgManifest{Type: "test", Version: "1.0.0"}
zipData, err := PackDir(srcDir, manifest, nil)
if err != nil {
t.Fatal(err)
}
data, err := ExtractFile(zipData, "data.json")
if err != nil {
t.Fatal(err)
}
if string(data) != `{"key":"value"}` {
t.Errorf("unexpected: %s", data)
}
_, err = ExtractFile(zipData, "missing.json")
if err == nil {
t.Error("expected error for missing file")
}
}

View file

@ -0,0 +1,91 @@
package common
import (
"fmt"
"path/filepath"
"strings"
)
// ParsePackageID parses "@scope/name" into (scope, name).
// The leading "@" on scope is stripped.
// Examples:
//
// "@yao/keeper" → ("yao", "keeper")
// "@max/tools.search" → ("max", "tools.search")
func ParsePackageID(id string) (scope, name string, err error) {
if !strings.HasPrefix(id, "@") {
return "", "", fmt.Errorf("invalid package ID %q: must start with @", id)
}
parts := strings.SplitN(id[1:], "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", fmt.Errorf("invalid package ID %q: expected @scope/name", id)
}
return parts[0], parts[1], nil
}
// FormatPackageID formats scope and name into "@scope/name".
func FormatPackageID(scope, name string) string {
return "@" + scope + "/" + name
}
// PackageDir returns the installation directory for a package relative to appRoot.
// For assistants: assistants/{scope}/{name}/
// For mcps: mcps/{scope}/{name}/
func PackageDir(pkgType, scope, name, appRoot string) string {
dir := TypeToDir(pkgType)
namePath := strings.ReplaceAll(name, ".", "/")
return filepath.Join(appRoot, dir, scope, namePath)
}
// PackageDirRel returns the installation directory relative to appRoot (no leading appRoot prefix).
func PackageDirRel(pkgType, scope, name string) string {
dir := TypeToDir(pkgType)
namePath := strings.ReplaceAll(name, ".", "/")
return filepath.Join(dir, scope, namePath)
}
// IDFromYaoID converts a Yao dot-separated ID to (scope, name).
// "yao.keeper" → ("yao", "keeper")
// "max.tools.search" → ("max", "tools.search")
func IDFromYaoID(yaoID string) (scope, name string, err error) {
idx := strings.Index(yaoID, ".")
if idx <= 0 || idx >= len(yaoID)-1 {
return "", "", fmt.Errorf("invalid Yao ID %q: expected scope.name", yaoID)
}
return yaoID[:idx], yaoID[idx+1:], nil
}
// YaoIDFromPackageID converts "@scope/name" to "scope.name" (Yao dot-separated ID).
func YaoIDFromPackageID(pkgID string) (string, error) {
scope, name, err := ParsePackageID(pkgID)
if err != nil {
return "", err
}
return scope + "." + name, nil
}
// PackageIDFromYaoID converts "scope.name" to "@scope/name".
func PackageIDFromYaoID(yaoID string) (string, error) {
scope, name, err := IDFromYaoID(yaoID)
if err != nil {
return "", err
}
return FormatPackageID(scope, name), nil
}
// ScopeFromPath extracts the scope from a file path relative to appRoot.
// "assistants/yao/keeper/" → "yao"
// "mcps/max/rag-tools/" → "max"
func ScopeFromPath(relPath string) (string, error) {
parts := strings.Split(filepath.ToSlash(relPath), "/")
if len(parts) < 2 {
return "", fmt.Errorf("cannot extract scope from path %q", relPath)
}
return parts[1], nil
}
// IsLocalScope returns true if the scope is "@local" or "local".
func IsLocalScope(scope string) bool {
return scope == "local" || scope == "@local"
}

View file

@ -0,0 +1,163 @@
package common
import (
"testing"
)
func TestParsePackageID(t *testing.T) {
tests := []struct {
input string
scope string
name string
expectErr bool
}{
{"@yao/keeper", "yao", "keeper", false},
{"@max/tools.search", "max", "tools.search", false},
{"@local/my-mcp", "local", "my-mcp", false},
{"yao/keeper", "", "", true},
{"@/keeper", "", "", true},
{"@yao/", "", "", true},
{"@yao", "", "", true},
{"", "", "", true},
}
for _, tt := range tests {
scope, name, err := ParsePackageID(tt.input)
if tt.expectErr {
if err == nil {
t.Errorf("ParsePackageID(%q) expected error", tt.input)
}
continue
}
if err != nil {
t.Errorf("ParsePackageID(%q) unexpected error: %v", tt.input, err)
continue
}
if scope != tt.scope || name != tt.name {
t.Errorf("ParsePackageID(%q) = (%q, %q), want (%q, %q)", tt.input, scope, name, tt.scope, tt.name)
}
}
}
func TestFormatPackageID(t *testing.T) {
if got := FormatPackageID("yao", "keeper"); got != "@yao/keeper" {
t.Errorf("FormatPackageID = %q, want @yao/keeper", got)
}
}
func TestPackageDir(t *testing.T) {
got := PackageDir(TypeAssistant, "yao", "keeper", "/app")
want := "/app/assistants/yao/keeper"
if got != want {
t.Errorf("PackageDir = %q, want %q", got, want)
}
got = PackageDir(TypeMCP, "max", "rag-tools", "/app")
want = "/app/mcps/max/rag-tools"
if got != want {
t.Errorf("PackageDir = %q, want %q", got, want)
}
got = PackageDir(TypeAssistant, "max", "tools.search", "/app")
want = "/app/assistants/max/tools/search"
if got != want {
t.Errorf("PackageDir nested = %q, want %q", got, want)
}
}
func TestPackageDirRel(t *testing.T) {
got := PackageDirRel(TypeAssistant, "yao", "keeper")
want := "assistants/yao/keeper"
if got != want {
t.Errorf("PackageDirRel = %q, want %q", got, want)
}
}
func TestIDFromYaoID(t *testing.T) {
tests := []struct {
input string
scope string
name string
expectErr bool
}{
{"yao.keeper", "yao", "keeper", false},
{"max.tools.search", "max", "tools.search", false},
{"yao", "", "", true},
{".keeper", "", "", true},
{"yao.", "", "", true},
}
for _, tt := range tests {
scope, name, err := IDFromYaoID(tt.input)
if tt.expectErr {
if err == nil {
t.Errorf("IDFromYaoID(%q) expected error", tt.input)
}
continue
}
if err != nil {
t.Errorf("IDFromYaoID(%q) unexpected error: %v", tt.input, err)
continue
}
if scope != tt.scope || name != tt.name {
t.Errorf("IDFromYaoID(%q) = (%q, %q), want (%q, %q)", tt.input, scope, name, tt.scope, tt.name)
}
}
}
func TestYaoIDFromPackageID(t *testing.T) {
got, err := YaoIDFromPackageID("@yao/keeper")
if err != nil {
t.Fatal(err)
}
if got != "yao.keeper" {
t.Errorf("YaoIDFromPackageID = %q, want yao.keeper", got)
}
_, err = YaoIDFromPackageID("bad")
if err == nil {
t.Error("expected error for invalid input")
}
}
func TestPackageIDFromYaoID(t *testing.T) {
got, err := PackageIDFromYaoID("yao.keeper")
if err != nil {
t.Fatal(err)
}
if got != "@yao/keeper" {
t.Errorf("PackageIDFromYaoID = %q, want @yao/keeper", got)
}
_, err = PackageIDFromYaoID("bad")
if err == nil {
t.Error("expected error for invalid input")
}
}
func TestScopeFromPath(t *testing.T) {
got, err := ScopeFromPath("assistants/yao/keeper")
if err != nil {
t.Fatal(err)
}
if got != "yao" {
t.Errorf("ScopeFromPath = %q, want yao", got)
}
_, err = ScopeFromPath("single")
if err == nil {
t.Error("expected error for single-element path")
}
}
func TestIsLocalScope(t *testing.T) {
if !IsLocalScope("local") {
t.Error("expected local to be local scope")
}
if !IsLocalScope("@local") {
t.Error("expected @local to be local scope")
}
if IsLocalScope("yao") {
t.Error("expected yao to NOT be local scope")
}
}

View file

@ -0,0 +1,80 @@
package common
import (
"bufio"
"fmt"
"os"
"strings"
)
// Prompter abstracts user interaction for testability.
type Prompter interface {
Confirm(message string) bool
Choose(message string, options []string) int
}
// StdinPrompter reads user input from stdin.
type StdinPrompter struct{}
// Confirm asks a yes/no question. Returns true for "y" or "Y".
func (p *StdinPrompter) Confirm(message string) bool {
fmt.Printf("%s [Y/n] ", message)
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
return answer == "" || answer == "y" || answer == "yes"
}
// Choose presents options and returns the 0-based index of the selection.
func (p *StdinPrompter) Choose(message string, options []string) int {
fmt.Println(message)
for i, opt := range options {
fmt.Printf(" [%d] %s\n", i+1, opt)
}
fmt.Print("Enter choice: ")
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(answer)
var choice int
if _, err := fmt.Sscanf(answer, "%d", &choice); err != nil || choice < 1 || choice > len(options) {
return -1
}
return choice - 1
}
// AutoConfirmPrompter always confirms yes. Used for non-interactive mode and tests.
type AutoConfirmPrompter struct{}
func (p *AutoConfirmPrompter) Confirm(message string) bool { return true }
func (p *AutoConfirmPrompter) Choose(message string, _ []string) int { return 0 }
// MockPrompter records calls and returns pre-configured responses.
type MockPrompter struct {
ConfirmResponses []bool
ChooseResponses []int
ConfirmCalls []string
ChooseCalls []string
confirmIdx int
chooseIdx int
}
func (p *MockPrompter) Confirm(message string) bool {
p.ConfirmCalls = append(p.ConfirmCalls, message)
if p.confirmIdx < len(p.ConfirmResponses) {
resp := p.ConfirmResponses[p.confirmIdx]
p.confirmIdx++
return resp
}
return true
}
func (p *MockPrompter) Choose(message string, options []string) int {
p.ChooseCalls = append(p.ChooseCalls, message)
if p.chooseIdx < len(p.ChooseResponses) {
resp := p.ChooseResponses[p.chooseIdx]
p.chooseIdx++
return resp
}
return 0
}

View file

@ -0,0 +1,50 @@
package common
import (
"testing"
)
func TestMockPrompter(t *testing.T) {
m := &MockPrompter{
ConfirmResponses: []bool{true, false},
ChooseResponses: []int{1, 2},
}
if !m.Confirm("install?") {
t.Error("expected true")
}
if m.Confirm("upgrade?") {
t.Error("expected false")
}
if m.Confirm("extra?") != true {
t.Error("expected default true when responses exhausted")
}
if len(m.ConfirmCalls) != 3 {
t.Errorf("expected 3 confirm calls, got %d", len(m.ConfirmCalls))
}
if m.Choose("pick", []string{"a", "b"}) != 1 {
t.Error("expected 1")
}
if m.Choose("pick2", []string{"a", "b", "c"}) != 2 {
t.Error("expected 2")
}
if m.Choose("pick3", nil) != 0 {
t.Error("expected default 0 when responses exhausted")
}
if len(m.ChooseCalls) != 3 {
t.Errorf("expected 3 choose calls, got %d", len(m.ChooseCalls))
}
}
func TestAutoConfirmPrompter(t *testing.T) {
p := &AutoConfirmPrompter{}
if !p.Confirm("anything") {
t.Error("expected always true")
}
if p.Choose("anything", []string{"a", "b"}) != 0 {
t.Error("expected always 0")
}
}

View file

@ -0,0 +1,165 @@
// Package common provides shared types and utilities for the registry manager.
package common
import "encoding/json"
// RegistryYao represents the registry.yao lockfile that tracks installed packages.
type RegistryYao struct {
Scope string `json:"scope"`
Packages map[string]PackageInfo `json:"packages"`
}
// PackageInfo describes an installed package in registry.yao.
type PackageInfo struct {
Type string `json:"type"`
Version string `json:"version"`
Integrity string `json:"integrity"`
Dependencies map[string]string `json:"dependencies,omitempty"`
RequiredBy []string `json:"required_by,omitempty"`
Files map[string]string `json:"files,omitempty"`
Managed *bool `json:"managed,omitempty"`
ForkedFrom string `json:"forked_from,omitempty"`
MemberID string `json:"member_id,omitempty"`
TeamID string `json:"team_id,omitempty"`
}
// IsManaged returns true if the package is managed by the registry (not forked).
func (p *PackageInfo) IsManaged() bool {
if p.Managed == nil {
return true
}
return *p.Managed
}
// PkgManifest represents the pkg.yao file inside a .yao.zip package.
// Dependencies can come from the registry in array format [{type,scope,name,version}]
// and are normalized to map["@scope/name"] = "version" after loading.
type PkgManifest struct {
Type string `json:"type"`
Scope string `json:"scope"`
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description,omitempty"`
Dependencies map[string]string `json:"-"`
RawDependencies json.RawMessage `json:"dependencies,omitempty"`
Keywords []string `json:"keywords,omitempty"`
License string `json:"license,omitempty"`
Author *ManifestAuthor `json:"author,omitempty"`
Engines map[string]string `json:"engines,omitempty"`
}
// ManifestDep represents a dependency entry in the array format from the registry.
type ManifestDep struct {
Type string `json:"type"`
Scope string `json:"scope"`
Name string `json:"name"`
Version string `json:"version"`
}
// NormalizeDependencies parses RawDependencies into the Dependencies map.
// Supports both formats:
// - Array: [{"type":"mcp","scope":"@test","name":"dep","version":"^1.0.0"}]
// - Map: {"@test/dep": "^1.0.0"}
func (m *PkgManifest) NormalizeDependencies() {
if m.Dependencies != nil || len(m.RawDependencies) == 0 {
return
}
m.Dependencies = map[string]string{}
// Try array format first
var arrDeps []ManifestDep
if err := json.Unmarshal(m.RawDependencies, &arrDeps); err == nil {
for _, d := range arrDeps {
scope := d.Scope
if len(scope) > 0 && scope[0] != '@' {
scope = "@" + scope
}
pkgID := scope + "/" + d.Name
m.Dependencies[pkgID] = d.Version
}
return
}
// Try map format
var mapDeps map[string]string
if err := json.Unmarshal(m.RawDependencies, &mapDeps); err == nil {
m.Dependencies = mapDeps
}
}
// PrepareMarshal syncs Dependencies map into RawDependencies for JSON serialization.
// Converts the internal map["@scope/name"] = "version" format to the array format
// required by the registry server: [{"type":"...","scope":"@...","name":"...","version":"..."}].
func (m *PkgManifest) PrepareMarshal() {
if len(m.Dependencies) == 0 {
return
}
var arr []ManifestDep
for pkgID, ver := range m.Dependencies {
scope, name, err := ParsePackageID(pkgID)
if err != nil {
continue
}
depType := TypeDirMCPs
arr = append(arr, ManifestDep{
Type: depType,
Scope: "@" + scope,
Name: name,
Version: ver,
})
}
data, err := json.Marshal(arr)
if err == nil {
m.RawDependencies = data
}
}
// ManifestAuthor holds author information in pkg.yao.
type ManifestAuthor struct {
Name string `json:"name"`
Email string `json:"email,omitempty"`
}
// PackageType constants map to registry API type strings and local directory names.
const (
TypeAssistant = "assistant"
TypeMCP = "mcp"
TypeRobot = "robot"
TypeDirAssistants = "assistants"
TypeDirMCPs = "mcps"
TypeDirRobots = "robots"
)
// TypeToDir maps package type to its top-level directory name.
func TypeToDir(pkgType string) string {
switch pkgType {
case TypeAssistant:
return TypeDirAssistants
case TypeMCP:
return TypeDirMCPs
case TypeRobot:
return TypeDirRobots
default:
return pkgType
}
}
// TypeToRegistryType maps package type to the registry API type string.
func TypeToRegistryType(pkgType string) string {
switch pkgType {
case TypeAssistant:
return TypeDirAssistants
case TypeMCP:
return TypeDirMCPs
case TypeRobot:
return TypeDirRobots
default:
return pkgType
}
}
// BoolPtr returns a pointer to a bool value.
func BoolPtr(v bool) *bool {
return &v
}

View file

@ -0,0 +1,361 @@
package manager_test
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/yaoapp/yao/registry"
"github.com/yaoapp/yao/registry/manager/common"
)
// testScope is the scope aligned with registry CI credentials (yaoagents:yaoagents).
const testScope = "yaoagents"
func registryURL() string {
if u := os.Getenv("YAO_REGISTRY_URL"); u != "" {
return u
}
return "http://localhost:8080"
}
func authClient() *registry.Client {
return registry.New(registryURL(), registry.WithAuth(testScope, testScope))
}
func cleanupPkg(c *registry.Client, pkgType, scope, name, version string) {
c.DeleteVersion(pkgType, scope, name, version)
}
// appRoot returns the path to yao-dev-app, which contains the real test fixtures
// under assistants/yaoagents/, mcps/yaoagents/, scripts/yaoagents/.
//
// Resolution order:
// 1. YAO_TEST_APPLICATION env var (set by CI and local env.local.sh)
// 2. ../yao-dev-app (local development layout)
// 3. ../app (CI layout after "Move Dependencies" step)
func appRoot(t *testing.T) string {
t.Helper()
check := func(root string) bool {
_, err := os.Stat(filepath.Join(root, "assistants", testScope, "registry-agent", "package.yao"))
return err == nil
}
if root := os.Getenv("YAO_TEST_APPLICATION"); root != "" {
abs, _ := filepath.Abs(root)
if check(abs) {
return abs
}
t.Logf("YAO_TEST_APPLICATION=%s exists but missing registry test fixtures", root)
}
for _, rel := range []string{
filepath.Join("..", "..", "..", "yao-dev-app"),
filepath.Join("..", "..", "..", "..", "app"),
filepath.Join("..", "yao-dev-app"),
} {
abs, _ := filepath.Abs(rel)
if check(abs) {
return abs
}
}
t.Skip("yao-dev-app with registry test fixtures not found; set YAO_TEST_APPLICATION")
return ""
}
// mustMkdir creates a directory tree, failing the test on error.
func mustMkdir(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(path, 0755); err != nil {
t.Fatal(err)
}
}
// mustWriteFile writes content to a file, creating parent directories as needed.
func mustWriteFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
// requireFileExists asserts that a file exists on disk.
func requireFileExists(t *testing.T, path string) {
t.Helper()
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected file to exist: %s", path)
}
}
// requireFileNotExists asserts that a file does NOT exist on disk.
func requireFileNotExists(t *testing.T, path string) {
t.Helper()
if _, err := os.Stat(path); err == nil {
t.Fatalf("expected file NOT to exist: %s", path)
}
}
// requireFileContains asserts that a file exists and its content contains substr.
func requireFileContains(t *testing.T, path, substr string) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("cannot read %s: %v", path, err)
}
if !strings.Contains(string(data), substr) {
t.Errorf("expected %s to contain %q, got:\n%s", filepath.Base(path), substr, data)
}
}
// requireFileNotContains asserts that a file's content does NOT contain substr.
func requireFileNotContains(t *testing.T, path, substr string) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("cannot read %s: %v", path, err)
}
if strings.Contains(string(data), substr) {
t.Errorf("expected %s NOT to contain %q, got:\n%s", filepath.Base(path), substr, data)
}
}
// requireLockfileHas asserts that the lockfile at appRoot has a package with the given ID.
func requireLockfileHas(t *testing.T, appRoot, pkgID string) common.PackageInfo {
t.Helper()
lf, err := common.LoadLockfile(appRoot)
if err != nil {
t.Fatalf("load lockfile: %v", err)
}
pkg, ok := lf.GetPackage(pkgID)
if !ok {
t.Fatalf("expected %s in lockfile, packages: %v", pkgID, lockfileKeys(lf))
}
return pkg
}
// requireLockfileNotHas asserts that the lockfile does NOT contain the given package.
func requireLockfileNotHas(t *testing.T, appRoot, pkgID string) {
t.Helper()
lf, err := common.LoadLockfile(appRoot)
if err != nil {
t.Fatalf("load lockfile: %v", err)
}
if _, ok := lf.GetPackage(pkgID); ok {
t.Fatalf("expected %s NOT in lockfile", pkgID)
}
}
// lockfileKeys returns all package IDs in a lockfile (for debug output).
func lockfileKeys(lf *common.RegistryYao) []string {
var keys []string
for k := range lf.Packages {
keys = append(keys, k)
}
return keys
}
// buildV2AgentApp creates a v2 variant of the agent+MCP fixtures in a temp directory.
// Content differs from v1 in yao-dev-app to verify update logic.
func buildV2AgentApp(t *testing.T) string {
t.Helper()
root := t.TempDir()
// Agent v2: updated description, new tools.ts
assistDir := filepath.Join(root, "assistants", testScope, "registry-agent")
mustMkdir(t, assistDir)
mustWriteFile(t, filepath.Join(assistDir, "package.yao"), `{
"name": "Registry Test Agent v2",
"avatar": "/api/__yao/app/icons/app.png",
"connector": "gpt-4o",
"description": "Enhanced v2 test assistant for registry E2E verification",
"options": { "temperature": 0.5 },
"public": false,
"mcp": {
"servers": [
{ "server_id": "`+testScope+`.registry-mcp" }
]
},
"tags": ["Test", "Registry", "V2"],
"sort": 999,
"readonly": true,
"automated": false,
"mentionable": false
}`)
mustWriteFile(t, filepath.Join(assistDir, "prompts.yml"),
"- role: system\n content: |\n You are the v2 registry test assistant with enhanced capabilities.\n")
mustWriteFile(t, filepath.Join(assistDir, "tools.ts"),
`export function newV2Tool(): number { return 42; }`)
// MCP v2: added "suggest" tool
mcpDir := filepath.Join(root, "mcps", testScope, "registry-mcp")
mustMkdir(t, mcpDir)
mustWriteFile(t, filepath.Join(mcpDir, "server.mcp.yao"), `{
"label": "Registry Test MCP v2",
"description": "Enhanced v2 MCP for registry E2E testing",
"transport": "process",
"capabilities": {
"tools": { "listChanged": false },
"resources": { "subscribe": false, "listChanged": false }
},
"tools": {
"ping": "scripts.`+testScope+`.registry_mcp.Ping",
"echo": "scripts.`+testScope+`.registry_mcp.Echo",
"suggest": "scripts.`+testScope+`.registry_mcp.Suggest"
}
}`)
scriptDir := filepath.Join(root, "scripts", testScope)
mustMkdir(t, scriptDir)
mustWriteFile(t, filepath.Join(scriptDir, "registry_mcp.ts"), `/**
* Registry MCP test script v2
*/
function Ping(): string {
return "pong-v2";
}
function Echo(input: string): string {
return input;
}
function Suggest(prefix: string): string[] {
return ["v2-suggestion1", "v2-suggestion2"];
}
`)
return root
}
// buildV2MCPApp creates a v2 variant of the data-tools MCP for update testing.
func buildV2MCPApp(t *testing.T) string {
t.Helper()
root := t.TempDir()
mcpDir := filepath.Join(root, "mcps", testScope, "data-tools")
mustMkdir(t, mcpDir)
mustWriteFile(t, filepath.Join(mcpDir, "server.mcp.yao"), `{
"label": "Data Tools MCP v2",
"description": "Enhanced v2 data tools with new merge capability",
"transport": "process",
"capabilities": {
"tools": { "listChanged": false },
"resources": { "subscribe": false, "listChanged": false }
},
"tools": {
"aggregate": "scripts.`+testScope+`.data_tools.Aggregate",
"transform": "scripts.`+testScope+`.data_tools.Transform",
"validate": "scripts.`+testScope+`.data_tools.Validate",
"merge": "scripts.`+testScope+`.data_tools.Merge",
"format_csv": "scripts.`+testScope+`.data_utils.FormatCSV",
"format_json": "scripts.`+testScope+`.data_utils.FormatJSON"
}
}`)
scriptDir := filepath.Join(root, "scripts", testScope)
mustMkdir(t, scriptDir)
mustWriteFile(t, filepath.Join(scriptDir, "data_tools.ts"), `/**
* Data Tools MCP v2 - primary script
*/
function Aggregate(data: any[]): Record<string, number> {
return { count: data.length, version: 2 };
}
function Transform(input: string): string {
return input.toUpperCase() + "-v2";
}
function Validate(schema: string, data: any): boolean {
return schema !== "" && data !== null;
}
function Merge(a: any, b: any): any {
return { ...a, ...b };
}
`)
mustWriteFile(t, filepath.Join(scriptDir, "data_utils.ts"), `/**
* Data Tools MCP v2 - utility script
*/
function FormatCSV(rows: string[][]): string {
return "header\\n" + rows.map((r) => r.join(",")).join("\\n");
}
function FormatJSON(data: any): string {
return JSON.stringify(data, null, 2);
}
`)
return root
}
// buildV2AnalyticsApp creates a v2 variant of the analytics agent for update testing.
func buildV2AnalyticsApp(t *testing.T) string {
t.Helper()
root := t.TempDir()
assistDir := filepath.Join(root, "assistants", testScope, "analytics")
mustMkdir(t, assistDir)
mustWriteFile(t, filepath.Join(assistDir, "package.yao"), `{
"name": "Analytics Agent v2",
"avatar": "/api/__yao/app/icons/app.png",
"connector": "gpt-4o",
"description": "Enhanced v2 analytics assistant with charting support",
"options": { "temperature": 0.2 },
"public": false,
"mcp": {
"servers": [
{ "server_id": "`+testScope+`.registry-mcp" },
{ "server_id": "`+testScope+`.data-tools" }
]
},
"tags": ["Test", "Registry", "Analytics", "V2"],
"sort": 998,
"readonly": true,
"automated": false,
"mentionable": false
}`)
mustWriteFile(t, filepath.Join(assistDir, "prompts.yml"),
"- role: system\n content: |\n You are the v2 analytics assistant with charting capabilities.\n")
mustWriteFile(t, filepath.Join(assistDir, "chart_helper.ts"),
`export function renderChart(): string { return "chart-v2"; }`)
return root
}
// buildRobotZip creates a robot package zip and pushes it to the registry.
func buildAndPushRobotZip(t *testing.T, c *registry.Client, name string, robot interface{}, version string) {
t.Helper()
robotBytes, _ := json.Marshal(robot)
robotZipRoot := t.TempDir()
robotDir := filepath.Join(robotZipRoot, "package")
mustMkdir(t, robotDir)
mustWriteFile(t, filepath.Join(robotDir, "pkg.yao"), `{
"type": "robot",
"scope": "@`+testScope+`",
"name": "`+name+`",
"version": "`+version+`",
"description": "E2E test robot"
}`)
mustWriteFile(t, filepath.Join(robotDir, "robot.json"), string(robotBytes))
robotZip, err := common.PackDir(robotDir, &common.PkgManifest{
Type: common.TypeRobot,
Scope: "@" + testScope,
Name: name,
Version: version,
}, nil)
if err != nil {
t.Fatalf("pack robot %s: %v", name, err)
}
if _, err := c.Push("robots", "@"+testScope, name, version, robotZip); err != nil {
t.Fatalf("push robot %s: %v", name, err)
}
}

151
registry/manager/mcp/add.go Normal file
View file

@ -0,0 +1,151 @@
package mcp
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/yaoapp/yao/registry/manager/common"
)
// AddOptions configures the Add operation.
type AddOptions struct {
Version string
Force bool
}
// Add installs an MCP package from the registry.
// Per DESIGN-MCP.md:
// 1. Check conflict
// 2. Pull from registry
// 3. Check dependencies (MCPs currently have none, but structure supports it)
// 4. Unpack .mcp.yao + mapping/ to mcps/{scope}/{name}/
// 5. Extract scripts/ to project root scripts/{scope}/
// 6. Check script conflicts
// 7. Write registry.yao (files include both MCP dir and scripts)
// 8. Hot-reload
func (m *Manager) Add(pkgID string, opts AddOptions) error {
if opts.Version == "" {
opts.Version = "latest"
}
scope, name, err := common.ParsePackageID(pkgID)
if err != nil {
return err
}
lf, err := common.LoadLockfile(m.appRoot)
if err != nil {
return err
}
if existing, ok := lf.GetPackage(pkgID); ok && !opts.Force {
return fmt.Errorf("package %s is already installed (version %s). Use --force to reinstall", pkgID, existing.Version)
}
destDir := common.PackageDir(common.TypeMCP, scope, name, m.appRoot)
if _, err := os.Stat(destDir); err == nil {
if _, ok := lf.GetPackage(pkgID); !ok {
return fmt.Errorf("directory %s already exists but is not managed by registry. Please remove or relocate it first", destDir)
}
}
regType := common.TypeToRegistryType(common.TypeMCP)
zipData, digest, err := m.client.Pull(regType, "@"+scope, name, opts.Version)
if err != nil {
return fmt.Errorf("pull %s: %w", pkgID, err)
}
manifest, err := common.ReadManifest(zipData)
if err != nil {
return fmt.Errorf("read manifest: %w", err)
}
// Unpack everything to a temp dir first, then sort into MCP dir and scripts
tempDir, err := os.MkdirTemp("", "yao-mcp-install-*")
if err != nil {
return err
}
defer os.RemoveAll(tempDir)
allFiles, err := common.UnpackTo(zipData, tempDir)
if err != nil {
return fmt.Errorf("unpack: %w", err)
}
fileHashes := map[string]string{}
mcpRelDir := common.PackageDirRel(common.TypeMCP, scope, name)
for _, f := range allFiles {
srcPath := filepath.Join(tempDir, f)
if strings.HasPrefix(f, "scripts/") {
// Script file → project root scripts/
destPath := filepath.Join(m.appRoot, f)
// Check script conflict: exists but not in registry.yao
if _, err := os.Stat(destPath); err == nil {
if !isScriptTracked(lf, f) {
return fmt.Errorf("script file %s already exists and is not managed by registry. Please remove or relocate it first", f)
}
}
if err := copyFileFromTo(srcPath, destPath); err != nil {
return err
}
hash, _ := common.HashFile(destPath)
fileHashes[f] = hash
} else {
// MCP file → mcps/{scope}/{name}/
destPath := filepath.Join(destDir, f)
if err := copyFileFromTo(srcPath, destPath); err != nil {
return err
}
relPath := mcpRelDir + "/" + f
hash, _ := common.HashFile(destPath)
fileHashes[relPath] = hash
}
}
info := common.PackageInfo{
Type: common.TypeMCP,
Version: manifest.Version,
Integrity: digest,
Dependencies: manifest.Dependencies,
Files: fileHashes,
}
lf.SetPackage(pkgID, info)
for depID := range manifest.Dependencies {
lf.AddRequiredBy(depID, pkgID)
}
if err := common.SaveLockfile(m.appRoot, lf); err != nil {
return err
}
fmt.Printf("✓ Installed %s@%s → %s\n", pkgID, manifest.Version, destDir)
return nil
}
// isScriptTracked checks if a script path is tracked by any package in the lockfile.
func isScriptTracked(lf *common.RegistryYao, scriptPath string) bool {
for _, pkg := range lf.Packages {
if _, ok := pkg.Files[scriptPath]; ok {
return true
}
}
return false
}
func copyFileFromTo(src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
}
data, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, data, 0644)
}

View file

@ -0,0 +1,209 @@
package mcp
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/yaoapp/yao/registry/manager/common"
)
// ForkOptions configures the Fork operation.
type ForkOptions struct {
TargetScope string
}
// Fork copies an MCP to a new scope with process reference rewriting.
// Per DESIGN-MCP.md Fork:
// 1. Copy mcps/{scope}/{name}/ → mcps/{target}/{name}/
// 2. Copy scripts precisely based on registry.yao files record
// 3. Rewrite process references in .mcp.yao (scripts.old. → scripts.new.)
// 4. Write registry.yao with managed:false
// 5. Hot-reload
func (m *Manager) Fork(pkgID string, opts ForkOptions) error {
scope, name, err := common.ParsePackageID(pkgID)
if err != nil {
return err
}
lf, err := common.LoadLockfile(m.appRoot)
if err != nil {
return err
}
targetScope := opts.TargetScope
if targetScope == "" {
targetScope = lf.DefaultScope()
}
targetPkgID := common.FormatPackageID(targetScope, name)
// Check target directory
targetDir := common.PackageDir(common.TypeMCP, targetScope, name, m.appRoot)
if _, err := os.Stat(targetDir); err == nil {
return fmt.Errorf("target directory %s already exists", targetDir)
}
sourceDir := common.PackageDir(common.TypeMCP, scope, name, m.appRoot)
var existing common.PackageInfo
var isLocal bool
if pkg, ok := lf.GetPackage(pkgID); ok {
existing = pkg
isLocal = true
}
if isLocal {
// Copy MCP directory
if err := copyDir(sourceDir, targetDir); err != nil {
return fmt.Errorf("copy MCP dir: %w", err)
}
// Copy scripts precisely based on registry.yao files record
scriptFiles := ScriptPathsFromFiles(existing.Files)
for scriptPath := range scriptFiles {
// Rewrite script path: scripts/{oldScope}/ → scripts/{targetScope}/
newScriptPath := rewriteScriptPath(scriptPath, scope, targetScope)
srcAbs := filepath.Join(m.appRoot, scriptPath)
dstAbs := filepath.Join(m.appRoot, newScriptPath)
if err := copyFileTo(srcAbs, dstAbs); err != nil {
return fmt.Errorf("copy script %s: %w", scriptPath, err)
}
}
} else {
// Pull from registry
regType := common.TypeToRegistryType(common.TypeMCP)
zipData, _, err := m.client.Pull(regType, "@"+scope, name, "latest")
if err != nil {
return fmt.Errorf("pull %s: %w", pkgID, err)
}
// Unpack to temp, then sort
tempDir, err := os.MkdirTemp("", "yao-mcp-fork-*")
if err != nil {
return err
}
defer os.RemoveAll(tempDir)
files, err := common.UnpackTo(zipData, tempDir)
if err != nil {
return err
}
for _, f := range files {
srcPath := filepath.Join(tempDir, f)
if strings.HasPrefix(f, "scripts/") {
newPath := rewriteScriptPath(f, scope, targetScope)
dstPath := filepath.Join(m.appRoot, newPath)
if err := copyFileTo(srcPath, dstPath); err != nil {
return err
}
} else {
dstPath := filepath.Join(targetDir, f)
if err := copyFileTo(srcPath, dstPath); err != nil {
return err
}
}
}
}
// Rewrite process references in all .mcp.yao files in the target directory
mcpFiles, err := FindMCPYaoFiles(targetDir)
if err != nil {
return err
}
for _, mcpFile := range mcpFiles {
data, err := os.ReadFile(mcpFile)
if err != nil {
return err
}
rewritten := RewriteProcessRefs(data, scope, targetScope)
if err := os.WriteFile(mcpFile, rewritten, 0644); err != nil {
return err
}
}
// Compute file hashes for the forked package
mcpRelDir := common.PackageDirRel(common.TypeMCP, targetScope, name)
fileHashes, err := common.HashDir(targetDir, mcpRelDir)
if err != nil {
return err
}
// Also hash the forked scripts
scriptsDir := filepath.Join(m.appRoot, "scripts", targetScope)
if _, err := os.Stat(scriptsDir); err == nil {
scriptHashes, err := common.HashDir(scriptsDir, "scripts/"+targetScope)
if err != nil {
return err
}
for k, v := range scriptHashes {
fileHashes[k] = v
}
}
info := common.PackageInfo{
Type: common.TypeMCP,
Version: "0.0.0",
ForkedFrom: pkgID,
Managed: common.BoolPtr(false),
Files: fileHashes,
}
if isLocal {
info.Version = existing.Version
}
lf.SetPackage(targetPkgID, info)
if err := common.SaveLockfile(m.appRoot, lf); err != nil {
return err
}
yaoID := targetScope + "." + name
fmt.Printf("✓ Forked %s → %s (ID: %s)\n", pkgID, targetDir, yaoID)
fmt.Printf(" Process references rewritten: scripts.%s.* → scripts.%s.*\n", scope, targetScope)
return nil
}
// rewriteScriptPath changes "scripts/{oldScope}/..." to "scripts/{newScope}/..."
func rewriteScriptPath(path, oldScope, newScope string) string {
old := "scripts/" + oldScope + "/"
replacement := "scripts/" + newScope + "/"
return strings.Replace(path, old, replacement, 1)
}
func copyDir(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if info.IsDir() {
return os.MkdirAll(target, info.Mode())
}
return copyFileTo(path, target)
})
}
func copyFileTo(src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
}
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}

View file

@ -0,0 +1,26 @@
// Package mcp implements the MCP package manager for the Yao registry.
package mcp
import (
"github.com/yaoapp/yao/registry"
"github.com/yaoapp/yao/registry/manager/common"
)
// Manager handles MCP package operations (add, update, push, fork).
type Manager struct {
client *registry.Client
appRoot string
prompter common.Prompter
}
// New creates an MCP Manager.
func New(client *registry.Client, appRoot string, prompter common.Prompter) *Manager {
if prompter == nil {
prompter = &common.StdinPrompter{}
}
return &Manager{
client: client,
appRoot: appRoot,
prompter: prompter,
}
}

View file

@ -0,0 +1,385 @@
package mcp
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/yaoapp/yao/registry"
"github.com/yaoapp/yao/registry/manager/common"
"github.com/yaoapp/yao/registry/testdata"
)
func buildMCPZip(scope, name, version string, files map[string]string) []byte {
zip, err := testdata.BuildZip(&testdata.Manifest{
Type: "mcp",
Scope: scope,
Name: name,
Version: version,
}, files)
if err != nil {
panic(err)
}
return zip
}
func mockServer(packages map[string][]byte) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/.well-known/yao-registry" {
json.NewEncoder(w).Encode(map[string]interface{}{
"registry": map[string]string{"version": "1.0.0", "api": "/v1"},
"types": []string{"assistants", "mcps", "robots"},
})
return
}
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/pull") {
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/"), "/")
if len(parts) >= 4 {
key := parts[0] + "/" + parts[1] + "/" + parts[2]
if zipData, ok := packages[key]; ok {
w.Header().Set("X-Digest", "sha256-test")
w.Write(zipData)
return
}
}
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"error": "not found"})
return
}
if r.Method == http.MethodPut {
w.WriteHeader(http.StatusCreated)
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/"), "/")
json.NewEncoder(w).Encode(map[string]string{
"type": parts[0], "scope": parts[1], "name": parts[2],
"version": parts[3], "digest": "sha256-pushed",
})
return
}
w.WriteHeader(http.StatusNotFound)
}))
}
func TestAddMCP(t *testing.T) {
appRoot := t.TempDir()
zip := buildMCPZip("@test", "echo-mcp", "1.0.0", map[string]string{
"echo.mcp.yao": `{"transport":"process","tools":{"echo":"scripts.test.echo.Echo"}}`,
"scripts/test/echo.ts": "export function Echo() {}",
})
srv := mockServer(map[string][]byte{"mcps/@test/echo-mcp": zip})
defer srv.Close()
client := registry.New(srv.URL)
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
err := mgr.Add("@test/echo-mcp", AddOptions{})
if err != nil {
t.Fatalf("Add MCP failed: %v", err)
}
// Verify MCP directory
mcpDir := filepath.Join(appRoot, "mcps", "test", "echo-mcp")
if _, err := os.Stat(filepath.Join(mcpDir, "echo.mcp.yao")); err != nil {
t.Error("expected echo.mcp.yao in MCP dir")
}
// Verify scripts extracted to project root
scriptPath := filepath.Join(appRoot, "scripts", "test", "echo.ts")
if _, err := os.Stat(scriptPath); err != nil {
t.Error("expected scripts/test/echo.ts in project root")
}
// Verify lockfile
lf, _ := common.LoadLockfile(appRoot)
pkg, ok := lf.GetPackage("@test/echo-mcp")
if !ok {
t.Fatal("expected @test/echo-mcp in lockfile")
}
if pkg.Type != common.TypeMCP {
t.Errorf("expected type mcp, got %s", pkg.Type)
}
// Verify files include both MCP dir and scripts
hasScript := false
hasMCPFile := false
for path := range pkg.Files {
if strings.HasPrefix(path, "scripts/") {
hasScript = true
}
if strings.HasPrefix(path, "mcps/") {
hasMCPFile = true
}
}
if !hasScript {
t.Error("expected script path in files")
}
if !hasMCPFile {
t.Error("expected MCP file path in files")
}
}
func TestUpdateMCP(t *testing.T) {
appRoot := t.TempDir()
zipV1 := buildMCPZip("@test", "upd-mcp", "1.0.0", map[string]string{
"upd.mcp.yao": `{"transport":"process","tools":{"run":"scripts.test.upd.Run"}}`,
"scripts/test/upd.ts": "export function Run() { return 'v1'; }",
})
zipV2 := buildMCPZip("@test", "upd-mcp", "2.0.0", map[string]string{
"upd.mcp.yao": `{"transport":"process","tools":{"run":"scripts.test.upd.Run"}}`,
"scripts/test/upd.ts": "export function Run() { return 'v2'; }",
})
srvV1 := mockServer(map[string][]byte{"mcps/@test/upd-mcp": zipV1})
clientV1 := registry.New(srvV1.URL)
mgrV1 := New(clientV1, appRoot, &common.AutoConfirmPrompter{})
if err := mgrV1.Add("@test/upd-mcp", AddOptions{}); err != nil {
t.Fatal(err)
}
srvV1.Close()
srvV2 := mockServer(map[string][]byte{"mcps/@test/upd-mcp": zipV2})
defer srvV2.Close()
clientV2 := registry.New(srvV2.URL)
mgrV2 := New(clientV2, appRoot, &common.AutoConfirmPrompter{})
err := mgrV2.Update("@test/upd-mcp", UpdateOptions{})
if err != nil {
t.Fatalf("Update MCP failed: %v", err)
}
lf, _ := common.LoadLockfile(appRoot)
pkg, _ := lf.GetPackage("@test/upd-mcp")
if pkg.Version != "2.0.0" {
t.Errorf("expected version 2.0.0, got %s", pkg.Version)
}
// Verify updated script content
data, _ := os.ReadFile(filepath.Join(appRoot, "scripts", "test", "upd.ts"))
if !strings.Contains(string(data), "v2") {
t.Errorf("expected updated script content, got: %s", data)
}
}
func TestPushMCP(t *testing.T) {
appRoot := t.TempDir()
// Create MCP directory structure
mcpDir := filepath.Join(appRoot, "mcps", "max", "search")
os.MkdirAll(mcpDir, 0755)
os.WriteFile(filepath.Join(mcpDir, "search.mcp.yao"), []byte(`{
"transport": "process",
"tools": {"search": "scripts.max.search.Search"}
}`), 0644)
// Create scripts in the proper scope directory
scriptDir := filepath.Join(appRoot, "scripts", "max")
os.MkdirAll(scriptDir, 0755)
os.WriteFile(filepath.Join(scriptDir, "search.ts"), []byte("export function Search() {}"), 0644)
srv := mockServer(nil)
defer srv.Close()
client := registry.New(srv.URL, registry.WithAuth("u", "p"))
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
err := mgr.Push("max.search", PushOptions{Version: "1.0.0"})
if err != nil {
t.Fatalf("Push MCP failed: %v", err)
}
}
func TestPushMCPWrongScope(t *testing.T) {
appRoot := t.TempDir()
mcpDir := filepath.Join(appRoot, "mcps", "max", "bad-scope")
os.MkdirAll(mcpDir, 0755)
os.WriteFile(filepath.Join(mcpDir, "bad.mcp.yao"), []byte(`{
"transport": "process",
"tools": {"run": "scripts.other.bad.Run"}
}`), 0644)
// Scripts in wrong scope
os.MkdirAll(filepath.Join(appRoot, "scripts", "other"), 0755)
os.WriteFile(filepath.Join(appRoot, "scripts", "other", "bad.ts"), []byte("nope"), 0644)
srv := mockServer(nil)
defer srv.Close()
client := registry.New(srv.URL, registry.WithAuth("u", "p"))
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
err := mgr.Push("max.bad-scope", PushOptions{Version: "1.0.0"})
if err == nil {
t.Fatal("expected error for wrong script scope")
}
if !strings.Contains(err.Error(), "scope mismatch") {
t.Errorf("unexpected error: %v", err)
}
}
func TestForkMCPLocal(t *testing.T) {
appRoot := t.TempDir()
// Create installed MCP
mcpDir := filepath.Join(appRoot, "mcps", "yao", "rag-tools")
os.MkdirAll(mcpDir, 0755)
mcpContent := `{"transport":"process","tools":{"search":"scripts.yao.rag.Search"}}`
os.WriteFile(filepath.Join(mcpDir, "rag-tools.mcp.yao"), []byte(mcpContent), 0644)
// Create scripts
os.MkdirAll(filepath.Join(appRoot, "scripts", "yao"), 0755)
os.WriteFile(filepath.Join(appRoot, "scripts", "yao", "rag.ts"), []byte("export function Search() {}"), 0644)
lf := &common.RegistryYao{
Scope: "@local",
Packages: map[string]common.PackageInfo{
"@yao/rag-tools": {
Type: common.TypeMCP,
Version: "1.0.0",
Files: map[string]string{
"mcps/yao/rag-tools/rag-tools.mcp.yao": "sha256-aaa",
"scripts/yao/rag.ts": "sha256-bbb",
},
},
},
}
common.SaveLockfile(appRoot, lf)
srv := mockServer(nil)
defer srv.Close()
client := registry.New(srv.URL)
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
err := mgr.Fork("@yao/rag-tools", ForkOptions{})
if err != nil {
t.Fatalf("Fork MCP failed: %v", err)
}
// Verify forked MCP directory
forkedDir := filepath.Join(appRoot, "mcps", "local", "rag-tools")
if _, err := os.Stat(forkedDir); err != nil {
t.Fatal("expected forked MCP directory")
}
// Verify process references rewritten
data, err := os.ReadFile(filepath.Join(forkedDir, "rag-tools.mcp.yao"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "scripts.local.rag.Search") {
t.Errorf("expected rewritten process ref, got: %s", data)
}
if strings.Contains(string(data), "scripts.yao.rag.Search") {
t.Error("expected old process ref to be removed")
}
// Verify scripts copied to new scope
forkedScript := filepath.Join(appRoot, "scripts", "local", "rag.ts")
if _, err := os.Stat(forkedScript); err != nil {
t.Error("expected forked script in scripts/local/")
}
// Verify lockfile
lf, _ = common.LoadLockfile(appRoot)
pkg, ok := lf.GetPackage("@local/rag-tools")
if !ok {
t.Fatal("expected @local/rag-tools in lockfile")
}
if pkg.ForkedFrom != "@yao/rag-tools" {
t.Errorf("expected forked_from @yao/rag-tools, got %s", pkg.ForkedFrom)
}
if pkg.IsManaged() {
t.Error("expected managed=false")
}
}
func TestScriptExtraction(t *testing.T) {
refs, err := ExtractProcessRefsFromBytes([]byte(`{
"transport": "process",
"tools": {
"search": "scripts.yao.rag.Search",
"index": "scripts.yao.rag.Index",
"status": "agents.robot.host.tools.Status"
}
}`))
if err != nil {
t.Fatal(err)
}
if len(refs) != 2 {
t.Fatalf("expected 2 process refs, got %d", len(refs))
}
for _, ref := range refs {
if ref.Scope != "yao" {
t.Errorf("expected scope yao, got %s", ref.Scope)
}
if !strings.HasPrefix(ref.ScriptPath, "scripts/yao/") {
t.Errorf("expected scripts/yao/ prefix, got %s", ref.ScriptPath)
}
}
}
func TestScriptExtractionNonProcess(t *testing.T) {
refs, err := ExtractProcessRefsFromBytes([]byte(`{
"transport": "stdio",
"command": "echo"
}`))
if err != nil {
t.Fatal(err)
}
if len(refs) != 0 {
t.Error("expected no refs for non-process transport")
}
}
func TestRewriteProcessRefs(t *testing.T) {
original := []byte(`{"tools":{"search":"scripts.yao.rag.Search","index":"scripts.yao.rag.Index"}}`)
rewritten := RewriteProcessRefs(original, "yao", "local")
if !strings.Contains(string(rewritten), "scripts.local.rag.Search") {
t.Error("expected rewritten search ref")
}
if !strings.Contains(string(rewritten), "scripts.local.rag.Index") {
t.Error("expected rewritten index ref")
}
if strings.Contains(string(rewritten), "scripts.yao.") {
t.Error("expected no remaining yao refs")
}
}
func TestExtractScopeFromProcessRef(t *testing.T) {
if s := ExtractScopeFromProcessRef("scripts.yao.rag.Search"); s != "yao" {
t.Errorf("expected yao, got %s", s)
}
if s := ExtractScopeFromProcessRef("scripts.max.search.Do"); s != "max" {
t.Errorf("expected max, got %s", s)
}
if s := ExtractScopeFromProcessRef("agents.robot.host"); s != "" {
t.Errorf("expected empty for non-scripts ref, got %s", s)
}
}
func TestScriptPathsFromFiles(t *testing.T) {
files := map[string]string{
"mcps/yao/rag-tools/rag.mcp.yao": "sha256-aaa",
"scripts/yao/rag.ts": "sha256-bbb",
"scripts/yao/index.ts": "sha256-ccc",
}
scripts := ScriptPathsFromFiles(files)
if len(scripts) != 2 {
t.Fatalf("expected 2 scripts, got %d", len(scripts))
}
if _, ok := scripts["scripts/yao/rag.ts"]; !ok {
t.Error("expected scripts/yao/rag.ts")
}
}

View file

@ -0,0 +1,125 @@
package mcp
import (
"fmt"
"os"
"path/filepath"
"github.com/yaoapp/yao/registry/manager/common"
)
// PushOptions configures the Push operation.
type PushOptions struct {
Version string
}
// Push packages and uploads an MCP to the registry.
// Per DESIGN-MCP.md:
// 1. ID → path
// 2. Validate .mcp.yao exists
// 3. Derive scope/name, reject @local
// 4. Validate scripts are in scripts/{scope}/
// 5. Pack MCP dir + collect scripts from project root
// 6. Generate pkg.yao
// 7. Push to registry
func (m *Manager) Push(yaoID string, opts PushOptions) error {
if opts.Version == "" {
return fmt.Errorf("--version is required for push")
}
scope, name, err := common.IDFromYaoID(yaoID)
if err != nil {
return fmt.Errorf("invalid MCP ID %q: %w", yaoID, err)
}
if common.IsLocalScope(scope) {
return fmt.Errorf("cannot push @local packages. Fork to your own scope first")
}
mcpDir := common.PackageDir(common.TypeMCP, scope, name, m.appRoot)
// Find .mcp.yao files
mcpYaoFiles, err := FindMCPYaoFiles(mcpDir)
if err != nil || len(mcpYaoFiles) == 0 {
return fmt.Errorf(".mcp.yao not found in %s", mcpDir)
}
// Validate script scope and collect scripts
allScripts := map[string]string{}
for _, mcpFile := range mcpYaoFiles {
if err := ValidateScriptScope(mcpFile, scope, m.appRoot); err != nil {
return err
}
scripts, err := CollectScripts(mcpFile, m.appRoot)
if err != nil {
return err
}
for k, v := range scripts {
allScripts[k] = v
}
}
manifest := &common.PkgManifest{
Type: common.TypeMCP,
Scope: "@" + scope,
Name: name,
Version: opts.Version,
}
zipData, err := common.PackDir(mcpDir, manifest, allScripts)
if err != nil {
return fmt.Errorf("pack: %w", err)
}
regType := common.TypeToRegistryType(common.TypeMCP)
result, err := m.client.Push(regType, "@"+scope, name, opts.Version, zipData)
if err != nil {
return fmt.Errorf("push: %w", err)
}
fmt.Printf("✓ Pushed %s@%s (digest: %s)\n", common.FormatPackageID(scope, name), result.Version, result.Digest)
// Report packed scripts
if len(allScripts) > 0 {
fmt.Printf(" Scripts packed:\n")
for scriptPath := range allScripts {
fmt.Printf(" %s\n", scriptPath)
}
}
return nil
}
// pushValidateMCPDir checks that the MCP directory structure is valid for push.
func pushValidateMCPDir(mcpDir string) error {
if _, err := os.Stat(mcpDir); err != nil {
return fmt.Errorf("MCP directory %s not found", mcpDir)
}
mcpFiles, err := FindMCPYaoFiles(mcpDir)
if err != nil || len(mcpFiles) == 0 {
return fmt.Errorf("no .mcp.yao files found in %s", mcpDir)
}
return nil
}
// listDirFiles lists all files under a directory relative to root.
func listDirFiles(dir string) ([]string, error) {
var files []string
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
rel, err := filepath.Rel(dir, path)
if err != nil {
return err
}
files = append(files, filepath.ToSlash(rel))
return nil
})
return files, err
}

View file

@ -0,0 +1,177 @@
package mcp
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
goujson "github.com/yaoapp/gou/json"
)
// ProcessRef represents a process reference extracted from a .mcp.yao file.
type ProcessRef struct {
ToolName string // e.g. "search"
ProcessPath string // e.g. "scripts.yao.rag.Search"
ScriptPath string // resolved filesystem path: "scripts/yao/rag.ts"
Scope string // extracted scope: "yao"
}
// mcpDSL is a minimal representation of .mcp.yao for process reference extraction.
type mcpDSL struct {
Transport string `json:"transport"`
Tools map[string]string `json:"tools,omitempty"`
}
// ExtractProcessRefs parses a .mcp.yao file and extracts all "scripts.*" process references.
func ExtractProcessRefs(mcpYaoPath string) ([]ProcessRef, error) {
data, err := os.ReadFile(mcpYaoPath)
if err != nil {
return nil, err
}
return ExtractProcessRefsFromBytes(data)
}
// ExtractProcessRefsFromBytes extracts process refs from .mcp.yao content bytes.
// Supports JSONC format (// and /* */ comments) used by Yao DSL files.
func ExtractProcessRefsFromBytes(data []byte) ([]ProcessRef, error) {
var dsl mcpDSL
if err := goujson.ParseFile(".mcp.yao", data, &dsl); err != nil {
return nil, fmt.Errorf("parse .mcp.yao: %w", err)
}
if dsl.Transport != "process" {
return nil, nil
}
var refs []ProcessRef
for toolName, processPath := range dsl.Tools {
if !strings.HasPrefix(processPath, "scripts.") {
continue
}
ref, err := parseProcessRef(toolName, processPath)
if err != nil {
continue
}
refs = append(refs, ref)
}
return refs, nil
}
// parseProcessRef parses "scripts.yao.rag.Search" into a ProcessRef.
// Convention: scripts.{scope}.{path...}.{Function}
// Script file: scripts/{scope}/{path_joined}.ts
func parseProcessRef(toolName, processPath string) (ProcessRef, error) {
parts := strings.Split(processPath, ".")
// At minimum: scripts.scope.file.Function = 4 parts
if len(parts) < 4 {
return ProcessRef{}, fmt.Errorf("process path %q too short", processPath)
}
scope := parts[1]
// The middle parts (between scope and function name) form the script path
scriptParts := parts[2 : len(parts)-1]
scriptFile := strings.Join(scriptParts, "/") + ".ts"
scriptPath := filepath.Join("scripts", scope, scriptFile)
return ProcessRef{
ToolName: toolName,
ProcessPath: processPath,
ScriptPath: filepath.ToSlash(scriptPath),
Scope: scope,
}, nil
}
// RewriteProcessRefs rewrites all "scripts.{oldScope}." references to
// "scripts.{newScope}." in a .mcp.yao file content.
func RewriteProcessRefs(mcpContent []byte, oldScope, newScope string) []byte {
old := "scripts." + oldScope + "."
replacement := "scripts." + newScope + "."
return []byte(strings.ReplaceAll(string(mcpContent), old, replacement))
}
// ValidateScriptScope checks that all process references in a .mcp.yao point to
// scripts in the expected scope directory. Returns an error if any violate the rule.
func ValidateScriptScope(mcpYaoPath, expectedScope, appRoot string) error {
refs, err := ExtractProcessRefs(mcpYaoPath)
if err != nil {
return err
}
for _, ref := range refs {
if ref.Scope != expectedScope {
return fmt.Errorf(
"MCP script scope mismatch: %s references scripts.%s.* but expected scripts.%s.*\n"+
" Scripts must be in scripts/%s/ to match MCP scope",
mcpYaoPath, ref.Scope, expectedScope, expectedScope,
)
}
// Verify script file exists
scriptPath := filepath.Join(appRoot, ref.ScriptPath)
if _, err := os.Stat(scriptPath); err != nil {
return fmt.Errorf("script file %s referenced by %s not found", ref.ScriptPath, ref.ProcessPath)
}
}
return nil
}
// CollectScripts gathers all script files referenced by a .mcp.yao file.
// Returns a map of relative path (under package/) → absolute path on disk.
func CollectScripts(mcpYaoPath, appRoot string) (map[string]string, error) {
refs, err := ExtractProcessRefs(mcpYaoPath)
if err != nil {
return nil, err
}
scripts := map[string]string{}
for _, ref := range refs {
absPath := filepath.Join(appRoot, ref.ScriptPath)
if _, err := os.Stat(absPath); err != nil {
return nil, fmt.Errorf("script %s not found: %w", ref.ScriptPath, err)
}
scripts[ref.ScriptPath] = absPath
}
return scripts, nil
}
// FindMCPYaoFiles finds all .mcp.yao files in an MCP package directory.
func FindMCPYaoFiles(mcpDir string) ([]string, error) {
var files []string
err := filepath.Walk(mcpDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && strings.HasSuffix(info.Name(), ".mcp.yao") {
files = append(files, path)
}
return nil
})
return files, err
}
// ScriptPathsFromFiles extracts script-related paths from a file hash map.
// Returns only entries starting with "scripts/".
func ScriptPathsFromFiles(files map[string]string) map[string]string {
result := map[string]string{}
for path, hash := range files {
if strings.HasPrefix(path, "scripts/") {
result[path] = hash
}
}
return result
}
// processRefRegex matches "scripts.{scope}.{rest}" patterns.
var processRefRegex = regexp.MustCompile(`scripts\.([a-zA-Z0-9_-]+)\.`)
// ExtractScopeFromProcessRef extracts the scope from a process reference like "scripts.yao.rag.Search".
func ExtractScopeFromProcessRef(processPath string) string {
matches := processRefRegex.FindStringSubmatch(processPath)
if len(matches) >= 2 {
return matches[1]
}
return ""
}

View file

@ -0,0 +1,200 @@
package mcp
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/yaoapp/yao/registry/manager/common"
)
// UpdateOptions configures the Update operation.
type UpdateOptions struct {
Version string
}
// Update performs a hash-based safe update for an MCP package.
// Same strategy as agent update but also handles scripts under project root.
func (m *Manager) Update(pkgID string, opts UpdateOptions) error {
if opts.Version == "" {
opts.Version = "latest"
}
scope, name, err := common.ParsePackageID(pkgID)
if err != nil {
return err
}
lf, err := common.LoadLockfile(m.appRoot)
if err != nil {
return err
}
existing, ok := lf.GetPackage(pkgID)
if !ok {
return fmt.Errorf("package %s is not installed", pkgID)
}
if !existing.IsManaged() {
return fmt.Errorf("package %s is forked (from %s) and not managed by registry", pkgID, existing.ForkedFrom)
}
regType := common.TypeToRegistryType(common.TypeMCP)
zipData, digest, err := m.client.Pull(regType, "@"+scope, name, opts.Version)
if err != nil {
return fmt.Errorf("pull %s: %w", pkgID, err)
}
manifest, err := common.ReadManifest(zipData)
if err != nil {
return fmt.Errorf("read manifest: %w", err)
}
// Check required_by compatibility
if len(existing.RequiredBy) > 0 {
var warnings []string
for _, depID := range existing.RequiredBy {
depPkg, depOK := lf.GetPackage(depID)
if !depOK {
continue
}
if constraint, has := depPkg.Dependencies[pkgID]; has {
if !common.VersionSatisfies(manifest.Version, constraint) {
warnings = append(warnings, fmt.Sprintf(" %s requires %s ← incompatible", depID, constraint))
}
}
}
if len(warnings) > 0 {
msg := fmt.Sprintf("%s is depended on by:\n%s\nContinue update?", pkgID, strings.Join(warnings, "\n"))
if !m.prompter.Confirm(msg) {
return fmt.Errorf("update aborted by user")
}
}
}
// Unpack new version to temp
tempDir, err := os.MkdirTemp("", "yao-mcp-update-*")
if err != nil {
return err
}
defer os.RemoveAll(tempDir)
newFileList, err := common.UnpackTo(zipData, tempDir)
if err != nil {
return err
}
mcpRelDir := common.PackageDirRel(common.TypeMCP, scope, name)
destDir := common.PackageDir(common.TypeMCP, scope, name, m.appRoot)
// Build new file set with full relative paths
newFileMap := map[string]string{} // fullRelPath → temp file path
for _, f := range newFileList {
if strings.HasPrefix(f, "scripts/") {
newFileMap[f] = filepath.Join(tempDir, f)
} else {
fullRel := mcpRelDir + "/" + f
newFileMap[fullRel] = filepath.Join(tempDir, f)
}
}
newHashes := map[string]string{}
// Process each new file
for fullRel, tempPath := range newFileMap {
newContent, err := os.ReadFile(tempPath)
if err != nil {
return err
}
newHash := common.HashBytes(newContent)
// Determine local path
var localPath string
if strings.HasPrefix(fullRel, "scripts/") {
localPath = filepath.Join(m.appRoot, fullRel)
} else {
relInMCP := strings.TrimPrefix(fullRel, mcpRelDir+"/")
localPath = filepath.Join(destDir, relInMCP)
}
oldHash, wasTracked := existing.Files[fullRel]
if !wasTracked {
if err := writeFileTo(localPath, newContent); err != nil {
return err
}
fmt.Printf("+ %s — new file, added\n", filepath.Base(fullRel))
newHashes[fullRel] = newHash
continue
}
localHash, err := common.HashFile(localPath)
if err != nil {
if err := writeFileTo(localPath, newContent); err != nil {
return err
}
fmt.Printf("✓ %s — restored (was missing locally)\n", filepath.Base(fullRel))
newHashes[fullRel] = newHash
continue
}
if localHash == oldHash {
if err := writeFileTo(localPath, newContent); err != nil {
return err
}
fmt.Printf("✓ %s — unmodified, updated\n", filepath.Base(fullRel))
newHashes[fullRel] = newHash
} else {
newPath := localPath + ".new"
if err := writeFileTo(newPath, newContent); err != nil {
return err
}
fmt.Printf("✗ %s — locally modified, skipped (new version → .new)\n", filepath.Base(fullRel))
newHashes[fullRel] = newHash
}
}
// Handle deleted files
for oldFile, oldHash := range existing.Files {
if _, inNew := newFileMap[oldFile]; inNew {
continue
}
var localPath string
if strings.HasPrefix(oldFile, "scripts/") {
localPath = filepath.Join(m.appRoot, oldFile)
} else {
relInMCP := strings.TrimPrefix(oldFile, mcpRelDir+"/")
localPath = filepath.Join(destDir, relInMCP)
}
localHash, err := common.HashFile(localPath)
if err != nil {
continue
}
if localHash == oldHash {
os.Remove(localPath)
fmt.Printf("- %s — removed\n", filepath.Base(oldFile))
} else {
fmt.Printf("⚠ %s — locally modified, kept\n", filepath.Base(oldFile))
}
}
existing.Version = manifest.Version
existing.Integrity = digest
existing.Dependencies = manifest.Dependencies
existing.Files = newHashes
lf.SetPackage(pkgID, existing)
if err := common.SaveLockfile(m.appRoot, lf); err != nil {
return err
}
fmt.Printf("✓ Updated %s to %s\n", pkgID, manifest.Version)
return nil
}
func writeFileTo(path string, content []byte) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
return os.WriteFile(path, content, 0644)
}

View file

@ -0,0 +1,645 @@
package manager_test
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/yaoapp/yao/registry/manager/common"
mcpmgr "github.com/yaoapp/yao/registry/manager/mcp"
)
// =============================================================================
// Process MCP: Full lifecycle — Push → Add → Update → Fork
// =============================================================================
func TestE2EMCP_ProcessLifecycle(t *testing.T) {
c := authClient()
devApp := appRoot(t)
defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0")
defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "2.0.0")
// Phase 1: Push v1 from yao-dev-app
pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{})
if err := pushMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"}); err != nil {
t.Fatalf("Push MCP v1: %v", err)
}
packument, err := c.GetPackument("mcps", "@"+testScope, "registry-mcp")
if err != nil {
t.Fatalf("GetPackument: %v", err)
}
if packument.DistTags["latest"] != "1.0.0" {
t.Errorf("expected latest=1.0.0, got %s", packument.DistTags["latest"])
}
// Phase 2: Add to a fresh app
installApp := t.TempDir()
installMgr := mcpmgr.New(c, installApp, &common.AutoConfirmPrompter{})
if err := installMgr.Add("@"+testScope+"/registry-mcp", mcpmgr.AddOptions{}); err != nil {
t.Fatalf("Add MCP: %v", err)
}
// Verify .mcp.yao on disk
installedMCP := filepath.Join(installApp, "mcps", testScope, "registry-mcp", "server.mcp.yao")
requireFileExists(t, installedMCP)
requireFileContains(t, installedMCP, "scripts."+testScope+".registry_mcp.Ping")
// Verify scripts extracted to project root
installedScript := filepath.Join(installApp, "scripts", testScope, "registry_mcp.ts")
requireFileExists(t, installedScript)
requireFileContains(t, installedScript, "pong")
// Verify lockfile
pkg := requireLockfileHas(t, installApp, "@"+testScope+"/registry-mcp")
if pkg.Version != "1.0.0" {
t.Errorf("lockfile version: want 1.0.0, got %s", pkg.Version)
}
if pkg.Type != common.TypeMCP {
t.Errorf("lockfile type: want mcp, got %s", pkg.Type)
}
if pkg.Integrity == "" {
t.Error("expected integrity digest in lockfile")
}
hasScript, hasMCP := false, false
for path := range pkg.Files {
if strings.HasPrefix(path, "scripts/") {
hasScript = true
}
if strings.HasPrefix(path, "mcps/") {
hasMCP = true
}
}
if !hasScript {
t.Error("lockfile missing script file entries")
}
if !hasMCP {
t.Error("lockfile missing MCP file entries")
}
// Phase 3: Push v2, then Update
v2App := buildV2AgentApp(t)
pushMgrV2 := mcpmgr.New(c, v2App, &common.AutoConfirmPrompter{})
if err := pushMgrV2.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "2.0.0"}); err != nil {
t.Fatalf("Push MCP v2: %v", err)
}
if err := installMgr.Update("@"+testScope+"/registry-mcp", mcpmgr.UpdateOptions{Version: "2.0.0"}); err != nil {
t.Fatalf("Update MCP to v2: %v", err)
}
pkg = requireLockfileHas(t, installApp, "@"+testScope+"/registry-mcp")
if pkg.Version != "2.0.0" {
t.Errorf("expected v2.0.0 after update, got %s", pkg.Version)
}
requireFileContains(t, installedScript, "pong-v2")
// Phase 4: Fork to @local
if err := installMgr.Fork("@"+testScope+"/registry-mcp", mcpmgr.ForkOptions{TargetScope: "local"}); err != nil {
t.Fatalf("Fork MCP: %v", err)
}
forkedDir := filepath.Join(installApp, "mcps", "local", "registry-mcp")
requireFileExists(t, forkedDir)
forkedMCPPath := filepath.Join(forkedDir, "server.mcp.yao")
requireFileContains(t, forkedMCPPath, "scripts.local.registry_mcp.Ping")
requireFileNotContains(t, forkedMCPPath, "scripts."+testScope+".")
forkedScript := filepath.Join(installApp, "scripts", "local", "registry_mcp.ts")
requireFileExists(t, forkedScript)
forkedPkg := requireLockfileHas(t, installApp, "@local/registry-mcp")
if forkedPkg.ForkedFrom != "@"+testScope+"/registry-mcp" {
t.Errorf("expected forked_from=@%s/registry-mcp, got %s", testScope, forkedPkg.ForkedFrom)
}
if forkedPkg.IsManaged() {
t.Error("forked package should not be managed")
}
}
// =============================================================================
// SSE MCP: Push → Add → Fork (no scripts, no process refs)
// =============================================================================
func TestE2EMCP_SSELifecycle(t *testing.T) {
c := authClient()
devApp := appRoot(t)
defer cleanupPkg(c, "mcps", "@"+testScope, "sse-proxy", "1.0.0")
pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{})
if err := pushMgr.Push(testScope+".sse-proxy", mcpmgr.PushOptions{Version: "1.0.0"}); err != nil {
t.Fatalf("Push SSE MCP: %v", err)
}
// Add to fresh app
installApp := t.TempDir()
installMgr := mcpmgr.New(c, installApp, &common.AutoConfirmPrompter{})
if err := installMgr.Add("@"+testScope+"/sse-proxy", mcpmgr.AddOptions{}); err != nil {
t.Fatalf("Add SSE MCP: %v", err)
}
// Verify .mcp.yao on disk
installedMCP := filepath.Join(installApp, "mcps", testScope, "sse-proxy", "server.mcp.yao")
requireFileExists(t, installedMCP)
requireFileContains(t, installedMCP, `"transport": "sse"`)
requireFileContains(t, installedMCP, "mcp.example.com")
// No scripts should be extracted for SSE
scriptsDir := filepath.Join(installApp, "scripts")
if _, err := os.Stat(scriptsDir); err == nil {
entries, _ := os.ReadDir(scriptsDir)
if len(entries) > 0 {
t.Errorf("SSE MCP should not extract any scripts, found entries under scripts/")
}
}
// Lockfile
pkg := requireLockfileHas(t, installApp, "@"+testScope+"/sse-proxy")
if pkg.Version != "1.0.0" {
t.Errorf("want version 1.0.0, got %s", pkg.Version)
}
if pkg.Type != common.TypeMCP {
t.Errorf("want type mcp, got %s", pkg.Type)
}
// No script files tracked in lockfile
for path := range pkg.Files {
if strings.HasPrefix(path, "scripts/") {
t.Errorf("SSE MCP lockfile should not track scripts, found: %s", path)
}
}
// Fork to @local — no process ref rewriting needed
if err := installMgr.Fork("@"+testScope+"/sse-proxy", mcpmgr.ForkOptions{TargetScope: "local"}); err != nil {
t.Fatalf("Fork SSE MCP: %v", err)
}
forkedMCP := filepath.Join(installApp, "mcps", "local", "sse-proxy", "server.mcp.yao")
requireFileExists(t, forkedMCP)
requireFileContains(t, forkedMCP, `"transport": "sse"`)
forkedPkg := requireLockfileHas(t, installApp, "@local/sse-proxy")
if forkedPkg.ForkedFrom != "@"+testScope+"/sse-proxy" {
t.Errorf("expected forked_from=@%s/sse-proxy, got %s", testScope, forkedPkg.ForkedFrom)
}
}
// =============================================================================
// Multi-script MCP: Push → Add → verify all scripts unpacked
// =============================================================================
func TestE2EMCP_MultiScriptPack(t *testing.T) {
c := authClient()
devApp := appRoot(t)
defer cleanupPkg(c, "mcps", "@"+testScope, "data-tools", "1.0.0")
pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{})
if err := pushMgr.Push(testScope+".data-tools", mcpmgr.PushOptions{Version: "1.0.0"}); err != nil {
t.Fatalf("Push data-tools: %v", err)
}
installApp := t.TempDir()
installMgr := mcpmgr.New(c, installApp, &common.AutoConfirmPrompter{})
if err := installMgr.Add("@"+testScope+"/data-tools", mcpmgr.AddOptions{}); err != nil {
t.Fatalf("Add data-tools: %v", err)
}
// Both script files should be extracted
requireFileExists(t, filepath.Join(installApp, "scripts", testScope, "data_tools.ts"))
requireFileExists(t, filepath.Join(installApp, "scripts", testScope, "data_utils.ts"))
requireFileContains(t, filepath.Join(installApp, "scripts", testScope, "data_tools.ts"), "Aggregate")
requireFileContains(t, filepath.Join(installApp, "scripts", testScope, "data_utils.ts"), "FormatCSV")
// MCP definition on disk
requireFileExists(t, filepath.Join(installApp, "mcps", testScope, "data-tools", "server.mcp.yao"))
requireFileContains(t, filepath.Join(installApp, "mcps", testScope, "data-tools", "server.mcp.yao"),
"scripts."+testScope+".data_utils.FormatCSV")
// Lockfile tracks both script files
pkg := requireLockfileHas(t, installApp, "@"+testScope+"/data-tools")
scriptCount := 0
for path := range pkg.Files {
if strings.HasPrefix(path, "scripts/") {
scriptCount++
}
}
if scriptCount < 2 {
t.Errorf("expected at least 2 script files tracked in lockfile, got %d", scriptCount)
}
}
// =============================================================================
// Multi-script MCP: Update with local modification
// =============================================================================
func TestE2EMCP_MultiScriptUpdate(t *testing.T) {
c := authClient()
devApp := appRoot(t)
defer cleanupPkg(c, "mcps", "@"+testScope, "data-tools", "1.0.0")
defer cleanupPkg(c, "mcps", "@"+testScope, "data-tools", "2.0.0")
// Push v1
pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{})
if err := pushMgr.Push(testScope+".data-tools", mcpmgr.PushOptions{Version: "1.0.0"}); err != nil {
t.Fatalf("Push v1: %v", err)
}
// Install v1
installApp := t.TempDir()
installMgr := mcpmgr.New(c, installApp, &common.AutoConfirmPrompter{})
if err := installMgr.Add("@"+testScope+"/data-tools", mcpmgr.AddOptions{}); err != nil {
t.Fatalf("Add v1: %v", err)
}
// Locally modify one of the script files
modifiedScript := filepath.Join(installApp, "scripts", testScope, "data_tools.ts")
customContent := "// MY CUSTOM DATA TOOLS\nfunction Aggregate() { return 'custom'; }\n"
os.WriteFile(modifiedScript, []byte(customContent), 0644)
// Push v2
v2App := buildV2MCPApp(t)
pushMgrV2 := mcpmgr.New(c, v2App, &common.AutoConfirmPrompter{})
if err := pushMgrV2.Push(testScope+".data-tools", mcpmgr.PushOptions{Version: "2.0.0"}); err != nil {
t.Fatalf("Push v2: %v", err)
}
// Update to v2
if err := installMgr.Update("@"+testScope+"/data-tools", mcpmgr.UpdateOptions{Version: "2.0.0"}); err != nil {
t.Fatalf("Update to v2: %v", err)
}
// Modified script should be preserved, .new file created
content, _ := os.ReadFile(modifiedScript)
if !strings.Contains(string(content), "MY CUSTOM DATA TOOLS") {
t.Error("local modification to data_tools.ts should be preserved")
}
requireFileExists(t, modifiedScript+".new")
requireFileContains(t, modifiedScript+".new", "version: 2")
// Unmodified script should be updated in-place
utilsScript := filepath.Join(installApp, "scripts", testScope, "data_utils.ts")
requireFileContains(t, utilsScript, "header")
// Lockfile version should be 2.0.0
pkg := requireLockfileHas(t, installApp, "@"+testScope+"/data-tools")
if pkg.Version != "2.0.0" {
t.Errorf("expected v2.0.0 after update, got %s", pkg.Version)
}
}
// =============================================================================
// Push → Pull roundtrip (byte-for-byte content verification)
// =============================================================================
func TestE2EMCP_PushPullRoundtrip(t *testing.T) {
c := authClient()
devApp := appRoot(t)
defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0")
pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{})
if err := pushMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"}); err != nil {
t.Fatalf("Push: %v", err)
}
pullApp := t.TempDir()
pullMgr := mcpmgr.New(c, pullApp, &common.AutoConfirmPrompter{})
if err := pullMgr.Add("@"+testScope+"/registry-mcp", mcpmgr.AddOptions{}); err != nil {
t.Fatalf("Add: %v", err)
}
origScript, _ := os.ReadFile(filepath.Join(devApp, "scripts", testScope, "registry_mcp.ts"))
pulledScript, _ := os.ReadFile(filepath.Join(pullApp, "scripts", testScope, "registry_mcp.ts"))
if string(origScript) != string(pulledScript) {
t.Errorf("script mismatch.\nOriginal:\n%s\nPulled:\n%s", origScript, pulledScript)
}
origMCP, _ := os.ReadFile(filepath.Join(devApp, "mcps", testScope, "registry-mcp", "server.mcp.yao"))
pulledMCP, _ := os.ReadFile(filepath.Join(pullApp, "mcps", testScope, "registry-mcp", "server.mcp.yao"))
if string(origMCP) != string(pulledMCP) {
t.Errorf("MCP mismatch.\nOriginal:\n%s\nPulled:\n%s", origMCP, pulledMCP)
}
}
// =============================================================================
// Push rejects wrong script scope
// =============================================================================
func TestE2EMCP_PushWrongScriptScope(t *testing.T) {
c := authClient()
devApp := t.TempDir()
mcpDir := filepath.Join(devApp, "mcps", testScope, "bad-mcp")
mustMkdir(t, mcpDir)
mustWriteFile(t, filepath.Join(mcpDir, "bad.mcp.yao"), `{
"transport": "process",
"tools": {
"run": "scripts.other.bad.Run"
}
}`)
mustWriteFile(t, filepath.Join(devApp, "scripts", "other", "bad.ts"), "export function Run() {}")
mgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{})
err := mgr.Push(testScope+".bad-mcp", mcpmgr.PushOptions{Version: "1.0.0"})
if err == nil {
t.Fatal("expected push to be rejected due to script scope mismatch")
}
if !strings.Contains(err.Error(), "scope mismatch") {
t.Errorf("expected scope mismatch error, got: %v", err)
}
}
// =============================================================================
// Push rejects when referenced script file is missing
// =============================================================================
func TestE2EMCP_PushMissingScript(t *testing.T) {
c := authClient()
devApp := t.TempDir()
mcpDir := filepath.Join(devApp, "mcps", testScope, "missing-script-mcp")
mustMkdir(t, mcpDir)
mustWriteFile(t, filepath.Join(mcpDir, "server.mcp.yao"), `{
"transport": "process",
"tools": {
"run": "scripts.`+testScope+`.nonexistent.Run"
}
}`)
mgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{})
err := mgr.Push(testScope+".missing-script-mcp", mcpmgr.PushOptions{Version: "1.0.0"})
if err == nil {
t.Fatal("expected push to fail when script file is missing")
}
if !strings.Contains(err.Error(), "not found") {
t.Errorf("expected 'not found' error, got: %v", err)
}
}
// =============================================================================
// Push requires --version
// =============================================================================
func TestE2EMCP_PushNoVersion(t *testing.T) {
c := authClient()
devApp := appRoot(t)
mgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{})
err := mgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{})
if err == nil {
t.Fatal("expected push to fail without --version")
}
if !strings.Contains(err.Error(), "version") {
t.Errorf("expected version error, got: %v", err)
}
}
// =============================================================================
// Add: directory conflict (exists but not managed)
// =============================================================================
func TestE2EMCP_AddDirectoryConflict(t *testing.T) {
c := authClient()
devApp := appRoot(t)
defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0")
pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{})
pushMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"})
installApp := t.TempDir()
conflictDir := filepath.Join(installApp, "mcps", testScope, "registry-mcp")
mustMkdir(t, conflictDir)
mustWriteFile(t, filepath.Join(conflictDir, "my-custom.mcp.yao"), `{"transport":"sse"}`)
installMgr := mcpmgr.New(c, installApp, &common.AutoConfirmPrompter{})
err := installMgr.Add("@"+testScope+"/registry-mcp", mcpmgr.AddOptions{})
if err == nil {
t.Fatal("expected directory conflict error")
}
if !strings.Contains(err.Error(), "already exists") {
t.Errorf("expected 'already exists' error, got: %v", err)
}
}
// =============================================================================
// Add: script conflict (scripts/ file exists but not tracked)
// =============================================================================
func TestE2EMCP_AddScriptConflict(t *testing.T) {
c := authClient()
devApp := appRoot(t)
defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0")
pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{})
pushMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"})
installApp := t.TempDir()
mustWriteFile(t, filepath.Join(installApp, "scripts", testScope, "registry_mcp.ts"),
"// my existing custom script — should block install\n")
installMgr := mcpmgr.New(c, installApp, &common.AutoConfirmPrompter{})
err := installMgr.Add("@"+testScope+"/registry-mcp", mcpmgr.AddOptions{})
if err == nil {
t.Fatal("expected script conflict error")
}
if !strings.Contains(err.Error(), "already exists") {
t.Errorf("expected 'already exists' error about script, got: %v", err)
}
}
// =============================================================================
// Add: already installed — reject without --force
// =============================================================================
func TestE2EMCP_AddAlreadyInstalled(t *testing.T) {
c := authClient()
devApp := appRoot(t)
defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0")
pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{})
pushMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"})
installApp := t.TempDir()
installMgr := mcpmgr.New(c, installApp, &common.AutoConfirmPrompter{})
installMgr.Add("@"+testScope+"/registry-mcp", mcpmgr.AddOptions{})
// Second add should fail
err := installMgr.Add("@"+testScope+"/registry-mcp", mcpmgr.AddOptions{})
if err == nil {
t.Fatal("expected already-installed error on second add")
}
if !strings.Contains(err.Error(), "already installed") {
t.Errorf("expected 'already installed' error, got: %v", err)
}
}
// =============================================================================
// Add: --force reinstall
// =============================================================================
func TestE2EMCP_AddForceReinstall(t *testing.T) {
c := authClient()
devApp := appRoot(t)
defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0")
pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{})
pushMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"})
installApp := t.TempDir()
installMgr := mcpmgr.New(c, installApp, &common.AutoConfirmPrompter{})
installMgr.Add("@"+testScope+"/registry-mcp", mcpmgr.AddOptions{})
// Force reinstall should succeed
err := installMgr.Add("@"+testScope+"/registry-mcp", mcpmgr.AddOptions{Force: true})
if err != nil {
t.Fatalf("force reinstall should succeed: %v", err)
}
pkg := requireLockfileHas(t, installApp, "@"+testScope+"/registry-mcp")
if pkg.Version != "1.0.0" {
t.Errorf("expected 1.0.0 after force reinstall, got %s", pkg.Version)
}
}
// =============================================================================
// Update of forked package is rejected
// =============================================================================
func TestE2EMCP_UpdateForkedRejected(t *testing.T) {
c := authClient()
devApp := appRoot(t)
defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0")
pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{})
pushMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"})
installApp := t.TempDir()
installMgr := mcpmgr.New(c, installApp, &common.AutoConfirmPrompter{})
installMgr.Add("@"+testScope+"/registry-mcp", mcpmgr.AddOptions{})
installMgr.Fork("@"+testScope+"/registry-mcp", mcpmgr.ForkOptions{TargetScope: "local"})
err := installMgr.Update("@local/registry-mcp", mcpmgr.UpdateOptions{Version: "2.0.0"})
if err == nil {
t.Fatal("expected update of forked package to be rejected")
}
if !strings.Contains(err.Error(), "forked") {
t.Errorf("expected 'forked' in error, got: %v", err)
}
}
// =============================================================================
// Fork: target already exists
// =============================================================================
func TestE2EMCP_ForkTargetExists(t *testing.T) {
c := authClient()
devApp := appRoot(t)
defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0")
pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{})
pushMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"})
installApp := t.TempDir()
installMgr := mcpmgr.New(c, installApp, &common.AutoConfirmPrompter{})
installMgr.Add("@"+testScope+"/registry-mcp", mcpmgr.AddOptions{})
// Pre-create target directory
targetDir := filepath.Join(installApp, "mcps", "local", "registry-mcp")
mustMkdir(t, targetDir)
err := installMgr.Fork("@"+testScope+"/registry-mcp", mcpmgr.ForkOptions{TargetScope: "local"})
if err == nil {
t.Fatal("expected fork to fail when target directory exists")
}
if !strings.Contains(err.Error(), "already exists") {
t.Errorf("expected 'already exists' error, got: %v", err)
}
}
// =============================================================================
// Fork from registry: package not installed locally, pull then fork
// =============================================================================
func TestE2EMCP_ForkFromRegistry(t *testing.T) {
c := authClient()
devApp := appRoot(t)
defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0")
pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{})
pushMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"})
// Fresh app — nothing installed
forkApp := t.TempDir()
forkMgr := mcpmgr.New(c, forkApp, &common.AutoConfirmPrompter{})
if err := forkMgr.Fork("@"+testScope+"/registry-mcp", mcpmgr.ForkOptions{TargetScope: "local"}); err != nil {
t.Fatalf("Fork from registry: %v", err)
}
// Forked MCP on disk
forkedMCP := filepath.Join(forkApp, "mcps", "local", "registry-mcp", "server.mcp.yao")
requireFileExists(t, forkedMCP)
requireFileContains(t, forkedMCP, "scripts.local.registry_mcp.Ping")
// Forked scripts on disk
requireFileExists(t, filepath.Join(forkApp, "scripts", "local", "registry_mcp.ts"))
// Lockfile entry
pkg := requireLockfileHas(t, forkApp, "@local/registry-mcp")
if pkg.ForkedFrom != "@"+testScope+"/registry-mcp" {
t.Errorf("expected forked_from, got %s", pkg.ForkedFrom)
}
if pkg.IsManaged() {
t.Error("forked package should not be managed")
}
}
// =============================================================================
// Fork to custom scope (not @local)
// =============================================================================
func TestE2EMCP_ForkToCustomScope(t *testing.T) {
c := authClient()
devApp := appRoot(t)
defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0")
pushMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{})
pushMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"})
installApp := t.TempDir()
installMgr := mcpmgr.New(c, installApp, &common.AutoConfirmPrompter{})
installMgr.Add("@"+testScope+"/registry-mcp", mcpmgr.AddOptions{})
if err := installMgr.Fork("@"+testScope+"/registry-mcp", mcpmgr.ForkOptions{TargetScope: "mycompany"}); err != nil {
t.Fatalf("Fork to custom scope: %v", err)
}
forkedMCP := filepath.Join(installApp, "mcps", "mycompany", "registry-mcp", "server.mcp.yao")
requireFileExists(t, forkedMCP)
requireFileContains(t, forkedMCP, "scripts.mycompany.registry_mcp.Ping")
forkedScript := filepath.Join(installApp, "scripts", "mycompany", "registry_mcp.ts")
requireFileExists(t, forkedScript)
pkg := requireLockfileHas(t, installApp, "@mycompany/registry-mcp")
if pkg.ForkedFrom != "@"+testScope+"/registry-mcp" {
t.Errorf("unexpected forked_from: %s", pkg.ForkedFrom)
}
}

View file

@ -0,0 +1,157 @@
package robot
import (
"encoding/json"
"fmt"
agentmgr "github.com/yaoapp/yao/registry/manager/agent"
"github.com/yaoapp/yao/registry/manager/common"
mcpmgr "github.com/yaoapp/yao/registry/manager/mcp"
)
// AddOptions configures the Add operation.
type AddOptions struct {
Version string
TeamID string // required: which team to add the robot to
}
// Add installs a robot package from the registry.
// Per DESIGN-ROBOT.md:
// 1. Pull .yao.zip
// 2. Parse robot.json + pkg.yao
// 3. Install dependencies (assistants + MCPs)
// 4. Write member DB record (deferred to CLI layer which has DB access)
// 5. Write registry.yao
func (m *Manager) Add(pkgID string, opts AddOptions) (*RobotJSON, error) {
if opts.Version == "" {
opts.Version = "latest"
}
if opts.TeamID == "" {
return nil, fmt.Errorf("--team is required for robot add")
}
scope, name, err := common.ParsePackageID(pkgID)
if err != nil {
return nil, err
}
lf, err := common.LoadLockfile(m.appRoot)
if err != nil {
return nil, err
}
regType := common.TypeToRegistryType(common.TypeRobot)
zipData, digest, err := m.client.Pull(regType, "@"+scope, name, opts.Version)
if err != nil {
return nil, fmt.Errorf("pull %s: %w", pkgID, err)
}
manifest, err := common.ReadManifest(zipData)
if err != nil {
return nil, fmt.Errorf("read manifest: %w", err)
}
// Read robot.json from zip
robotData, err := common.ExtractFile(zipData, "robot.json")
if err != nil {
return nil, fmt.Errorf("extract robot.json: %w", err)
}
var robot RobotJSON
if err := json.Unmarshal(robotData, &robot); err != nil {
return nil, fmt.Errorf("parse robot.json: %w", err)
}
// Analyze dependencies from robot configuration
analyzedDeps := AnalyzeDeps(&robot)
// Merge with pkg.yao declared dependencies
allDeps := map[string]string{}
for _, dep := range analyzedDeps {
allDeps[dep.PackageID] = "*"
}
for depID, ver := range manifest.Dependencies {
allDeps[depID] = ver
}
// Install dependencies
if len(allDeps) > 0 {
missing, _, _ := common.CheckDependencies(allDeps, lf)
if len(missing) > 0 {
var summary string
for _, dep := range missing {
summary += fmt.Sprintf(" %s %s\n", dep.PackageID, dep.RequiredVersion)
}
if !m.prompter.Confirm(fmt.Sprintf("The following dependencies need to be installed:\n%sInstall?", summary)) {
return nil, fmt.Errorf("dependency installation declined, aborting")
}
for _, dep := range missing {
depScope, depName, err := common.ParsePackageID(dep.PackageID)
if err != nil {
return nil, err
}
// Reload lockfile before each dep install — a previous dep may
// have recursively installed this one already.
freshLF, _ := common.LoadLockfile(m.appRoot)
if _, already := freshLF.GetPackage(dep.PackageID); already {
fmt.Printf(" ✓ Dependency %s already installed (transitive)\n", dep.PackageID)
continue
}
depType := depTypeFor(dep.PackageID, analyzedDeps)
switch depType {
case "mcp":
err = m.mcpMgr.Add(dep.PackageID, mcpmgr.AddOptions{})
default:
err = m.agentMgr.Add(dep.PackageID, agentmgr.AddOptions{})
}
if err != nil {
return nil, fmt.Errorf("failed to install dependency %s (%s/%s): %w", dep.PackageID, depScope, depName, err)
}
fmt.Printf(" ✓ Dependency %s installed\n", dep.PackageID)
}
// Reload lockfile after dependency installation
lf, err = common.LoadLockfile(m.appRoot)
if err != nil {
return nil, err
}
}
}
// Write to registry.yao (member record writing is done by CLI layer)
info := common.PackageInfo{
Type: common.TypeRobot,
Version: manifest.Version,
Integrity: digest,
Dependencies: allDeps,
TeamID: opts.TeamID,
}
lf.SetPackage(pkgID, info)
// Add required_by references
for depID := range allDeps {
lf.AddRequiredBy(depID, pkgID)
}
if err := common.SaveLockfile(m.appRoot, lf); err != nil {
return nil, err
}
fmt.Printf("✓ Robot %s@%s installed (dependencies ready, team: %s)\n", pkgID, manifest.Version, opts.TeamID)
fmt.Printf(" The member record needs to be created in the database.\n")
return &robot, nil
}
// depTypeFor finds the type of a dependency from the analyzed deps list.
func depTypeFor(pkgID string, analyzedDeps []RobotDep) string {
for _, d := range analyzedDeps {
if d.PackageID == pkgID {
return d.Type
}
}
return "assistant"
}

View file

@ -0,0 +1,115 @@
package robot
import (
"encoding/json"
"strings"
)
// RobotJSON represents the portable fields exported from a robot member record.
type RobotJSON struct {
DisplayName string `json:"display_name,omitempty"`
Bio *string `json:"bio,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
LanguageModel string `json:"language_model,omitempty"`
RobotConfig json.RawMessage `json:"robot_config,omitempty"`
Agents []string `json:"agents,omitempty"`
MCPServers []string `json:"mcp_servers,omitempty"`
}
// robotConfig is a partial parse of robot_config for dependency extraction.
type robotConfig struct {
Resources struct {
Phases map[string]string `json:"phases,omitempty"`
} `json:"resources,omitempty"`
}
// RobotDep represents a dependency extracted from a robot configuration.
type RobotDep struct {
PackageID string // "@scope/name"
Type string // "assistant" or "mcp"
}
// AnalyzeDeps extracts dependencies from a RobotJSON following DESIGN-ROBOT.md rules:
// - phases values: "yao.robot-host" → @yao/robot-host (assistant)
// - agents values: "yao.keeper.fetch" → @yao/keeper (first-layer assistant, take first 2 segments)
// - mcp_servers values: "ark.image.text2img" → @ark/image.text2img (mcp)
// - Excludes: __yao.* prefixed built-in agents
func AnalyzeDeps(robot *RobotJSON) []RobotDep {
seen := map[string]bool{}
var deps []RobotDep
addDep := func(pkgID, depType string) {
if seen[pkgID] {
return
}
seen[pkgID] = true
deps = append(deps, RobotDep{PackageID: pkgID, Type: depType})
}
// Extract from phases (all are assistants)
if len(robot.RobotConfig) > 0 {
var cfg robotConfig
if err := json.Unmarshal(robot.RobotConfig, &cfg); err == nil {
for _, yaoID := range cfg.Resources.Phases {
if isBuiltIn(yaoID) {
continue
}
pkgID := yaoIDToPackageID(yaoID)
if pkgID != "" {
addDep(pkgID, "assistant")
}
}
}
}
// Extract from agents (first-layer assistant)
for _, yaoID := range robot.Agents {
if isBuiltIn(yaoID) {
continue
}
pkgID := agentYaoIDToPackageID(yaoID)
if pkgID != "" {
addDep(pkgID, "assistant")
}
}
// Extract from mcp_servers
for _, yaoID := range robot.MCPServers {
if isBuiltIn(yaoID) {
continue
}
pkgID := yaoIDToPackageID(yaoID)
if pkgID != "" {
addDep(pkgID, "mcp")
}
}
return deps
}
// isBuiltIn returns true for __yao.* prefixed IDs.
func isBuiltIn(yaoID string) bool {
return strings.HasPrefix(yaoID, "__yao.")
}
// yaoIDToPackageID converts "yao.robot-host" → "@yao/robot-host".
// First "." separates scope from name.
func yaoIDToPackageID(yaoID string) string {
idx := strings.Index(yaoID, ".")
if idx <= 0 || idx >= len(yaoID)-1 {
return ""
}
scope := yaoID[:idx]
name := yaoID[idx+1:]
return "@" + scope + "/" + name
}
// agentYaoIDToPackageID converts "yao.keeper.fetch" → "@yao/keeper".
// Takes the first two segments only (first-layer assistant).
func agentYaoIDToPackageID(yaoID string) string {
parts := strings.SplitN(yaoID, ".", 3)
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
return ""
}
return "@" + parts[0] + "/" + parts[1]
}

View file

@ -0,0 +1,32 @@
// Package robot implements the Robot package manager for the Yao registry.
package robot
import (
"github.com/yaoapp/yao/registry"
agentmgr "github.com/yaoapp/yao/registry/manager/agent"
"github.com/yaoapp/yao/registry/manager/common"
mcpmgr "github.com/yaoapp/yao/registry/manager/mcp"
)
// Manager handles robot package operations (add only for P0).
type Manager struct {
client *registry.Client
appRoot string
prompter common.Prompter
agentMgr *agentmgr.Manager
mcpMgr *mcpmgr.Manager
}
// New creates a Robot Manager.
func New(client *registry.Client, appRoot string, prompter common.Prompter) *Manager {
if prompter == nil {
prompter = &common.StdinPrompter{}
}
return &Manager{
client: client,
appRoot: appRoot,
prompter: prompter,
agentMgr: agentmgr.New(client, appRoot, prompter),
mcpMgr: mcpmgr.New(client, appRoot, prompter),
}
}

View file

@ -0,0 +1,324 @@
package robot
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/yaoapp/yao/registry"
"github.com/yaoapp/yao/registry/manager/common"
"github.com/yaoapp/yao/registry/testdata"
)
func buildRobotZip(scope, name, version string, robotJSON *RobotJSON, deps []testdata.ManifestDep) []byte {
robotBytes, _ := json.Marshal(robotJSON)
zip, err := testdata.BuildZip(&testdata.Manifest{
Type: "robot",
Scope: scope,
Name: name,
Version: version,
Dependencies: deps,
}, map[string]string{
"robot.json": string(robotBytes),
})
if err != nil {
panic(err)
}
return zip
}
func buildAgentZip(scope, name, version string) []byte {
zip, err := testdata.BuildZip(&testdata.Manifest{
Type: "assistant",
Scope: scope,
Name: name,
Version: version,
}, map[string]string{
"package.yao": `{"name":"` + name + `"}`,
})
if err != nil {
panic(err)
}
return zip
}
func buildMCPZip(scope, name, version string) []byte {
zip, err := testdata.BuildZip(&testdata.Manifest{
Type: "mcp",
Scope: scope,
Name: name,
Version: version,
}, map[string]string{
name + ".mcp.yao": `{"transport":"stdio","command":"echo"}`,
})
if err != nil {
panic(err)
}
return zip
}
func mockServer(packages map[string][]byte) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/.well-known/yao-registry" {
json.NewEncoder(w).Encode(map[string]interface{}{
"registry": map[string]string{"version": "1.0.0", "api": "/v1"},
"types": []string{"assistants", "mcps", "robots"},
})
return
}
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/pull") {
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/"), "/")
if len(parts) >= 4 {
key := parts[0] + "/" + parts[1] + "/" + parts[2]
if zipData, ok := packages[key]; ok {
w.Header().Set("X-Digest", "sha256-test")
w.Write(zipData)
return
}
}
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"error": "not found"})
return
}
w.WriteHeader(http.StatusNotFound)
}))
}
func TestAnalyzeDeps(t *testing.T) {
robot := &RobotJSON{
RobotConfig: json.RawMessage(`{
"resources": {
"phases": {
"host": "yao.robot-host",
"goals": "yao.robot-goals",
"builtin": "__yao.default-host"
}
}
}`),
Agents: []string{"yao.keeper.fetch", "__yao.system"},
MCPServers: []string{"ark.image.text2img"},
}
deps := AnalyzeDeps(robot)
depMap := map[string]string{}
for _, d := range deps {
depMap[d.PackageID] = d.Type
}
// phases
if depMap["@yao/robot-host"] != "assistant" {
t.Error("expected @yao/robot-host as assistant")
}
if depMap["@yao/robot-goals"] != "assistant" {
t.Error("expected @yao/robot-goals as assistant")
}
// agents (first-layer)
if depMap["@yao/keeper"] != "assistant" {
t.Error("expected @yao/keeper as assistant")
}
// mcp_servers
if depMap["@ark/image.text2img"] != "mcp" {
t.Error("expected @ark/image.text2img as mcp")
}
// __yao.* should be excluded
if _, ok := depMap["@__yao/default-host"]; ok {
t.Error("expected __yao.default-host to be excluded")
}
if _, ok := depMap["@__yao/system"]; ok {
t.Error("expected __yao.system to be excluded")
}
}
func TestAnalyzeDepsEmpty(t *testing.T) {
robot := &RobotJSON{}
deps := AnalyzeDeps(robot)
if len(deps) != 0 {
t.Errorf("expected 0 deps, got %d", len(deps))
}
}
func TestAnalyzeDepsDedupe(t *testing.T) {
robot := &RobotJSON{
RobotConfig: json.RawMessage(`{
"resources": {
"phases": {
"host": "yao.robot-host"
}
}
}`),
Agents: []string{"yao.robot-host.run"},
}
deps := AnalyzeDeps(robot)
count := 0
for _, d := range deps {
if d.PackageID == "@yao/robot-host" {
count++
}
}
if count != 1 {
t.Errorf("expected @yao/robot-host once, got %d times", count)
}
}
func TestAddRobotNoTeam(t *testing.T) {
appRoot := t.TempDir()
srv := mockServer(nil)
defer srv.Close()
client := registry.New(srv.URL)
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
_, err := mgr.Add("@test/my-robot", AddOptions{})
if err == nil {
t.Fatal("expected error for missing team")
}
if !strings.Contains(err.Error(), "--team") {
t.Errorf("expected team error, got: %v", err)
}
}
func TestAddRobotWithDeps(t *testing.T) {
appRoot := t.TempDir()
agentZip := buildAgentZip("@test", "robot-host", "1.0.0")
mcpZip := buildMCPZip("@test", "image-gen", "1.0.0")
robotJSON := &RobotJSON{
DisplayName: "Test Robot",
RobotConfig: json.RawMessage(`{
"resources": {
"phases": {
"host": "test.robot-host"
}
}
}`),
MCPServers: []string{"test.image-gen"},
}
robotZip := buildRobotZip("@test", "my-robot", "1.0.0", robotJSON, nil)
srv := mockServer(map[string][]byte{
"robots/@test/my-robot": robotZip,
"assistants/@test/robot-host": agentZip,
"mcps/@test/image-gen": mcpZip,
})
defer srv.Close()
client := registry.New(srv.URL)
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
robot, err := mgr.Add("@test/my-robot", AddOptions{TeamID: "team-123"})
if err != nil {
t.Fatalf("Add robot failed: %v", err)
}
if robot.DisplayName != "Test Robot" {
t.Errorf("expected display_name 'Test Robot', got %q", robot.DisplayName)
}
// Verify lockfile
lf, _ := common.LoadLockfile(appRoot)
pkg, ok := lf.GetPackage("@test/my-robot")
if !ok {
t.Fatal("expected @test/my-robot in lockfile")
}
if pkg.Type != common.TypeRobot {
t.Errorf("expected type robot, got %s", pkg.Type)
}
if pkg.TeamID != "team-123" {
t.Errorf("expected team_id team-123, got %s", pkg.TeamID)
}
// Verify dependencies were installed
if _, ok := lf.GetPackage("@test/robot-host"); !ok {
t.Error("expected @test/robot-host dependency installed")
}
if _, ok := lf.GetPackage("@test/image-gen"); !ok {
t.Error("expected @test/image-gen dependency installed")
}
// Verify assistant directory was created
agentDir := filepath.Join(appRoot, "assistants", "test", "robot-host")
if _, err := os.Stat(agentDir); err != nil {
t.Error("expected assistant directory created")
}
}
func TestAddRobotNoDeps(t *testing.T) {
appRoot := t.TempDir()
robotJSON := &RobotJSON{
DisplayName: "Simple Robot",
}
robotZip := buildRobotZip("@test", "simple-bot", "1.0.0", robotJSON, nil)
srv := mockServer(map[string][]byte{
"robots/@test/simple-bot": robotZip,
})
defer srv.Close()
client := registry.New(srv.URL)
mgr := New(client, appRoot, &common.AutoConfirmPrompter{})
robot, err := mgr.Add("@test/simple-bot", AddOptions{TeamID: "team-1"})
if err != nil {
t.Fatalf("Add simple robot failed: %v", err)
}
if robot.DisplayName != "Simple Robot" {
t.Errorf("expected 'Simple Robot', got %q", robot.DisplayName)
}
}
func TestYaoIDToPackageID(t *testing.T) {
tests := []struct {
input string
want string
}{
{"yao.robot-host", "@yao/robot-host"},
{"ark.image.text2img", "@ark/image.text2img"},
{"bad", ""},
{"", ""},
}
for _, tt := range tests {
got := yaoIDToPackageID(tt.input)
if got != tt.want {
t.Errorf("yaoIDToPackageID(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestAgentYaoIDToPackageID(t *testing.T) {
tests := []struct {
input string
want string
}{
{"yao.keeper.fetch", "@yao/keeper"},
{"yao.keeper", "@yao/keeper"},
{"bad", ""},
}
for _, tt := range tests {
got := agentYaoIDToPackageID(tt.input)
if got != tt.want {
t.Errorf("agentYaoIDToPackageID(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestIsBuiltIn(t *testing.T) {
if !isBuiltIn("__yao.default-host") {
t.Error("expected __yao.default-host to be built-in")
}
if isBuiltIn("yao.robot-host") {
t.Error("expected yao.robot-host NOT to be built-in")
}
}

View file

@ -0,0 +1,256 @@
package manager_test
import (
"testing"
agentmgr "github.com/yaoapp/yao/registry/manager/agent"
"github.com/yaoapp/yao/registry/manager/common"
mcpmgr "github.com/yaoapp/yao/registry/manager/mcp"
robotmgr "github.com/yaoapp/yao/registry/manager/robot"
)
// =============================================================================
// Robot Add with agent + MCP dependencies
// =============================================================================
func TestE2ERobot_AddWithDeps(t *testing.T) {
c := authClient()
devApp := appRoot(t)
defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0")
defer cleanupPkg(c, "assistants", "@"+testScope, "registry-agent", "1.0.0")
defer cleanupPkg(c, "robots", "@"+testScope, "test-bot", "1.0.0")
// Push dependencies
mcpMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{})
agentMgr := agentmgr.New(c, devApp, &common.AutoConfirmPrompter{})
if err := mcpMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"}); err != nil {
t.Fatalf("Push MCP: %v", err)
}
if err := agentMgr.Push(testScope+".registry-agent", agentmgr.PushOptions{Version: "1.0.0"}); err != nil {
t.Fatalf("Push agent: %v", err)
}
// Build and push robot package
robotJSON := map[string]interface{}{
"display_name": "E2E Test Bot",
"system_prompt": "You are an E2E test robot.",
"language_model": "gpt-4o",
"robot_config": map[string]interface{}{
"resources": map[string]interface{}{
"phases": map[string]string{
"host": testScope + ".registry-agent",
},
},
},
"mcp_servers": []string{testScope + ".registry-mcp"},
}
buildAndPushRobotZip(t, c, "test-bot", robotJSON, "1.0.0")
// Install robot to fresh app
installApp := t.TempDir()
rMgr := robotmgr.New(c, installApp, &common.AutoConfirmPrompter{})
robot, err := rMgr.Add("@"+testScope+"/test-bot", robotmgr.AddOptions{TeamID: "team-e2e"})
if err != nil {
t.Fatalf("Add robot: %v", err)
}
if robot.DisplayName != "E2E Test Bot" {
t.Errorf("want display_name 'E2E Test Bot', got %q", robot.DisplayName)
}
if robot.SystemPrompt != "You are an E2E test robot." {
t.Errorf("unexpected system_prompt: %s", robot.SystemPrompt)
}
// Lockfile: robot entry
robotPkg := requireLockfileHas(t, installApp, "@"+testScope+"/test-bot")
if robotPkg.Type != common.TypeRobot {
t.Errorf("want robot type, got %s", robotPkg.Type)
}
if robotPkg.TeamID != "team-e2e" {
t.Errorf("want team_id team-e2e, got %s", robotPkg.TeamID)
}
// Dependencies auto-installed
requireLockfileHas(t, installApp, "@"+testScope+"/registry-agent")
requireLockfileHas(t, installApp, "@"+testScope+"/registry-mcp")
// Files on disk
requireFileExists(t, installApp+"/assistants/"+testScope+"/registry-agent/package.yao")
requireFileExists(t, installApp+"/mcps/"+testScope+"/registry-mcp/server.mcp.yao")
// required_by on agent from robot
lf, _ := common.LoadLockfile(installApp)
agentPkg, _ := lf.GetPackage("@" + testScope + "/registry-agent")
foundRB := false
for _, rb := range agentPkg.RequiredBy {
if rb == "@"+testScope+"/test-bot" {
foundRB = true
}
}
if !foundRB {
t.Errorf("expected robot in agent's required_by, got %v", agentPkg.RequiredBy)
}
}
// =============================================================================
// Robot Add with no dependencies
// =============================================================================
func TestE2ERobot_AddNoDeps(t *testing.T) {
c := authClient()
defer cleanupPkg(c, "robots", "@"+testScope, "simple-bot", "1.0.0")
robotJSON := map[string]interface{}{
"display_name": "Simple Bot",
"system_prompt": "You are a simple bot with no dependencies.",
"language_model": "gpt-4o-mini",
}
buildAndPushRobotZip(t, c, "simple-bot", robotJSON, "1.0.0")
installApp := t.TempDir()
rMgr := robotmgr.New(c, installApp, &common.AutoConfirmPrompter{})
robot, err := rMgr.Add("@"+testScope+"/simple-bot", robotmgr.AddOptions{TeamID: "team-simple"})
if err != nil {
t.Fatalf("Add robot: %v", err)
}
if robot.DisplayName != "Simple Bot" {
t.Errorf("want 'Simple Bot', got %q", robot.DisplayName)
}
if robot.LanguageModel != "gpt-4o-mini" {
t.Errorf("want gpt-4o-mini, got %s", robot.LanguageModel)
}
robotPkg := requireLockfileHas(t, installApp, "@"+testScope+"/simple-bot")
if robotPkg.Type != common.TypeRobot {
t.Errorf("want robot type, got %s", robotPkg.Type)
}
if robotPkg.TeamID != "team-simple" {
t.Errorf("want team_id team-simple, got %s", robotPkg.TeamID)
}
// No other packages should be installed
lf, _ := common.LoadLockfile(installApp)
for id := range lf.Packages {
if id != "@"+testScope+"/simple-bot" {
t.Errorf("unexpected package %s in lockfile (no-dep robot should be alone)", id)
}
}
}
// =============================================================================
// Robot Add: team ID is required
// =============================================================================
func TestE2ERobot_AddRequiresTeam(t *testing.T) {
c := authClient()
defer cleanupPkg(c, "robots", "@"+testScope, "simple-bot", "1.0.0")
robotJSON := map[string]interface{}{
"display_name": "Simple Bot",
"system_prompt": "You are a simple bot.",
}
buildAndPushRobotZip(t, c, "simple-bot", robotJSON, "1.0.0")
installApp := t.TempDir()
rMgr := robotmgr.New(c, installApp, &common.AutoConfirmPrompter{})
_, err := rMgr.Add("@"+testScope+"/simple-bot", robotmgr.AddOptions{})
if err == nil {
t.Fatal("expected error when team is missing")
}
if err.Error() != "--team is required for robot add" {
t.Errorf("unexpected error: %v", err)
}
}
// =============================================================================
// Robot → Agent → MCP dependency chain: required_by propagation
// =============================================================================
func TestE2ERobot_RequiredByChain(t *testing.T) {
c := authClient()
devApp := appRoot(t)
defer cleanupPkg(c, "mcps", "@"+testScope, "registry-mcp", "1.0.0")
defer cleanupPkg(c, "mcps", "@"+testScope, "data-tools", "1.0.0")
defer cleanupPkg(c, "assistants", "@"+testScope, "analytics", "1.0.0")
defer cleanupPkg(c, "robots", "@"+testScope, "analytics-bot", "1.0.0")
// Push full dependency tree: 2 MCPs → analytics agent → robot
mcpMgr := mcpmgr.New(c, devApp, &common.AutoConfirmPrompter{})
mcpMgr.Push(testScope+".registry-mcp", mcpmgr.PushOptions{Version: "1.0.0"})
mcpMgr.Push(testScope+".data-tools", mcpmgr.PushOptions{Version: "1.0.0"})
agentMgr := agentmgr.New(c, devApp, &common.AutoConfirmPrompter{})
agentMgr.Push(testScope+".analytics", agentmgr.PushOptions{Version: "1.0.0"})
robotJSON := map[string]interface{}{
"display_name": "Analytics Bot",
"system_prompt": "You are an analytics bot.",
"language_model": "gpt-4o",
"robot_config": map[string]interface{}{
"resources": map[string]interface{}{
"phases": map[string]string{
"host": testScope + ".analytics",
},
},
},
}
buildAndPushRobotZip(t, c, "analytics-bot", robotJSON, "1.0.0")
// Install robot to fresh app
installApp := t.TempDir()
rMgr := robotmgr.New(c, installApp, &common.AutoConfirmPrompter{})
_, err := rMgr.Add("@"+testScope+"/analytics-bot", robotmgr.AddOptions{TeamID: "team-chain"})
if err != nil {
t.Fatalf("Add robot: %v", err)
}
// Entire dependency chain should be installed
requireLockfileHas(t, installApp, "@"+testScope+"/analytics-bot")
requireLockfileHas(t, installApp, "@"+testScope+"/analytics")
requireLockfileHas(t, installApp, "@"+testScope+"/registry-mcp")
requireLockfileHas(t, installApp, "@"+testScope+"/data-tools")
// required_by: robot → analytics
lf, _ := common.LoadLockfile(installApp)
analyticsPkg, _ := lf.GetPackage("@" + testScope + "/analytics")
foundRobot := false
for _, rb := range analyticsPkg.RequiredBy {
if rb == "@"+testScope+"/analytics-bot" {
foundRobot = true
}
}
if !foundRobot {
t.Errorf("expected analytics-bot in analytics's required_by, got %v", analyticsPkg.RequiredBy)
}
// required_by: analytics → MCPs (set by agent Add's dependency installation)
// The MCP's required_by may include analytics (set by agent add) and/or analytics-bot (set by robot add)
for _, mcpID := range []string{"@" + testScope + "/registry-mcp", "@" + testScope + "/data-tools"} {
mcpPkg, ok := lf.GetPackage(mcpID)
if !ok {
t.Errorf("MCP %s not found in lockfile", mcpID)
continue
}
if len(mcpPkg.RequiredBy) == 0 {
t.Errorf("expected required_by on %s, got empty", mcpID)
}
}
// Verify disk completeness
requireFileExists(t, installApp+"/assistants/"+testScope+"/analytics/package.yao")
requireFileExists(t, installApp+"/mcps/"+testScope+"/registry-mcp/server.mcp.yao")
requireFileExists(t, installApp+"/mcps/"+testScope+"/data-tools/server.mcp.yao")
requireFileExists(t, installApp+"/scripts/"+testScope+"/registry_mcp.ts")
requireFileExists(t, installApp+"/scripts/"+testScope+"/data_tools.ts")
requireFileExists(t, installApp+"/scripts/"+testScope+"/data_utils.ts")
}