Merge pull request #1484 from trheyi/main

Implement force push option for agent and MCP commands
This commit is contained in:
Max 2026-03-03 11:31:41 +08:00 committed by GitHub
commit c1754d821c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 272 additions and 10 deletions

View file

@ -2,6 +2,7 @@ package assistant
import (
"fmt"
"log"
"time"
jsoniter "github.com/json-iterator/go"
@ -567,29 +568,34 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
return finalResponse, nil
}
// GetConnector get the connector object, capabilities, and error with priority: opts.Connector > ast.Connector
// GetConnector get the connector object, capabilities, and error with priority:
// opts.Connector > ast.Connector > defaultConnector (fallback)
// Note: opts.Connector may be set by Create hook's applyOptionsAdjustments
// Returns: (connector, capabilities, error)
func (ast *Assistant) GetConnector(ctx *context.Context, opts ...*context.Options) (connector.Connector, *goullm.Capabilities, error) {
// Determine connector ID with priority: opts.Connector > ast.Connector
connectorID := ast.Connector
if len(opts) > 0 && opts[0] != nil && opts[0].Connector != "" {
connectorID = opts[0].Connector
}
// If empty, return error
if connectorID == "" {
connectorID = defaultConnector
}
if connectorID == "" {
return nil, nil, fmt.Errorf("connector not specified")
}
// Load gou connector
conn, err := connector.Select(connectorID)
if err != nil && connectorID != defaultConnector && defaultConnector != "" {
log.Printf("[Assistant] connector %q not found, falling back to default %q", connectorID, defaultConnector)
conn, err = connector.Select(defaultConnector)
}
if err != nil {
return nil, nil, err
}
capabilities := llm.GetCapabilitiesFromConn(conn)
return conn, capabilities, nil
}

View file

