From 5890dfd678883280130c76cf855ef1a5b007fbfb Mon Sep 17 00:00:00 2001 From: kkumar-gcc Date: Sun, 15 Mar 2026 19:04:40 +0530 Subject: [PATCH 1/3] add agents:install and agents:update command --- ai/console/helpers.go | 282 +++++++++++++++++++++++++++++ ai/console/helpers_test.go | 118 ++++++++++++ ai/console/install_command.go | 192 ++++++++++++++++++++ ai/console/install_command_test.go | 140 ++++++++++++++ ai/console/update_command.go | 173 ++++++++++++++++++ ai/console/update_command_test.go | 204 +++++++++++++++++++++ ai/service_provider.go | 17 +- 7 files changed, 1125 insertions(+), 1 deletion(-) create mode 100644 ai/console/helpers.go create mode 100644 ai/console/helpers_test.go create mode 100644 ai/console/install_command.go create mode 100644 ai/console/install_command_test.go create mode 100644 ai/console/update_command.go create mode 100644 ai/console/update_command_test.go diff --git a/ai/console/helpers.go b/ai/console/helpers.go new file mode 100644 index 000000000..febea6583 --- /dev/null +++ b/ai/console/helpers.go @@ -0,0 +1,282 @@ +package console + +import ( + "bufio" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" +) + +const ( + versionFilePath = ".ai/.version" + docsFallbackBranch = "master" +) + +// VersionFile is the local .ai/.version tracking file. +// It is never fetched from goravel/docs — it is created and maintained by these commands. +// files maps each relative path (e.g. "prompt/route.md") to the SHA256 of its content +// at the time it was installed or last updated. Used to detect both upstream changes +// and local user modifications during agents:update. +type VersionFile struct { + Version string `json:"version"` + Files map[string]string `json:"files"` +} + +type githubBranch struct { + Name string `json:"name"` +} + +type gitTreeEntry struct { + Path string `json:"path"` + Type string `json:"type"` +} + +type gitTreeResponse struct { + Tree []gitTreeEntry `json:"tree"` +} + +// isSupportedVersion reports whether a version string has agent file support. +// Agent files were introduced in Goravel v1.17. "master" and "latest" are always accepted. +func isSupportedVersion(version string) bool { + if version == docsFallbackBranch || version == "latest" { + return true + } + major, minor := parseVersionParts(version) + if major > 1 { + return true + } + return major == 1 && minor >= 17 +} + +// resolveBranch maps a framework version to its goravel/docs branch. +// "latest" is an alias for master. All other versions use their string as the branch name. +func resolveBranch(version string) string { + if version == "latest" { + return docsFallbackBranch + } + return version +} + +// encodeBranchForURL percent-encodes characters that break URLs (e.g. #) while +// preserving forward slashes, which are valid in git branch names and GitHub URLs. +func encodeBranchForURL(branch string) string { + encoded := url.PathEscape(branch) + return strings.ReplaceAll(encoded, "%2F", "/") +} + +// fetchFileTree lists all downloadable files under .ai/ in the goravel/docs repo +// for the given branch. Returns paths relative to .ai/ (e.g. "AGENTS.md", "prompt/route.md"). +func fetchFileTree(branch string) ([]string, error) { + encodedBranch := encodeBranchForURL(branch) + apiURL := fmt.Sprintf("https://api.github.com/repos/goravel/docs/git/trees/%s?recursive=1", encodedBranch) + + req, err := http.NewRequest(http.MethodGet, apiURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github.v3+json") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("GET %s: %w", apiURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GET %s: status %d", apiURL, resp.StatusCode) + } + + var tree gitTreeResponse + if err := json.NewDecoder(resp.Body).Decode(&tree); err != nil { + return nil, fmt.Errorf("decode tree: %w", err) + } + + var paths []string + for _, entry := range tree.Tree { + if entry.Type != "blob" { + continue + } + if !strings.HasPrefix(entry.Path, ".ai/") { + continue + } + rel := strings.TrimPrefix(entry.Path, ".ai/") + if rel == "" { + continue + } + paths = append(paths, rel) + } + + return paths, nil +} + +// fetchAvailableBranches returns versioned branches (v1.17+) from goravel/docs, +// sorted newest first. Used for the interactive version picker when go.mod detection fails. +func fetchAvailableBranches() ([]string, error) { + req, err := http.NewRequest(http.MethodGet, "https://api.github.com/repos/goravel/docs/branches?per_page=100", nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github.v3+json") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch available versions: %w", err) + } + defer resp.Body.Close() + + var branches []githubBranch + if err := json.NewDecoder(resp.Body).Decode(&branches); err != nil { + return nil, fmt.Errorf("failed to parse available versions: %w", err) + } + + re := regexp.MustCompile(`^v\d+\.\d+$`) + var versions []string + for _, b := range branches { + if re.MatchString(b.Name) && isSupportedVersion(b.Name) { + versions = append(versions, b.Name) + } + } + + sort.Slice(versions, func(i, j int) bool { + maj1, min1 := parseVersionParts(versions[i]) + maj2, min2 := parseVersionParts(versions[j]) + if maj1 != maj2 { + return maj1 > maj2 + } + return min1 > min2 + }) + + return versions, nil +} + +func parseVersionParts(v string) (int, int) { + v = strings.TrimPrefix(v, "v") + parts := strings.SplitN(v, ".", 2) + if len(parts) < 2 { + return 0, 0 + } + major, _ := strconv.Atoi(parts[0]) + minor, _ := strconv.Atoi(parts[1]) + return major, minor +} + +func detectGoravelVersion() (string, error) { + return detectGoravelVersionFrom("go.mod") +} + +func detectGoravelVersionFrom(gomodPath string) (string, error) { + f, err := os.Open(gomodPath) + if err != nil { + return "", fmt.Errorf("cannot read %s: %w", gomodPath, err) + } + defer f.Close() + + re := regexp.MustCompile(`github\.com/goravel/framework\s+v(\d+)\.(\d+)`) + scanner := bufio.NewScanner(f) + for scanner.Scan() { + if m := re.FindStringSubmatch(scanner.Text()); m != nil { + return fmt.Sprintf("v%s.%s", m[1], m[2]), nil + } + } + return "", fmt.Errorf("github.com/goravel/framework not found in %s", gomodPath) +} + +func fetchRaw(branch, path string) ([]byte, error) { + encodedBranch := encodeBranchForURL(branch) + rawURL := fmt.Sprintf("https://raw.githubusercontent.com/goravel/docs/%s/.ai/%s", encodedBranch, path) + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(rawURL) //nolint:noctx + if err != nil { + return nil, fmt.Errorf("GET %s: %w", rawURL, err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GET %s: status %d", rawURL, resp.StatusCode) + } + return io.ReadAll(resp.Body) +} + +func sha256sum(content []byte) string { + sum := sha256.Sum256(content) + return hex.EncodeToString(sum[:]) +} + +func readVersionFile() (VersionFile, error) { + data, err := os.ReadFile(versionFilePath) + if os.IsNotExist(err) { + return VersionFile{Files: make(map[string]string)}, nil + } + if err != nil { + return VersionFile{}, err + } + var v VersionFile + if err := json.Unmarshal(data, &v); err != nil { + return VersionFile{}, err + } + if v.Files == nil { + v.Files = make(map[string]string) + } + return v, nil +} + +func writeVersionFile(v VersionFile) error { + if err := os.MkdirAll(filepath.Dir(versionFilePath), 0755); err != nil { + return err + } + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + return os.WriteFile(versionFilePath, data, 0644) +} + +func destPathFor(key string) string { + if key == "AGENTS.md" { + return "AGENTS.md" + } + return filepath.Join(".ai", key) +} + +// filterPaths returns only paths whose base filename (without extension) matches filter. +// Returns all paths when filter is empty. +func filterPaths(paths []string, filter string) []string { + if filter == "" { + return paths + } + var filtered []string + for _, p := range paths { + base := filepath.Base(p) + baseName := strings.TrimSuffix(base, filepath.Ext(base)) + if baseName == filter { + filtered = append(filtered, p) + } + } + return filtered +} + +func writeAgentFile(key string, content []byte) error { + dest := destPathFor(key) + if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { + return err + } + return os.WriteFile(dest, content, 0644) +} diff --git a/ai/console/helpers_test.go b/ai/console/helpers_test.go new file mode 100644 index 000000000..c88696f19 --- /dev/null +++ b/ai/console/helpers_test.go @@ -0,0 +1,118 @@ +package console + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDetectGoravelVersionFrom(t *testing.T) { + tests := []struct { + name string + content string + expected string + hasError bool + }{ + { + name: "valid go.mod", + content: "module example\n\nrequire github.com/goravel/framework v1.17.3\n", + expected: "v1.17", + }, + { + name: "malformed version string", + content: "module example\n\nrequire github.com/goravel/framework vX.Y.Z\n", + hasError: true, + }, + { + name: "framework not found", + content: "module example\n\nrequire github.com/some/other v1.0.0\n", + hasError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, err := os.CreateTemp("", "go.mod.*") + assert.Nil(t, err) + defer os.Remove(f.Name()) + + _, err = f.WriteString(tt.content) + assert.Nil(t, err) + f.Close() + + result, err := detectGoravelVersionFrom(f.Name()) + if tt.hasError { + assert.NotNil(t, err) + } else { + assert.Nil(t, err) + assert.Equal(t, tt.expected, result) + } + }) + } + + t.Run("missing go.mod", func(t *testing.T) { + _, err := detectGoravelVersionFrom("/nonexistent/path/go.mod") + assert.NotNil(t, err) + }) +} + +func TestSha256sum(t *testing.T) { + result := sha256sum([]byte("hello")) + assert.Equal(t, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", result) +} + +func TestIsSupportedVersion(t *testing.T) { + tests := []struct { + version string + supported bool + }{ + {"master", true}, + {"latest", true}, + {"v1.17", true}, + {"v1.18", true}, + {"v2.0", true}, + {"v1.16", false}, + {"v1.0", false}, + {"v0.9", false}, + } + + for _, tt := range tests { + t.Run(tt.version, func(t *testing.T) { + assert.Equal(t, tt.supported, isSupportedVersion(tt.version)) + }) + } +} + +func TestResolveBranch(t *testing.T) { + tests := []struct { + version string + expected string + }{ + {"v1.17", "v1.17"}, + {"v1.16", "v1.16"}, + {"v1.13", "v1.13"}, + {"v1.99", "v1.99"}, + {"v2.0", "v2.0"}, + } + + for _, tt := range tests { + t.Run(tt.version, func(t *testing.T) { + assert.Equal(t, tt.expected, resolveBranch(tt.version)) + }) + } +} + +func TestParseVersionParts(t *testing.T) { + major, minor := parseVersionParts("v1.17") + assert.Equal(t, 1, major) + assert.Equal(t, 17, minor) + + major, minor = parseVersionParts("v2.5") + assert.Equal(t, 2, major) + assert.Equal(t, 5, minor) + + major, minor = parseVersionParts("invalid") + assert.Equal(t, 0, major) + assert.Equal(t, 0, minor) +} diff --git a/ai/console/install_command.go b/ai/console/install_command.go new file mode 100644 index 000000000..94b08592e --- /dev/null +++ b/ai/console/install_command.go @@ -0,0 +1,192 @@ +package console + +import ( + "fmt" + "os" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" +) + +type AgentsInstallCommand struct { + treeFetcher func(branch string) ([]string, error) + fetcher func(branch, path string) ([]byte, error) +} + +func NewAgentsInstallCommand() *AgentsInstallCommand { + return &AgentsInstallCommand{ + treeFetcher: fetchFileTree, + fetcher: fetchRaw, + } +} + +func (r *AgentsInstallCommand) Signature() string { + return "agents:install" +} + +func (r *AgentsInstallCommand) Description() string { + return "Install AI agent skill files for the current Goravel version" +} + +func (r *AgentsInstallCommand) Extend() command.Extend { + return command.Extend{ + Category: "agents", + Flags: []command.Flag{ + &command.StringFlag{ + Name: "version", + Usage: "Override detected Goravel version (e.g. v1.17)", + }, + &command.BoolFlag{ + Name: "force", + Value: false, + Usage: "Skip confirmation and overwrite existing files", + DisableDefaultText: true, + }, + &command.StringFlag{ + Name: "file", + Usage: "Install only one prompt file (e.g. route)", + }, + }, + } +} + +func (r *AgentsInstallCommand) Handle(ctx console.Context) error { + version, branch, err := r.resolveVersionAndBranch(ctx) + if err != nil { + ctx.Error(err.Error()) + return nil + } + + paths, branch, err := r.resolveFilePaths(branch) + if err != nil { + ctx.Error(err.Error()) + return nil + } + if len(paths) == 0 { + ctx.Error(fmt.Sprintf("No agent files found for version %s. Check https://github.com/goravel/docs", version)) + return nil + } + + if !ctx.OptionBool("force") { + if _, statErr := os.Stat(versionFilePath); statErr == nil { + if !ctx.Confirm("Agent files are already installed. Overwrite?") { + ctx.Warning("Cancelled.") + return nil + } + } + } + + filter := ctx.Option("file") + pathsToInstall := filterPaths(paths, filter) + if filter != "" && len(pathsToInstall) == 0 { + ctx.Error(fmt.Sprintf("No file matching '%s' found in remote repository.", filter)) + + return nil + } + + type downloadResult struct { + key string + content []byte + err error + } + + ch := make(chan downloadResult, len(pathsToInstall)) + for _, key := range pathsToInstall { + go func(k string) { + content, fetchErr := r.fetcher(branch, k) + ch <- downloadResult{key: k, content: content, err: fetchErr} + }(key) + } + + downloaded := make(map[string][]byte) + for range pathsToInstall { + res := <-ch + if res.err != nil { + ctx.Error(res.err.Error()) + return nil + } + if res.content == nil { + ctx.Error(fmt.Sprintf("File not found upstream: %s", res.key)) + return nil + } + downloaded[res.key] = res.content + } + + existing, _ := readVersionFile() + local := VersionFile{Version: version, Files: make(map[string]string)} + for k, v := range existing.Files { + local.Files[k] = v + } + + for key, content := range downloaded { + if err := writeAgentFile(key, content); err != nil { + ctx.Error(fmt.Sprintf("Failed to write %s: %v", key, err)) + return nil + } + local.Files[key] = sha256sum(content) + } + + if err := os.MkdirAll(".ai/skills", 0755); err != nil { + ctx.Error(fmt.Sprintf("Failed to create .ai/skills: %v", err)) + return nil + } + + if err := writeVersionFile(local); err != nil { + ctx.Error(fmt.Sprintf("Failed to write .version: %v", err)) + return nil + } + + ctx.Info(fmt.Sprintf("Installed %d file(s) for version %s.", len(downloaded), version)) + return nil +} + +// resolveVersionAndBranch determines the framework version and docs branch. +// Precedence: --version flag → go.mod detection → interactive picker. +func (r *AgentsInstallCommand) resolveVersionAndBranch(ctx console.Context) (version, branch string, err error) { + version = ctx.Option("version") + if version != "" { + if !isSupportedVersion(version) { + return "", "", fmt.Errorf("agent files are only available for Goravel v1.17 and above (got %s)", version) + } + return version, resolveBranch(version), nil + } + + version, err = detectGoravelVersion() + if err == nil { + if !isSupportedVersion(version) { + return "", "", fmt.Errorf("agent files are only available for Goravel v1.17 and above (got %s)", version) + } + return version, resolveBranch(version), nil + } + + available, fetchErr := fetchAvailableBranches() + if fetchErr != nil || len(available) == 0 { + return "", "", fmt.Errorf("cannot detect Goravel version from go.mod. Use --version to specify it") + } + + choices := make([]console.Choice, len(available)) + for i, v := range available { + choices[i] = console.Choice{Key: v, Value: v} + } + + version, err = ctx.Choice("Select Goravel version to install agent files for", choices) + if err != nil { + return "", "", fmt.Errorf("version selection failed: %w", err) + } + + return version, resolveBranch(version), nil +} + +// resolveFilePaths fetches the file tree for the given branch, falling back to +// master if the version branch has no .ai/ files. +func (r *AgentsInstallCommand) resolveFilePaths(branch string) ([]string, string, error) { + paths, err := r.treeFetcher(branch) + if err != nil { + return nil, branch, err + } + if len(paths) == 0 && branch != docsFallbackBranch { + paths, err = r.treeFetcher(docsFallbackBranch) + return paths, docsFallbackBranch, err + } + return paths, branch, nil +} diff --git a/ai/console/install_command_test.go b/ai/console/install_command_test.go new file mode 100644 index 000000000..f50389c09 --- /dev/null +++ b/ai/console/install_command_test.go @@ -0,0 +1,140 @@ +package console + +import ( + "errors" + "os" + "testing" + + "github.com/stretchr/testify/assert" + + mocksconsole "github.com/goravel/framework/mocks/console" +) + +func TestAgentsInstallCommand(t *testing.T) { + var ( + mockContext *mocksconsole.Context + installCommand *AgentsInstallCommand + ) + + beforeEach := func() { + mockContext = mocksconsole.NewContext(t) + installCommand = &AgentsInstallCommand{} + } + + cleanup := func() { + os.RemoveAll(".ai") + os.Remove("AGENTS.md") + } + + filePaths := []string{"AGENTS.md", "prompt/route.md"} + + tests := []struct { + name string + setup func() + }{ + { + name: "Happy path - install all files", + setup: func() { + installCommand.treeFetcher = func(branch string) ([]string, error) { + return filePaths, nil + } + installCommand.fetcher = func(branch, path string) ([]byte, error) { + return []byte("# " + path), nil + } + + mockContext.EXPECT().Option("version").Return("v1.17").Once() + mockContext.EXPECT().OptionBool("force").Return(true).Once() + mockContext.EXPECT().Option("file").Return("").Once() + mockContext.EXPECT().Info("Installed 2 file(s) for version v1.17.").Once() + }, + }, + { + name: "Happy path - falls back to master when version branch has no agent files", + setup: func() { + installCommand.treeFetcher = func(branch string) ([]string, error) { + if branch == docsFallbackBranch { + return filePaths, nil + } + return nil, nil + } + installCommand.fetcher = func(branch, path string) ([]byte, error) { + return []byte("# " + path), nil + } + + mockContext.EXPECT().Option("version").Return("v1.99").Once() + mockContext.EXPECT().OptionBool("force").Return(true).Once() + mockContext.EXPECT().Option("file").Return("").Once() + mockContext.EXPECT().Info("Installed 2 file(s) for version v1.99.").Once() + }, + }, + { + name: "Sad path - no files on branch or master", + setup: func() { + installCommand.treeFetcher = func(branch string) ([]string, error) { + return nil, nil + } + + mockContext.EXPECT().Option("version").Return("v9.99").Once() + mockContext.EXPECT().Error("No agent files found for version v9.99. Check https://github.com/goravel/docs").Once() + }, + }, + { + name: "Sad path - unsupported version", + setup: func() { + mockContext.EXPECT().Option("version").Return("v1.16").Once() + mockContext.EXPECT().Error("agent files are only available for Goravel v1.17 and above (got v1.16)").Once() + }, + }, + { + name: "Sad path - tree fetch error", + setup: func() { + installCommand.treeFetcher = func(branch string) ([]string, error) { + return nil, errors.New("network error") + } + + mockContext.EXPECT().Option("version").Return("v1.17").Once() + mockContext.EXPECT().Error("network error").Once() + }, + }, + { + name: "Sad path - file filter no match", + setup: func() { + installCommand.treeFetcher = func(branch string) ([]string, error) { + return filePaths, nil + } + + mockContext.EXPECT().Option("version").Return("v1.17").Once() + mockContext.EXPECT().OptionBool("force").Return(true).Once() + mockContext.EXPECT().Option("file").Return("nonexistent").Once() + mockContext.EXPECT().Error("No file matching 'nonexistent' found in remote repository.").Once() + }, + }, + { + name: "Sad path - existing install, user cancels", + setup: func() { + installCommand.treeFetcher = func(branch string) ([]string, error) { + return filePaths, nil + } + + os.MkdirAll(".ai", 0755) + os.WriteFile(versionFilePath, []byte(`{"version":"v1.16","files":{}}`), 0644) + + mockContext.EXPECT().Option("version").Return("v1.17").Once() + mockContext.EXPECT().OptionBool("force").Return(false).Once() + mockContext.EXPECT().Confirm("Agent files are already installed. Overwrite?").Return(false).Once() + mockContext.EXPECT().Warning("Cancelled.").Once() + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + beforeEach() + cleanup() + test.setup() + defer cleanup() + + assert.Nil(t, installCommand.Handle(mockContext)) + }) + } +} diff --git a/ai/console/update_command.go b/ai/console/update_command.go new file mode 100644 index 000000000..ac62f5ebd --- /dev/null +++ b/ai/console/update_command.go @@ -0,0 +1,173 @@ +package console + +import ( + "fmt" + "os" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" +) + +type AgentsUpdateCommand struct { + treeFetcher func(branch string) ([]string, error) + fetcher func(branch, path string) ([]byte, error) +} + +func NewAgentsUpdateCommand() *AgentsUpdateCommand { + return &AgentsUpdateCommand{ + treeFetcher: fetchFileTree, + fetcher: fetchRaw, + } +} + +func (r *AgentsUpdateCommand) Signature() string { + return "agents:update" +} + +func (r *AgentsUpdateCommand) Description() string { + return "Update AI agent skill files to match the current Goravel version" +} + +func (r *AgentsUpdateCommand) Extend() command.Extend { + return command.Extend{ + Category: "agents", + Flags: []command.Flag{ + &command.BoolFlag{ + Name: "force", + Value: false, + Usage: "Overwrite even if user modified locally", + DisableDefaultText: true, + }, + &command.StringFlag{ + Name: "file", + Usage: "Update only one file (e.g. route)", + }, + &command.StringFlag{ + Name: "version", + Usage: "Force a specific version (e.g. v1.17)", + }, + }, + } +} + +func (r *AgentsUpdateCommand) Handle(ctx console.Context) error { + if _, err := os.Stat(versionFilePath); os.IsNotExist(err) { + ctx.Error("No .ai/.version found. Run agents:install first.") + return nil + } + + local, err := readVersionFile() + if err != nil { + ctx.Error(fmt.Sprintf("Failed to read .ai/.version: %v", err)) + return nil + } + + version := ctx.Option("version") + if version == "" { + version, err = detectGoravelVersion() + if err != nil { + ctx.Error("Cannot detect Goravel version from go.mod. Use --version to specify it.") + return nil + } + } + if !isSupportedVersion(version) { + ctx.Error(fmt.Sprintf("Agent files are only available for Goravel v1.17 and above (got %s).", version)) + return nil + } + + branch := resolveBranch(version) + paths, err := r.treeFetcher(branch) + if err != nil { + ctx.Error(err.Error()) + return nil + } + if len(paths) == 0 && branch != docsFallbackBranch { + branch = docsFallbackBranch + paths, err = r.treeFetcher(branch) + if err != nil { + ctx.Error(err.Error()) + return nil + } + } + if len(paths) == 0 { + ctx.Error(fmt.Sprintf("No agent files found for version %s. Check https://github.com/goravel/docs", version)) + return nil + } + + filter := ctx.Option("file") + force := ctx.OptionBool("force") + pathsToCheck := filterPaths(paths, filter) + + var updated, skippedUserModified, conflicts, alreadyUpToDate int + + for _, key := range pathsToCheck { + upstreamContent, fetchErr := r.fetcher(branch, key) + if fetchErr != nil { + ctx.Error(fetchErr.Error()) + return nil + } + if upstreamContent == nil { + ctx.Warning(fmt.Sprintf("File not found upstream: %s", key)) + continue + } + upstreamSHA256 := sha256sum(upstreamContent) + + storedSHA256, exists := local.Files[key] + if !exists { + if writeErr := writeAgentFile(key, upstreamContent); writeErr != nil { + ctx.Error(fmt.Sprintf("Failed to write %s: %v", key, writeErr)) + return nil + } + local.Files[key] = upstreamSHA256 + updated++ + continue + } + + localPath := destPathFor(key) + localContent, readErr := os.ReadFile(localPath) + if readErr != nil { + if writeErr := writeAgentFile(key, upstreamContent); writeErr != nil { + ctx.Error(fmt.Sprintf("Failed to write %s: %v", key, writeErr)) + return nil + } + local.Files[key] = upstreamSHA256 + updated++ + continue + } + + localCurrentSHA256 := sha256sum(localContent) + localModified := localCurrentSHA256 != storedSHA256 + upstreamChanged := upstreamSHA256 != storedSHA256 + + if !localModified && !upstreamChanged { + alreadyUpToDate++ + continue + } + + if localModified && !upstreamChanged { + skippedUserModified++ + continue + } + + if localModified && !force { + ctx.Warning(fmt.Sprintf("Conflict: %s modified locally and changed upstream. Use --force to overwrite.", key)) + conflicts++ + continue + } + + if writeErr := writeAgentFile(key, upstreamContent); writeErr != nil { + ctx.Error(fmt.Sprintf("Failed to write %s: %v", key, writeErr)) + return nil + } + local.Files[key] = upstreamSHA256 + updated++ + } + + if err := writeVersionFile(local); err != nil { + ctx.Error(fmt.Sprintf("Failed to write .version: %v", err)) + return nil + } + + ctx.Info(fmt.Sprintf("%d updated, %d skipped (user modified), %d conflicts (use --force to overwrite), %d already up to date.", updated, skippedUserModified, conflicts, alreadyUpToDate)) + return nil +} diff --git a/ai/console/update_command_test.go b/ai/console/update_command_test.go new file mode 100644 index 000000000..eb9809fac --- /dev/null +++ b/ai/console/update_command_test.go @@ -0,0 +1,204 @@ +package console + +import ( + "encoding/json" + "os" + "testing" + + "github.com/stretchr/testify/assert" + + mocksconsole "github.com/goravel/framework/mocks/console" +) + +func TestAgentsUpdateCommandMissingVersionFile(t *testing.T) { + os.RemoveAll(".ai") + + mockContext := mocksconsole.NewContext(t) + mockContext.EXPECT().Error("No .ai/.version found. Run agents:install first.").Once() + + cmd := &AgentsUpdateCommand{ + treeFetcher: func(branch string) ([]string, error) { return nil, nil }, + fetcher: func(branch, path string) ([]byte, error) { return nil, nil }, + } + assert.Nil(t, cmd.Handle(mockContext)) +} + +func TestAgentsUpdateCommandUnsupportedVersion(t *testing.T) { + os.MkdirAll(".ai", 0755) + os.WriteFile(versionFilePath, []byte(`{"version":"v1.16","files":{}}`), 0644) + defer os.RemoveAll(".ai") + + mockContext := mocksconsole.NewContext(t) + mockContext.EXPECT().Option("version").Return("v1.16").Once() + mockContext.EXPECT().Error("Agent files are only available for Goravel v1.17 and above (got v1.16).").Once() + + cmd := &AgentsUpdateCommand{ + treeFetcher: func(branch string) ([]string, error) { return nil, nil }, + fetcher: func(branch, path string) ([]byte, error) { return nil, nil }, + } + assert.Nil(t, cmd.Handle(mockContext)) +} + +func TestAgentsUpdateCommandConflictDetection(t *testing.T) { + var ( + mockContext *mocksconsole.Context + cmd *AgentsUpdateCommand + ) + + beforeEach := func() { + mockContext = mocksconsole.NewContext(t) + cmd = &AgentsUpdateCommand{} + } + + cleanup := func() { + os.RemoveAll(".ai") + os.Remove("AGENTS.md") + } + + originalContent := []byte("original content") + storedChecksum := sha256sum(originalContent) + + upstreamContent := []byte("upstream changed content") + userContent := []byte("user modified content") + + setupLocalVersionFile := func(checksum string) { + vf := VersionFile{ + Version: "v1.17", + Files: map[string]string{"prompt/route.md": checksum}, + } + data, _ := json.MarshalIndent(vf, "", " ") + os.MkdirAll(".ai/prompt", 0755) + os.WriteFile(versionFilePath, data, 0644) + } + + makeTreeFetcher := func() func(string) ([]string, error) { + return func(branch string) ([]string, error) { + return []string{"prompt/route.md"}, nil + } + } + + tests := []struct { + name string + setup func() + }{ + { + name: "Conflict - user modified and upstream changed, no force", + setup: func() { + setupLocalVersionFile(storedChecksum) + os.WriteFile(".ai/prompt/route.md", userContent, 0644) + + cmd.treeFetcher = makeTreeFetcher() + cmd.fetcher = func(branch, path string) ([]byte, error) { + return upstreamContent, nil + } + + mockContext.EXPECT().Option("version").Return("v1.17").Once() + mockContext.EXPECT().Option("file").Return("").Once() + mockContext.EXPECT().OptionBool("force").Return(false).Once() + mockContext.EXPECT().Warning("Conflict: prompt/route.md modified locally and changed upstream. Use --force to overwrite.").Once() + mockContext.EXPECT().Info("0 updated, 0 skipped (user modified), 1 conflicts (use --force to overwrite), 0 already up to date.").Once() + }, + }, + { + name: "Conflict - user modified and upstream changed, force overwrites", + setup: func() { + setupLocalVersionFile(storedChecksum) + os.WriteFile(".ai/prompt/route.md", userContent, 0644) + + cmd.treeFetcher = makeTreeFetcher() + cmd.fetcher = func(branch, path string) ([]byte, error) { + return upstreamContent, nil + } + + mockContext.EXPECT().Option("version").Return("v1.17").Once() + mockContext.EXPECT().Option("file").Return("").Once() + mockContext.EXPECT().OptionBool("force").Return(true).Once() + mockContext.EXPECT().Info("1 updated, 0 skipped (user modified), 0 conflicts (use --force to overwrite), 0 already up to date.").Once() + }, + }, + { + name: "Upstream changed, user did not modify - download", + setup: func() { + setupLocalVersionFile(storedChecksum) + os.WriteFile(".ai/prompt/route.md", originalContent, 0644) + + cmd.treeFetcher = makeTreeFetcher() + cmd.fetcher = func(branch, path string) ([]byte, error) { + return upstreamContent, nil + } + + mockContext.EXPECT().Option("version").Return("v1.17").Once() + mockContext.EXPECT().Option("file").Return("").Once() + mockContext.EXPECT().OptionBool("force").Return(false).Once() + mockContext.EXPECT().Info("1 updated, 0 skipped (user modified), 0 conflicts (use --force to overwrite), 0 already up to date.").Once() + }, + }, + { + name: "User modified, upstream unchanged - skip", + setup: func() { + setupLocalVersionFile(storedChecksum) + os.WriteFile(".ai/prompt/route.md", userContent, 0644) + + cmd.treeFetcher = makeTreeFetcher() + cmd.fetcher = func(branch, path string) ([]byte, error) { + return originalContent, nil + } + + mockContext.EXPECT().Option("version").Return("v1.17").Once() + mockContext.EXPECT().Option("file").Return("").Once() + mockContext.EXPECT().OptionBool("force").Return(false).Once() + mockContext.EXPECT().Info("0 updated, 1 skipped (user modified), 0 conflicts (use --force to overwrite), 0 already up to date.").Once() + }, + }, + { + name: "Already up to date", + setup: func() { + setupLocalVersionFile(storedChecksum) + os.WriteFile(".ai/prompt/route.md", originalContent, 0644) + + cmd.treeFetcher = makeTreeFetcher() + cmd.fetcher = func(branch, path string) ([]byte, error) { + return originalContent, nil + } + + mockContext.EXPECT().Option("version").Return("v1.17").Once() + mockContext.EXPECT().Option("file").Return("").Once() + mockContext.EXPECT().OptionBool("force").Return(false).Once() + mockContext.EXPECT().Info("0 updated, 0 skipped (user modified), 0 conflicts (use --force to overwrite), 1 already up to date.").Once() + }, + }, + { + name: "New file in upstream - download", + setup: func() { + setupLocalVersionFile(storedChecksum) + os.WriteFile(".ai/prompt/route.md", originalContent, 0644) + + cmd.treeFetcher = func(branch string) ([]string, error) { + return []string{"prompt/route.md", "prompt/auth.md"}, nil + } + cmd.fetcher = func(branch, path string) ([]byte, error) { + if path == "prompt/auth.md" { + return []byte("auth content"), nil + } + return originalContent, nil + } + + mockContext.EXPECT().Option("version").Return("v1.17").Once() + mockContext.EXPECT().Option("file").Return("").Once() + mockContext.EXPECT().OptionBool("force").Return(false).Once() + mockContext.EXPECT().Info("1 updated, 0 skipped (user modified), 0 conflicts (use --force to overwrite), 1 already up to date.").Once() + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + beforeEach() + cleanup() + test.setup() + defer cleanup() + + assert.Nil(t, cmd.Handle(mockContext)) + }) + } +} diff --git a/ai/service_provider.go b/ai/service_provider.go index e03e40432..2f2ae07e9 100644 --- a/ai/service_provider.go +++ b/ai/service_provider.go @@ -1,7 +1,9 @@ package ai import ( + "github.com/goravel/framework/ai/console" "github.com/goravel/framework/contracts/binding" + contractsconsole "github.com/goravel/framework/contracts/console" "github.com/goravel/framework/contracts/foundation" ) @@ -22,4 +24,17 @@ func (r *ServiceProvider) Register(app foundation.Application) { }) } -func (r *ServiceProvider) Boot(app foundation.Application) {} +func (r *ServiceProvider) Boot(app foundation.Application) { + r.registerCommands(app) +} + +func (r *ServiceProvider) registerCommands(app foundation.Application) { + artisan := app.MakeArtisan() + if artisan == nil { + return + } + artisan.Register([]contractsconsole.Command{ + console.NewAgentsInstallCommand(), + console.NewAgentsUpdateCommand(), + }) +} From baccd1b31a8785a3938d0907a89855d7a10ded77 Mon Sep 17 00:00:00 2001 From: kkumar-gcc Date: Sun, 15 Mar 2026 19:18:57 +0530 Subject: [PATCH 2/3] provide option to select facade instead of files --- ai/console/helpers.go | 83 +++++----------------- ai/console/install_command.go | 109 +++++++++++++++++++---------- ai/console/install_command_test.go | 95 ++++++++++++++++++------- ai/console/update_command.go | 85 ++++++++++++++-------- ai/console/update_command_test.go | 75 ++++++++++++++------ 5 files changed, 267 insertions(+), 180 deletions(-) diff --git a/ai/console/helpers.go b/ai/console/helpers.go index febea6583..47d0a879d 100644 --- a/ai/console/helpers.go +++ b/ai/console/helpers.go @@ -37,13 +37,13 @@ type githubBranch struct { Name string `json:"name"` } -type gitTreeEntry struct { - Path string `json:"path"` - Type string `json:"type"` -} - -type gitTreeResponse struct { - Tree []gitTreeEntry `json:"tree"` +// ManifestEntry describes a single agent file available in goravel/docs. +// Facade is the Goravel facade name (e.g. "Route", "Auth"); empty for non-facade files like AGENTS.md. +// Default marks files that are installed by agents:install when no specific facades are requested. +type ManifestEntry struct { + Facade string `json:"facade"` + Path string `json:"path"` + Default bool `json:"default"` } // isSupportedVersion reports whether a version string has agent file support. @@ -75,53 +75,21 @@ func encodeBranchForURL(branch string) string { return strings.ReplaceAll(encoded, "%2F", "/") } -// fetchFileTree lists all downloadable files under .ai/ in the goravel/docs repo -// for the given branch. Returns paths relative to .ai/ (e.g. "AGENTS.md", "prompt/route.md"). -func fetchFileTree(branch string) ([]string, error) { - encodedBranch := encodeBranchForURL(branch) - apiURL := fmt.Sprintf("https://api.github.com/repos/goravel/docs/git/trees/%s?recursive=1", encodedBranch) - - req, err := http.NewRequest(http.MethodGet, apiURL, nil) +// fetchManifest fetches and parses the manifest.json from the goravel/docs .ai/ directory. +// Returns nil entries (not an error) when the file is not found on the branch. +func fetchManifest(branch string) ([]ManifestEntry, error) { + data, err := fetchRaw(branch, "manifest.json") if err != nil { return nil, err } - req.Header.Set("Accept", "application/vnd.github.v3+json") - - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("GET %s: %w", apiURL, err) - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusNotFound { + if data == nil { return nil, nil } - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("GET %s: status %d", apiURL, resp.StatusCode) + var entries []ManifestEntry + if err := json.Unmarshal(data, &entries); err != nil { + return nil, fmt.Errorf("decode manifest: %w", err) } - - var tree gitTreeResponse - if err := json.NewDecoder(resp.Body).Decode(&tree); err != nil { - return nil, fmt.Errorf("decode tree: %w", err) - } - - var paths []string - for _, entry := range tree.Tree { - if entry.Type != "blob" { - continue - } - if !strings.HasPrefix(entry.Path, ".ai/") { - continue - } - rel := strings.TrimPrefix(entry.Path, ".ai/") - if rel == "" { - continue - } - paths = append(paths, rel) - } - - return paths, nil + return entries, nil } // fetchAvailableBranches returns versioned branches (v1.17+) from goravel/docs, @@ -187,7 +155,7 @@ func detectGoravelVersionFrom(gomodPath string) (string, error) { } defer f.Close() - re := regexp.MustCompile(`github\.com/goravel/framework\s+v(\d+)\.(\d+)`) + re := regexp.MustCompile(`^\s*(?:require\s+)?github\.com/goravel/framework\s+v(\d+)\.(\d+)`) scanner := bufio.NewScanner(f) for scanner.Scan() { if m := re.FindStringSubmatch(scanner.Text()); m != nil { @@ -256,23 +224,6 @@ func destPathFor(key string) string { return filepath.Join(".ai", key) } -// filterPaths returns only paths whose base filename (without extension) matches filter. -// Returns all paths when filter is empty. -func filterPaths(paths []string, filter string) []string { - if filter == "" { - return paths - } - var filtered []string - for _, p := range paths { - base := filepath.Base(p) - baseName := strings.TrimSuffix(base, filepath.Ext(base)) - if baseName == filter { - filtered = append(filtered, p) - } - } - return filtered -} - func writeAgentFile(key string, content []byte) error { dest := destPathFor(key) if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { diff --git a/ai/console/install_command.go b/ai/console/install_command.go index 94b08592e..920a7ae2d 100644 --- a/ai/console/install_command.go +++ b/ai/console/install_command.go @@ -3,20 +3,21 @@ package console import ( "fmt" "os" + "strings" "github.com/goravel/framework/contracts/console" "github.com/goravel/framework/contracts/console/command" ) type AgentsInstallCommand struct { - treeFetcher func(branch string) ([]string, error) - fetcher func(branch, path string) ([]byte, error) + manifestFetcher func(branch string) ([]ManifestEntry, error) + fetcher func(branch, path string) ([]byte, error) } func NewAgentsInstallCommand() *AgentsInstallCommand { return &AgentsInstallCommand{ - treeFetcher: fetchFileTree, - fetcher: fetchRaw, + manifestFetcher: fetchManifest, + fetcher: fetchRaw, } } @@ -42,9 +43,12 @@ func (r *AgentsInstallCommand) Extend() command.Extend { Usage: "Skip confirmation and overwrite existing files", DisableDefaultText: true, }, - &command.StringFlag{ - Name: "file", - Usage: "Install only one prompt file (e.g. route)", + &command.BoolFlag{ + Name: "all", + Aliases: []string{"a"}, + Value: false, + Usage: "Install all available facade agent files", + DisableDefaultText: true, }, }, } @@ -57,12 +61,12 @@ func (r *AgentsInstallCommand) Handle(ctx console.Context) error { return nil } - paths, branch, err := r.resolveFilePaths(branch) + entries, branch, err := r.resolveManifest(branch) if err != nil { ctx.Error(err.Error()) return nil } - if len(paths) == 0 { + if len(entries) == 0 { ctx.Error(fmt.Sprintf("No agent files found for version %s. Check https://github.com/goravel/docs", version)) return nil } @@ -76,40 +80,47 @@ func (r *AgentsInstallCommand) Handle(ctx console.Context) error { } } - filter := ctx.Option("file") - pathsToInstall := filterPaths(paths, filter) - if filter != "" && len(pathsToInstall) == 0 { - ctx.Error(fmt.Sprintf("No file matching '%s' found in remote repository.", filter)) - - return nil + facadeArgs := ctx.Arguments() + var toInstall []ManifestEntry + switch { + case ctx.OptionBool("all"): + toInstall = entries + case len(facadeArgs) > 0: + toInstall = entriesForFacades(entries, facadeArgs) + if len(toInstall) == 0 { + ctx.Error(fmt.Sprintf("No agent files found for facade(s): %s", strings.Join(facadeArgs, ", "))) + return nil + } + default: + toInstall = defaultEntries(entries) } type downloadResult struct { - key string + path string content []byte err error } - ch := make(chan downloadResult, len(pathsToInstall)) - for _, key := range pathsToInstall { - go func(k string) { - content, fetchErr := r.fetcher(branch, k) - ch <- downloadResult{key: k, content: content, err: fetchErr} - }(key) + ch := make(chan downloadResult, len(toInstall)) + for _, entry := range toInstall { + go func(e ManifestEntry) { + content, fetchErr := r.fetcher(branch, e.Path) + ch <- downloadResult{path: e.Path, content: content, err: fetchErr} + }(entry) } downloaded := make(map[string][]byte) - for range pathsToInstall { + for range toInstall { res := <-ch if res.err != nil { ctx.Error(res.err.Error()) return nil } if res.content == nil { - ctx.Error(fmt.Sprintf("File not found upstream: %s", res.key)) + ctx.Error(fmt.Sprintf("File not found upstream: %s", res.path)) return nil } - downloaded[res.key] = res.content + downloaded[res.path] = res.content } existing, _ := readVersionFile() @@ -118,12 +129,12 @@ func (r *AgentsInstallCommand) Handle(ctx console.Context) error { local.Files[k] = v } - for key, content := range downloaded { - if err := writeAgentFile(key, content); err != nil { - ctx.Error(fmt.Sprintf("Failed to write %s: %v", key, err)) + for path, content := range downloaded { + if err := writeAgentFile(path, content); err != nil { + ctx.Error(fmt.Sprintf("Failed to write %s: %v", path, err)) return nil } - local.Files[key] = sha256sum(content) + local.Files[path] = sha256sum(content) } if err := os.MkdirAll(".ai/skills", 0755); err != nil { @@ -177,16 +188,40 @@ func (r *AgentsInstallCommand) resolveVersionAndBranch(ctx console.Context) (ver return version, resolveBranch(version), nil } -// resolveFilePaths fetches the file tree for the given branch, falling back to -// master if the version branch has no .ai/ files. -func (r *AgentsInstallCommand) resolveFilePaths(branch string) ([]string, string, error) { - paths, err := r.treeFetcher(branch) +// resolveManifest fetches the manifest for the given branch, falling back to master +// if the version branch has no manifest. +func (r *AgentsInstallCommand) resolveManifest(branch string) ([]ManifestEntry, string, error) { + entries, err := r.manifestFetcher(branch) if err != nil { return nil, branch, err } - if len(paths) == 0 && branch != docsFallbackBranch { - paths, err = r.treeFetcher(docsFallbackBranch) - return paths, docsFallbackBranch, err + if len(entries) == 0 && branch != docsFallbackBranch { + entries, err = r.manifestFetcher(docsFallbackBranch) + return entries, docsFallbackBranch, err + } + return entries, branch, nil +} + +func entriesForFacades(entries []ManifestEntry, facades []string) []ManifestEntry { + set := make(map[string]bool, len(facades)) + for _, f := range facades { + set[f] = true + } + var out []ManifestEntry + for _, e := range entries { + if set[e.Facade] { + out = append(out, e) + } + } + return out +} + +func defaultEntries(entries []ManifestEntry) []ManifestEntry { + var out []ManifestEntry + for _, e := range entries { + if e.Default { + out = append(out, e) + } } - return paths, branch, nil + return out } diff --git a/ai/console/install_command_test.go b/ai/console/install_command_test.go index f50389c09..bb4e1465d 100644 --- a/ai/console/install_command_test.go +++ b/ai/console/install_command_test.go @@ -26,17 +26,21 @@ func TestAgentsInstallCommand(t *testing.T) { os.Remove("AGENTS.md") } - filePaths := []string{"AGENTS.md", "prompt/route.md"} + manifest := []ManifestEntry{ + {Facade: "", Path: "AGENTS.md", Default: true}, + {Facade: "Route", Path: "prompt/route.md", Default: true}, + {Facade: "Auth", Path: "prompt/auth.md", Default: false}, + } tests := []struct { name string setup func() }{ { - name: "Happy path - install all files", + name: "Happy path - installs defaults when no facades given", setup: func() { - installCommand.treeFetcher = func(branch string) ([]string, error) { - return filePaths, nil + installCommand.manifestFetcher = func(branch string) ([]ManifestEntry, error) { + return manifest, nil } installCommand.fetcher = func(branch, path string) ([]byte, error) { return []byte("# " + path), nil @@ -44,16 +48,51 @@ func TestAgentsInstallCommand(t *testing.T) { mockContext.EXPECT().Option("version").Return("v1.17").Once() mockContext.EXPECT().OptionBool("force").Return(true).Once() - mockContext.EXPECT().Option("file").Return("").Once() + mockContext.EXPECT().Arguments().Return([]string{}).Once() + mockContext.EXPECT().OptionBool("all").Return(false).Once() mockContext.EXPECT().Info("Installed 2 file(s) for version v1.17.").Once() }, }, { - name: "Happy path - falls back to master when version branch has no agent files", + name: "Happy path - installs all when --all flag set", setup: func() { - installCommand.treeFetcher = func(branch string) ([]string, error) { + installCommand.manifestFetcher = func(branch string) ([]ManifestEntry, error) { + return manifest, nil + } + installCommand.fetcher = func(branch, path string) ([]byte, error) { + return []byte("# " + path), nil + } + + mockContext.EXPECT().Option("version").Return("v1.17").Once() + mockContext.EXPECT().OptionBool("force").Return(true).Once() + mockContext.EXPECT().Arguments().Return([]string{}).Once() + mockContext.EXPECT().OptionBool("all").Return(true).Once() + mockContext.EXPECT().Info("Installed 3 file(s) for version v1.17.").Once() + }, + }, + { + name: "Happy path - installs specific facade by name", + setup: func() { + installCommand.manifestFetcher = func(branch string) ([]ManifestEntry, error) { + return manifest, nil + } + installCommand.fetcher = func(branch, path string) ([]byte, error) { + return []byte("# " + path), nil + } + + mockContext.EXPECT().Option("version").Return("v1.17").Once() + mockContext.EXPECT().OptionBool("force").Return(true).Once() + mockContext.EXPECT().Arguments().Return([]string{"Auth"}).Once() + mockContext.EXPECT().OptionBool("all").Return(false).Once() + mockContext.EXPECT().Info("Installed 1 file(s) for version v1.17.").Once() + }, + }, + { + name: "Happy path - falls back to master when version branch has no manifest", + setup: func() { + installCommand.manifestFetcher = func(branch string) ([]ManifestEntry, error) { if branch == docsFallbackBranch { - return filePaths, nil + return manifest, nil } return nil, nil } @@ -63,14 +102,15 @@ func TestAgentsInstallCommand(t *testing.T) { mockContext.EXPECT().Option("version").Return("v1.99").Once() mockContext.EXPECT().OptionBool("force").Return(true).Once() - mockContext.EXPECT().Option("file").Return("").Once() + mockContext.EXPECT().Arguments().Return([]string{}).Once() + mockContext.EXPECT().OptionBool("all").Return(false).Once() mockContext.EXPECT().Info("Installed 2 file(s) for version v1.99.").Once() }, }, { - name: "Sad path - no files on branch or master", + name: "Sad path - no manifest on branch or master", setup: func() { - installCommand.treeFetcher = func(branch string) ([]string, error) { + installCommand.manifestFetcher = func(branch string) ([]ManifestEntry, error) { return nil, nil } @@ -79,16 +119,9 @@ func TestAgentsInstallCommand(t *testing.T) { }, }, { - name: "Sad path - unsupported version", - setup: func() { - mockContext.EXPECT().Option("version").Return("v1.16").Once() - mockContext.EXPECT().Error("agent files are only available for Goravel v1.17 and above (got v1.16)").Once() - }, - }, - { - name: "Sad path - tree fetch error", + name: "Sad path - manifest fetch error", setup: func() { - installCommand.treeFetcher = func(branch string) ([]string, error) { + installCommand.manifestFetcher = func(branch string) ([]ManifestEntry, error) { return nil, errors.New("network error") } @@ -97,23 +130,31 @@ func TestAgentsInstallCommand(t *testing.T) { }, }, { - name: "Sad path - file filter no match", + name: "Sad path - facade not found in manifest", setup: func() { - installCommand.treeFetcher = func(branch string) ([]string, error) { - return filePaths, nil + installCommand.manifestFetcher = func(branch string) ([]ManifestEntry, error) { + return manifest, nil } mockContext.EXPECT().Option("version").Return("v1.17").Once() mockContext.EXPECT().OptionBool("force").Return(true).Once() - mockContext.EXPECT().Option("file").Return("nonexistent").Once() - mockContext.EXPECT().Error("No file matching 'nonexistent' found in remote repository.").Once() + mockContext.EXPECT().Arguments().Return([]string{"Nonexistent"}).Once() + mockContext.EXPECT().OptionBool("all").Return(false).Once() + mockContext.EXPECT().Error("No agent files found for facade(s): Nonexistent").Once() + }, + }, + { + name: "Sad path - unsupported version", + setup: func() { + mockContext.EXPECT().Option("version").Return("v1.16").Once() + mockContext.EXPECT().Error("agent files are only available for Goravel v1.17 and above (got v1.16)").Once() }, }, { name: "Sad path - existing install, user cancels", setup: func() { - installCommand.treeFetcher = func(branch string) ([]string, error) { - return filePaths, nil + installCommand.manifestFetcher = func(branch string) ([]ManifestEntry, error) { + return manifest, nil } os.MkdirAll(".ai", 0755) diff --git a/ai/console/update_command.go b/ai/console/update_command.go index ac62f5ebd..b41b22c39 100644 --- a/ai/console/update_command.go +++ b/ai/console/update_command.go @@ -3,20 +3,21 @@ package console import ( "fmt" "os" + "strings" "github.com/goravel/framework/contracts/console" "github.com/goravel/framework/contracts/console/command" ) type AgentsUpdateCommand struct { - treeFetcher func(branch string) ([]string, error) - fetcher func(branch, path string) ([]byte, error) + manifestFetcher func(branch string) ([]ManifestEntry, error) + fetcher func(branch, path string) ([]byte, error) } func NewAgentsUpdateCommand() *AgentsUpdateCommand { return &AgentsUpdateCommand{ - treeFetcher: fetchFileTree, - fetcher: fetchRaw, + manifestFetcher: fetchManifest, + fetcher: fetchRaw, } } @@ -38,9 +39,12 @@ func (r *AgentsUpdateCommand) Extend() command.Extend { Usage: "Overwrite even if user modified locally", DisableDefaultText: true, }, - &command.StringFlag{ - Name: "file", - Usage: "Update only one file (e.g. route)", + &command.BoolFlag{ + Name: "all", + Aliases: []string{"a"}, + Value: false, + Usage: "Also install new facade files not yet installed", + DisableDefaultText: true, }, &command.StringFlag{ Name: "version", @@ -76,61 +80,73 @@ func (r *AgentsUpdateCommand) Handle(ctx console.Context) error { } branch := resolveBranch(version) - paths, err := r.treeFetcher(branch) + entries, err := r.manifestFetcher(branch) if err != nil { ctx.Error(err.Error()) return nil } - if len(paths) == 0 && branch != docsFallbackBranch { - branch = docsFallbackBranch - paths, err = r.treeFetcher(branch) + if len(entries) == 0 && branch != docsFallbackBranch { + entries, err = r.manifestFetcher(docsFallbackBranch) if err != nil { ctx.Error(err.Error()) return nil } } - if len(paths) == 0 { + if len(entries) == 0 { ctx.Error(fmt.Sprintf("No agent files found for version %s. Check https://github.com/goravel/docs", version)) return nil } - filter := ctx.Option("file") + facadeArgs := ctx.Arguments() force := ctx.OptionBool("force") - pathsToCheck := filterPaths(paths, filter) + + var toProcess []ManifestEntry + switch { + case len(facadeArgs) > 0: + toProcess = entriesForFacades(entries, facadeArgs) + if len(toProcess) == 0 { + ctx.Error(fmt.Sprintf("No agent files found for facade(s): %s", strings.Join(facadeArgs, ", "))) + return nil + } + case ctx.OptionBool("all"): + toProcess = entries + default: + toProcess = installedEntries(entries, local.Files) + } var updated, skippedUserModified, conflicts, alreadyUpToDate int - for _, key := range pathsToCheck { - upstreamContent, fetchErr := r.fetcher(branch, key) + for _, entry := range toProcess { + upstreamContent, fetchErr := r.fetcher(branch, entry.Path) if fetchErr != nil { ctx.Error(fetchErr.Error()) return nil } if upstreamContent == nil { - ctx.Warning(fmt.Sprintf("File not found upstream: %s", key)) + ctx.Warning(fmt.Sprintf("File not found upstream: %s", entry.Path)) continue } upstreamSHA256 := sha256sum(upstreamContent) - storedSHA256, exists := local.Files[key] + storedSHA256, exists := local.Files[entry.Path] if !exists { - if writeErr := writeAgentFile(key, upstreamContent); writeErr != nil { - ctx.Error(fmt.Sprintf("Failed to write %s: %v", key, writeErr)) + if writeErr := writeAgentFile(entry.Path, upstreamContent); writeErr != nil { + ctx.Error(fmt.Sprintf("Failed to write %s: %v", entry.Path, writeErr)) return nil } - local.Files[key] = upstreamSHA256 + local.Files[entry.Path] = upstreamSHA256 updated++ continue } - localPath := destPathFor(key) + localPath := destPathFor(entry.Path) localContent, readErr := os.ReadFile(localPath) if readErr != nil { - if writeErr := writeAgentFile(key, upstreamContent); writeErr != nil { - ctx.Error(fmt.Sprintf("Failed to write %s: %v", key, writeErr)) + if writeErr := writeAgentFile(entry.Path, upstreamContent); writeErr != nil { + ctx.Error(fmt.Sprintf("Failed to write %s: %v", entry.Path, writeErr)) return nil } - local.Files[key] = upstreamSHA256 + local.Files[entry.Path] = upstreamSHA256 updated++ continue } @@ -150,16 +166,16 @@ func (r *AgentsUpdateCommand) Handle(ctx console.Context) error { } if localModified && !force { - ctx.Warning(fmt.Sprintf("Conflict: %s modified locally and changed upstream. Use --force to overwrite.", key)) + ctx.Warning(fmt.Sprintf("Conflict: %s modified locally and changed upstream. Use --force to overwrite.", entry.Path)) conflicts++ continue } - if writeErr := writeAgentFile(key, upstreamContent); writeErr != nil { - ctx.Error(fmt.Sprintf("Failed to write %s: %v", key, writeErr)) + if writeErr := writeAgentFile(entry.Path, upstreamContent); writeErr != nil { + ctx.Error(fmt.Sprintf("Failed to write %s: %v", entry.Path, writeErr)) return nil } - local.Files[key] = upstreamSHA256 + local.Files[entry.Path] = upstreamSHA256 updated++ } @@ -171,3 +187,14 @@ func (r *AgentsUpdateCommand) Handle(ctx console.Context) error { ctx.Info(fmt.Sprintf("%d updated, %d skipped (user modified), %d conflicts (use --force to overwrite), %d already up to date.", updated, skippedUserModified, conflicts, alreadyUpToDate)) return nil } + +// installedEntries returns only the entries whose paths are already tracked in the local .version file. +func installedEntries(entries []ManifestEntry, installedFiles map[string]string) []ManifestEntry { + var out []ManifestEntry + for _, e := range entries { + if _, ok := installedFiles[e.Path]; ok { + out = append(out, e) + } + } + return out +} diff --git a/ai/console/update_command_test.go b/ai/console/update_command_test.go index eb9809fac..cab49f83d 100644 --- a/ai/console/update_command_test.go +++ b/ai/console/update_command_test.go @@ -17,8 +17,8 @@ func TestAgentsUpdateCommandMissingVersionFile(t *testing.T) { mockContext.EXPECT().Error("No .ai/.version found. Run agents:install first.").Once() cmd := &AgentsUpdateCommand{ - treeFetcher: func(branch string) ([]string, error) { return nil, nil }, - fetcher: func(branch, path string) ([]byte, error) { return nil, nil }, + manifestFetcher: func(branch string) ([]ManifestEntry, error) { return nil, nil }, + fetcher: func(branch, path string) ([]byte, error) { return nil, nil }, } assert.Nil(t, cmd.Handle(mockContext)) } @@ -33,8 +33,8 @@ func TestAgentsUpdateCommandUnsupportedVersion(t *testing.T) { mockContext.EXPECT().Error("Agent files are only available for Goravel v1.17 and above (got v1.16).").Once() cmd := &AgentsUpdateCommand{ - treeFetcher: func(branch string) ([]string, error) { return nil, nil }, - fetcher: func(branch, path string) ([]byte, error) { return nil, nil }, + manifestFetcher: func(branch string) ([]ManifestEntry, error) { return nil, nil }, + fetcher: func(branch, path string) ([]byte, error) { return nil, nil }, } assert.Nil(t, cmd.Handle(mockContext)) } @@ -61,6 +61,8 @@ func TestAgentsUpdateCommandConflictDetection(t *testing.T) { upstreamContent := []byte("upstream changed content") userContent := []byte("user modified content") + routeEntry := ManifestEntry{Facade: "Route", Path: "prompt/route.md", Default: true} + setupLocalVersionFile := func(checksum string) { vf := VersionFile{ Version: "v1.17", @@ -71,9 +73,9 @@ func TestAgentsUpdateCommandConflictDetection(t *testing.T) { os.WriteFile(versionFilePath, data, 0644) } - makeTreeFetcher := func() func(string) ([]string, error) { - return func(branch string) ([]string, error) { - return []string{"prompt/route.md"}, nil + makeManifestFetcher := func() func(string) ([]ManifestEntry, error) { + return func(branch string) ([]ManifestEntry, error) { + return []ManifestEntry{routeEntry}, nil } } @@ -87,13 +89,14 @@ func TestAgentsUpdateCommandConflictDetection(t *testing.T) { setupLocalVersionFile(storedChecksum) os.WriteFile(".ai/prompt/route.md", userContent, 0644) - cmd.treeFetcher = makeTreeFetcher() + cmd.manifestFetcher = makeManifestFetcher() cmd.fetcher = func(branch, path string) ([]byte, error) { return upstreamContent, nil } mockContext.EXPECT().Option("version").Return("v1.17").Once() - mockContext.EXPECT().Option("file").Return("").Once() + mockContext.EXPECT().Arguments().Return([]string{}).Once() + mockContext.EXPECT().OptionBool("all").Return(false).Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Warning("Conflict: prompt/route.md modified locally and changed upstream. Use --force to overwrite.").Once() mockContext.EXPECT().Info("0 updated, 0 skipped (user modified), 1 conflicts (use --force to overwrite), 0 already up to date.").Once() @@ -105,13 +108,14 @@ func TestAgentsUpdateCommandConflictDetection(t *testing.T) { setupLocalVersionFile(storedChecksum) os.WriteFile(".ai/prompt/route.md", userContent, 0644) - cmd.treeFetcher = makeTreeFetcher() + cmd.manifestFetcher = makeManifestFetcher() cmd.fetcher = func(branch, path string) ([]byte, error) { return upstreamContent, nil } mockContext.EXPECT().Option("version").Return("v1.17").Once() - mockContext.EXPECT().Option("file").Return("").Once() + mockContext.EXPECT().Arguments().Return([]string{}).Once() + mockContext.EXPECT().OptionBool("all").Return(false).Once() mockContext.EXPECT().OptionBool("force").Return(true).Once() mockContext.EXPECT().Info("1 updated, 0 skipped (user modified), 0 conflicts (use --force to overwrite), 0 already up to date.").Once() }, @@ -122,13 +126,14 @@ func TestAgentsUpdateCommandConflictDetection(t *testing.T) { setupLocalVersionFile(storedChecksum) os.WriteFile(".ai/prompt/route.md", originalContent, 0644) - cmd.treeFetcher = makeTreeFetcher() + cmd.manifestFetcher = makeManifestFetcher() cmd.fetcher = func(branch, path string) ([]byte, error) { return upstreamContent, nil } mockContext.EXPECT().Option("version").Return("v1.17").Once() - mockContext.EXPECT().Option("file").Return("").Once() + mockContext.EXPECT().Arguments().Return([]string{}).Once() + mockContext.EXPECT().OptionBool("all").Return(false).Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Info("1 updated, 0 skipped (user modified), 0 conflicts (use --force to overwrite), 0 already up to date.").Once() }, @@ -139,13 +144,14 @@ func TestAgentsUpdateCommandConflictDetection(t *testing.T) { setupLocalVersionFile(storedChecksum) os.WriteFile(".ai/prompt/route.md", userContent, 0644) - cmd.treeFetcher = makeTreeFetcher() + cmd.manifestFetcher = makeManifestFetcher() cmd.fetcher = func(branch, path string) ([]byte, error) { return originalContent, nil } mockContext.EXPECT().Option("version").Return("v1.17").Once() - mockContext.EXPECT().Option("file").Return("").Once() + mockContext.EXPECT().Arguments().Return([]string{}).Once() + mockContext.EXPECT().OptionBool("all").Return(false).Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Info("0 updated, 1 skipped (user modified), 0 conflicts (use --force to overwrite), 0 already up to date.").Once() }, @@ -156,25 +162,29 @@ func TestAgentsUpdateCommandConflictDetection(t *testing.T) { setupLocalVersionFile(storedChecksum) os.WriteFile(".ai/prompt/route.md", originalContent, 0644) - cmd.treeFetcher = makeTreeFetcher() + cmd.manifestFetcher = makeManifestFetcher() cmd.fetcher = func(branch, path string) ([]byte, error) { return originalContent, nil } mockContext.EXPECT().Option("version").Return("v1.17").Once() - mockContext.EXPECT().Option("file").Return("").Once() + mockContext.EXPECT().Arguments().Return([]string{}).Once() + mockContext.EXPECT().OptionBool("all").Return(false).Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Info("0 updated, 0 skipped (user modified), 0 conflicts (use --force to overwrite), 1 already up to date.").Once() }, }, { - name: "New file in upstream - download", + name: "New file in manifest with --all - download", setup: func() { setupLocalVersionFile(storedChecksum) os.WriteFile(".ai/prompt/route.md", originalContent, 0644) - cmd.treeFetcher = func(branch string) ([]string, error) { - return []string{"prompt/route.md", "prompt/auth.md"}, nil + cmd.manifestFetcher = func(branch string) ([]ManifestEntry, error) { + return []ManifestEntry{ + routeEntry, + {Facade: "Auth", Path: "prompt/auth.md", Default: false}, + }, nil } cmd.fetcher = func(branch, path string) ([]byte, error) { if path == "prompt/auth.md" { @@ -184,11 +194,34 @@ func TestAgentsUpdateCommandConflictDetection(t *testing.T) { } mockContext.EXPECT().Option("version").Return("v1.17").Once() - mockContext.EXPECT().Option("file").Return("").Once() + mockContext.EXPECT().Arguments().Return([]string{}).Once() + mockContext.EXPECT().OptionBool("all").Return(true).Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Info("1 updated, 0 skipped (user modified), 0 conflicts (use --force to overwrite), 1 already up to date.").Once() }, }, + { + name: "Specific facade via argument", + setup: func() { + setupLocalVersionFile(storedChecksum) + os.WriteFile(".ai/prompt/route.md", originalContent, 0644) + + cmd.manifestFetcher = func(branch string) ([]ManifestEntry, error) { + return []ManifestEntry{ + routeEntry, + {Facade: "Auth", Path: "prompt/auth.md", Default: false}, + }, nil + } + cmd.fetcher = func(branch, path string) ([]byte, error) { + return upstreamContent, nil + } + + mockContext.EXPECT().Option("version").Return("v1.17").Once() + mockContext.EXPECT().Arguments().Return([]string{"Route"}).Once() + mockContext.EXPECT().OptionBool("force").Return(false).Once() + mockContext.EXPECT().Info("1 updated, 0 skipped (user modified), 0 conflicts (use --force to overwrite), 0 already up to date.").Once() + }, + }, } for _, test := range tests { From b0998fecce4f8ff9f53b70a54114a6766b7665ea Mon Sep 17 00:00:00 2001 From: kkumar-gcc Date: Sat, 21 Mar 2026 01:56:10 +0530 Subject: [PATCH 3/3] optimise ai:docs:install and update command --- ai/console/ai_docs_install_command.go | 126 ++++++++++ ...est.go => ai_docs_install_command_test.go} | 36 ++- ai/console/ai_docs_update_command.go | 162 +++++++++++++ ...test.go => ai_docs_update_command_test.go} | 75 +++--- ai/console/helpers.go | 228 ++++++++++-------- ai/console/helpers_test.go | 22 +- ai/console/install_command.go | 227 ----------------- ai/console/update_command.go | 200 --------------- ai/service_provider.go | 4 +- errors/list.go | 5 + 10 files changed, 475 insertions(+), 610 deletions(-) create mode 100644 ai/console/ai_docs_install_command.go rename ai/console/{install_command_test.go => ai_docs_install_command_test.go} (77%) create mode 100644 ai/console/ai_docs_update_command.go rename ai/console/{update_command_test.go => ai_docs_update_command_test.go} (71%) delete mode 100644 ai/console/install_command.go delete mode 100644 ai/console/update_command.go diff --git a/ai/console/ai_docs_install_command.go b/ai/console/ai_docs_install_command.go new file mode 100644 index 000000000..850ba39b3 --- /dev/null +++ b/ai/console/ai_docs_install_command.go @@ -0,0 +1,126 @@ +package console + +import ( + "fmt" + "os" + "strings" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" +) + +type AiDocsInstallCommand struct { + manifestFetcher func(branch string) ([]ManifestEntry, error) + fetcher func(branch, path string) ([]byte, error) + versionDetector func() (string, error) +} + +func NewAiDocsInstallCommand() *AiDocsInstallCommand { + return &AiDocsInstallCommand{ + manifestFetcher: fetchManifest, + fetcher: fetchRaw, + versionDetector: detectGoravelVersion, + } +} + +func (r *AiDocsInstallCommand) Signature() string { + return "ai:docs:install" +} + +func (r *AiDocsInstallCommand) Description() string { + return "Install AI documentation and skill files for Goravel (e.g., 'artisan ai:docs:install Auth Route')" +} + +func (r *AiDocsInstallCommand) Extend() command.Extend { + return command.Extend{ + Category: "ai", + Flags: []command.Flag{ + &command.BoolFlag{ + Name: "force", + Value: false, + Usage: "Skip confirmation and overwrite existing files", + DisableDefaultText: true, + }, + &command.BoolFlag{ + Name: "all", + Aliases: []string{"a"}, + Value: false, + Usage: "Install all available facade agent files", + DisableDefaultText: true, + }, + }, + } +} + +func (r *AiDocsInstallCommand) Handle(ctx console.Context) error { + version, err := r.versionDetector() + if err != nil { + ctx.Error(fmt.Sprintf("Failed to detect version: %v", err)) + return nil + } + + if !isSupportedVersion(version) { + ctx.Error(fmt.Sprintf("AI docs are only available for Goravel v1.17 and above (got %s)", version)) + return nil + } + + branch := resolveBranch(version) + entries, err := r.manifestFetcher(branch) + if err != nil { + ctx.Error(err.Error()) + return nil + } + + if len(entries) == 0 { + ctx.Error(fmt.Sprintf("No AI docs found for version %s. Check https://github.com/goravel/docs", version)) + return nil + } + + if !ctx.OptionBool("force") { + if _, statErr := os.Stat(versionFilePath); statErr == nil { + if !ctx.Confirm("AI docs are already installed. Overwrite?") { + ctx.Warning("Cancelled.") + return nil + } + } + } + + toInstall := r.determineFilesToInstall(ctx, entries) + if len(toInstall) == 0 { + return nil + } + + downloaded, err := downloadFiles(branch, toInstall, r.fetcher) + if err != nil { + ctx.Error(err.Error()) + return nil + } + + if err := saveFiles(version, downloaded); err != nil { + ctx.Error(err.Error()) + return nil + } + + ctx.Info(fmt.Sprintf("Installed %d file(s) for version %s.", len(downloaded), version)) + return nil +} + +// determineFilesToInstall checks how the command was called to figure out what to download. +// Order of precedence: --all flag -> specific arguments (e.g., Auth) -> defaults. +func (r *AiDocsInstallCommand) determineFilesToInstall(ctx console.Context, entries []ManifestEntry) []ManifestEntry { + if ctx.OptionBool("all") { + return entries + } + + // This allows an AI agent to run `artisan ai:docs:install Auth Route` + facadeArgs := ctx.Arguments() + if len(facadeArgs) > 0 { + toInstall := entriesForFacades(entries, facadeArgs) + if len(toInstall) == 0 { + ctx.Error(fmt.Sprintf("No AI docs found for facade(s): %s", strings.Join(facadeArgs, ", "))) + } + return toInstall + } + + return defaultEntries(entries) +} diff --git a/ai/console/install_command_test.go b/ai/console/ai_docs_install_command_test.go similarity index 77% rename from ai/console/install_command_test.go rename to ai/console/ai_docs_install_command_test.go index bb4e1465d..a029f7977 100644 --- a/ai/console/install_command_test.go +++ b/ai/console/ai_docs_install_command_test.go @@ -10,20 +10,22 @@ import ( mocksconsole "github.com/goravel/framework/mocks/console" ) -func TestAgentsInstallCommand(t *testing.T) { +func TestAiDocsInstallCommand(t *testing.T) { var ( mockContext *mocksconsole.Context - installCommand *AgentsInstallCommand + installCommand *AiDocsInstallCommand ) beforeEach := func() { mockContext = mocksconsole.NewContext(t) - installCommand = &AgentsInstallCommand{} + installCommand = &AiDocsInstallCommand{ + versionDetector: func() (string, error) { return "v1.17", nil }, + } } cleanup := func() { - os.RemoveAll(".ai") - os.Remove("AGENTS.md") + assert.Nil(t, os.RemoveAll(".ai")) + assert.Nil(t, os.RemoveAll("AGENTS.md")) } manifest := []ManifestEntry{ @@ -46,7 +48,6 @@ func TestAgentsInstallCommand(t *testing.T) { return []byte("# " + path), nil } - mockContext.EXPECT().Option("version").Return("v1.17").Once() mockContext.EXPECT().OptionBool("force").Return(true).Once() mockContext.EXPECT().Arguments().Return([]string{}).Once() mockContext.EXPECT().OptionBool("all").Return(false).Once() @@ -63,7 +64,6 @@ func TestAgentsInstallCommand(t *testing.T) { return []byte("# " + path), nil } - mockContext.EXPECT().Option("version").Return("v1.17").Once() mockContext.EXPECT().OptionBool("force").Return(true).Once() mockContext.EXPECT().Arguments().Return([]string{}).Once() mockContext.EXPECT().OptionBool("all").Return(true).Once() @@ -80,7 +80,6 @@ func TestAgentsInstallCommand(t *testing.T) { return []byte("# " + path), nil } - mockContext.EXPECT().Option("version").Return("v1.17").Once() mockContext.EXPECT().OptionBool("force").Return(true).Once() mockContext.EXPECT().Arguments().Return([]string{"Auth"}).Once() mockContext.EXPECT().OptionBool("all").Return(false).Once() @@ -90,6 +89,7 @@ func TestAgentsInstallCommand(t *testing.T) { { name: "Happy path - falls back to master when version branch has no manifest", setup: func() { + installCommand.versionDetector = func() (string, error) { return "v1.99", nil } installCommand.manifestFetcher = func(branch string) ([]ManifestEntry, error) { if branch == docsFallbackBranch { return manifest, nil @@ -100,7 +100,6 @@ func TestAgentsInstallCommand(t *testing.T) { return []byte("# " + path), nil } - mockContext.EXPECT().Option("version").Return("v1.99").Once() mockContext.EXPECT().OptionBool("force").Return(true).Once() mockContext.EXPECT().Arguments().Return([]string{}).Once() mockContext.EXPECT().OptionBool("all").Return(false).Once() @@ -110,12 +109,12 @@ func TestAgentsInstallCommand(t *testing.T) { { name: "Sad path - no manifest on branch or master", setup: func() { + installCommand.versionDetector = func() (string, error) { return "v9.99", nil } installCommand.manifestFetcher = func(branch string) ([]ManifestEntry, error) { return nil, nil } - mockContext.EXPECT().Option("version").Return("v9.99").Once() - mockContext.EXPECT().Error("No agent files found for version v9.99. Check https://github.com/goravel/docs").Once() + mockContext.EXPECT().Error("No AI docs found for version v9.99. Check https://github.com/goravel/docs").Once() }, }, { @@ -125,7 +124,6 @@ func TestAgentsInstallCommand(t *testing.T) { return nil, errors.New("network error") } - mockContext.EXPECT().Option("version").Return("v1.17").Once() mockContext.EXPECT().Error("network error").Once() }, }, @@ -136,18 +134,17 @@ func TestAgentsInstallCommand(t *testing.T) { return manifest, nil } - mockContext.EXPECT().Option("version").Return("v1.17").Once() mockContext.EXPECT().OptionBool("force").Return(true).Once() mockContext.EXPECT().Arguments().Return([]string{"Nonexistent"}).Once() mockContext.EXPECT().OptionBool("all").Return(false).Once() - mockContext.EXPECT().Error("No agent files found for facade(s): Nonexistent").Once() + mockContext.EXPECT().Error("No AI docs found for facade(s): Nonexistent").Once() }, }, { name: "Sad path - unsupported version", setup: func() { - mockContext.EXPECT().Option("version").Return("v1.16").Once() - mockContext.EXPECT().Error("agent files are only available for Goravel v1.17 and above (got v1.16)").Once() + installCommand.versionDetector = func() (string, error) { return "v1.16", nil } + mockContext.EXPECT().Error("AI docs are only available for Goravel v1.17 and above (got v1.16)").Once() }, }, { @@ -157,12 +154,11 @@ func TestAgentsInstallCommand(t *testing.T) { return manifest, nil } - os.MkdirAll(".ai", 0755) - os.WriteFile(versionFilePath, []byte(`{"version":"v1.16","files":{}}`), 0644) + assert.Nil(t, os.MkdirAll(".ai", 0755)) + assert.Nil(t, os.WriteFile(versionFilePath, []byte(`{"version":"v1.17","files":{}}`), 0644)) - mockContext.EXPECT().Option("version").Return("v1.17").Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() - mockContext.EXPECT().Confirm("Agent files are already installed. Overwrite?").Return(false).Once() + mockContext.EXPECT().Confirm("AI docs are already installed. Overwrite?").Return(false).Once() mockContext.EXPECT().Warning("Cancelled.").Once() }, }, diff --git a/ai/console/ai_docs_update_command.go b/ai/console/ai_docs_update_command.go new file mode 100644 index 000000000..e7538ee93 --- /dev/null +++ b/ai/console/ai_docs_update_command.go @@ -0,0 +1,162 @@ +package console + +import ( + "fmt" + "os" + "strings" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" + "github.com/goravel/framework/errors" +) + +type AiDocsUpdateCommand struct { + manifestFetcher func(branch string) ([]ManifestEntry, error) + fetcher func(branch, path string) ([]byte, error) + versionDetector func() (string, error) +} + +func NewAiDocsUpdateCommand() *AiDocsUpdateCommand { + return &AiDocsUpdateCommand{ + manifestFetcher: fetchManifest, + fetcher: fetchRaw, + versionDetector: detectGoravelVersion, + } +} + +func (r *AiDocsUpdateCommand) Signature() string { + return "ai:docs:update" +} + +func (r *AiDocsUpdateCommand) Description() string { + return "Update installed AI documentation files to match the current Goravel version" +} + +func (r *AiDocsUpdateCommand) Extend() command.Extend { + return command.Extend{ + Category: "ai", + Flags: []command.Flag{ + &command.BoolFlag{ + Name: "force", + Value: false, + Usage: "Overwrite even if user modified locally", + DisableDefaultText: true, + }, + &command.BoolFlag{ + Name: "all", + Aliases: []string{"a"}, + Value: false, + Usage: "Also install new facade files not yet installed", + DisableDefaultText: true, + }, + }, + } +} + +func (r *AiDocsUpdateCommand) Handle(ctx console.Context) error { + local, err := readVersionFile() + if err != nil || local.Version == "" { + ctx.Error("No .ai/.version found. Run 'artisan ai:docs:install' first.") + return nil + } + + branch := resolveBranch(local.Version) + entries, err := r.manifestFetcher(branch) + if err != nil { + ctx.Error(err.Error()) + return nil + } + + if len(entries) == 0 { + ctx.Error(fmt.Sprintf("No AI docs found for version %s. Check https://github.com/goravel/docs", local.Version)) + return nil + } + + toProcess := r.determineFilesToProcess(ctx, entries, local.Files) + if len(toProcess) == 0 { + return nil + } + + var updated, skipped, conflicts, upToDate int + force := ctx.OptionBool("force") + + for _, entry := range toProcess { + upstreamContent, err := r.fetcher(branch, entry.Path) + if err != nil || upstreamContent == nil { + ctx.Warning(fmt.Sprintf("File not found upstream: %s", entry.Path)) + continue + } + + upstreamSHA := sha256sum(upstreamContent) + storedSHA, exists := local.Files[entry.Path] + + // New file being added via --all or specific facade args + if !exists { + if err := writeAgentFile(entry.Path, upstreamContent); err == nil { + local.Files[entry.Path] = upstreamSHA + updated++ + } + continue + } + + localContent, err := os.ReadFile(destPathFor(entry.Path)) + if err != nil { + // File went missing locally, restore it + if err := writeAgentFile(entry.Path, upstreamContent); err == nil { + local.Files[entry.Path] = upstreamSHA + updated++ + } + continue + } + + localCurrentSHA := sha256sum(localContent) + localModified := localCurrentSHA != storedSHA + upstreamChanged := upstreamSHA != storedSHA + + if !localModified && !upstreamChanged { + upToDate++ + continue + } + if localModified && !upstreamChanged { + skipped++ + continue + } + if localModified && !force { + ctx.Warning(fmt.Sprintf("Conflict: %s modified locally and changed upstream. Use --force to overwrite.", entry.Path)) + conflicts++ + continue + } + + if err := writeAgentFile(entry.Path, upstreamContent); err == nil { + local.Files[entry.Path] = upstreamSHA + updated++ + } + } + + if err := writeVersionFile(local); err != nil { + ctx.Error(fmt.Sprintf("Failed to write .version: %v", err)) + return nil + } + + ctx.Info(fmt.Sprintf("%d updated, %d skipped (user modified), %d conflicts (use --force), %d up to date.", updated, skipped, conflicts, upToDate)) + return nil +} + +func (r *AiDocsUpdateCommand) determineFilesToProcess(ctx console.Context, entries []ManifestEntry, installedFiles map[string]string) []ManifestEntry { + facadeArgs := ctx.Arguments() + + // If the AI agent explicitly requests 'artisan ai:docs:update Auth', only update/install Auth + if len(facadeArgs) > 0 { + toProcess := entriesForFacades(entries, facadeArgs) + if len(toProcess) == 0 { + ctx.Error(errors.AiDocsFacadeNotFound.Args(strings.Join(facadeArgs, ", ")).Error()) + } + return toProcess + } + + if ctx.OptionBool("all") { + return entries + } + + return installedEntries(entries, installedFiles) +} diff --git a/ai/console/update_command_test.go b/ai/console/ai_docs_update_command_test.go similarity index 71% rename from ai/console/update_command_test.go rename to ai/console/ai_docs_update_command_test.go index cab49f83d..89e6f2d3e 100644 --- a/ai/console/update_command_test.go +++ b/ai/console/ai_docs_update_command_test.go @@ -7,52 +7,52 @@ import ( "github.com/stretchr/testify/assert" + "github.com/goravel/framework/errors" // Adjust import path as needed mocksconsole "github.com/goravel/framework/mocks/console" ) -func TestAgentsUpdateCommandMissingVersionFile(t *testing.T) { - os.RemoveAll(".ai") +func TestAiDocsUpdateCommandMissingVersionFile(t *testing.T) { + assert.Nil(t, os.RemoveAll(".ai")) mockContext := mocksconsole.NewContext(t) - mockContext.EXPECT().Error("No .ai/.version found. Run agents:install first.").Once() + mockContext.EXPECT().Error(errors.AiDocsNotInstalled.Error()).Once() - cmd := &AgentsUpdateCommand{ + cmd := &AiDocsUpdateCommand{ manifestFetcher: func(branch string) ([]ManifestEntry, error) { return nil, nil }, fetcher: func(branch, path string) ([]byte, error) { return nil, nil }, } assert.Nil(t, cmd.Handle(mockContext)) } -func TestAgentsUpdateCommandUnsupportedVersion(t *testing.T) { - os.MkdirAll(".ai", 0755) - os.WriteFile(versionFilePath, []byte(`{"version":"v1.16","files":{}}`), 0644) - defer os.RemoveAll(".ai") +func TestAiDocsUpdateCommandNoDocsFound(t *testing.T) { + assert.Nil(t, os.MkdirAll(".ai", 0755)) + assert.Nil(t, os.WriteFile(versionFilePath, []byte(`{"version":"v1.16","files":{}}`), 0644)) + defer func() { assert.Nil(t, os.RemoveAll(".ai")) }() mockContext := mocksconsole.NewContext(t) - mockContext.EXPECT().Option("version").Return("v1.16").Once() - mockContext.EXPECT().Error("Agent files are only available for Goravel v1.17 and above (got v1.16).").Once() + mockContext.EXPECT().Error(errors.AiDocsManifestFailed.Error()).Once() - cmd := &AgentsUpdateCommand{ + cmd := &AiDocsUpdateCommand{ manifestFetcher: func(branch string) ([]ManifestEntry, error) { return nil, nil }, fetcher: func(branch, path string) ([]byte, error) { return nil, nil }, } assert.Nil(t, cmd.Handle(mockContext)) } -func TestAgentsUpdateCommandConflictDetection(t *testing.T) { +func TestAiDocsUpdateCommandConflictDetection(t *testing.T) { var ( mockContext *mocksconsole.Context - cmd *AgentsUpdateCommand + cmd *AiDocsUpdateCommand ) beforeEach := func() { mockContext = mocksconsole.NewContext(t) - cmd = &AgentsUpdateCommand{} + cmd = &AiDocsUpdateCommand{} } cleanup := func() { - os.RemoveAll(".ai") - os.Remove("AGENTS.md") + assert.Nil(t, os.RemoveAll(".ai")) + assert.Nil(t, os.RemoveAll("AGENTS.md")) } originalContent := []byte("original content") @@ -68,9 +68,11 @@ func TestAgentsUpdateCommandConflictDetection(t *testing.T) { Version: "v1.17", Files: map[string]string{"prompt/route.md": checksum}, } - data, _ := json.MarshalIndent(vf, "", " ") - os.MkdirAll(".ai/prompt", 0755) - os.WriteFile(versionFilePath, data, 0644) + data, err := json.MarshalIndent(vf, "", " ") + assert.Nil(t, err) + + assert.Nil(t, os.MkdirAll(".ai/prompt", 0755)) + assert.Nil(t, os.WriteFile(versionFilePath, data, 0644)) } makeManifestFetcher := func() func(string) ([]ManifestEntry, error) { @@ -87,98 +89,93 @@ func TestAgentsUpdateCommandConflictDetection(t *testing.T) { name: "Conflict - user modified and upstream changed, no force", setup: func() { setupLocalVersionFile(storedChecksum) - os.WriteFile(".ai/prompt/route.md", userContent, 0644) + assert.Nil(t, os.WriteFile(".ai/prompt/route.md", userContent, 0644)) cmd.manifestFetcher = makeManifestFetcher() cmd.fetcher = func(branch, path string) ([]byte, error) { return upstreamContent, nil } - mockContext.EXPECT().Option("version").Return("v1.17").Once() mockContext.EXPECT().Arguments().Return([]string{}).Once() mockContext.EXPECT().OptionBool("all").Return(false).Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() mockContext.EXPECT().Warning("Conflict: prompt/route.md modified locally and changed upstream. Use --force to overwrite.").Once() - mockContext.EXPECT().Info("0 updated, 0 skipped (user modified), 1 conflicts (use --force to overwrite), 0 already up to date.").Once() + mockContext.EXPECT().Info("0 updated, 0 skipped (user modified), 1 conflicts (use --force), 0 up to date.").Once() }, }, { name: "Conflict - user modified and upstream changed, force overwrites", setup: func() { setupLocalVersionFile(storedChecksum) - os.WriteFile(".ai/prompt/route.md", userContent, 0644) + assert.Nil(t, os.WriteFile(".ai/prompt/route.md", userContent, 0644)) cmd.manifestFetcher = makeManifestFetcher() cmd.fetcher = func(branch, path string) ([]byte, error) { return upstreamContent, nil } - mockContext.EXPECT().Option("version").Return("v1.17").Once() mockContext.EXPECT().Arguments().Return([]string{}).Once() mockContext.EXPECT().OptionBool("all").Return(false).Once() mockContext.EXPECT().OptionBool("force").Return(true).Once() - mockContext.EXPECT().Info("1 updated, 0 skipped (user modified), 0 conflicts (use --force to overwrite), 0 already up to date.").Once() + mockContext.EXPECT().Info("1 updated, 0 skipped (user modified), 0 conflicts (use --force), 0 up to date.").Once() }, }, { name: "Upstream changed, user did not modify - download", setup: func() { setupLocalVersionFile(storedChecksum) - os.WriteFile(".ai/prompt/route.md", originalContent, 0644) + assert.Nil(t, os.WriteFile(".ai/prompt/route.md", originalContent, 0644)) cmd.manifestFetcher = makeManifestFetcher() cmd.fetcher = func(branch, path string) ([]byte, error) { return upstreamContent, nil } - mockContext.EXPECT().Option("version").Return("v1.17").Once() mockContext.EXPECT().Arguments().Return([]string{}).Once() mockContext.EXPECT().OptionBool("all").Return(false).Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() - mockContext.EXPECT().Info("1 updated, 0 skipped (user modified), 0 conflicts (use --force to overwrite), 0 already up to date.").Once() + mockContext.EXPECT().Info("1 updated, 0 skipped (user modified), 0 conflicts (use --force), 0 up to date.").Once() }, }, { name: "User modified, upstream unchanged - skip", setup: func() { setupLocalVersionFile(storedChecksum) - os.WriteFile(".ai/prompt/route.md", userContent, 0644) + assert.Nil(t, os.WriteFile(".ai/prompt/route.md", userContent, 0644)) cmd.manifestFetcher = makeManifestFetcher() cmd.fetcher = func(branch, path string) ([]byte, error) { return originalContent, nil } - mockContext.EXPECT().Option("version").Return("v1.17").Once() mockContext.EXPECT().Arguments().Return([]string{}).Once() mockContext.EXPECT().OptionBool("all").Return(false).Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() - mockContext.EXPECT().Info("0 updated, 1 skipped (user modified), 0 conflicts (use --force to overwrite), 0 already up to date.").Once() + mockContext.EXPECT().Info("0 updated, 1 skipped (user modified), 0 conflicts (use --force), 0 up to date.").Once() }, }, { name: "Already up to date", setup: func() { setupLocalVersionFile(storedChecksum) - os.WriteFile(".ai/prompt/route.md", originalContent, 0644) + assert.Nil(t, os.WriteFile(".ai/prompt/route.md", originalContent, 0644)) cmd.manifestFetcher = makeManifestFetcher() cmd.fetcher = func(branch, path string) ([]byte, error) { return originalContent, nil } - mockContext.EXPECT().Option("version").Return("v1.17").Once() mockContext.EXPECT().Arguments().Return([]string{}).Once() mockContext.EXPECT().OptionBool("all").Return(false).Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() - mockContext.EXPECT().Info("0 updated, 0 skipped (user modified), 0 conflicts (use --force to overwrite), 1 already up to date.").Once() + mockContext.EXPECT().Info("0 updated, 0 skipped (user modified), 0 conflicts (use --force), 1 up to date.").Once() }, }, { name: "New file in manifest with --all - download", setup: func() { setupLocalVersionFile(storedChecksum) - os.WriteFile(".ai/prompt/route.md", originalContent, 0644) + assert.Nil(t, os.WriteFile(".ai/prompt/route.md", originalContent, 0644)) cmd.manifestFetcher = func(branch string) ([]ManifestEntry, error) { return []ManifestEntry{ @@ -193,18 +190,17 @@ func TestAgentsUpdateCommandConflictDetection(t *testing.T) { return originalContent, nil } - mockContext.EXPECT().Option("version").Return("v1.17").Once() mockContext.EXPECT().Arguments().Return([]string{}).Once() mockContext.EXPECT().OptionBool("all").Return(true).Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() - mockContext.EXPECT().Info("1 updated, 0 skipped (user modified), 0 conflicts (use --force to overwrite), 1 already up to date.").Once() + mockContext.EXPECT().Info("1 updated, 0 skipped (user modified), 0 conflicts (use --force), 1 up to date.").Once() }, }, { name: "Specific facade via argument", setup: func() { setupLocalVersionFile(storedChecksum) - os.WriteFile(".ai/prompt/route.md", originalContent, 0644) + assert.Nil(t, os.WriteFile(".ai/prompt/route.md", originalContent, 0644)) cmd.manifestFetcher = func(branch string) ([]ManifestEntry, error) { return []ManifestEntry{ @@ -216,10 +212,9 @@ func TestAgentsUpdateCommandConflictDetection(t *testing.T) { return upstreamContent, nil } - mockContext.EXPECT().Option("version").Return("v1.17").Once() mockContext.EXPECT().Arguments().Return([]string{"Route"}).Once() mockContext.EXPECT().OptionBool("force").Return(false).Once() - mockContext.EXPECT().Info("1 updated, 0 skipped (user modified), 0 conflicts (use --force to overwrite), 0 already up to date.").Once() + mockContext.EXPECT().Info("1 updated, 0 skipped (user modified), 0 conflicts (use --force), 0 up to date.").Once() }, }, } diff --git a/ai/console/helpers.go b/ai/console/helpers.go index 47d0a879d..b66c3489c 100644 --- a/ai/console/helpers.go +++ b/ai/console/helpers.go @@ -12,7 +12,6 @@ import ( "os" "path/filepath" "regexp" - "sort" "strconv" "strings" "time" @@ -24,43 +23,54 @@ const ( ) // VersionFile is the local .ai/.version tracking file. -// It is never fetched from goravel/docs — it is created and maintained by these commands. -// files maps each relative path (e.g. "prompt/route.md") to the SHA256 of its content -// at the time it was installed or last updated. Used to detect both upstream changes -// and local user modifications during agents:update. +// It maps each relative path to its installed SHA256 content hash. type VersionFile struct { Version string `json:"version"` Files map[string]string `json:"files"` } -type githubBranch struct { - Name string `json:"name"` -} - -// ManifestEntry describes a single agent file available in goravel/docs. -// Facade is the Goravel facade name (e.g. "Route", "Auth"); empty for non-facade files like AGENTS.md. -// Default marks files that are installed by agents:install when no specific facades are requested. +// ManifestEntry describes a single AI doc file available upstream. type ManifestEntry struct { Facade string `json:"facade"` Path string `json:"path"` Default bool `json:"default"` } -// isSupportedVersion reports whether a version string has agent file support. -// Agent files were introduced in Goravel v1.17. "master" and "latest" are always accepted. +func detectGoravelVersion() (string, error) { + return detectGoravelVersionFrom("go.mod") +} + +func detectGoravelVersionFrom(gomodPath string) (string, error) { + f, err := os.Open(gomodPath) + if err != nil { + return "", fmt.Errorf("cannot read %s: %w", gomodPath, err) + } + defer f.Close() + + re := regexp.MustCompile(`^\s*(?:require\s+)?github\.com/goravel/framework\s+v(\d+)\.(\d+)`) + scanner := bufio.NewScanner(f) + for scanner.Scan() { + if m := re.FindStringSubmatch(scanner.Text()); m != nil { + return fmt.Sprintf("v%s.%s", m[1], m[2]), nil + } + } + return "", fmt.Errorf("github.com/goravel/framework not found in %s", gomodPath) +} + func isSupportedVersion(version string) bool { if version == docsFallbackBranch || version == "latest" { return true } - major, minor := parseVersionParts(version) - if major > 1 { - return true + v := strings.TrimPrefix(version, "v") + parts := strings.SplitN(v, ".", 2) + if len(parts) < 2 { + return false } - return major == 1 && minor >= 17 + major, _ := strconv.Atoi(parts[0]) + minor, _ := strconv.Atoi(parts[1]) + return major > 1 || (major == 1 && minor >= 17) } -// resolveBranch maps a framework version to its goravel/docs branch. -// "latest" is an alias for master. All other versions use their string as the branch name. func resolveBranch(version string) string { if version == "latest" { return docsFallbackBranch @@ -68,23 +78,15 @@ func resolveBranch(version string) string { return version } -// encodeBranchForURL percent-encodes characters that break URLs (e.g. #) while -// preserving forward slashes, which are valid in git branch names and GitHub URLs. -func encodeBranchForURL(branch string) string { - encoded := url.PathEscape(branch) - return strings.ReplaceAll(encoded, "%2F", "/") -} - -// fetchManifest fetches and parses the manifest.json from the goravel/docs .ai/ directory. -// Returns nil entries (not an error) when the file is not found on the branch. func fetchManifest(branch string) ([]ManifestEntry, error) { data, err := fetchRaw(branch, "manifest.json") - if err != nil { + if err != nil || data == nil { + // Fallback to master if specific branch manifest doesn't exist + if branch != docsFallbackBranch { + return fetchManifest(docsFallbackBranch) + } return nil, err } - if data == nil { - return nil, nil - } var entries []ManifestEntry if err := json.Unmarshal(data, &entries); err != nil { return nil, fmt.Errorf("decode manifest: %w", err) @@ -92,100 +94,75 @@ func fetchManifest(branch string) ([]ManifestEntry, error) { return entries, nil } -// fetchAvailableBranches returns versioned branches (v1.17+) from goravel/docs, -// sorted newest first. Used for the interactive version picker when go.mod detection fails. -func fetchAvailableBranches() ([]string, error) { - req, err := http.NewRequest(http.MethodGet, "https://api.github.com/repos/goravel/docs/branches?per_page=100", nil) - if err != nil { - return nil, err - } - req.Header.Set("Accept", "application/vnd.github.v3+json") +func fetchRaw(branch, path string) ([]byte, error) { + encodedBranch := strings.ReplaceAll(url.PathEscape(branch), "%2F", "/") + rawURL := fmt.Sprintf("https://raw.githubusercontent.com/goravel/docs/%s/.ai/%s", encodedBranch, path) - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Do(req) + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(rawURL) if err != nil { - return nil, fmt.Errorf("failed to fetch available versions: %w", err) + return nil, fmt.Errorf("GET %s: %w", rawURL, err) } defer resp.Body.Close() - var branches []githubBranch - if err := json.NewDecoder(resp.Body).Decode(&branches); err != nil { - return nil, fmt.Errorf("failed to parse available versions: %w", err) + if resp.StatusCode == http.StatusNotFound { + return nil, nil } - - re := regexp.MustCompile(`^v\d+\.\d+$`) - var versions []string - for _, b := range branches { - if re.MatchString(b.Name) && isSupportedVersion(b.Name) { - versions = append(versions, b.Name) - } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GET %s: status %d", rawURL, resp.StatusCode) } - - sort.Slice(versions, func(i, j int) bool { - maj1, min1 := parseVersionParts(versions[i]) - maj2, min2 := parseVersionParts(versions[j]) - if maj1 != maj2 { - return maj1 > maj2 - } - return min1 > min2 - }) - - return versions, nil + return io.ReadAll(resp.Body) } -func parseVersionParts(v string) (int, int) { - v = strings.TrimPrefix(v, "v") - parts := strings.SplitN(v, ".", 2) - if len(parts) < 2 { - return 0, 0 +func downloadFiles(branch string, toInstall []ManifestEntry, fetcher func(string, string) ([]byte, error)) (map[string][]byte, error) { + type result struct { + path string + content []byte + err error } - major, _ := strconv.Atoi(parts[0]) - minor, _ := strconv.Atoi(parts[1]) - return major, minor -} -func detectGoravelVersion() (string, error) { - return detectGoravelVersionFrom("go.mod") -} - -func detectGoravelVersionFrom(gomodPath string) (string, error) { - f, err := os.Open(gomodPath) - if err != nil { - return "", fmt.Errorf("cannot read %s: %w", gomodPath, err) + ch := make(chan result, len(toInstall)) + for _, entry := range toInstall { + go func(e ManifestEntry) { + content, err := fetcher(branch, e.Path) + ch <- result{path: e.Path, content: content, err: err} + }(entry) } - defer f.Close() - re := regexp.MustCompile(`^\s*(?:require\s+)?github\.com/goravel/framework\s+v(\d+)\.(\d+)`) - scanner := bufio.NewScanner(f) - for scanner.Scan() { - if m := re.FindStringSubmatch(scanner.Text()); m != nil { - return fmt.Sprintf("v%s.%s", m[1], m[2]), nil + downloaded := make(map[string][]byte) + for range toInstall { + res := <-ch + if res.err != nil { + return nil, res.err + } + if res.content == nil { + return nil, fmt.Errorf("file not found upstream: %s", res.path) } + downloaded[res.path] = res.content } - return "", fmt.Errorf("github.com/goravel/framework not found in %s", gomodPath) + return downloaded, nil } -func fetchRaw(branch, path string) ([]byte, error) { - encodedBranch := encodeBranchForURL(branch) - rawURL := fmt.Sprintf("https://raw.githubusercontent.com/goravel/docs/%s/.ai/%s", encodedBranch, path) - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Get(rawURL) //nolint:noctx - if err != nil { - return nil, fmt.Errorf("GET %s: %w", rawURL, err) +func saveFiles(version string, downloaded map[string][]byte) error { + existing, _ := readVersionFile() + local := VersionFile{Version: version, Files: make(map[string]string)} + + for k, v := range existing.Files { + local.Files[k] = v } - defer resp.Body.Close() - if resp.StatusCode == http.StatusNotFound { - return nil, nil + + if err := os.MkdirAll(".ai/skills", 0755); err != nil { + return err } - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("GET %s: status %d", rawURL, resp.StatusCode) + + for path, content := range downloaded { + if err := writeAgentFile(path, content); err != nil { + return err + } + local.Files[path] = sha256sum(content) } - return io.ReadAll(resp.Body) -} -func sha256sum(content []byte) string { - sum := sha256.Sum256(content) - return hex.EncodeToString(sum[:]) + return writeVersionFile(local) } func readVersionFile() (VersionFile, error) { @@ -231,3 +208,42 @@ func writeAgentFile(key string, content []byte) error { } return os.WriteFile(dest, content, 0644) } + +func sha256sum(content []byte) string { + sum := sha256.Sum256(content) + return hex.EncodeToString(sum[:]) +} + +func entriesForFacades(entries []ManifestEntry, facades []string) []ManifestEntry { + set := make(map[string]bool, len(facades)) + for _, f := range facades { + set[f] = true + } + var out []ManifestEntry + for _, e := range entries { + if set[e.Facade] { + out = append(out, e) + } + } + return out +} + +func defaultEntries(entries []ManifestEntry) []ManifestEntry { + var out []ManifestEntry + for _, e := range entries { + if e.Default { + out = append(out, e) + } + } + return out +} + +func installedEntries(entries []ManifestEntry, installedFiles map[string]string) []ManifestEntry { + var out []ManifestEntry + for _, e := range entries { + if _, ok := installedFiles[e.Path]; ok { + out = append(out, e) + } + } + return out +} diff --git a/ai/console/helpers_test.go b/ai/console/helpers_test.go index c88696f19..990be525b 100644 --- a/ai/console/helpers_test.go +++ b/ai/console/helpers_test.go @@ -18,15 +18,18 @@ func TestDetectGoravelVersionFrom(t *testing.T) { name: "valid go.mod", content: "module example\n\nrequire github.com/goravel/framework v1.17.3\n", expected: "v1.17", + hasError: false, }, { name: "malformed version string", content: "module example\n\nrequire github.com/goravel/framework vX.Y.Z\n", + expected: "", hasError: true, }, { name: "framework not found", content: "module example\n\nrequire github.com/some/other v1.0.0\n", + expected: "", hasError: true, }, } @@ -39,7 +42,7 @@ func TestDetectGoravelVersionFrom(t *testing.T) { _, err = f.WriteString(tt.content) assert.Nil(t, err) - f.Close() + assert.Nil(t, f.Close()) result, err := detectGoravelVersionFrom(f.Name()) if tt.hasError { @@ -75,6 +78,7 @@ func TestIsSupportedVersion(t *testing.T) { {"v1.16", false}, {"v1.0", false}, {"v0.9", false}, + {"invalid", false}, } for _, tt := range tests { @@ -89,6 +93,8 @@ func TestResolveBranch(t *testing.T) { version string expected string }{ + {"latest", "master"}, + {"master", "master"}, {"v1.17", "v1.17"}, {"v1.16", "v1.16"}, {"v1.13", "v1.13"}, @@ -102,17 +108,3 @@ func TestResolveBranch(t *testing.T) { }) } } - -func TestParseVersionParts(t *testing.T) { - major, minor := parseVersionParts("v1.17") - assert.Equal(t, 1, major) - assert.Equal(t, 17, minor) - - major, minor = parseVersionParts("v2.5") - assert.Equal(t, 2, major) - assert.Equal(t, 5, minor) - - major, minor = parseVersionParts("invalid") - assert.Equal(t, 0, major) - assert.Equal(t, 0, minor) -} diff --git a/ai/console/install_command.go b/ai/console/install_command.go deleted file mode 100644 index 920a7ae2d..000000000 --- a/ai/console/install_command.go +++ /dev/null @@ -1,227 +0,0 @@ -package console - -import ( - "fmt" - "os" - "strings" - - "github.com/goravel/framework/contracts/console" - "github.com/goravel/framework/contracts/console/command" -) - -type AgentsInstallCommand struct { - manifestFetcher func(branch string) ([]ManifestEntry, error) - fetcher func(branch, path string) ([]byte, error) -} - -func NewAgentsInstallCommand() *AgentsInstallCommand { - return &AgentsInstallCommand{ - manifestFetcher: fetchManifest, - fetcher: fetchRaw, - } -} - -func (r *AgentsInstallCommand) Signature() string { - return "agents:install" -} - -func (r *AgentsInstallCommand) Description() string { - return "Install AI agent skill files for the current Goravel version" -} - -func (r *AgentsInstallCommand) Extend() command.Extend { - return command.Extend{ - Category: "agents", - Flags: []command.Flag{ - &command.StringFlag{ - Name: "version", - Usage: "Override detected Goravel version (e.g. v1.17)", - }, - &command.BoolFlag{ - Name: "force", - Value: false, - Usage: "Skip confirmation and overwrite existing files", - DisableDefaultText: true, - }, - &command.BoolFlag{ - Name: "all", - Aliases: []string{"a"}, - Value: false, - Usage: "Install all available facade agent files", - DisableDefaultText: true, - }, - }, - } -} - -func (r *AgentsInstallCommand) Handle(ctx console.Context) error { - version, branch, err := r.resolveVersionAndBranch(ctx) - if err != nil { - ctx.Error(err.Error()) - return nil - } - - entries, branch, err := r.resolveManifest(branch) - if err != nil { - ctx.Error(err.Error()) - return nil - } - if len(entries) == 0 { - ctx.Error(fmt.Sprintf("No agent files found for version %s. Check https://github.com/goravel/docs", version)) - return nil - } - - if !ctx.OptionBool("force") { - if _, statErr := os.Stat(versionFilePath); statErr == nil { - if !ctx.Confirm("Agent files are already installed. Overwrite?") { - ctx.Warning("Cancelled.") - return nil - } - } - } - - facadeArgs := ctx.Arguments() - var toInstall []ManifestEntry - switch { - case ctx.OptionBool("all"): - toInstall = entries - case len(facadeArgs) > 0: - toInstall = entriesForFacades(entries, facadeArgs) - if len(toInstall) == 0 { - ctx.Error(fmt.Sprintf("No agent files found for facade(s): %s", strings.Join(facadeArgs, ", "))) - return nil - } - default: - toInstall = defaultEntries(entries) - } - - type downloadResult struct { - path string - content []byte - err error - } - - ch := make(chan downloadResult, len(toInstall)) - for _, entry := range toInstall { - go func(e ManifestEntry) { - content, fetchErr := r.fetcher(branch, e.Path) - ch <- downloadResult{path: e.Path, content: content, err: fetchErr} - }(entry) - } - - downloaded := make(map[string][]byte) - for range toInstall { - res := <-ch - if res.err != nil { - ctx.Error(res.err.Error()) - return nil - } - if res.content == nil { - ctx.Error(fmt.Sprintf("File not found upstream: %s", res.path)) - return nil - } - downloaded[res.path] = res.content - } - - existing, _ := readVersionFile() - local := VersionFile{Version: version, Files: make(map[string]string)} - for k, v := range existing.Files { - local.Files[k] = v - } - - for path, content := range downloaded { - if err := writeAgentFile(path, content); err != nil { - ctx.Error(fmt.Sprintf("Failed to write %s: %v", path, err)) - return nil - } - local.Files[path] = sha256sum(content) - } - - if err := os.MkdirAll(".ai/skills", 0755); err != nil { - ctx.Error(fmt.Sprintf("Failed to create .ai/skills: %v", err)) - return nil - } - - if err := writeVersionFile(local); err != nil { - ctx.Error(fmt.Sprintf("Failed to write .version: %v", err)) - return nil - } - - ctx.Info(fmt.Sprintf("Installed %d file(s) for version %s.", len(downloaded), version)) - return nil -} - -// resolveVersionAndBranch determines the framework version and docs branch. -// Precedence: --version flag → go.mod detection → interactive picker. -func (r *AgentsInstallCommand) resolveVersionAndBranch(ctx console.Context) (version, branch string, err error) { - version = ctx.Option("version") - if version != "" { - if !isSupportedVersion(version) { - return "", "", fmt.Errorf("agent files are only available for Goravel v1.17 and above (got %s)", version) - } - return version, resolveBranch(version), nil - } - - version, err = detectGoravelVersion() - if err == nil { - if !isSupportedVersion(version) { - return "", "", fmt.Errorf("agent files are only available for Goravel v1.17 and above (got %s)", version) - } - return version, resolveBranch(version), nil - } - - available, fetchErr := fetchAvailableBranches() - if fetchErr != nil || len(available) == 0 { - return "", "", fmt.Errorf("cannot detect Goravel version from go.mod. Use --version to specify it") - } - - choices := make([]console.Choice, len(available)) - for i, v := range available { - choices[i] = console.Choice{Key: v, Value: v} - } - - version, err = ctx.Choice("Select Goravel version to install agent files for", choices) - if err != nil { - return "", "", fmt.Errorf("version selection failed: %w", err) - } - - return version, resolveBranch(version), nil -} - -// resolveManifest fetches the manifest for the given branch, falling back to master -// if the version branch has no manifest. -func (r *AgentsInstallCommand) resolveManifest(branch string) ([]ManifestEntry, string, error) { - entries, err := r.manifestFetcher(branch) - if err != nil { - return nil, branch, err - } - if len(entries) == 0 && branch != docsFallbackBranch { - entries, err = r.manifestFetcher(docsFallbackBranch) - return entries, docsFallbackBranch, err - } - return entries, branch, nil -} - -func entriesForFacades(entries []ManifestEntry, facades []string) []ManifestEntry { - set := make(map[string]bool, len(facades)) - for _, f := range facades { - set[f] = true - } - var out []ManifestEntry - for _, e := range entries { - if set[e.Facade] { - out = append(out, e) - } - } - return out -} - -func defaultEntries(entries []ManifestEntry) []ManifestEntry { - var out []ManifestEntry - for _, e := range entries { - if e.Default { - out = append(out, e) - } - } - return out -} diff --git a/ai/console/update_command.go b/ai/console/update_command.go deleted file mode 100644 index b41b22c39..000000000 --- a/ai/console/update_command.go +++ /dev/null @@ -1,200 +0,0 @@ -package console - -import ( - "fmt" - "os" - "strings" - - "github.com/goravel/framework/contracts/console" - "github.com/goravel/framework/contracts/console/command" -) - -type AgentsUpdateCommand struct { - manifestFetcher func(branch string) ([]ManifestEntry, error) - fetcher func(branch, path string) ([]byte, error) -} - -func NewAgentsUpdateCommand() *AgentsUpdateCommand { - return &AgentsUpdateCommand{ - manifestFetcher: fetchManifest, - fetcher: fetchRaw, - } -} - -func (r *AgentsUpdateCommand) Signature() string { - return "agents:update" -} - -func (r *AgentsUpdateCommand) Description() string { - return "Update AI agent skill files to match the current Goravel version" -} - -func (r *AgentsUpdateCommand) Extend() command.Extend { - return command.Extend{ - Category: "agents", - Flags: []command.Flag{ - &command.BoolFlag{ - Name: "force", - Value: false, - Usage: "Overwrite even if user modified locally", - DisableDefaultText: true, - }, - &command.BoolFlag{ - Name: "all", - Aliases: []string{"a"}, - Value: false, - Usage: "Also install new facade files not yet installed", - DisableDefaultText: true, - }, - &command.StringFlag{ - Name: "version", - Usage: "Force a specific version (e.g. v1.17)", - }, - }, - } -} - -func (r *AgentsUpdateCommand) Handle(ctx console.Context) error { - if _, err := os.Stat(versionFilePath); os.IsNotExist(err) { - ctx.Error("No .ai/.version found. Run agents:install first.") - return nil - } - - local, err := readVersionFile() - if err != nil { - ctx.Error(fmt.Sprintf("Failed to read .ai/.version: %v", err)) - return nil - } - - version := ctx.Option("version") - if version == "" { - version, err = detectGoravelVersion() - if err != nil { - ctx.Error("Cannot detect Goravel version from go.mod. Use --version to specify it.") - return nil - } - } - if !isSupportedVersion(version) { - ctx.Error(fmt.Sprintf("Agent files are only available for Goravel v1.17 and above (got %s).", version)) - return nil - } - - branch := resolveBranch(version) - entries, err := r.manifestFetcher(branch) - if err != nil { - ctx.Error(err.Error()) - return nil - } - if len(entries) == 0 && branch != docsFallbackBranch { - entries, err = r.manifestFetcher(docsFallbackBranch) - if err != nil { - ctx.Error(err.Error()) - return nil - } - } - if len(entries) == 0 { - ctx.Error(fmt.Sprintf("No agent files found for version %s. Check https://github.com/goravel/docs", version)) - return nil - } - - facadeArgs := ctx.Arguments() - force := ctx.OptionBool("force") - - var toProcess []ManifestEntry - switch { - case len(facadeArgs) > 0: - toProcess = entriesForFacades(entries, facadeArgs) - if len(toProcess) == 0 { - ctx.Error(fmt.Sprintf("No agent files found for facade(s): %s", strings.Join(facadeArgs, ", "))) - return nil - } - case ctx.OptionBool("all"): - toProcess = entries - default: - toProcess = installedEntries(entries, local.Files) - } - - var updated, skippedUserModified, conflicts, alreadyUpToDate int - - for _, entry := range toProcess { - upstreamContent, fetchErr := r.fetcher(branch, entry.Path) - if fetchErr != nil { - ctx.Error(fetchErr.Error()) - return nil - } - if upstreamContent == nil { - ctx.Warning(fmt.Sprintf("File not found upstream: %s", entry.Path)) - continue - } - upstreamSHA256 := sha256sum(upstreamContent) - - storedSHA256, exists := local.Files[entry.Path] - if !exists { - if writeErr := writeAgentFile(entry.Path, upstreamContent); writeErr != nil { - ctx.Error(fmt.Sprintf("Failed to write %s: %v", entry.Path, writeErr)) - return nil - } - local.Files[entry.Path] = upstreamSHA256 - updated++ - continue - } - - localPath := destPathFor(entry.Path) - localContent, readErr := os.ReadFile(localPath) - if readErr != nil { - if writeErr := writeAgentFile(entry.Path, upstreamContent); writeErr != nil { - ctx.Error(fmt.Sprintf("Failed to write %s: %v", entry.Path, writeErr)) - return nil - } - local.Files[entry.Path] = upstreamSHA256 - updated++ - continue - } - - localCurrentSHA256 := sha256sum(localContent) - localModified := localCurrentSHA256 != storedSHA256 - upstreamChanged := upstreamSHA256 != storedSHA256 - - if !localModified && !upstreamChanged { - alreadyUpToDate++ - continue - } - - if localModified && !upstreamChanged { - skippedUserModified++ - continue - } - - if localModified && !force { - ctx.Warning(fmt.Sprintf("Conflict: %s modified locally and changed upstream. Use --force to overwrite.", entry.Path)) - conflicts++ - continue - } - - if writeErr := writeAgentFile(entry.Path, upstreamContent); writeErr != nil { - ctx.Error(fmt.Sprintf("Failed to write %s: %v", entry.Path, writeErr)) - return nil - } - local.Files[entry.Path] = upstreamSHA256 - updated++ - } - - if err := writeVersionFile(local); err != nil { - ctx.Error(fmt.Sprintf("Failed to write .version: %v", err)) - return nil - } - - ctx.Info(fmt.Sprintf("%d updated, %d skipped (user modified), %d conflicts (use --force to overwrite), %d already up to date.", updated, skippedUserModified, conflicts, alreadyUpToDate)) - return nil -} - -// installedEntries returns only the entries whose paths are already tracked in the local .version file. -func installedEntries(entries []ManifestEntry, installedFiles map[string]string) []ManifestEntry { - var out []ManifestEntry - for _, e := range entries { - if _, ok := installedFiles[e.Path]; ok { - out = append(out, e) - } - } - return out -} diff --git a/ai/service_provider.go b/ai/service_provider.go index 2f2ae07e9..80256edac 100644 --- a/ai/service_provider.go +++ b/ai/service_provider.go @@ -34,7 +34,7 @@ func (r *ServiceProvider) registerCommands(app foundation.Application) { return } artisan.Register([]contractsconsole.Command{ - console.NewAgentsInstallCommand(), - console.NewAgentsUpdateCommand(), + console.NewAiDocsInstallCommand(), + console.NewAiDocsUpdateCommand(), }) } diff --git a/errors/list.go b/errors/list.go index 3cd4d9865..0cc8cb613 100644 --- a/errors/list.go +++ b/errors/list.go @@ -20,6 +20,11 @@ var ( ServiceProviderCycle = New("circular dependency detected between providers: %s") TelemetryFacadeNotSet = New("telemetry facade is not initialized") + AiDocsNotInstalled = New("no .ai/.version found. Run 'artisan ai:docs:install' first") + AiDocsNotSupported = New("AI docs are only available for Goravel v1.17 and above") + AiDocsManifestFailed = New("no AI docs found for this version. Check https://github.com/goravel/docs") + AiDocsFacadeNotFound = New("no AI docs found for the specified facade(s): %s") + AuthEmptySecret = New("authentication secret is missing or required") AuthInvalidClaims = New("authentication token contains invalid claims") AuthInvalidKey = New("authentication key is invalid")