diff --git a/README.md b/README.md index 6be434f..f92de3a 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,20 @@ Native Agora CLI for authentication, project management, quickstart setup, and developer onboarding. Use it to go from an Agora account to a runnable app with one command. +```bash +agora login +agora init my-nextjs-demo --template nextjs +``` + +## What You Can Build Quickly + +| Goal | Command | What You Get | +|------|---------|--------------| +| Next.js video app | `agora init my-nextjs-demo --template nextjs` | A cloned Next.js quickstart, project binding, and `.env.local` | +| Python voice agent | `agora init my-python-demo --template python` | A Python quickstart with Agora credentials written for the backend | +| Go token service | `agora init my-go-demo --template go` | A Go server quickstart with project metadata and env wiring | +| Android conversational AI app | `agora init my-android-demo --template android` | An Android quickstart with project metadata and credentials in `local.properties` | + ## Install ### Requirements diff --git a/docs/llms.txt b/docs/llms.txt index fd44624..c1128f0 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -40,7 +40,7 @@ Check project health: agora project doctor --json - **Project Management**: Initialize, configure, and validate Agora projects - **JSON Output**: All commands support --json for automation and scripting (see Automation Notes below for one documented exception) - **Stable Exit Codes**: Consistent error codes for CI/CD integration -- **Template System**: Quick-start templates for Next.js, Python, and Go (see `agora init --help` for the current catalog). Quickstart clones drop upstream `.git` metadata so scaffolds start as clean local repos. +- **Template System**: Quick-start templates for Next.js, Python, Go, and Android (see `agora init --help` for the current catalog). Quickstart clones drop upstream `.git` metadata so scaffolds start as clean local repos. - **Cross-Platform**: macOS, Linux, Windows support - **Agentic discovery**: `agora introspect --json` and `agora --help --all --json` emit the same machine-readable command tree - **MCP server**: `agora mcp serve` exposes the CLI as Model Context Protocol tools for agents diff --git a/go.mod b/go.mod index 285a358..e024fdc 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/AgoraIO/cli -go 1.26.4 +go 1.26.5 require ( github.com/spf13/cobra v1.10.2 diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 61a3783..ef11c67 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -52,6 +52,8 @@ func quickstartAppIDKey(templateID string) string { return "NEXT_PUBLIC_AGORA_APP_ID" case "python", "go": return "APP_ID" + case "android": + return "AGORA_APP_ID" default: return "" } diff --git a/internal/cli/init.go b/internal/cli/init.go index de1b875..8c4b014 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -57,6 +57,7 @@ Use --feature to specify which features to enable on a newly created project (re agora init my-nextjs-demo --template nextjs agora init my-python-demo --template python agora init my-go-demo --template go --project my-existing-project + agora init my-android-demo --template android agora init my-rtm-demo --template nextjs --new-project --rtm-data-center AP agora init my-rtm-demo --template nextjs --new-project --feature rtc --feature rtm `), @@ -328,6 +329,10 @@ func (a *App) resolveInitProject(ctx projectContext, item projectSummary) (proje } func (a *App) initProject(name, targetDir string, template quickstartTemplate, existingProject string, features []string, rtmDataCenter string, newProject bool, promptForReuse bool, promptOut io.Writer, promptIn io.Reader, progress progressEmitter) (map[string]any, error) { + if !template.Available || !template.SupportsInit { + return nil, &cliError{Message: fmt.Sprintf("Quickstart template %q is not supported by `agora init`. Use `agora quickstart create` instead.", template.ID), Code: "QUICKSTART_TEMPLATE_UNAVAILABLE"} + } + var target projectTarget projectAction := "existing" projectSelectionReason := "explicit_project" diff --git a/internal/cli/integration_init_test.go b/internal/cli/integration_init_test.go index e47db9e..91ed2bc 100644 --- a/internal/cli/integration_init_test.go +++ b/internal/cli/integration_init_test.go @@ -80,3 +80,37 @@ func TestCLIInitRequiresTemplateWhenNoInputIsSet(t *testing.T) { t.Fatalf("expected QUICKSTART_TEMPLATE_REQUIRED, got %+v", result) } } + +func TestCLIInitCreatesAndroidQuickstart(t *testing.T) { + configHome := t.TempDir() + rootDir := t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + persistSessionForIntegration(t, configHome) + androidRepo := createLocalGitRepo(t, map[string]string{ + "settings.gradle.kts": "rootProject.name = \"android-quickstart\"\n", + "gradlew": "#!/bin/sh\n", + "app/src/main/AndroidManifest.xml": "\n", + }) + targetDir := filepath.Join(rootDir, "android-demo") + + result := runCLI(t, []string{"init", "android-demo", "--template", "android", "--new-project", "--dir", targetDir, "--json"}, cliRunOptions{ + env: map[string]string{ + "XDG_CONFIG_HOME": configHome, + "AGORA_API_BASE_URL": api.baseURL, + "AGORA_LOG_LEVEL": "error", + "AGORA_QUICKSTART_ANDROID_REPO_URL": androidRepo, + }, + workdir: rootDir, + }) + if result.exitCode != 0 || !strings.Contains(result.stdout, `"template":"android"`) || !strings.Contains(result.stdout, `"envPath":"local.properties"`) { + t.Fatalf("unexpected android init result: %+v", result) + } + localProperties, err := os.ReadFile(filepath.Join(targetDir, "local.properties")) + if err != nil { + t.Fatalf("expected Android local.properties: %v", err) + } + if !strings.Contains(string(localProperties), "AGORA_APP_ID=app_0001") || !strings.Contains(string(localProperties), "AGORA_APP_CERTIFICATE=4854d28b48a9439c9f2546e2216fc07a") { + t.Fatalf("unexpected Android local.properties: %s", string(localProperties)) + } +} diff --git a/internal/cli/integration_quickstart_test.go b/internal/cli/integration_quickstart_test.go index 936b46e..63f83aa 100644 --- a/internal/cli/integration_quickstart_test.go +++ b/internal/cli/integration_quickstart_test.go @@ -52,11 +52,14 @@ func TestCLIQuickstartListAndCreate(t *testing.T) { if list.exitCode != 0 || !strings.Contains(list.stdout, `"id":"nextjs"`) || !strings.Contains(list.stdout, `"id":"python"`) || !strings.Contains(list.stdout, `"id":"go"`) { t.Fatalf("unexpected quickstart list result: %+v", list) } + if !strings.Contains(list.stdout, `"id":"android"`) { + t.Fatalf("expected android quickstart in list result: %+v", list) + } listAll := runCLI(t, []string{"quickstart", "list", "--show-all", "--json"}, cliRunOptions{env: map[string]string{ "XDG_CONFIG_HOME": configHome, "AGORA_LOG_LEVEL": "error", }}) - if listAll.exitCode != 0 || !strings.Contains(listAll.stdout, `"id":"go"`) { + if listAll.exitCode != 0 || !strings.Contains(listAll.stdout, `"id":"go"`) || !strings.Contains(listAll.stdout, `"id":"android"`) { t.Fatalf("unexpected quickstart list --show-all result: %+v", listAll) } @@ -310,3 +313,47 @@ func TestCLIQuickstartEnvWriteMissingBindingEvenWhenEnvExists(t *testing.T) { t.Fatalf("unexpected missing binding result: %+v", result) } } + +func TestCLIAndroidQuickstartEnvWrite(t *testing.T) { + configHome := t.TempDir() + rootDir := t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + project := buildFakeProject("Android Project", "prj_android", "app_android", "global") + project.FeatureState.RTMEnabled = true + project.FeatureState.ConvoAIEnabled = true + api.projects[project.ProjectID] = &project + persistSessionForIntegration(t, configHome) + + if err := os.WriteFile(filepath.Join(rootDir, "settings.gradle.kts"), []byte("rootProject.name = \"android-quickstart\"\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(rootDir, "gradlew"), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(rootDir, "app", "src", "main"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(rootDir, "app", "src", "main", "AndroidManifest.xml"), []byte("\n"), 0o644); err != nil { + t.Fatal(err) + } + + result := runCLI(t, []string{"quickstart", "env", "write", rootDir, "--template", "android", "--project", project.ProjectID, "--json"}, cliRunOptions{ + env: map[string]string{ + "XDG_CONFIG_HOME": configHome, + "AGORA_API_BASE_URL": api.baseURL, + "AGORA_LOG_LEVEL": "error", + }, + workdir: t.TempDir(), + }) + if result.exitCode != 0 || !strings.Contains(result.stdout, `"template":"android"`) || !strings.Contains(result.stdout, `"envPath":"local.properties"`) { + t.Fatalf("unexpected Android env write result: %+v", result) + } + localProperties, err := os.ReadFile(filepath.Join(rootDir, "local.properties")) + if err != nil { + t.Fatalf("expected Android local.properties: %v", err) + } + if !strings.Contains(string(localProperties), "AGORA_APP_ID=app_android") || !strings.Contains(string(localProperties), "AGORA_APP_CERTIFICATE=4854d28b48a9439c9f2546e2216fc07a") { + t.Fatalf("unexpected Android local.properties: %s", string(localProperties)) + } +} diff --git a/internal/cli/integration_test.go b/internal/cli/integration_test.go index ab20056..2479d50 100644 --- a/internal/cli/integration_test.go +++ b/internal/cli/integration_test.go @@ -324,7 +324,7 @@ func createLocalGitRepo(t *testing.T, files map[string]string) string { t.Fatal(err) } } - init := exec.Command("git", "init") + init := exec.Command("git", "init", "--initial-branch=main") init.Dir = repoDir if output, err := init.CombinedOutput(); err != nil { t.Fatalf("git init failed: %v output=%s", err, string(output)) diff --git a/internal/cli/quickstart.go b/internal/cli/quickstart.go index 9c193b3..4069e7b 100644 --- a/internal/cli/quickstart.go +++ b/internal/cli/quickstart.go @@ -18,6 +18,7 @@ type quickstartTemplate struct { Description string Runtime string RepoURL string + Ref string // RepoURLCN / DocsURLCN are the cn-region variants. They currently // mirror the global URLs because the conversational-AI quickstarts // have no China-hosted mirror yet; set them to the cn URL when one @@ -92,6 +93,24 @@ func quickstartTemplates() []quickstartTemplate { SupportsInit: true, Available: true, }, + { + ID: "android", + Title: "Conversational AI Android Quickstart", + Description: "Clone the official Android conversational AI quickstart.", + Runtime: "android", + RepoURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-android", + Ref: "main", + RepoURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-android", + DocsURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-android", + DocsURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-android", + DetectPaths: []string{"settings.gradle.kts", "gradlew", "app/src/main/AndroidManifest.xml"}, + EnvTargetPath: "local.properties", + InstallCommand: "./gradlew :app:assembleDebug", + RunCommand: "Open in Android Studio or run ./gradlew :app:installDebug", + EnvDocsSummary: "Writes AGORA_APP_ID and AGORA_APP_CERTIFICATE to local.properties.", + SupportsInit: true, + Available: true, + }, } } @@ -305,11 +324,15 @@ func (a *App) quickstartCreate(template quickstartTemplate, targetDir, explicitP if err != nil { return nil, err } + effectiveRef := strings.TrimSpace(ref) + if effectiveRef == "" { + effectiveRef = strings.TrimSpace(template.Ref) + } if overrideKey != "" { progress.emit("clone:override", fmt.Sprintf("Using repo override from %s", overrideKey), map[string]any{"repoUrl": repoURL, "envVar": overrideKey}) } - progress.emit("clone:start", "Cloning quickstart repository", map[string]any{"repoUrl": repoURL, "targetPath": absTarget, "ref": ref}) - if err := cloneQuickstartRepo(repoURL, absTarget, ref); err != nil { + progress.emit("clone:start", "Cloning quickstart repository", map[string]any{"repoUrl": repoURL, "targetPath": absTarget, "ref": effectiveRef}) + if err := cloneQuickstartRepo(repoURL, absTarget, effectiveRef); err != nil { return nil, err } progress.emit("clone:complete", "Quickstart repository cloned", map[string]any{"targetPath": absTarget}) @@ -366,7 +389,7 @@ func (a *App) quickstartCreate(template quickstartTemplate, targetDir, explicitP "title": template.Title, "written": written, "nextSteps": initNextSteps(template, absTarget), - "ref": ref, + "ref": effectiveRef, } if boundProject != nil { result["projectId"] = boundProject.project.ProjectID @@ -663,6 +686,8 @@ func conflictingQuickstartEnvKeys(templateID string) []string { return []string{"AGORA_APP_ID", "AGORA_APP_CERTIFICATE", "APP_ID", "APP_CERTIFICATE"} case "python", "go": return []string{"AGORA_APP_ID", "AGORA_APP_CERTIFICATE", "NEXT_PUBLIC_AGORA_APP_ID", "NEXT_AGORA_APP_CERTIFICATE"} + case "android": + return []string{"APP_ID", "APP_CERTIFICATE", "NEXT_PUBLIC_AGORA_APP_ID", "NEXT_AGORA_APP_CERTIFICATE"} default: return nil } @@ -680,6 +705,11 @@ func renderQuickstartEnvValues(template quickstartTemplate, project projectDetai "APP_ID": project.AppID, "APP_CERTIFICATE": *project.SignKey, } + case "android": + return map[string]any{ + "AGORA_APP_ID": project.AppID, + "AGORA_APP_CERTIFICATE": *project.SignKey, + } default: return map[string]any{} } diff --git a/internal/cli/quickstart_test.go b/internal/cli/quickstart_test.go index 80f41de..f7eb894 100644 --- a/internal/cli/quickstart_test.go +++ b/internal/cli/quickstart_test.go @@ -186,6 +186,36 @@ func TestQuickstartRepoURLOverride(t *testing.T) { } } +func TestQuickstartTemplatesIncludeAndroid(t *testing.T) { + var android quickstartTemplate + found := false + for _, tmpl := range quickstartTemplates() { + if tmpl.ID == "android" { + android = tmpl + found = true + break + } + } + if !found { + t.Fatal("expected android quickstart template to exist") + } + if android.RepoURL != "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-android" { + t.Fatalf("unexpected android repo url: %q", android.RepoURL) + } + if android.Ref != "main" { + t.Fatalf("unexpected android default ref: %q", android.Ref) + } + if android.EnvTargetPath != "local.properties" { + t.Fatalf("unexpected android env target: %q", android.EnvTargetPath) + } + if !android.Available || !android.SupportsInit { + t.Fatalf("unexpected android flags: available=%v supportsInit=%v", android.Available, android.SupportsInit) + } + if quickstartAppIDKey("android") != "AGORA_APP_ID" { + t.Fatalf("unexpected android doctor app ID key: %q", quickstartAppIDKey("android")) + } +} + func TestQuickstartRepoURLForRegion(t *testing.T) { tmpl := quickstartTemplate{ RepoURL: "https://global.example/repo",