Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions ai/console/ai_docs_install_command.go
Original file line number Diff line number Diff line change
@@ -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')"
}
Comment on lines +26 to +32

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
}
Comment on lines +62 to +65

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
Comment on lines +117 to +122
}

return defaultEntries(entries)
}
177 changes: 177 additions & 0 deletions ai/console/ai_docs_install_command_test.go
Original file line number Diff line number Diff line change
@@ -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"))
}
Comment thread
krishankumar01 marked this conversation as resolved.

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))
})
}
}
Loading
Loading