@ -21,6 +21,7 @@ var PushCmd = &cobra.Command{
yaoID := args[0]
version, _ := cmd.Flags().GetString("version")
force, _ := cmd.Flags().GetBool("force")
client := registry.New(config.Conf.Registry,
registry.WithAuth(
@ -32,6 +33,7 @@ var PushCmd = &cobra.Command{
mgr := agentmgr.New(client, config.Conf.Root, nil)
if err := mgr.Push(yaoID, agentmgr.PushOptions{
Version: version,
Force: force,
}); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
@ -41,6 +43,7 @@ var PushCmd = &cobra.Command{
func init() {
PushCmd.Flags().StringP("version", "v", "", L("Package version (required)"))
PushCmd.Flags().Bool("force", false, L("Overwrite existing version"))
PushCmd.PersistentFlags().StringVarP(&appPath, "app", "a", "", L("Application directory"))
PushCmd.PersistentFlags().StringVarP(&envFile, "env", "e", "", L("Environment file"))
}

View file

@ -20,6 +20,7 @@ var PushCmd = &cobra.Command{
Boot()
yaoID := args[0]
version, _ := cmd.Flags().GetString("version")
force, _ := cmd.Flags().GetBool("force")
client := registry.New(config.Conf.Registry,
registry.WithAuth(
@ -31,6 +32,7 @@ var PushCmd = &cobra.Command{
mgr := mcpmgr.New(client, config.Conf.Root, nil)
if err := mgr.Push(yaoID, mcpmgr.PushOptions{
Version: version,
Force: force,
}); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
@ -40,6 +42,7 @@ var PushCmd = &cobra.Command{
func init() {
PushCmd.Flags().StringP("version", "v", "", L("Package version (required)"))
PushCmd.Flags().Bool("force", false, L("Overwrite existing version"))
PushCmd.PersistentFlags().StringVarP(&appPath, "app", "a", "", L("Application directory"))
PushCmd.PersistentFlags().StringVarP(&envFile, "env", "e", "", L("Environment file"))
}

View file

@ -73,6 +73,13 @@ func mockRegistryServer(packages map[string][]byte) *httptest.Server {
return
}
// Delete: DELETE /v1/{type}/{scope}/{name}/{version}
if r.Method == http.MethodDelete {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "deleted"})
return
}
// Push: PUT /v1/{type}/{scope}/{name}/{version}
if r.Method == http.MethodPut {
w.WriteHeader(http.StatusCreated)
@ -425,6 +432,52 @@ func TestPushLocalScope(t *testing.T) {
}
}
func TestPushForce(t *testing.T) {
appRoot := t.TempDir()
assistantDir := filepath.Join(appRoot, "assistants", "max", "my-agent")
os.MkdirAll(assistantDir, 0755)
os.WriteFile(filepath.Join(assistantDir, "package.yao"), []byte(`{"name":"my-agent"}`), 0644)
var deleteCalled bool
srv := 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"},
})
return
}
if r.Method == http.MethodDelete {
deleteCalled = true
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "deleted"})
return
}
if r.Method == http.MethodPut {
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{
"type": "assistants", "scope": "@max",
"name": "my-agent", "version": "1.0.0", "digest": "sha256-forced",
})
return
}
w.WriteHeader(http.StatusNotFound)
}))
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", Force: true})
if err != nil {
t.Fatalf("Force push failed: %v", err)
}
if !deleteCalled {
t.Error("expected DELETE to be called before PUT when Force=true")
}
}
func TestPushNoVersion(t *testing.T) {
appRoot := t.TempDir()
srv := mockRegistryServer(nil)

View file

@ -11,6 +11,7 @@ import (
// PushOptions configures the Push operation.
type PushOptions struct {
Version string // required semver
Force bool // delete existing version before push
}
// Push packages and uploads an assistant to the registry.
@ -66,8 +67,14 @@ func (m *Manager) Push(yaoID string, opts PushOptions) error {
return fmt.Errorf("pack: %w", err)
}
// Push to registry
regType := common.TypeToRegistryType(common.TypeAssistant)
// Force: delete existing version first (ignore 404)
if opts.Force {
m.client.DeleteVersion(regType, "@"+scope, name, opts.Version)
}
// Push to registry
result, err := m.client.Push(regType, "@"+scope, name, opts.Version, zipData)
if err != nil {
return fmt.Errorf("push: %w", err)

View file

@ -29,6 +29,13 @@ func TestE2EAgent_SingleDepLifecycle(t *testing.T) {
t.Fatalf("Push MCP: %v", err)
}
// Verify .yaoignore fixtures exist in source before push
srcAgent := filepath.Join(devApp, "assistants", testScope, "registry-agent")
requireFileExists(t, filepath.Join(srcAgent, ".yaoignore"))
requireFileExists(t, filepath.Join(srcAgent, "dev-notes.md"))
requireFileExists(t, filepath.Join(srcAgent, "wireframe.sketch"))
requireFileExists(t, filepath.Join(srcAgent, "debug", "trace.log"))
// Push agent
agentMgr := agentmgr.New(c, devApp, &common.AutoConfirmPrompter{})
if err := agentMgr.Push(testScope+".registry-agent", agentmgr.PushOptions{Version: "1.0.0"}); err != nil {
@ -56,6 +63,12 @@ func TestE2EAgent_SingleDepLifecycle(t *testing.T) {
requireFileExists(t, filepath.Join(agentDir, "package.yao"))
requireFileContains(t, filepath.Join(agentDir, "prompts.yml"), "registry E2E testing")
// .yaoignore: excluded files must NOT appear in the installed package
requireFileNotExists(t, filepath.Join(agentDir, ".yaoignore"))
requireFileNotExists(t, filepath.Join(agentDir, "dev-notes.md"))
requireFileNotExists(t, filepath.Join(agentDir, "wireframe.sketch"))
requireFileNotExists(t, filepath.Join(agentDir, "debug", "trace.log"))
// Lockfile: agent entry
agentPkg := requireLockfileHas(t, installApp, "@"+testScope+"/registry-agent")
if agentPkg.Version != "1.0.0" {

View file

@ -9,8 +9,30 @@ import (
"os"
"path/filepath"
"strings"
"github.com/yaoapp/gou/application/ignore"
)
// DefaultIgnorePatterns are always excluded when packing, regardless of
// whether a .yaoignore file exists. The syntax is identical to .gitignore.
var DefaultIgnorePatterns = []string{
".git/",
".gitignore",
".DS_Store",
"Thumbs.db",
"*.swp",
"*.swo",
"*.bak",
"*.tmp",
"*.log",
"__debug_bin*",
".vscode/",
".cursor/",
".idea/",
"node_modules/",
".yaoignore",
}
// 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.
@ -35,23 +57,32 @@ func PackDir(dir string, manifest *PkgManifest, extraFiles map[string]string) ([
return nil, err
}
// Load ignore rules: built-in defaults first, then .yaoignore on top so
// that user negation patterns (e.g. !important.tmp) can override defaults.
gi := loadIgnoreRules(filepath.Join(dir, ".yaoignore"))
// 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 info.IsDir() {
if rel != "." && gi.MatchesPath(rel+"/") {
return filepath.SkipDir
}
return nil
}
if rel == "pkg.yao" {
return nil
}
if gi.MatchesPath(rel) {
return nil
}
return addFileToZip(w, "package/"+rel, path)
}); err != nil {
return nil, fmt.Errorf("walk dir %s: %w", dir, err)
@ -191,6 +222,20 @@ func ListZipFiles(zipData []byte) ([]string, error) {
return files, nil
}
// loadIgnoreRules compiles ignore patterns with defaults first, then the
// .yaoignore file contents appended so user rules (including negations) win.
func loadIgnoreRules(yaoignorePath string) *ignore.GitIgnore {
lines := make([]string, 0, len(DefaultIgnorePatterns)+16)
lines = append(lines, DefaultIgnorePatterns...)
if data, err := os.ReadFile(yaoignorePath); err == nil {
for _, l := range strings.Split(string(data), "\n") {
lines = append(lines, l)
}
}
return ignore.CompileIgnoreLines(lines...)
}
func addFileToZip(w *zip.Writer, zipPath, srcPath string) error {
f, err := w.Create(zipPath)
if err != nil {

View file

@ -133,6 +133,132 @@ func TestReadManifestMissing(t *testing.T) {
}
}
func TestPackDirBuiltinIgnore(t *testing.T) {
srcDir := t.TempDir()
os.WriteFile(filepath.Join(srcDir, "package.yao"), []byte(`{}`), 0644)
os.WriteFile(filepath.Join(srcDir, "prompts.md"), []byte("hello"), 0644)
// Files that should be excluded by built-in defaults
os.WriteFile(filepath.Join(srcDir, ".DS_Store"), []byte{}, 0644)
os.WriteFile(filepath.Join(srcDir, "debug.swp"), []byte{}, 0644)
os.WriteFile(filepath.Join(srcDir, "notes.bak"), []byte{}, 0644)
os.MkdirAll(filepath.Join(srcDir, ".git", "objects"), 0755)
os.WriteFile(filepath.Join(srcDir, ".git", "config"), []byte{}, 0644)
os.MkdirAll(filepath.Join(srcDir, ".vscode"), 0755)
os.WriteFile(filepath.Join(srcDir, ".vscode", "settings.json"), []byte{}, 0644)
os.MkdirAll(filepath.Join(srcDir, "node_modules", "foo"), 0755)
os.WriteFile(filepath.Join(srcDir, "node_modules", "foo", "index.js"), []byte{}, 0644)
manifest := &PkgManifest{Type: TypeAssistant, Scope: "test", Name: "ign", Version: "1.0.0"}
zipData, err := PackDir(srcDir, manifest, nil)
if err != nil {
t.Fatalf("PackDir: %v", err)
}
files, err := ListZipFiles(zipData)
if err != nil {
t.Fatal(err)
}
fileSet := map[string]bool{}
for _, f := range files {
fileSet[f] = true
}
if !fileSet["package.yao"] {
t.Error("expected package.yao in zip")
}
if !fileSet["prompts.md"] {
t.Error("expected prompts.md in zip")
}
for _, excluded := range []string{".DS_Store", "debug.swp", "notes.bak", ".git/config", ".vscode/settings.json", "node_modules/foo/index.js"} {
if fileSet[excluded] {
t.Errorf("expected %s to be excluded from zip", excluded)
}
}
}
func TestPackDirYaoignoreFile(t *testing.T) {
srcDir := t.TempDir()
os.WriteFile(filepath.Join(srcDir, "package.yao"), []byte(`{}`), 0644)
os.WriteFile(filepath.Join(srcDir, "keep.txt"), []byte("keep"), 0644)
os.WriteFile(filepath.Join(srcDir, "secret.key"), []byte("secret"), 0644)
os.MkdirAll(filepath.Join(srcDir, "drafts"), 0755)
os.WriteFile(filepath.Join(srcDir, "drafts", "notes.md"), []byte("draft"), 0644)
os.WriteFile(filepath.Join(srcDir, "test.log"), []byte("log"), 0644)
// .yaoignore excludes *.key and drafts/
os.WriteFile(filepath.Join(srcDir, ".yaoignore"), []byte("*.key\ndrafts/\n"), 0644)
manifest := &PkgManifest{Type: TypeAssistant, Scope: "test", Name: "ign2", Version: "1.0.0"}
zipData, err := PackDir(srcDir, manifest, nil)
if err != nil {
t.Fatalf("PackDir: %v", err)
}
files, err := ListZipFiles(zipData)
if err != nil {
t.Fatal(err)
}
fileSet := map[string]bool{}
for _, f := range files {
fileSet[f] = true
}
if !fileSet["package.yao"] {
t.Error("expected package.yao")
}
if !fileSet["keep.txt"] {
t.Error("expected keep.txt")
}
if fileSet["secret.key"] {
t.Error("secret.key should be excluded by .yaoignore")
}
if fileSet["drafts/notes.md"] {
t.Error("drafts/notes.md should be excluded by .yaoignore")
}
if fileSet["test.log"] {
t.Error("test.log should be excluded by built-in *.log pattern")
}
if fileSet[".yaoignore"] {
t.Error(".yaoignore itself should be excluded")
}
}
func TestPackDirYaoignoreNegation(t *testing.T) {
srcDir := t.TempDir()
os.WriteFile(filepath.Join(srcDir, "package.yao"), []byte(`{}`), 0644)
os.WriteFile(filepath.Join(srcDir, "a.tmp"), []byte("tmp"), 0644)
os.WriteFile(filepath.Join(srcDir, "important.tmp"), []byte("keep"), 0644)
// *.tmp is in defaults, but negate important.tmp
os.WriteFile(filepath.Join(srcDir, ".yaoignore"), []byte("!important.tmp\n"), 0644)
manifest := &PkgManifest{Type: TypeAssistant, Scope: "test", Name: "neg", Version: "1.0.0"}
zipData, err := PackDir(srcDir, manifest, nil)
if err != nil {
t.Fatalf("PackDir: %v", err)
}
files, err := ListZipFiles(zipData)
if err != nil {
t.Fatal(err)
}
fileSet := map[string]bool{}
for _, f := range files {
fileSet[f] = true
}
if fileSet["a.tmp"] {
t.Error("a.tmp should be excluded by built-in *.tmp")
}
if !fileSet["important.tmp"] {
t.Error("important.tmp should be included via negation in .yaoignore")
}
}
func TestExtractFile(t *testing.T) {
srcDir := t.TempDir()
os.WriteFile(filepath.Join(srcDir, "data.json"), []byte(`{"key":"value"}`), 0644)

View file

@ -11,6 +11,7 @@ import (
// PushOptions configures the Push operation.
type PushOptions struct {
Version string
Force bool // delete existing version before push
}
// Push packages and uploads an MCP to the registry.
@ -72,6 +73,11 @@ func (m *Manager) Push(yaoID string, opts PushOptions) error {
}
regType := common.TypeToRegistryType(common.TypeMCP)
if opts.Force {
m.client.DeleteVersion(regType, "@"+scope, name, opts.Version)
}
result, err := m.client.Push(regType, "@"+scope, name, opts.Version, zipData)
if err != nil {
return fmt.Errorf("push: %w", err)