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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 2 additions & 0 deletions internal/cli/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
}
Expand Down
5 changes: 5 additions & 0 deletions internal/cli/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
`),
Expand Down Expand Up @@ -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"
Expand Down
34 changes: 34 additions & 0 deletions internal/cli/integration_init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<manifest />\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))
}
}
49 changes: 48 additions & 1 deletion internal/cli/integration_quickstart_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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("<manifest />\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))
}
}
2 changes: 1 addition & 1 deletion internal/cli/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
36 changes: 33 additions & 3 deletions internal/cli/quickstart.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type quickstartTemplate struct {
Description string
Runtime string
RepoURL string
Ref string

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we don't need this anymore. WE had update the android repo for default branch

// 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
Expand Down Expand Up @@ -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,
},
}
}

Expand Down Expand Up @@ -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})
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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{}
}
Expand Down
30 changes: 30 additions & 0 deletions internal/cli/quickstart_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading