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/ai_docs_install_command_test.go b/ai/console/ai_docs_install_command_test.go new file mode 100644 index 000000000..a029f7977 --- /dev/null +++ b/ai/console/ai_docs_install_command_test.go @@ -0,0 +1,177 @@ +package console + +import ( + "errors" + "os" + "testing" + + "github.com/stretchr/testify/assert" + + mocksconsole "github.com/goravel/framework/mocks/console" +) + +func TestAiDocsInstallCommand(t *testing.T) { + var ( + mockContext *mocksconsole.Context + installCommand *AiDocsInstallCommand + ) + + beforeEach := func() { + mockContext = mocksconsole.NewContext(t) + installCommand = &AiDocsInstallCommand{ + versionDetector: func() (string, error) { return "v1.17", nil }, + } + } + + cleanup := func() { + assert.Nil(t, os.RemoveAll(".ai")) + assert.Nil(t, os.RemoveAll("AGENTS.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 - installs defaults when no facades given", + 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().OptionBool("force").Return(true).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 - installs all when --all flag set", + 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().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().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.versionDetector = func() (string, error) { return "v1.99", nil } + installCommand.manifestFetcher = func(branch string) ([]ManifestEntry, error) { + if branch == docsFallbackBranch { + return manifest, nil + } + return nil, nil + } + installCommand.fetcher = func(branch, path string) ([]byte, error) { + return []byte("# " + path), nil + } + + mockContext.EXPECT().OptionBool("force").Return(true).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 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().Error("No AI docs found for version v9.99. Check https://github.com/goravel/docs").Once() + }, + }, + { + name: "Sad path - manifest fetch error", + setup: func() { + installCommand.manifestFetcher = func(branch string) ([]ManifestEntry, error) { + return nil, errors.New("network error") + } + + mockContext.EXPECT().Error("network error").Once() + }, + }, + { + name: "Sad path - facade not found in manifest", + setup: func() { + installCommand.manifestFetcher = func(branch string) ([]ManifestEntry, error) { + return manifest, nil + } + + 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 AI docs found for facade(s): Nonexistent").Once() + }, + }, + { + name: "Sad path - unsupported version", + setup: func() { + 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() + }, + }, + { + name: "Sad path - existing install, user cancels", + setup: func() { + installCommand.manifestFetcher = func(branch string) ([]ManifestEntry, error) { + return manifest, nil + } + + assert.Nil(t, os.MkdirAll(".ai", 0755)) + assert.Nil(t, os.WriteFile(versionFilePath, []byte(`{"version":"v1.17","files":{}}`), 0644)) + + mockContext.EXPECT().OptionBool("force").Return(false).Once() + mockContext.EXPECT().Confirm("AI docs 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/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/ai_docs_update_command_test.go b/ai/console/ai_docs_update_command_test.go new file mode 100644 index 000000000..89e6f2d3e --- /dev/null +++ b/ai/console/ai_docs_update_command_test.go @@ -0,0 +1,232 @@ +package console + +import ( + "encoding/json" + "os" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/goravel/framework/errors" // Adjust import path as needed + mocksconsole "github.com/goravel/framework/mocks/console" +) + +func TestAiDocsUpdateCommandMissingVersionFile(t *testing.T) { + assert.Nil(t, os.RemoveAll(".ai")) + + mockContext := mocksconsole.NewContext(t) + mockContext.EXPECT().Error(errors.AiDocsNotInstalled.Error()).Once() + + 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 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().Error(errors.AiDocsManifestFailed.Error()).Once() + + 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 TestAiDocsUpdateCommandConflictDetection(t *testing.T) { + var ( + mockContext *mocksconsole.Context + cmd *AiDocsUpdateCommand + ) + + beforeEach := func() { + mockContext = mocksconsole.NewContext(t) + cmd = &AiDocsUpdateCommand{} + } + + cleanup := func() { + assert.Nil(t, os.RemoveAll(".ai")) + assert.Nil(t, os.RemoveAll("AGENTS.md")) + } + + originalContent := []byte("original content") + storedChecksum := sha256sum(originalContent) + + 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", + Files: map[string]string{"prompt/route.md": checksum}, + } + 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) { + return func(branch string) ([]ManifestEntry, error) { + return []ManifestEntry{routeEntry}, nil + } + } + + tests := []struct { + name string + setup func() + }{ + { + name: "Conflict - user modified and upstream changed, no force", + setup: func() { + setupLocalVersionFile(storedChecksum) + 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().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), 0 up to date.").Once() + }, + }, + { + name: "Conflict - user modified and upstream changed, force overwrites", + setup: func() { + setupLocalVersionFile(storedChecksum) + 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().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), 0 up to date.").Once() + }, + }, + { + name: "Upstream changed, user did not modify - download", + setup: func() { + setupLocalVersionFile(storedChecksum) + 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().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), 0 up to date.").Once() + }, + }, + { + name: "User modified, upstream unchanged - skip", + setup: func() { + setupLocalVersionFile(storedChecksum) + 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().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), 0 up to date.").Once() + }, + }, + { + name: "Already up to date", + setup: func() { + setupLocalVersionFile(storedChecksum) + 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().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), 1 up to date.").Once() + }, + }, + { + name: "New file in manifest with --all - download", + setup: func() { + setupLocalVersionFile(storedChecksum) + assert.Nil(t, 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) { + if path == "prompt/auth.md" { + return []byte("auth content"), nil + } + return originalContent, nil + } + + 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), 1 up to date.").Once() + }, + }, + { + name: "Specific facade via argument", + setup: func() { + setupLocalVersionFile(storedChecksum) + assert.Nil(t, 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().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), 0 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/console/helpers.go b/ai/console/helpers.go new file mode 100644 index 000000000..b66c3489c --- /dev/null +++ b/ai/console/helpers.go @@ -0,0 +1,249 @@ +package console + +import ( + "bufio" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" +) + +const ( + versionFilePath = ".ai/.version" + docsFallbackBranch = "master" +) + +// VersionFile is the local .ai/.version tracking file. +// It maps each relative path to its installed SHA256 content hash. +type VersionFile struct { + Version string `json:"version"` + Files map[string]string `json:"files"` +} + +// ManifestEntry describes a single AI doc file available upstream. +type ManifestEntry struct { + Facade string `json:"facade"` + Path string `json:"path"` + Default bool `json:"default"` +} + +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 + } + v := strings.TrimPrefix(version, "v") + parts := strings.SplitN(v, ".", 2) + if len(parts) < 2 { + return false + } + major, _ := strconv.Atoi(parts[0]) + minor, _ := strconv.Atoi(parts[1]) + return major > 1 || (major == 1 && minor >= 17) +} + +func resolveBranch(version string) string { + if version == "latest" { + return docsFallbackBranch + } + return version +} + +func fetchManifest(branch string) ([]ManifestEntry, error) { + data, err := fetchRaw(branch, "manifest.json") + if err != nil || data == nil { + // Fallback to master if specific branch manifest doesn't exist + if branch != docsFallbackBranch { + return fetchManifest(docsFallbackBranch) + } + return nil, err + } + var entries []ManifestEntry + if err := json.Unmarshal(data, &entries); err != nil { + return nil, fmt.Errorf("decode manifest: %w", err) + } + return entries, nil +} + +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: 30 * time.Second} + resp, err := client.Get(rawURL) + 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 downloadFiles(branch string, toInstall []ManifestEntry, fetcher func(string, string) ([]byte, error)) (map[string][]byte, error) { + type result struct { + path string + content []byte + err error + } + + 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) + } + + 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 downloaded, nil +} + +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 + } + + if err := os.MkdirAll(".ai/skills", 0755); err != nil { + return err + } + + for path, content := range downloaded { + if err := writeAgentFile(path, content); err != nil { + return err + } + local.Files[path] = sha256sum(content) + } + + return writeVersionFile(local) +} + +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) +} + +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) +} + +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 new file mode 100644 index 000000000..990be525b --- /dev/null +++ b/ai/console/helpers_test.go @@ -0,0 +1,110 @@ +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", + 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, + }, + } + + 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) + assert.Nil(t, 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}, + {"invalid", 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 + }{ + {"latest", "master"}, + {"master", "master"}, + {"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)) + }) + } +} diff --git a/ai/service_provider.go b/ai/service_provider.go index 18639583d..d234a38e0 100644 --- a/ai/service_provider.go +++ b/ai/service_provider.go @@ -47,6 +47,8 @@ func (r *ServiceProvider) Boot(app foundation.Application) { httpFacade = app.MakeHttp() app.Commands([]contractsconsole.Command{ + console.NewAiDocsInstallCommand(), + console.NewAiDocsUpdateCommand(), &console.AgentMakeCommand{}, }) } diff --git a/errors/list.go b/errors/list.go index 9be0e9fec..df52eddf0 100644 --- a/errors/list.go +++ b/errors/list.go @@ -21,6 +21,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") AuthGuardMismatch = New("authentication token guard mismatch: expected %s, got %s") AuthInvalidClaims = New("authentication token contains invalid claims")