diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index e9e87832..938e1fc3 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -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 diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 93d8cb46..c736b3ec 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -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 diff --git a/.gitignore b/.gitignore index e85e3e17..56d0d398 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,4 @@ tg-session.json tg-login tg-send registry/data/ +registry/manager/DESIGN*.md diff --git a/Makefile b/Makefile index e2cce08e..1a0e23b7 100644 --- a/Makefile +++ b/Makefile @@ -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; \ diff --git a/cmd/agent/add.go b/cmd/agent/add.go new file mode 100644 index 00000000..ba7edf98 --- /dev/null +++ b/cmd/agent/add.go @@ -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")) +} diff --git a/cmd/agent/agent.go b/cmd/agent/agent.go index 8fbde5e6..95a06ad2 100644 --- a/cmd/agent/agent.go +++ b/cmd/agent/agent.go @@ -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 文件", diff --git a/cmd/agent/fork.go b/cmd/agent/fork.go new file mode 100644 index 00000000..443bdc9e --- /dev/null +++ b/cmd/agent/fork.go @@ -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")) +} diff --git a/cmd/agent/push.go b/cmd/agent/push.go new file mode 100644 index 00000000..7bd29d42 --- /dev/null +++ b/cmd/agent/push.go @@ -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")) +} diff --git a/cmd/agent/update.go b/cmd/agent/update.go new file mode 100644 index 00000000..2d4676f2 --- /dev/null +++ b/cmd/agent/update.go @@ -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")) +} diff --git a/cmd/mcp/add.go b/cmd/mcp/add.go new file mode 100644 index 00000000..2d78a090 --- /dev/null +++ b/cmd/mcp/add.go @@ -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")) +} diff --git a/cmd/mcp/fork.go b/cmd/mcp/fork.go new file mode 100644 index 00000000..779169a7 --- /dev/null +++ b/cmd/mcp/fork.go @@ -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")) +} diff --git a/cmd/mcp/mcp.go b/cmd/mcp/mcp.go new file mode 100644 index 00000000..6d1bb2d6 --- /dev/null +++ b/cmd/mcp/mcp.go @@ -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() +} diff --git a/cmd/mcp/push.go b/cmd/mcp/push.go new file mode 100644 index 00000000..f264033a --- /dev/null +++ b/cmd/mcp/push.go @@ -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")) +} diff --git a/cmd/mcp/update.go b/cmd/mcp/update.go new file mode 100644 index 00000000..d6fa9ed9 --- /dev/null +++ b/cmd/mcp/update.go @@ -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")) +} diff --git a/cmd/robot/add.go b/cmd/robot/add.go new file mode 100644 index 00000000..bc50e490 --- /dev/null +++ b/cmd/robot/add.go @@ -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")) +} diff --git a/cmd/robot/robot.go b/cmd/robot/robot.go new file mode 100644 index 00000000..29786adb --- /dev/null +++ b/cmd/robot/robot.go @@ -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() +} diff --git a/cmd/root.go b/cmd/root.go index 9e513f7a..1a228402 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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) diff --git a/config/types.go b/config/types.go index 772421e6..16e81bb5 100644 --- a/config/types.go +++ b/config/types.go @@ -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 数据库配置 diff --git a/registry/client_test.go b/registry/client_test.go index 4940a8eb..534445ea 100644 --- a/registry/client_test.go +++ b/registry/client_test.go @@ -12,7 +12,7 @@ import ( ) const ( - testScope = "@test" + testScope = "@yaoagents" ) func serverURL() string { diff --git a/registry/manager/agent/add.go b/registry/manager/agent/add.go new file mode 100644 index 00000000..7963116f --- /dev/null +++ b/registry/manager/agent/add.go @@ -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 +} diff --git a/registry/manager/agent/agent.go b/registry/manager/agent/agent.go new file mode 100644 index 00000000..d28f4143 --- /dev/null +++ b/registry/manager/agent/agent.go @@ -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), + } +} diff --git a/registry/manager/agent/agent_test.go b/registry/manager/agent/agent_test.go new file mode 100644 index 00000000..1cf32fa4 --- /dev/null +++ b/registry/manager/agent/agent_test.go @@ -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) + } +} diff --git a/registry/manager/agent/fork.go b/registry/manager/agent/fork.go new file mode 100644 index 00000000..4fb7ff82 --- /dev/null +++ b/registry/manager/agent/fork.go @@ -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 +} diff --git a/registry/manager/agent/push.go b/registry/manager/agent/push.go new file mode 100644 index 00000000..b63ab0f7 --- /dev/null +++ b/registry/manager/agent/push.go @@ -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 +} diff --git a/registry/manager/agent/scan.go b/registry/manager/agent/scan.go new file mode 100644 index 00000000..36885c60 --- /dev/null +++ b/registry/manager/agent/scan.go @@ -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 +} diff --git a/registry/manager/agent/update.go b/registry/manager/agent/update.go new file mode 100644 index 00000000..91fe53d8 --- /dev/null +++ b/registry/manager/agent/update.go @@ -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) +} diff --git a/registry/manager/agent_e2e_test.go b/registry/manager/agent_e2e_test.go new file mode 100644 index 00000000..9c199fce --- /dev/null +++ b/registry/manager/agent_e2e_test.go @@ -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) + } +} diff --git a/registry/manager/common/deps.go b/registry/manager/common/deps.go new file mode 100644 index 00000000..cccbc460 --- /dev/null +++ b/registry/manager/common/deps.go @@ -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 +} diff --git a/registry/manager/common/deps_test.go b/registry/manager/common/deps_test.go new file mode 100644 index 00000000..f5b241fc --- /dev/null +++ b/registry/manager/common/deps_test.go @@ -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") + } +} diff --git a/registry/manager/common/hash.go b/registry/manager/common/hash.go new file mode 100644 index 00000000..393ad0a1 --- /dev/null +++ b/registry/manager/common/hash.go @@ -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-". +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-". +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 +} diff --git a/registry/manager/common/hash_test.go b/registry/manager/common/hash_test.go new file mode 100644 index 00000000..8966426e --- /dev/null +++ b/registry/manager/common/hash_test.go @@ -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") + } +} diff --git a/registry/manager/common/lockfile.go b/registry/manager/common/lockfile.go new file mode 100644 index 00000000..6d016b60 --- /dev/null +++ b/registry/manager/common/lockfile.go @@ -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 +} diff --git a/registry/manager/common/lockfile_test.go b/registry/manager/common/lockfile_test.go new file mode 100644 index 00000000..a6fddc68 --- /dev/null +++ b/registry/manager/common/lockfile_test.go @@ -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") + } +} diff --git a/registry/manager/common/packer.go b/registry/manager/common/packer.go new file mode 100644 index 00000000..582420ff --- /dev/null +++ b/registry/manager/common/packer.go @@ -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 +} diff --git a/registry/manager/common/packer_test.go b/registry/manager/common/packer_test.go new file mode 100644 index 00000000..94f0acd6 --- /dev/null +++ b/registry/manager/common/packer_test.go @@ -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") + } +} diff --git a/registry/manager/common/path.go b/registry/manager/common/path.go new file mode 100644 index 00000000..06c2083e --- /dev/null +++ b/registry/manager/common/path.go @@ -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" +} diff --git a/registry/manager/common/path_test.go b/registry/manager/common/path_test.go new file mode 100644 index 00000000..7c4577d6 --- /dev/null +++ b/registry/manager/common/path_test.go @@ -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") + } +} diff --git a/registry/manager/common/prompt.go b/registry/manager/common/prompt.go new file mode 100644 index 00000000..dedaa12c --- /dev/null +++ b/registry/manager/common/prompt.go @@ -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 +} diff --git a/registry/manager/common/prompt_test.go b/registry/manager/common/prompt_test.go new file mode 100644 index 00000000..3a2ac58c --- /dev/null +++ b/registry/manager/common/prompt_test.go @@ -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") + } +} diff --git a/registry/manager/common/types.go b/registry/manager/common/types.go new file mode 100644 index 00000000..9de1477a --- /dev/null +++ b/registry/manager/common/types.go @@ -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 +} diff --git a/registry/manager/e2e_helpers_test.go b/registry/manager/e2e_helpers_test.go new file mode 100644 index 00000000..81529b87 --- /dev/null +++ b/registry/manager/e2e_helpers_test.go @@ -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 { + 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) + } +} diff --git a/registry/manager/mcp/add.go b/registry/manager/mcp/add.go new file mode 100644 index 00000000..c1e20fa9 --- /dev/null +++ b/registry/manager/mcp/add.go @@ -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) +} diff --git a/registry/manager/mcp/fork.go b/registry/manager/mcp/fork.go new file mode 100644 index 00000000..38ab456c --- /dev/null +++ b/registry/manager/mcp/fork.go @@ -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 +} diff --git a/registry/manager/mcp/mcp.go b/registry/manager/mcp/mcp.go new file mode 100644 index 00000000..2e389972 --- /dev/null +++ b/registry/manager/mcp/mcp.go @@ -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, + } +} diff --git a/registry/manager/mcp/mcp_test.go b/registry/manager/mcp/mcp_test.go new file mode 100644 index 00000000..93c34367 --- /dev/null +++ b/registry/manager/mcp/mcp_test.go @@ -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") + } +} diff --git a/registry/manager/mcp/push.go b/registry/manager/mcp/push.go new file mode 100644 index 00000000..1628c315 --- /dev/null +++ b/registry/manager/mcp/push.go @@ -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 +} diff --git a/registry/manager/mcp/script.go b/registry/manager/mcp/script.go new file mode 100644 index 00000000..806df712 --- /dev/null +++ b/registry/manager/mcp/script.go @@ -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 "" +} diff --git a/registry/manager/mcp/update.go b/registry/manager/mcp/update.go new file mode 100644 index 00000000..d0fa445d --- /dev/null +++ b/registry/manager/mcp/update.go @@ -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) +} diff --git a/registry/manager/mcp_e2e_test.go b/registry/manager/mcp_e2e_test.go new file mode 100644 index 00000000..8f960a0d --- /dev/null +++ b/registry/manager/mcp_e2e_test.go @@ -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) + } +} diff --git a/registry/manager/robot/add.go b/registry/manager/robot/add.go new file mode 100644 index 00000000..d28c88e9 --- /dev/null +++ b/registry/manager/robot/add.go @@ -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" +} diff --git a/registry/manager/robot/deps.go b/registry/manager/robot/deps.go new file mode 100644 index 00000000..e4f97199 --- /dev/null +++ b/registry/manager/robot/deps.go @@ -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] +} diff --git a/registry/manager/robot/robot.go b/registry/manager/robot/robot.go new file mode 100644 index 00000000..d4a89178 --- /dev/null +++ b/registry/manager/robot/robot.go @@ -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), + } +} diff --git a/registry/manager/robot/robot_test.go b/registry/manager/robot/robot_test.go new file mode 100644 index 00000000..a2d0b7e1 --- /dev/null +++ b/registry/manager/robot/robot_test.go @@ -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") + } +} diff --git a/registry/manager/robot_e2e_test.go b/registry/manager/robot_e2e_test.go new file mode 100644 index 00000000..5884fa3b --- /dev/null +++ b/registry/manager/robot_e2e_test.go @@ -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") +}