diff --git a/CHANGELOG.md b/CHANGELOG.md index 358656a..cef0fb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,14 @@ Earlier entries pre-date this convention and only carry their version's compare ## [Unreleased] +## [0.2.10] - 2026-09-20 + +RTC video-call onboarding and package-manager compatibility. + +### Added + +- Add RTC video-call onboarding to `agora init` and `agora quickstart`, with scenario selection, RTC-only project defaults, workspace readiness checks, and native npm setup commands when the required pnpm version is unavailable ([#69](https://github.com/AgoraIO/cli/pull/69)). + ## [0.2.9] - 2026-08-24 Quickstart project and environment consistency, Android onboarding, Windows installer compatibility, and release workflow maintenance. @@ -385,7 +393,8 @@ Set `AGORA_ALLOW_UPGRADE_IN_CI=1` only when a CI job intentionally needs to muta - Support machine-readable JSON output for automation and agent workflows. - Ship automated release packaging through GoReleaser, including cross-platform archives, Linux packages, Homebrew, Scoop, npm wrapper packages, Docker images, and install scripts. -[Unreleased]: https://github.com/AgoraIO/cli/compare/v0.2.9...HEAD +[Unreleased]: https://github.com/AgoraIO/cli/compare/v0.2.10...HEAD +[0.2.10]: https://github.com/AgoraIO/cli/compare/v0.2.9...v0.2.10 [0.2.9]: https://github.com/AgoraIO/cli/compare/v0.2.8...v0.2.9 [0.2.8]: https://github.com/AgoraIO/cli/compare/v0.2.7...v0.2.8 [0.2.7]: https://github.com/AgoraIO/cli/compare/v0.2.6...v0.2.7 diff --git a/README.md b/README.md index 922f656..e88785d 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,8 @@ Command examples use `agora` for the installed CLI. Local source builds use `./a | Goal | Command | What You Get | |------|---------|--------------| -| Next.js video app | `agora init my-nextjs-demo --template nextjs` | A cloned Next.js quickstart, `.agora` binding, and `.env.local` | +| Next.js voice agent | `agora init my-nextjs-demo --template nextjs` | A cloned conversational AI quickstart, `.agora` binding, and `.env.local` | +| RTC video call | `agora init my-video-demo --template nextjs --scenario video-call` | An RTC-only Next.js quickstart, project binding, and `.env.local` | | Python voice agent | `agora init my-python-demo --template python` | A Python quickstart with `server/.env` credentials | | Go voice agent | `agora init my-go-demo --template go` | A Go quickstart with `server/.env` credentials | | Android voice AI app | `agora init my-android-demo --template android` | An Android client with credentials written only to the included Python server | @@ -274,6 +275,10 @@ Quickstart template behavior: - Python quickstarts copy `server/.env.example` to `server/.env`, then use `AGORA_APP_ID` plus `AGORA_APP_CERTIFICATE` - Go quickstarts copy `server/.env.example` to `server/.env`, then use `AGORA_APP_ID` plus `AGORA_APP_CERTIFICATE` - Existing Python and Go quickstarts keep their recorded env path when reconfigured, while legacy `APP_ID` / `APP_CERTIFICATE` assignments are commented out and replaced with `AGORA_APP_ID` / `AGORA_APP_CERTIFICATE`. +- The RTC Next.js quickstart reads its pinned pnpm version after clone. Matching + pnpm produces reproducible setup steps; otherwise the CLI uses native npm + without creating `package-lock.json`. The CLI never installs dependencies or + global tools. `project env write` auto-detects Next.js workspaces (or accepts `--template nextjs|standard`) and writes `AGORA_APP_ID` / `AGORA_APP_CERTIFICATE` or the Next.js equivalents. @@ -306,6 +311,7 @@ It stores durable non-secret metadata: - `projectName` - `region` - `template` +- `scenario` (when the binding identifies a quickstart scenario) - `projectType` (framework hint used for env layout when present) - `envPath` @@ -359,7 +365,7 @@ Example: ```bash export AGORA_HOME="$(mktemp -d)" -agora init my-nextjs-demo --template nextjs --json +agora init my-nextjs-demo --template nextjs --scenario video-call --json agora quickstart create my-python-demo --template python --project my-project --json agora quickstart env write my-python-demo --json agora project doctor --json diff --git a/docs/automation.md b/docs/automation.md index 59aca96..f9a99e2 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -345,11 +345,18 @@ Example: ./agora init my-nextjs-demo --template nextjs --json ./agora init my-nextjs-demo --template nextjs --new-project --json ./agora init my-agent --recipe tool-calling --new-project --json +./agora init my-video-demo --template nextjs --scenario video-call --new-project --json ``` By default `init` reuses an existing project — preferring one named exactly `"Default Project"`. If no default exists, interactive sessions show existing projects with a create-new option and default to the most recently created project; JSON, CI, and non-TTY runs select the most recent project automatically. Pass `--new-project` to force creation. Use `--project ` to bind to a specific project. For deterministic automation, always pass `--project ` or `--new-project`. +For newly created projects, `init` uses explicit `--feature` values when supplied, replacing the scaffold defaults. Without explicit features, `nextjs + video-call` defaults to `rtc`; existing voice-agent quickstarts and recipes default to `rtc`, `rtm`, and `convoai`. Explicit `convoai` also enables its `rtm` dependency. Unlike `project create --template `, init does not merge a project preset into the explicit feature list. + +When reusing an existing project (explicitly or automatically), `init` does not enable features or require the scaffold's feature list to be enabled before cloning. `--feature` only controls new project creation. Use `project feature enable` to enable features on an existing project, and `project doctor --feature ` to check runtime readiness. Invalid inputs, credential requirements, and scaffold identity checks still apply. + +Migration note for RTC onboarding previews: `init` no longer returns `QUICKSTART_REQUIRED_FEATURE_MISSING` for reused projects. Explicit quickstart features now replace scenario creation defaults rather than adding to them. The existing `project create` preset-merging behavior is unchanged. + Required `data` fields: - `action` Always `init`. @@ -375,20 +382,28 @@ Required `data` fields: - `metadataPath` Repo-local project binding file path, currently `.agora/project.json`. - `enabledFeatures` - Array of features enabled during this run. Defaults to `rtc`, `rtm`, and `convoai` for newly created projects unless overridden with `--feature`. Empty for existing projects since the CLI did not create them in this run. + Array of features enabled during this run, using the creation rules above. For example, voice-agent with explicit `--feature rtc` returns only `rtc`, while video-call without explicit features defaults to `rtc`. Empty for reused projects; this field does not enumerate all features already available on the project. - `nextSteps` - Ordered list of suggested follow-up commands for the selected source. + Ordered list of suggested follow-up commands for the selected source. For the RTC Next.js quickstart, these use matching pnpm or a native npm fallback detected after clone. - `status` - Currently `ready`. + Currently `ready`, meaning the scaffold and configuration are prepared; it does not certify that all runtime features are enabled. Optional fields: - `template` Present for built-in quickstart initialization. +- `scenario`, `requiredFeatures` + Present for built-in quickstart initialization; describe the selected scenario and its runtime requirements. `requiredFeatures` is not an initialization gate or a list of features enabled during this run. - `recipe`, `recipeUrl`, `recipeRawUrl`, `primaryPrompt`, `cloneUrl` Present for recipe-backed initialization. The CLI resolves this metadata from the official recipes API before it selects or creates a project. - `rtmDataCenter` RTM data center configured on the new project when RTM was enabled. Defaults to `NA` when `--rtm-data-center` is omitted. +- `packageManager` + Present when the selected quickstart exposes a supported pinned package manager. + Existing fields are `name`, `requiredVersion`, optional `detectedVersion`, + `strategy` (`native`, `npm`, or `unavailable`), `ready`, and optional + `message`. `selectedName` and `selectedVersion` identify the command runner + used by the resolved steps. Display-oriented fields: - `title` @@ -396,6 +411,14 @@ Display-oriented fields: Safe branch fields: - `sourceType` - `sourceId` +- `template` +- `scenario` +- `requiredFeatures` +- `packageManager.name` +- `packageManager.requiredVersion` +- `packageManager.strategy` +- `packageManager.ready` +- `packageManager.selectedName` - `projectAction` - `projectId` - `path` @@ -414,6 +437,7 @@ Example: ./agora project create my-agent-demo --json ./agora project create my-agent-demo --rtm-data-center EU --json ./agora project create my-agent-demo --feature rtc --feature convoai --json +./agora project create my-video-demo --template video-call --json ``` Required `data` fields (success): @@ -424,7 +448,9 @@ Required `data` fields (success): - `appId` - `region` - `enabledFeatures` - Array of features that were enabled on the new project. Defaults to `["rtc", "rtm", "convoai"]` when no `--feature` flags are passed. Explicit `convoai` requests also include `rtm`. + Array of features that were enabled on the new project. With `--template video-call`, starts with `["rtc"]`; with `--template voice-agent`, starts with `["rtc", "rtm", "convoai"]`. Explicit `--feature` values are added to the preset's required features. Without a preset, explicit features are used, or `["rtc", "rtm", "convoai"]` when none are specified. Requests containing `convoai` also include `rtm`. +- `template` + Project preset applied (`video-call` or `voice-agent`), or an empty string when not requested. Optional fields: - `rtmDataCenter` @@ -653,6 +679,9 @@ Required `data` fields: Each item currently includes: - `id` +- `template` +- `scenario` +- `requiredFeatures` - `title` - `description` - `runtime` @@ -661,9 +690,14 @@ Each item currently includes: - `available` - `envDocs` - `supportsInit` +- `installCommand` +- `runCommand` Safe branch fields: - `items[].id` +- `items[].template` +- `items[].scenario` +- `items[].requiredFeatures` - `items[].runtime` - `items[].repoUrl` - `items[].available` @@ -680,6 +714,15 @@ Display-oriented fields: Automation notes: - `--ref ` pins the cloned quickstart source for workshops and reproducible demos. - `--template-only` explicitly skips project lookup and env-file creation. Without a resolved project or this flag, non-interactive runs fail with `QUICKSTART_PROJECT_REQUIRED` before cloning. +- `--scenario ` selects an exact scenario; when omitted, the template's default scenario is used. +- `nextjs + video-call` requires only `rtc` and honors `AGORA_QUICKSTART_NEXTJS_VIDEO_CALL_REPO_URL` for local mirrors and fixtures. +- Non-default scenarios such as `nextjs + video-call` must provide `agora.quickstart.json` with matching `template` and `scenario`. The CLI validates it after clone and removes the target before writing env or binding data when validation fails. Existing default-scenario quickstarts remain compatible without a manifest. +- After cloning `nextjs + video-call`, the CLI reads `package.json#packageManager`. + An exact pnpm match produces `pnpm install --frozen-lockfile` and `pnpm dev`. + Missing or mismatched pnpm produces `npm install --package-lock=false` and + `npm run dev` when npm is available. If neither is available, + `packageManager.ready` is false and no unusable + install or run command is included in `nextSteps`. Example: @@ -691,6 +734,8 @@ Required `data` fields: - `action` Always `create`. - `template` +- `scenario` +- `requiredFeatures` - `title` - `runtime` - `cloneUrl` @@ -711,9 +756,22 @@ Required `data` fields: Optional fields: - `projectId` - `projectName` +- `packageManager` + Present for RTC Next.js when `package.json#packageManager` is a strict + `pnpm@..` value. Its fields are `name`, + `requiredVersion`, optional `detectedVersion`, `strategy`, `ready`, and an + optional diagnostic `message`. `selectedName` and `selectedVersion` identify + the package manager used by the resolved steps. Safe branch fields: - `template` +- `scenario` +- `requiredFeatures` +- `packageManager.name` +- `packageManager.requiredVersion` +- `packageManager.strategy` +- `packageManager.ready` +- `packageManager.selectedName` - `path` - `envStatus` - `envPath` @@ -737,6 +795,8 @@ Required `data` fields: - `action` Always `env-write`. - `template` +- `scenario` +- `requiredFeatures` - `title` - `path` Absolute path to the quickstart root. @@ -750,6 +810,9 @@ Required `data` fields: Currently `created`, `updated`, or `appended`. Env write behavior: +- for an existing workspace, omitted template/scenario fields are inherited from `.agora/project.json` and `agora.quickstart.json`; only fields absent from both sources fall back to detection or the template default +- `--template nextjs` alone does not change a recorded `video-call` scenario to `voice-agent`; legacy bindings without a scenario can inherit it from the manifest +- conflicting nonempty template/scenario declarations fail with `QUICKSTART_SELECTION_MISMATCH` before changing env or binding files - quickstart env files contain only the App ID and App Certificate variable names required by the template - Next.js uses `NEXT_PUBLIC_AGORA_APP_ID` and `NEXT_AGORA_APP_CERTIFICATE` - Python and Go use `AGORA_APP_ID` and `AGORA_APP_CERTIFICATE` @@ -760,6 +823,8 @@ Env write behavior: Safe branch fields: - `template` +- `scenario` +- `requiredFeatures` - `path` - `envPath` - `projectId` @@ -804,6 +869,12 @@ Safe branch fields: - `blockingIssues` - `warnings` +Deep workspace checks: +- Recipe bindings are not validated against built-in quickstart layouts. They return a `WORKSPACE_TEMPLATE_UNKNOWN` warning explaining that recipe runtime/env checks are not covered. Generic project and binding identity checks still run. +- An unrecognized directory without a declared quickstart also produces a warning, not a template blocking issue. +- With no other issues, these cases return `healthy: true`, `status: "warning"`, `ok: false`, and exit code `2`. This means no blocking issue was found, not that the workspace was fully verified. +- Invalid declared quickstarts, required manifests, selection conflicts, and project/credential mismatches remain blocking. A workspace warning does not remove an existing blocking issue. + Recommended agent behavior: - branch first on `status` - use `healthy` as a fast readiness boolean diff --git a/docs/commands.md b/docs/commands.md index 90d0cbc..b709ea0 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -108,11 +108,12 @@ Create a project and initialize an official quickstart or recipe |------|------|---------|-------------| | `--add-agent-rules` | `stringArray` | `[]` | write AI agent rules into the quickstart (repeatable: cursor, claude, windsurf) | | `--dir` | `string` | — | target directory for the cloned quickstart; defaults to | -| `--feature` | `stringArray` | `[]` | enable a feature on the newly created project (repeatable); defaults to rtc, rtm, convoai; convoai also enables rtm | +| `--feature` | `stringArray` | `[]` | select features for new projects (repeatable); explicit values override scenario defaults; omitted uses scenario defaults; ignored when reusing a project; convoai also enables rtm | | `--new-project` | `bool` | — | always create a new Agora project instead of reusing an existing one | | `--project` | `string` | — | existing project ID or exact project name to bind to | | `--recipe` | `string` | — | official Agora recipe slug (run agora recipes list to discover slugs) | | `--rtm-data-center` | `string` | — | RTM data center to configure when rtm is enabled on a newly created project (CN, NA, EU, or AP); defaults to NA | +| `--scenario` | `string` | — | quickstart scenario; omitted selects the template default | | `--template` | `string` | — | quickstart template ID to use | ### `agora introspect` @@ -174,7 +175,7 @@ Create a new remote Agora project | `--feature` | `stringArray` | `[]` | enable one or more features after creation; defaults to rtc, rtm, convoai; convoai also enables rtm | | `--idempotency-key` | `string` | — | caller-provided key for safe retries when supported by the API | | `--rtm-data-center` | `string` | — | RTM data center to configure when rtm is enabled (CN, NA, EU, or AP); defaults to NA | -| `--template` | `string` | — | apply a higher-level project preset such as voice-agent | +| `--template` | `string` | — | apply a project scenario preset: video-call, voice-agent | ### `agora project doctor` @@ -333,6 +334,7 @@ Clone an official Agora quickstart into a new directory | `--dir` | `string` | — | target directory for the cloned quickstart; defaults to | | `--project` | `string` | — | project ID or exact project name to use for env seeding | | `--ref` | `string` | — | git branch, tag, or ref to clone for pinned workshops | +| `--scenario` | `string` | — | quickstart scenario; omitted selects the template default | | `--template` | `string` | — | quickstart template ID from `agora quickstart list` | | `--template-only` | `bool` | — | clone without resolving a project or writing credentials | @@ -349,6 +351,7 @@ Write the quickstart env file for the current or selected project | Flag | Type | Default | Description | |------|------|---------|-------------| | `--project` | `string` | — | project ID or exact project name to use for env seeding | +| `--scenario` | `string` | — | quickstart scenario; if omitted, use binding, manifest, or the template default | | `--template` | `string` | — | quickstart template ID; if omitted, the CLI detects it from the repo layout | ### `agora quickstart list` @@ -461,4 +464,10 @@ Show the current auth status **`doctorStatus`**: `healthy`, `warning`, `not_ready`, `auth_error` +**`projectTemplates`**: `video-call`, `voice-agent` + +**`quickstartScenarios`**: `voice-agent`, `video-call` + +**`quickstartTemplates`**: `nextjs`, `python`, `go`, `android` + **`recipeTypes`**: `all`, `ai`, `rtc` diff --git a/docs/error-codes.md b/docs/error-codes.md index 3b31465..dc3b70f 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -30,12 +30,19 @@ This catalog is the source of truth for stable codes. CI runs `make snapshot-err | `PROJECT_NO_CERTIFICATE` | 1 | The selected project has no app certificate for env seeding. | Enable an app certificate in Console or select another project. | | `PROJECT_ENV_TEMPLATE_UNKNOWN` | 1 | The `--template` value for `project env write` is not supported. | Use `nextjs` or `standard`. | | `PROJECT_NOT_READY` | 1 | `project doctor` could not surface a more specific issue. | Re-run `project doctor` for details. | +| `PROJECT_TEMPLATE_UNKNOWN` | 1 | `project create --template` is not a known project scenario preset. | Use `video-call` or `voice-agent`; inspect `agora introspect --json` for the current catalog. | ### Quickstart / init | Code | Exit | Meaning | Recovery | |------|------|---------|----------| | `QUICKSTART_TEMPLATE_UNKNOWN` | 1 | The template ID is not known to this CLI. | Run `agora quickstart list`. | +| `QUICKSTART_TEMPLATE_REQUIRED` | 1 | `quickstart env write` received `--scenario` without `--template`, and the target directory has no project binding or quickstart manifest to identify the template. | Pass `--template ` or run from a quickstart directory with `.agora/project.json` or `agora.quickstart.json`. | +| `QUICKSTART_SCENARIO_UNKNOWN` | 1 | The scenario is not known to this CLI. | Run `agora quickstart list` and use an `items[].scenario` value. | +| `QUICKSTART_SCENARIO_UNSUPPORTED` | 1 | The scenario exists but is not supported by the selected template. | Choose a template/scenario pair returned by `agora quickstart list`. | +| `QUICKSTART_SELECTION_MISMATCH` | 1 | Nonempty declarations in explicit flags, `.agora/project.json`, and `agora.quickstart.json` disagree about template/scenario identity; omitted fields inherit the existing selection. | Correct the conflicting selection; do not overwrite env until all sources agree. | +| `QUICKSTART_MANIFEST_INVALID` | 1 | `agora.quickstart.json` is malformed, uses an unsupported schema, has missing identity fields, or is required but absent. | Restore a schema v1 manifest with `template` and `scenario` matching the quickstart. | +| `QUICKSTART_REQUIRED_FEATURE_MISSING` | — (retired) | Previously used by RTC onboarding previews; no longer returned by `init` when reusing a project. | Initialization does not require feature enablement. Use `agora project doctor --feature ` to check runtime readiness and `project feature enable` when needed. | | `QUICKSTART_TEMPLATE_UNAVAILABLE` | 1 | The template exists but is not currently available. | Choose an available template. | | `QUICKSTART_TEMPLATE_ENV_UNSUPPORTED` | 1 | The selected template does not define an env target path. | Choose a template with env support or configure the env file manually. | | `QUICKSTART_PROJECT_REQUIRED` | 1 | `quickstart create` could not resolve a project in a non-interactive run, or the account has no projects to select interactively. | Pass `--project`, set global context with `agora project use`, use `agora init`, or explicitly pass `--template-only`. | @@ -80,12 +87,11 @@ These codes appear inside `data.checks[].issues[].code` and (for blocking issues | `WORKSPACE_SCAN_FAILED` | 1 | `project doctor --deep` could not enumerate the repo workspace. | Inspect the directory permissions and retry. | | `LOCAL_PROJECT_BINDING_INVALID` | 1 | `.agora/project.json` exists but is missing `projectId`. | Re-bind the repo: `agora project use ` or `agora init` from the repo root. | | `LOCAL_PROJECT_BINDING_MISMATCH` | 1 | `.agora/project.json` points at a project that does not match the selected project. | Use `--project` to select the bound project, or rebind. | -| `WORKSPACE_TEMPLATE_UNKNOWN` | 1 | The CLI could not detect the quickstart template for this repo. | Pass `--template` to the failing command. | +| `WORKSPACE_TEMPLATE_UNKNOWN` | 2 (warning only) | Built-in quickstart layout checks do not cover this recipe or unrecognized workspace. | Inspect the recipe/workspace setup manually. `project doctor` has no `--template` flag. This warning does not override other blocking issues. | | `WORKSPACE_ENV_PATH_UNKNOWN` | 1 | The CLI could not determine the quickstart env target path. | Pass `--template` and re-run; if persistent, file an issue. | | `WORKSPACE_ENV_FILE_MISSING` | 1 | A quickstart env file expected by the bound template is missing. | Run the command from `suggestedCommand` (typically `agora quickstart env write`). | | `WORKSPACE_ENV_READ_FAILED` | 1 | The CLI could not read the quickstart env file. | Run the command from `suggestedCommand` (`agora quickstart env write . --project `); if it still fails, inspect file permissions and contents. | | `WORKSPACE_ENV_PROJECT_MISMATCH` | 1 | The quickstart env file points at a different App ID than the selected project. | Run the command from `suggestedCommand` to overwrite the env file. | -| `WORKSPACE_ENV_METADATA_MISSING` | 1 | The quickstart env file is missing Agora-managed project metadata comments. | Run the command from `suggestedCommand` to refresh metadata. | | `WORKSPACE_ENV_APP_ID_MISSING` | 1 | A quickstart env file is missing the required app ID key. | Run the command from `suggestedCommand`. | | `WORKSPACE_ENV_APP_ID_MISMATCH` | 1 | A quickstart env file points at a different app ID. | Run the command from `suggestedCommand`. | | `APP_CREDENTIALS_MISSING` | 1 | The selected project has no app ID / app certificate yet. | Run the command from `suggestedCommand` (`agora project show --project `) to re-fetch credentials; if still missing, enable the app certificate in Console (`agora open --target console`). | diff --git a/docs/llms.txt b/docs/llms.txt index 1f1083a..0551621 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -11,6 +11,8 @@ Login: agora login Initialize project: agora init my-demo --template nextjs --new-project Initialize official recipe: agora init my-agent --recipe tool-calling --new-project Check project health: agora project doctor --json +Create a project for video calling: agora project create demo --template video-call --json +Create the Next.js video-call quickstart: agora quickstart create demo --template nextjs --scenario video-call --json ## Documentation Index diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 25a1e09..f7879d2 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -610,7 +610,10 @@ func (a *App) buildProjectCreate() *cobra.Command { return err } if dryRun { - plannedFeatures := projectCreateFeatures(template, features) + plannedFeatures, err := resolveProjectCreateFeatures(template, features) + if err != nil { + return err + } plannedRTMDataCenter, err := rtmDataCenterForFeatures(plannedFeatures, normalizedRTMDataCenter) if err != nil { return err @@ -638,10 +641,11 @@ func (a *App) buildProjectCreate() *cobra.Command { }, } cmd.Flags().StringVar(&rtmDataCenter, "rtm-data-center", "", "RTM data center to configure when rtm is enabled (CN, NA, EU, or AP); defaults to NA") - cmd.Flags().StringVar(&template, "template", "", "apply a higher-level project preset such as voice-agent") + cmd.Flags().StringVar(&template, "template", "", "apply a project scenario preset: "+strings.Join(projectPresetIDs(), ", ")) cmd.Flags().StringArrayVar(&features, "feature", nil, fmt.Sprintf("enable one or more features after creation; defaults to %s; convoai also enables rtm", featureListString())) cmd.Flags().BoolVar(&dryRun, "dry-run", false, "return the planned project create result without creating remote resources") cmd.Flags().StringVar(&idempotencyKey, "idempotency-key", "", "caller-provided key for safe retries when supported by the API") + _ = cmd.RegisterFlagCompletionFunc("template", completeProjectPresetIDs) return cmd } diff --git a/internal/cli/completion.go b/internal/cli/completion.go index d6b6dc8..4bd38b9 100644 --- a/internal/cli/completion.go +++ b/internal/cli/completion.go @@ -79,12 +79,40 @@ func filterProjectCompletions(items []projectSummary, toComplete string) []strin func completeQuickstartTemplateIDs(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { results := []string{} + seen := map[string]bool{} for _, template := range quickstartTemplates() { if !template.Available { continue } - if strings.HasPrefix(strings.ToLower(template.ID), strings.ToLower(toComplete)) { - results = append(results, fmt.Sprintf("%s\t%s", template.ID, template.Title)) + if !seen[template.Template] && strings.HasPrefix(strings.ToLower(template.Template), strings.ToLower(toComplete)) { + results = append(results, template.Template) + seen[template.Template] = true + } + } + return results, cobra.ShellCompDirectiveNoFileComp +} + +func completeQuickstartScenarios(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + templateID, _ := cmd.Flags().GetString("template") + results := []string{} + seen := map[string]bool{} + for _, definition := range quickstartTemplates() { + if !definition.Available || (templateID != "" && definition.Template != templateID) || seen[definition.Scenario] { + continue + } + if strings.HasPrefix(strings.ToLower(definition.Scenario), strings.ToLower(toComplete)) { + results = append(results, fmt.Sprintf("%s\t%s", definition.Scenario, definition.Title)) + seen[definition.Scenario] = true + } + } + return results, cobra.ShellCompDirectiveNoFileComp +} + +func completeProjectPresetIDs(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + results := []string{} + for _, preset := range projectPresetIDs() { + if strings.HasPrefix(strings.ToLower(preset), strings.ToLower(toComplete)) { + results = append(results, preset) } } return results, cobra.ShellCompDirectiveNoFileComp diff --git a/internal/cli/completion_test.go b/internal/cli/completion_test.go new file mode 100644 index 0000000..582b28c --- /dev/null +++ b/internal/cli/completion_test.go @@ -0,0 +1,34 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestQuickstartScenarioCompletionFiltersByTemplate(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("template", "nextjs", "") + items, _ := completeQuickstartScenarios(cmd, nil, "") + joined := strings.Join(items, "\n") + if !strings.Contains(joined, "voice-agent") || !strings.Contains(joined, "video-call") { + t.Fatalf("nextjs scenarios = %v", items) + } + + if err := cmd.Flags().Set("template", "go"); err != nil { + t.Fatal(err) + } + items, _ = completeQuickstartScenarios(cmd, nil, "") + joined = strings.Join(items, "\n") + if !strings.Contains(joined, "voice-agent") || strings.Contains(joined, "video-call") { + t.Fatalf("go scenarios = %v", items) + } +} + +func TestProjectPresetCompletionUsesCatalog(t *testing.T) { + items, _ := completeProjectPresetIDs(nil, nil, "") + if strings.Join(items, ",") != "video-call,voice-agent" { + t.Fatalf("project preset completions = %v", items) + } +} diff --git a/internal/cli/docgen.go b/internal/cli/docgen.go index 0084183..95b3b1a 100644 --- a/internal/cli/docgen.go +++ b/internal/cli/docgen.go @@ -12,6 +12,7 @@ package cli import ( "fmt" "io" + "sort" "strings" "github.com/spf13/cobra" @@ -82,11 +83,16 @@ func RenderCommandReference(out io.Writer, root *cobra.Command) error { seen[key] = true } } - for key, values := range enums { + remaining := make([]string, 0, len(enums)-len(seen)) + for key := range enums { if !seen[key] { - writeEnumRow(&b, key, values) + remaining = append(remaining, key) } } + sort.Strings(remaining) + for _, key := range remaining { + writeEnumRow(&b, key, enums[key]) + } } _, err := io.WriteString(out, strings.TrimRight(b.String(), "\n")+"\n") diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 6a86100..f54be21 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -1,6 +1,7 @@ package cli import ( + "errors" "os" "path/filepath" "strings" @@ -167,40 +168,69 @@ func buildWorkspaceDoctorDetails(target projectTarget) (doctorCheckCategory, map items = append(items, doctorCheckItem{Name: "metadata_project_match", Message: "Repo binding matches the selected project", Status: "pass"}) } - templateID := strings.TrimSpace(binding.Template) - if templateID == "" { - if template, detectErr := resolveQuickstartTemplateForPath(root, ""); detectErr == nil { - templateID = template.ID - } + if strings.TrimSpace(binding.Recipe) != "" { + message := "Recipe workspace detected; built-in quickstart layout checks do not apply. Recipe runtime and env validation are not covered by this check." + workspace["recipe"] = binding.Recipe + items = append(items, doctorCheckItem{Name: "workspace_selection", Message: message, Status: "warn"}) + warnings = append(warnings, doctorIssue{Code: "WORKSPACE_TEMPLATE_UNKNOWN", Message: message}) + check := doctorCheckCategory{Category: "workspace", Items: items, Status: summarizeCategoryStatus(items)} + return check, workspace, blocking, warnings } - if templateID == "" { - items = append(items, doctorCheckItem{Name: "workspace_template", Message: "Could not detect quickstart template for this repo", Status: "warn"}) - warnings = append(warnings, doctorIssue{Code: "WORKSPACE_TEMPLATE_UNKNOWN", Message: "Could not detect quickstart template for this repo"}) + template, selectionErr := resolveQuickstartTemplateForPath(root, binding.Template, binding.Scenario) + if selectionErr != nil { + code := "WORKSPACE_TEMPLATE_UNKNOWN" + var structured *cliError + if errors.As(selectionErr, &structured) && structured.Code != "" { + code = structured.Code + } + if errors.Is(selectionErr, errQuickstartTemplateUndetected) { + message := "Could not detect a built-in quickstart for this workspace; template-specific env checks were skipped." + items = append(items, doctorCheckItem{Name: "workspace_selection", Message: message, Status: "warn"}) + warnings = append(warnings, doctorIssue{Code: code, Message: message}) + } else { + items = append(items, doctorCheckItem{Name: "workspace_selection", Message: selectionErr.Error(), Status: "fail"}) + blocking = append(blocking, doctorIssue{Code: code, Message: selectionErr.Error()}) + } check := doctorCheckCategory{Category: "workspace", Items: items} check.Status = summarizeCategoryStatus(items) return check, workspace, blocking, warnings } - workspace["template"] = templateID - items = append(items, doctorCheckItem{Name: "workspace_template", Message: "Detected template: " + templateID, Status: "pass"}) + if !template.DefaultScenario { + if manifestErr := validateRequiredQuickstartManifest(root, template); manifestErr != nil { + code := "QUICKSTART_MANIFEST_INVALID" + var structured *cliError + if errors.As(manifestErr, &structured) && structured.Code != "" { + code = structured.Code + } + message := manifestErr.Error() + items = append(items, doctorCheckItem{Name: "workspace_manifest", Message: message, Status: "fail"}) + blocking = append(blocking, doctorIssue{Code: code, Message: message}) + check := doctorCheckCategory{Category: "workspace", Items: items} + check.Status = summarizeCategoryStatus(items) + return check, workspace, blocking, warnings + } + items = append(items, doctorCheckItem{Name: "workspace_manifest", Message: "Quickstart manifest matches the selected scenario", Status: "pass"}) + } + workspace["template"] = template.Template + workspace["scenario"] = template.Scenario + workspace["requiredFeatures"] = append([]string{}, template.RequiredFeatures...) + items = append(items, doctorCheckItem{Name: "workspace_selection", Message: "Detected quickstart: " + template.Template + " + " + template.Scenario, Status: "pass"}) - template, found := findQuickstartTemplate(templateID) envRel := strings.TrimSpace(binding.EnvPath) layout := quickstartEnvLayout{} - if found { - if envRel != "" { - layout, _ = quickstartEnvLayoutForEnvPath(*template, envRel) - } - if layout.EnvTargetPath == "" { - if detected, ok := quickstartEnvLayoutForPath(root, *template); ok { - layout = detected - } else if fallback, ok := template.defaultEnvLayout(); ok { - layout = fallback - } - } - if envRel == "" { - envRel = layout.EnvTargetPath + if envRel != "" { + layout, _ = quickstartEnvLayoutForEnvPath(template, envRel) + } + if layout.EnvTargetPath == "" { + if detected, ok := quickstartEnvLayoutForPath(root, template); ok { + layout = detected + } else if fallback, ok := template.defaultEnvLayout(); ok { + layout = fallback } } + if envRel == "" { + envRel = layout.EnvTargetPath + } if envRel == "" { items = append(items, doctorCheckItem{Name: "workspace_env_path", Message: "Could not determine quickstart env target path", Status: "warn"}) warnings = append(warnings, doctorIssue{Code: "WORKSPACE_ENV_PATH_UNKNOWN", Message: "Could not determine quickstart env target path"}) @@ -268,8 +298,7 @@ func buildWorkspaceDoctorDetails(target projectTarget) (doctorCheckCategory, map items = append(items, doctorCheckItem{Name: "workspace_env_project_match", Message: "Env metadata matches the selected project", Status: "pass"}) } } else { - items = append(items, doctorCheckItem{Name: "workspace_env_project_match", Message: "Env metadata is missing project comments from Agora-managed block", Status: "warn"}) - warnings = append(warnings, doctorIssue{Code: "WORKSPACE_ENV_METADATA_MISSING", Message: "Quickstart env file is missing Agora-managed project metadata comments"}) + items = append(items, doctorCheckItem{Name: "workspace_env_project_match", Message: "Project identity is recorded in .agora/project.json; env metadata comments are optional", Status: "pass"}) } appIDKey := layout.AppIDKey diff --git a/internal/cli/doctor_test.go b/internal/cli/doctor_test.go new file mode 100644 index 0000000..0850e7a --- /dev/null +++ b/internal/cli/doctor_test.go @@ -0,0 +1,59 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" +) + +func TestWorkspaceDoctorSelectionScope(t *testing.T) { + for _, tc := range []struct { + name, recipe, template, scenario, manifest, projectID, blockingCode string + wantWarning bool + }{ + {name: "unknown directory", wantWarning: true}, + {name: "recipe ignores quickstart metadata", recipe: "custom", manifest: "invalid", wantWarning: true}, + {name: "unknown directory with wrong project", projectID: "other", blockingCode: "LOCAL_PROJECT_BINDING_MISMATCH", wantWarning: true}, + {name: "recipe with wrong project", recipe: "custom", projectID: "other", blockingCode: "LOCAL_PROJECT_BINDING_MISMATCH", wantWarning: true}, + {name: "declared unknown template", template: "unknown", blockingCode: "QUICKSTART_TEMPLATE_UNKNOWN"}, + {name: "RTC missing manifest", template: "nextjs", scenario: "video-call", blockingCode: "QUICKSTART_MANIFEST_INVALID"}, + {name: "invalid manifest", manifest: "invalid", blockingCode: "QUICKSTART_MANIFEST_INVALID"}, + {name: "conflicting manifest", template: "nextjs", scenario: "video-call", manifest: `{"schemaVersion":1,"template":"nextjs","scenario":"voice-agent"}`, blockingCode: "QUICKSTART_SELECTION_MISMATCH"}, + } { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + id := tc.projectID + if id == "" { + id = "prj_1" + } + if err := writeLocalProjectBinding(root, localProjectBinding{ProjectID: id, Recipe: tc.recipe, Template: tc.template, Scenario: tc.scenario}); err != nil { + t.Fatal(err) + } + if tc.manifest != "" { + if err := os.WriteFile(filepath.Join(root, quickstartManifestFileName), []byte(tc.manifest), 0o600); err != nil { + t.Fatal(err) + } + } + t.Chdir(root) + _, _, blocking, warnings := buildWorkspaceDoctorDetails(projectTarget{project: projectDetail{ProjectID: "prj_1"}}) + if tc.blockingCode == "" { + if len(blocking) != 0 { + t.Fatalf("unexpected blocking issues: %+v", blocking) + } + } else if len(blocking) != 1 || blocking[0].Code != tc.blockingCode { + t.Fatalf("blocking=%+v want=%s", blocking, tc.blockingCode) + } + if (len(warnings) > 0) != tc.wantWarning { + t.Fatalf("unexpected warnings: %+v", warnings) + } + result := projectDoctorResult{Feature: "rtc", BlockingIssues: blocking, Warnings: warnings} + finalizeDoctorOutcome(&result) + if tc.blockingCode == "" && (!result.Healthy || result.Status != "warning") { + t.Fatalf("unexpected outcome: %+v", result) + } + if tc.blockingCode != "" && (result.Healthy || result.Status != "not_ready") { + t.Fatalf("unexpected outcome: %+v", result) + } + }) + } +} diff --git a/internal/cli/init.go b/internal/cli/init.go index e734f23..fc7b2f3 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -39,6 +39,7 @@ func initNextSteps(template quickstartTemplate, targetDir string) []string { func (a *App) buildInitCommand() *cobra.Command { var templateID string var recipeID string + var scenario string var dir string var existingProject string var rtmDataCenter string @@ -78,6 +79,9 @@ Use --feature to specify which features to enable on a newly created project (re } templateID = selected } + if recipeID != "" && scenario != "" { + return &cliError{Message: "--scenario is only valid with a quickstart template.", Code: "INIT_SOURCE_CONFLICT"} + } targetDir := dir if strings.TrimSpace(targetDir) == "" { targetDir = args[0] @@ -101,11 +105,11 @@ Use --feature to specify which features to enable on a newly created project (re } result, err = a.initRecipeProject(args[0], targetDir, recipe, existingProject, features, rtmDataCenter, newProject, promptForReuse, cmd.ErrOrStderr(), os.Stdin, progress) } else { - template, ok := findQuickstartTemplate(templateID) - if !ok { - return &cliError{Message: fmt.Sprintf("unknown quickstart template %q. Run `agora quickstart list` to see available templates.", templateID), Code: "QUICKSTART_TEMPLATE_UNKNOWN"} + template, selectErr := selectQuickstartDefinition(templateID, scenario) + if selectErr != nil { + return selectErr } - result, err = a.initProject(args[0], targetDir, *template, existingProject, features, rtmDataCenter, newProject, promptForReuse, cmd.ErrOrStderr(), os.Stdin, progress) + result, err = a.initProject(args[0], targetDir, template, existingProject, features, rtmDataCenter, newProject, promptForReuse, cmd.ErrOrStderr(), os.Stdin, progress) } if err != nil { return err @@ -122,12 +126,15 @@ Use --feature to specify which features to enable on a newly created project (re } cmd.Flags().StringVar(&templateID, "template", "", "quickstart template ID to use") cmd.Flags().StringVar(&recipeID, "recipe", "", "official Agora recipe slug (run agora recipes list to discover slugs)") + cmd.Flags().StringVar(&scenario, "scenario", "", "quickstart scenario; omitted selects the template default") cmd.Flags().StringVar(&dir, "dir", "", "target directory for the cloned quickstart; defaults to ") cmd.Flags().StringVar(&existingProject, "project", "", "existing project ID or exact project name to bind to") cmd.Flags().StringVar(&rtmDataCenter, "rtm-data-center", "", "RTM data center to configure when rtm is enabled on a newly created project (CN, NA, EU, or AP); defaults to NA") - cmd.Flags().StringArrayVar(&features, "feature", nil, fmt.Sprintf("enable a feature on the newly created project (repeatable); defaults to %s; convoai also enables rtm", featureListString())) + cmd.Flags().StringArrayVar(&features, "feature", nil, "select features for new projects (repeatable); explicit values override scenario defaults; omitted uses scenario defaults; ignored when reusing a project; convoai also enables rtm") cmd.Flags().StringArrayVar(&agentRules, "add-agent-rules", nil, "write AI agent rules into the quickstart (repeatable: cursor, claude, windsurf)") cmd.Flags().BoolVar(&newProject, "new-project", false, "always create a new Agora project instead of reusing an existing one") + _ = cmd.RegisterFlagCompletionFunc("template", completeQuickstartTemplateIDs) + _ = cmd.RegisterFlagCompletionFunc("scenario", completeQuickstartScenarios) return cmd } @@ -140,7 +147,7 @@ func (a *App) selectInitTemplate(cmd *cobra.Command) (string, error) { } templates := []quickstartTemplate{} for _, template := range quickstartTemplates() { - if template.Available && template.SupportsInit { + if template.Available && template.SupportsInit && template.DefaultScenario { templates = append(templates, template) } } @@ -150,7 +157,7 @@ func (a *App) selectInitTemplate(cmd *cobra.Command) (string, error) { out := cmd.ErrOrStderr() fmt.Fprintln(out, "Choose a quickstart template:") for index, template := range templates { - fmt.Fprintf(out, " %d. %s (%s)\n", index+1, template.ID, template.Title) + fmt.Fprintf(out, " %d. %s (%s)\n", index+1, template.Template, template.Title) } fmt.Fprint(out, "Template: ") reader := bufio.NewReader(os.Stdin) @@ -160,13 +167,13 @@ func (a *App) selectInitTemplate(cmd *cobra.Command) (string, error) { } answer = strings.TrimSpace(answer) if answer == "" { - return templates[0].ID, nil + return templates[0].Template, nil } if index, err := strconv.Atoi(answer); err == nil && index >= 1 && index <= len(templates) { - return templates[index-1].ID, nil + return templates[index-1].Template, nil } - if _, ok := findQuickstartTemplate(answer); ok { - return answer, nil + if definition, err := selectQuickstartDefinition(answer, ""); err == nil { + return definition.Template, nil } return "", &cliError{Message: fmt.Sprintf("unknown quickstart template %q. Run `agora quickstart list` to see available templates.", answer), Code: "QUICKSTART_TEMPLATE_UNKNOWN"} } @@ -360,7 +367,16 @@ func (a *App) initProject(name, targetDir string, template quickstartTemplate, e if _, err := resolveScaffoldTarget(targetDir); err != nil { return nil, err } - resolution, err := a.resolveInitProjectForScaffold(name, existingProject, features, rtmDataCenter, newProject, promptForReuse, promptOut, promptIn, progress) + // A code template supplies defaults, not a project preset. Explicit + // feature choices replace those defaults, as in the legacy init flow. + if len(features) == 0 { + features = template.RequiredFeatures + } + createFeatures, err := mergeFeatureRequirements(features) + if err != nil { + return nil, err + } + resolution, err := a.resolveInitProjectForScaffold(name, createFeatures, existingProject, rtmDataCenter, newProject, promptForReuse, promptOut, promptIn, progress) if err != nil { return nil, err } @@ -380,7 +396,7 @@ func (a *App) initProject(name, targetDir string, template quickstartTemplate, e "envPath": quickstartResult["envPath"], "envStatus": quickstartResult["envStatus"], "metadataPath": filepath.ToSlash(filepath.Join(localAgoraDirName, localProjectFileName)), - "nextSteps": initNextSteps(template, asString(quickstartResult["path"])), + "nextSteps": quickstartResult["nextSteps"], "path": quickstartResult["path"], "projectAction": resolution.projectAction, "projectId": target.project.ProjectID, @@ -391,16 +407,21 @@ func (a *App) initProject(name, targetDir string, template quickstartTemplate, e "sourceId": template.ID, "sourceType": "quickstart", "status": "ready", - "template": template.ID, + "template": template.Template, + "scenario": template.Scenario, + "requiredFeatures": append([]string{}, template.RequiredFeatures...), "title": template.Title, } + if packageManager, ok := quickstartResult["packageManager"]; ok { + result["packageManager"] = packageManager + } if resolution.createdRTMDataCenter != "" { result["rtmDataCenter"] = resolution.createdRTMDataCenter } return result, nil } -func (a *App) resolveInitProjectForScaffold(name, existingProject string, features []string, rtmDataCenter string, newProject bool, promptForReuse bool, promptOut io.Writer, promptIn io.Reader, progress progressEmitter) (initProjectResolution, error) { +func (a *App) resolveInitProjectForScaffold(name string, createFeatures []string, existingProject string, rtmDataCenter string, newProject bool, promptForReuse bool, promptOut io.Writer, promptIn io.Reader, progress progressEmitter) (initProjectResolution, error) { var target projectTarget projectAction := "existing" projectSelectionReason := "explicit_project" @@ -473,7 +494,7 @@ func (a *App) resolveInitProjectForScaffold(name, existingProject string, featur } if needsCreate { - featuresToEnable := normalizeProjectCreateFeatures(features) + featuresToEnable := createFeatures progress.emit("project:create", "Creating Agora project", map[string]any{"projectName": name, "features": featuresToEnable}) projectResult, err := a.projectCreate(name, "", featuresToEnable, rtmDataCenter, "") if err != nil { @@ -483,7 +504,9 @@ func (a *App) resolveInitProjectForScaffold(name, existingProject string, featur if list, ok := projectResult["enabledFeatures"].([]string); ok { enabledFeatures = list } - createdRTMDataCenter = asString(projectResult["rtmDataCenter"]) + if value, ok := projectResult["rtmDataCenter"]; ok { + createdRTMDataCenter = asString(value) + } resolved, err := a.resolveProjectTarget(asString(projectResult["projectId"])) if err != nil { return initProjectResolution{}, err diff --git a/internal/cli/integration_init_test.go b/internal/cli/integration_init_test.go index 63e6e89..be4cf1c 100644 --- a/internal/cli/integration_init_test.go +++ b/internal/cli/integration_init_test.go @@ -4,12 +4,209 @@ package cli // Shared helpers live in integration_test.go. import ( + "encoding/json" + "net/http" "os" "path/filepath" + "slices" "strings" "testing" ) +func TestCLIInitRTCVideoCallCreatesRTCOnlyProject(t *testing.T) { + configHome := t.TempDir() + rootDir := t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + persistSessionForIntegration(t, configHome) + repo := createLocalGitRepo(t, map[string]string{ + "agora.quickstart.json": `{"schemaVersion":1,"template":"nextjs","scenario":"video-call"}`, + "env.local.example": "NEXT_PUBLIC_AGORA_APP_ID=\nNEXT_AGORA_APP_CERTIFICATE=\n", + "package.json": `{"packageManager":"pnpm@9.15.9","scripts":{"dev":"next dev"}}`, + }) + target := filepath.Join(rootDir, "rtc-init") + result := runCLI(t, []string{"init", "rtc-init", "--template", "nextjs", "--scenario", "video-call", "--new-project", "--dir", target, "--json"}, cliRunOptions{env: map[string]string{ + "XDG_CONFIG_HOME": configHome, + "AGORA_API_BASE_URL": api.baseURL, + "AGORA_LOG_LEVEL": "error", + "AGORA_QUICKSTART_NEXTJS_VIDEO_CALL_REPO_URL": repo, + }, workdir: rootDir}) + if result.exitCode != 0 || !strings.Contains(result.stdout, `"enabledFeatures":["rtc"]`) || !strings.Contains(result.stdout, `"scenario":"video-call"`) || !strings.Contains(result.stdout, `"requiredFeatures":["rtc"]`) { + t.Fatalf("unexpected rtc init result: %+v", result) + } + if !strings.Contains(result.stdout, `"packageManager":{"name":"pnpm","requiredVersion":"9.15.9"`) { + t.Fatalf("rtc init is missing package manager setup metadata: %+v", result) + } + if !strings.Contains(result.stdout, `"selectedName":`) { + t.Fatalf("rtc init is missing selected package manager metadata: %+v", result) + } + if strings.Contains(result.stdout, `"rtmDataCenter"`) || strings.Contains(result.stdout, `"convoai"`) { + t.Fatalf("rtc init enabled unrelated features: %+v", result) + } + binding, err := loadLocalProjectBinding(target) + if err != nil { + t.Fatal(err) + } + if binding.Template != "nextjs" || binding.Scenario != "video-call" { + t.Fatalf("unexpected rtc init binding: %+v", binding) + } +} + +func TestInitFeatureSelectionCompatibility(t *testing.T) { + for _, transport := range []string{"cli", "mcp"} { + for _, tc := range []struct { + name, scenario, reuse, template string + features, want []string + invalid bool + }{ + {name: "video defaults", scenario: "video-call", want: []string{"rtc"}}, + {name: "voice defaults", scenario: "voice-agent", want: []string{"rtc", "rtm", "convoai"}}, + {name: "voice explicit rtc", scenario: "voice-agent", features: []string{"rtc"}, want: []string{"rtc"}}, + {name: "video explicit rtm", scenario: "video-call", features: []string{"rtm"}, want: []string{"rtm"}}, + {name: "voice explicit convoai", scenario: "voice-agent", features: []string{"convoai"}, want: []string{"rtm", "convoai"}}, + {name: "reuse voice", scenario: "voice-agent", reuse: "explicit", want: []string{}}, + {name: "reuse voice with features", scenario: "voice-agent", reuse: "explicit", features: []string{"convoai"}, want: []string{}}, + {name: "automatic reuse", scenario: "voice-agent", reuse: "auto", features: []string{"convoai"}, want: []string{}}, + {name: "reuse video", scenario: "video-call", reuse: "explicit", features: []string{"rtm"}, want: []string{}}, + {name: "invalid feature", scenario: "video-call", features: []string{"bad-feature"}, invalid: true}, + {name: "invalid scenario", scenario: "bad-scenario", invalid: true}, + {name: "invalid template", template: "unknown", scenario: "video-call", invalid: true}, + {name: "unsupported combination", template: "python", scenario: "video-call", invalid: true}, + } { + t.Run(transport+"/"+tc.name, func(t *testing.T) { + templateID := tc.template + if templateID == "" { + templateID = "nextjs" + } + root, configHome := t.TempDir(), t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + persistSessionForIntegration(t, configHome) + if tc.reuse != "" { + project := buildFakeProject("Default Project", "prj_0001", "app_0001", "global") + api.projects[project.ProjectID] = &project + } + repo := createLocalGitRepo(t, map[string]string{ + "agora.quickstart.json": `{"schemaVersion":1,"template":"nextjs","scenario":"` + tc.scenario + `"}`, + "env.local.example": "NEXT_PUBLIC_AGORA_APP_ID=\nNEXT_AGORA_APP_CERTIFICATE=\n", + "package.json": `{"name":"fixture"}`, + }) + env := map[string]string{"AGORA_HOME": "", "XDG_CONFIG_HOME": configHome, "AGORA_API_BASE_URL": api.baseURL, "AGORA_LOG_LEVEL": "error", "AGORA_QUICKSTART_NEXTJS_REPO_URL": repo, "AGORA_QUICKSTART_NEXTJS_VIDEO_CALL_REPO_URL": repo} + target := filepath.Join(root, "demo") + var data map[string]any + failed := false + if transport == "cli" { + args := []string{"init", "demo", "--template", templateID, "--scenario", tc.scenario, "--dir", target, "--json"} + switch tc.reuse { + case "": + args = append(args, "--new-project") + case "explicit": + args = append(args, "--project", "prj_0001") + } + for _, f := range tc.features { + args = append(args, "--feature", f) + } + result := runCLI(t, args, cliRunOptions{env: env, workdir: root}) + failed = result.exitCode != 0 + if !failed { + lines := strings.Split(strings.TrimSpace(result.stdout), "\n") + var envelope struct { + Data map[string]any `json:"data"` + } + if err := json.Unmarshal([]byte(lines[len(lines)-1]), &envelope); err != nil { + t.Fatal(err) + } + data = envelope.Data + } else if !tc.invalid { + t.Fatalf("init failed: %+v", result) + } + } else { + t.Chdir(root) + for key, value := range env { + t.Setenv(key, value) + } + app, err := NewApp() + if err != nil { + t.Fatal(err) + } + args := map[string]any{"name": "demo", "template": templateID, "scenario": tc.scenario, "dir": target, "newProject": tc.reuse == "", "features": tc.features} + if tc.reuse == "explicit" { + args["project"] = "prj_0001" + } + result, err := app.callMCPTool("agora.init", args, nil) + failed = err != nil + if !failed { + data = result.(map[string]any) + } else if !tc.invalid { + t.Fatal(err) + } + } + if tc.invalid { + if !failed { + t.Fatal("invalid input succeeded") + } + api.mu.Lock() + requests := len(api.requests) + api.mu.Unlock() + if requests != 0 { + t.Fatalf("invalid input reached API: %d requests", requests) + } + if _, err := os.Stat(target); !os.IsNotExist(err) { + t.Fatalf("invalid input created scaffold: %v", err) + } + return + } + got, err := json.Marshal(data["enabledFeatures"]) + if err != nil { + t.Fatal(err) + } + want, err := json.Marshal(tc.want) + if err != nil { + t.Fatal(err) + } + if string(got) != string(want) { + t.Fatalf("features=%s want=%s", got, want) + } + api.mu.Lock() + project := api.projects["prj_0001"] + rtm, convoai := project.FeatureState.RTMEnabled, project.FeatureState.ConvoAIEnabled + writes, featureReads := 0, 0 + for _, request := range api.requests { + if request.Method != http.MethodGet { + writes++ + } + if strings.Contains(request.Pathname, "/uap-configs/") || strings.HasSuffix(request.Pathname, "/rtm2-config") { + featureReads++ + } + } + api.mu.Unlock() + if rtm != slices.Contains(tc.want, "rtm") || convoai != slices.Contains(tc.want, "convoai") { + t.Fatalf("unexpected API state rtm=%v convoai=%v", rtm, convoai) + } + if tc.reuse != "" { + if writes != 0 || featureReads != 0 { + t.Fatalf("reuse performed %d writes and %d feature requests", writes, featureReads) + } + if data["projectAction"] != "existing" { + t.Fatalf("unexpected reuse result: %+v", data) + } + doctor := runCLI(t, []string{"project", "doctor", "prj_0001", "--feature", "convoai", "--json"}, cliRunOptions{env: env, workdir: root}) + if doctor.exitCode != 1 || !strings.Contains(doctor.stdout, `"status":"not_ready"`) { + t.Fatalf("doctor failed to report missing functionality: %+v", doctor) + } + } + binding, err := loadLocalProjectBinding(target) + if err != nil { + t.Fatal(err) + } + if binding.ProjectID != "prj_0001" || binding.Scenario != tc.scenario { + t.Fatalf("unexpected binding: %+v", binding) + } + }) + } + } +} + func TestCLIInitCreatesProjectAndQuickstart(t *testing.T) { configHome := t.TempDir() rootDir := t.TempDir() diff --git a/internal/cli/integration_project_test.go b/internal/cli/integration_project_test.go index b6874cf..45e2b43 100644 --- a/internal/cli/integration_project_test.go +++ b/internal/cli/integration_project_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "regexp" + "slices" "strings" "testing" ) @@ -173,6 +174,43 @@ func TestCLIProjectCreateDefaultsToCoreFeatures(t *testing.T) { } } +func TestCLIProjectCreateVideoCallPresetAndRejectsUnknownPresetBeforeRemoteWrite(t *testing.T) { + configHome := t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + persistSessionForIntegration(t, configHome) + + videoCall := runCLI(t, []string{"project", "create", "RTC Demo", "--template", "video-call", "--dry-run", "--json"}, cliRunOptions{env: map[string]string{ + "XDG_CONFIG_HOME": configHome, + "AGORA_API_BASE_URL": api.baseURL, + "AGORA_LOG_LEVEL": "error", + }}) + if videoCall.exitCode != 0 || !strings.Contains(videoCall.stdout, `"template":"video-call"`) || !strings.Contains(videoCall.stdout, `"enabledFeatures":["rtc"]`) { + t.Fatalf("unexpected video-call preset dry-run result: %+v", videoCall) + } + if strings.Contains(videoCall.stdout, `"rtm"`) || strings.Contains(videoCall.stdout, `"convoai"`) { + t.Fatalf("video-call preset must not include unrelated features: %+v", videoCall) + } + + api.mu.Lock() + requestsBefore := len(api.requests) + api.mu.Unlock() + unknown := runCLI(t, []string{"project", "create", "Unknown Demo", "--template", "not-a-preset", "--json"}, cliRunOptions{env: map[string]string{ + "XDG_CONFIG_HOME": configHome, + "AGORA_API_BASE_URL": api.baseURL, + "AGORA_LOG_LEVEL": "error", + }}) + if unknown.exitCode != 1 || !strings.Contains(unknown.stdout, `"code":"PROJECT_TEMPLATE_UNKNOWN"`) { + t.Fatalf("unexpected unknown preset result: %+v", unknown) + } + api.mu.Lock() + requestsAfter := len(api.requests) + api.mu.Unlock() + if requestsAfter != requestsBefore { + t.Fatalf("unknown preset reached the remote API: requests before=%d after=%d", requestsBefore, requestsAfter) + } +} + func TestCLIProjectUseShowFeatureAndDoctorHappyPath(t *testing.T) { configHome := t.TempDir() api := newFakeCLIBFF() @@ -541,3 +579,91 @@ func TestCLIFeatureEnableAndDoctorAuthError(t *testing.T) { t.Fatalf("unexpected unauth doctor result: %+v", unauthDoctor) } } + +func TestProjectCreatePresetFeatureCompatibility(t *testing.T) { + for _, tc := range []struct { + name, preset string + features, want []string + }{ + {name: "defaults", want: []string{"rtc", "rtm", "convoai"}}, + {name: "explicit rtc", features: []string{"rtc"}, want: []string{"rtc"}}, + {name: "voice preset", preset: "voice-agent", features: []string{"rtc"}, want: []string{"rtc", "rtm", "convoai"}}, + {name: "video preset", preset: "video-call", want: []string{"rtc"}}, + {name: "video plus rtm", preset: "video-call", features: []string{"rtm"}, want: []string{"rtc", "rtm"}}, + {name: "video plus convoai", preset: "video-call", features: []string{"convoai"}, want: []string{"rtc", "rtm", "convoai"}}, + } { + for _, transport := range []string{"dry-run", "cli", "mcp"} { + t.Run(tc.name+"/"+transport, func(t *testing.T) { + configHome, root := t.TempDir(), t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + persistSessionForIntegration(t, configHome) + env := map[string]string{"AGORA_HOME": "", "XDG_CONFIG_HOME": configHome, "AGORA_API_BASE_URL": api.baseURL, "AGORA_LOG_LEVEL": "error"} + var data map[string]any + if transport == "mcp" { + t.Chdir(root) + for key, value := range env { + t.Setenv(key, value) + } + app, err := NewApp() + if err != nil { + t.Fatal(err) + } + result, err := app.callMCPTool("agora.project.create", map[string]any{"name": "demo", "template": tc.preset, "features": tc.features}, nil) + if err != nil { + t.Fatal(err) + } + data = result.(map[string]any) + } else { + args := []string{"project", "create", "demo", "--json"} + if tc.preset != "" { + args = append(args, "--template", tc.preset) + } + for _, feature := range tc.features { + args = append(args, "--feature", feature) + } + if transport == "dry-run" { + args = append(args, "--dry-run") + } + result := runCLI(t, args, cliRunOptions{env: env, workdir: root}) + if result.exitCode != 0 { + t.Fatalf("create failed: %+v", result) + } + var envelope struct { + Data map[string]any `json:"data"` + } + if err := json.Unmarshal([]byte(result.stdout), &envelope); err != nil { + t.Fatal(err) + } + data = envelope.Data + } + got, err := json.Marshal(data["enabledFeatures"]) + if err != nil { + t.Fatal(err) + } + want, err := json.Marshal(tc.want) + if err != nil { + t.Fatal(err) + } + if string(got) != string(want) { + t.Fatalf("features=%s want=%s", got, want) + } + api.mu.Lock() + defer api.mu.Unlock() + if transport == "dry-run" { + if len(api.projects) != 0 || len(api.requests) != 0 { + t.Fatal("dry-run reached API") + } + } else { + project := api.projects["prj_0001"] + if project == nil { + t.Fatal("project not created") + } + if project.FeatureState.RTMEnabled != slices.Contains(tc.want, "rtm") || project.FeatureState.ConvoAIEnabled != slices.Contains(tc.want, "convoai") { + t.Fatal("API feature state differs from reported features") + } + } + }) + } + } +} diff --git a/internal/cli/integration_quickstart_test.go b/internal/cli/integration_quickstart_test.go index 1c4f2d3..820d181 100644 --- a/internal/cli/integration_quickstart_test.go +++ b/internal/cli/integration_quickstart_test.go @@ -4,6 +4,7 @@ package cli // Shared helpers live in integration_test.go. import ( + "bytes" "errors" "os" "path/filepath" @@ -11,6 +12,183 @@ import ( "testing" ) +func TestCLIRTCVideoCallQuickstartScenario(t *testing.T) { + configHome := t.TempDir() + rootDir := t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + project := buildFakeProject("RTC Project", "prj_rtc", "app_rtc", "global") + api.projects[project.ProjectID] = &project + persistSessionForIntegration(t, configHome) + + repo := createLocalGitRepo(t, map[string]string{ + "agora.quickstart.json": `{"schemaVersion":1,"template":"nextjs","scenario":"video-call"}`, + "env.local.example": "NEXT_PUBLIC_AGORA_APP_ID=\nNEXT_AGORA_APP_CERTIFICATE=\n", + "package.json": `{"packageManager":"pnpm@9.15.9","scripts":{"dev":"next dev"}}`, + }) + commonEnv := map[string]string{ + "XDG_CONFIG_HOME": configHome, + "AGORA_API_BASE_URL": api.baseURL, + "AGORA_LOG_LEVEL": "error", + "AGORA_QUICKSTART_NEXTJS_VIDEO_CALL_REPO_URL": repo, + } + + list := runCLI(t, []string{"quickstart", "list", "--json"}, cliRunOptions{env: commonEnv, workdir: rootDir}) + if list.exitCode != 0 || !strings.Contains(list.stdout, `"id":"nextjs-video-call"`) || !strings.Contains(list.stdout, `"scenario":"video-call"`) || !strings.Contains(list.stdout, `"requiredFeatures":["rtc"]`) { + t.Fatalf("unexpected quickstart list result: %+v", list) + } + + target := filepath.Join(rootDir, "rtc-demo") + create := runCLI(t, []string{"quickstart", "create", "rtc-demo", "--template", "nextjs", "--scenario", "video-call", "--project", project.ProjectID, "--dir", target, "--json"}, cliRunOptions{env: commonEnv, workdir: rootDir}) + if create.exitCode != 0 || !strings.Contains(create.stdout, `"template":"nextjs"`) || !strings.Contains(create.stdout, `"scenario":"video-call"`) || !strings.Contains(create.stdout, `"requiredFeatures":["rtc"]`) { + t.Fatalf("unexpected rtc quickstart create result: %+v", create) + } + if !strings.Contains(create.stdout, `"packageManager":{"name":"pnpm","requiredVersion":"9.15.9"`) { + t.Fatalf("rtc quickstart create is missing package manager setup metadata: %+v", create) + } + if !strings.Contains(create.stdout, `"selectedName":`) { + t.Fatalf("rtc quickstart create is missing selected package manager metadata: %+v", create) + } + binding, err := loadLocalProjectBinding(target) + if err != nil { + t.Fatal(err) + } + if binding.Template != "nextjs" || binding.Scenario != "video-call" || binding.EnvPath != ".env.local" { + t.Fatalf("unexpected rtc quickstart binding: %+v", binding) + } + envRaw, err := os.ReadFile(filepath.Join(target, ".env.local")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(envRaw), "NEXT_PUBLIC_AGORA_APP_ID=app_rtc") || !strings.Contains(string(envRaw), "NEXT_AGORA_APP_CERTIFICATE=") { + t.Fatalf("unexpected rtc env: %s", envRaw) + } + if project.SignKey != nil && strings.Contains(create.stdout, *project.SignKey) { + t.Fatal("quickstart JSON leaked the app certificate") + } + + write := runCLI(t, []string{"quickstart", "env", "write", target, "--template", "nextjs", "--json"}, cliRunOptions{env: commonEnv, workdir: rootDir}) + if write.exitCode != 0 || !strings.Contains(write.stdout, `"scenario":"video-call"`) { + t.Fatalf("unexpected rtc quickstart env write result: %+v", write) + } + + t.Setenv("AGORA_HOME", "") + for key, value := range commonEnv { + t.Setenv(key, value) + } + app, err := NewApp() + if err != nil { + t.Fatal(err) + } + mcpResult, err := app.callMCPTool("agora.quickstart.env_write", map[string]any{"dir": target, "template": "nextjs", "project": project.ProjectID}, nil) + if err != nil { + t.Fatal(err) + } + if mcpResult.(map[string]any)["scenario"] != "video-call" { + t.Fatalf("unexpected MCP scenario: %+v", mcpResult) + } + beforeEnv, err := os.ReadFile(filepath.Join(target, ".env.local")) + if err != nil { + t.Fatal(err) + } + beforeBinding, err := os.ReadFile(resolveLocalProjectFile(target)) + if err != nil { + t.Fatal(err) + } + conflict := runCLI(t, []string{"quickstart", "env", "write", target, "--template", "nextjs", "--scenario", "voice-agent", "--project", project.ProjectID, "--json"}, cliRunOptions{env: commonEnv, workdir: rootDir}) + if conflict.exitCode != 1 || !strings.Contains(conflict.stdout, `"code":"QUICKSTART_SELECTION_MISMATCH"`) { + t.Fatalf("unexpected conflict: %+v", conflict) + } + _, err = app.callMCPTool("agora.quickstart.env_write", map[string]any{"dir": target, "template": "nextjs", "scenario": "voice-agent", "project": project.ProjectID}, nil) + assertCLIErrorCode(t, err, "QUICKSTART_SELECTION_MISMATCH") + afterEnv, err := os.ReadFile(filepath.Join(target, ".env.local")) + if err != nil { + t.Fatal(err) + } + afterBinding, err := os.ReadFile(resolveLocalProjectFile(target)) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(beforeEnv, afterEnv) || !bytes.Equal(beforeBinding, afterBinding) { + t.Fatal("conflicting selection changed env or binding") + } + + doctor := runCLI(t, []string{"project", "doctor", "--feature", "rtc", "--deep", "--json"}, cliRunOptions{env: commonEnv, workdir: target}) + if doctor.exitCode != 0 || !strings.Contains(doctor.stdout, `"status":"healthy"`) || !strings.Contains(doctor.stdout, `"scenario":"video-call"`) { + t.Fatalf("unexpected rtc deep doctor result: %+v", doctor) + } +} + +func TestCLIVideoCallQuickstartValidatesManifestBeforeSetup(t *testing.T) { + for _, tt := range []struct { + name string + files map[string]string + code string + }{ + { + name: "missing manifest", + files: map[string]string{ + "env.local.example": "NEXT_PUBLIC_AGORA_APP_ID=\nNEXT_AGORA_APP_CERTIFICATE=\n", + }, + code: "QUICKSTART_MANIFEST_INVALID", + }, + { + name: "mismatched manifest", + files: map[string]string{ + "agora.quickstart.json": `{"schemaVersion":1,"template":"nextjs","scenario":"voice-agent"}`, + "env.local.example": "NEXT_PUBLIC_AGORA_APP_ID=\nNEXT_AGORA_APP_CERTIFICATE=\n", + }, + code: "QUICKSTART_SELECTION_MISMATCH", + }, + } { + t.Run(tt.name, func(t *testing.T) { + configHome := t.TempDir() + rootDir := t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + project := buildFakeProject("RTC Project", "prj_manifest", "app_manifest", "global") + api.projects[project.ProjectID] = &project + persistSessionForIntegration(t, configHome) + repo := createLocalGitRepo(t, tt.files) + target := filepath.Join(rootDir, "rtc-demo") + + result := runCLI(t, []string{"quickstart", "create", "rtc-demo", "--template", "nextjs", "--scenario", "video-call", "--project", project.ProjectID, "--dir", target, "--json"}, cliRunOptions{env: map[string]string{ + "XDG_CONFIG_HOME": configHome, + "AGORA_API_BASE_URL": api.baseURL, + "AGORA_LOG_LEVEL": "error", + "AGORA_QUICKSTART_NEXTJS_VIDEO_CALL_REPO_URL": repo, + }, workdir: rootDir}) + if result.exitCode != 1 || !strings.Contains(result.stdout, `"code":"`+tt.code+`"`) { + t.Fatalf("unexpected manifest validation result: %+v", result) + } + if _, err := os.Stat(target); !os.IsNotExist(err) { + t.Fatalf("invalid clone target must be removed, stat err=%v", err) + } + }) + } +} + +func TestCLIDefaultQuickstartDoesNotRequireManifest(t *testing.T) { + rootDir := t.TempDir() + repo := createLocalGitRepo(t, map[string]string{ + "env.local.example": "NEXT_PUBLIC_AGORA_APP_ID=\nNEXT_AGORA_APP_CERTIFICATE=\n", + "app/page.tsx": "export default function Page() { return null }\n", + }) + target := filepath.Join(rootDir, "voice-agent-demo") + + result := runCLI(t, []string{"quickstart", "create", "voice-agent-demo", "--template", "nextjs", "--template-only", "--dir", target, "--json"}, cliRunOptions{env: map[string]string{ + "XDG_CONFIG_HOME": t.TempDir(), + "AGORA_LOG_LEVEL": "error", + "AGORA_QUICKSTART_NEXTJS_REPO_URL": repo, + }, workdir: rootDir}) + if result.exitCode != 0 || !strings.Contains(result.stdout, `"scenario":"voice-agent"`) { + t.Fatalf("default quickstart without manifest failed: %+v", result) + } + if _, err := os.Stat(target); err != nil { + t.Fatalf("expected default quickstart target to remain: %v", err) + } +} + func TestCLIQuickstartListAndCreate(t *testing.T) { configHome := t.TempDir() api := newFakeCLIBFF() diff --git a/internal/cli/integration_recipe_test.go b/internal/cli/integration_recipe_test.go index 6bb6163..5694cce 100644 --- a/internal/cli/integration_recipe_test.go +++ b/internal/cli/integration_recipe_test.go @@ -6,83 +6,169 @@ import ( "net/http/httptest" "os" "path/filepath" + "slices" "strings" "testing" ) func TestCLIInitFromOfficialRecipeUsesCatalogEnvContract(t *testing.T) { - configHome := t.TempDir() - rootDir := t.TempDir() - api := newFakeCLIBFF() - defer api.server.Close() - persistSessionForIntegration(t, configHome) + for _, transport := range []string{"cli", "mcp"} { + for _, tc := range []struct { + name string + features, wantFeatures []string + reuse bool + }{ + {name: "defaults", wantFeatures: []string{"rtc", "rtm", "convoai"}}, + {name: "rtc", features: []string{"rtc"}, wantFeatures: []string{"rtc"}}, + {name: "convoai", features: []string{"convoai"}, wantFeatures: []string{"rtm", "convoai"}}, + {name: "reuse", reuse: true, wantFeatures: []string{}}, + {name: "reuse with features", reuse: true, features: []string{"convoai"}, wantFeatures: []string{}}, + } { + t.Run(transport+"/"+tc.name, func(t *testing.T) { - recipeRepo := createLocalGitRepo(t, map[string]string{ - "README.md": "# Tool Calling\n", - "server/.env.example": "AGORA_APP_ID=\nAGORA_APP_CERTIFICATE=\nCUSTOM_LLM_URL=\n", - }) - recipes := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/recipes/tool-calling" { - http.NotFound(w, r) - return - } - _ = json.NewEncoder(w).Encode(map[string]any{ - "schemaVersion": 1, - "recipe": map[string]any{ - "slug": "tool-calling", - "title": "Tool Calling", - "mainRepoUrl": recipeRepo, - "recipeUrl": "https://recipes.agora.io/recipes/tool-calling", - "recipeRawUrl": "https://example.com/RECIPE.md", - "primaryPrompt": "Follow the official recipe.", - "type": "ai", - "official": true, - "cli": map[string]any{ - "projectType": "python", - "env": map[string]any{ - "examplePath": "server/.env.example", - "targetPath": "server/.env.local", - "appIdKey": "AGORA_APP_ID", - "appCertificateKey": "AGORA_APP_CERTIFICATE", - }, - "installCommand": "bun run setup", - "runCommand": "bun run dev", - }, - }, - }) - })) - defer recipes.Close() + configHome := t.TempDir() + rootDir := t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + persistSessionForIntegration(t, configHome) + if tc.reuse { + project := buildFakeProject("Existing RTC", "prj_0001", "app_0001", "global") + api.projects[project.ProjectID] = &project + } - targetDir := filepath.Join(rootDir, "tool-demo") - result := runCLI(t, []string{"init", "tool-demo", "--recipe", "tool-calling", "--new-project", "--dir", targetDir, "--json"}, cliRunOptions{ - env: map[string]string{ - "XDG_CONFIG_HOME": configHome, - "AGORA_API_BASE_URL": api.baseURL, - "AGORA_RECIPES_BASE_URL": recipes.URL + "/api/v1", - "AGORA_LOG_LEVEL": "error", - }, - workdir: rootDir, - }) - if result.exitCode != 0 || !strings.Contains(result.stdout, `"sourceType":"recipe"`) || !strings.Contains(result.stdout, `"sourceId":"tool-calling"`) || !strings.Contains(result.stdout, `"envPath":"server/.env.local"`) { - t.Fatalf("unexpected recipe init result: %+v", result) - } + recipeRepo := createLocalGitRepo(t, map[string]string{ + "README.md": "# Tool Calling\n", + "server/.env.example": "AGORA_APP_ID=\nAGORA_APP_CERTIFICATE=\nCUSTOM_LLM_URL=\n", + }) + recipes := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/recipes/tool-calling" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "schemaVersion": 1, + "recipe": map[string]any{ + "slug": "tool-calling", + "title": "Tool Calling", + "mainRepoUrl": recipeRepo, + "recipeUrl": "https://recipes.agora.io/recipes/tool-calling", + "recipeRawUrl": "https://example.com/RECIPE.md", + "primaryPrompt": "Follow the official recipe.", + "type": "ai", + "official": true, + "cli": map[string]any{ + "projectType": "python", + "env": map[string]any{ + "examplePath": "server/.env.example", + "targetPath": "server/.env.local", + "appIdKey": "AGORA_APP_ID", + "appCertificateKey": "AGORA_APP_CERTIFICATE", + }, + "installCommand": "bun run setup", + "runCommand": "bun run dev", + }, + }, + }) + })) + defer recipes.Close() - envFile, err := os.ReadFile(filepath.Join(targetDir, "server", ".env.local")) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(envFile), "AGORA_APP_ID=app_0001") || !strings.Contains(string(envFile), "CUSTOM_LLM_URL=") { - t.Fatalf("unexpected recipe env: %s", envFile) - } - binding, err := loadLocalProjectBinding(targetDir) - if err != nil { - t.Fatal(err) - } - if binding.Recipe != "tool-calling" || binding.Template != "" || binding.ProjectType != "python" || binding.EnvPath != "server/.env.local" { - t.Fatalf("unexpected recipe binding: %+v", binding) - } - if _, err := os.Stat(filepath.Join(targetDir, ".git")); !os.IsNotExist(err) { - t.Fatalf("expected cloned recipe git metadata removed, got %v", err) + targetDir := filepath.Join(rootDir, "tool-demo") + commonEnv := map[string]string{"AGORA_HOME": "", "XDG_CONFIG_HOME": configHome, "AGORA_API_BASE_URL": api.baseURL, "AGORA_RECIPES_BASE_URL": recipes.URL + "/api/v1", "AGORA_LOG_LEVEL": "error"} + var result cliResult + if transport == "cli" { + args := []string{"init", "tool-demo", "--recipe", "tool-calling", "--dir", targetDir, "--json"} + if tc.reuse { + args = append(args, "--project", "prj_0001") + } else { + args = append(args, "--new-project") + } + for _, feature := range tc.features { + args = append(args, "--feature", feature) + } + result = runCLI(t, args, cliRunOptions{env: commonEnv, workdir: rootDir}) + } else { + t.Chdir(rootDir) + for key, value := range commonEnv { + t.Setenv(key, value) + } + app, err := NewApp() + if err != nil { + t.Fatal(err) + } + args := map[string]any{"name": "tool-demo", "recipe": "tool-calling", "dir": targetDir, "newProject": !tc.reuse, "features": tc.features} + if tc.reuse { + args["project"] = "prj_0001" + } + data, err := app.callMCPTool("agora.init", args, nil) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(data) + if err != nil { + t.Fatal(err) + } + result = cliResult{stdout: string(raw)} + } + + if result.exitCode != 0 || !strings.Contains(result.stdout, `"sourceType":"recipe"`) || !strings.Contains(result.stdout, `"sourceId":"tool-calling"`) || !strings.Contains(result.stdout, `"envPath":"server/.env.local"`) { + t.Fatalf("unexpected recipe init result: %+v", result) + } + + wantFeatures, err := json.Marshal(tc.wantFeatures) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(result.stdout, `"enabledFeatures":`+string(wantFeatures)) { + t.Fatalf("unexpected enabled features: %s", result.stdout) + } + api.mu.Lock() + project := api.projects["prj_0001"] + rtm, convoai := project.FeatureState.RTMEnabled, project.FeatureState.ConvoAIEnabled + writes := 0 + for _, request := range api.requests { + if request.Method != http.MethodGet { + writes++ + } + } + api.mu.Unlock() + if rtm != slices.Contains(tc.wantFeatures, "rtm") || convoai != slices.Contains(tc.wantFeatures, "convoai") { + t.Fatalf("unexpected API feature state: rtm=%v convoai=%v", rtm, convoai) + } + if tc.reuse && writes != 0 { + t.Fatalf("recipe reuse made %d remote writes", writes) + } + if tc.name == "defaults" { + doctor := runCLI(t, []string{"project", "doctor", "--deep", "--json"}, cliRunOptions{env: commonEnv, workdir: targetDir}) + var report struct { + Data projectDoctorResult `json:"data"` + } + if err := json.Unmarshal([]byte(doctor.stdout), &report); err != nil { + t.Fatal(err) + } + if doctor.exitCode != 2 || !report.Data.Healthy || report.Data.Status != "warning" || len(report.Data.BlockingIssues) != 0 { + t.Fatalf("recipe incorrectly blocked: %+v", doctor) + } + } + envFile, err := os.ReadFile(filepath.Join(targetDir, "server", ".env.local")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(envFile), "AGORA_APP_ID=app_0001") || !strings.Contains(string(envFile), "CUSTOM_LLM_URL=") { + t.Fatalf("unexpected recipe env: %s", envFile) + } + binding, err := loadLocalProjectBinding(targetDir) + if err != nil { + t.Fatal(err) + } + if binding.Recipe != "tool-calling" || binding.Template != "" || binding.ProjectType != "python" || binding.EnvPath != "server/.env.local" { + t.Fatalf("unexpected recipe binding: %+v", binding) + } + if _, err := os.Stat(filepath.Join(targetDir, ".git")); !os.IsNotExist(err) { + t.Fatalf("expected cloned recipe git metadata removed, got %v", err) + } + + }) + } } } diff --git a/internal/cli/introspect.go b/internal/cli/introspect.go index 274b3cd..37559ec 100644 --- a/internal/cli/introspect.go +++ b/internal/cli/introspect.go @@ -79,10 +79,13 @@ func buildIntrospectionData(root *cobra.Command) map[string]any { "globalFlags": globalFlags, "pseudoCommands": buildPseudoCommands(), "enums": map[string][]string{ - "features": featureIDs(), - "recipeTypes": {"all", "ai", "rtc"}, - "outputModes": {"pretty", "json"}, - "doctorStatus": {"healthy", "warning", "not_ready", "auth_error"}, + "features": featureIDs(), + "recipeTypes": {"all", "ai", "rtc"}, + "outputModes": {"pretty", "json"}, + "doctorStatus": {"healthy", "warning", "not_ready", "auth_error"}, + "projectTemplates": projectPresetIDs(), + "quickstartTemplates": quickstartTemplateIDs(), + "quickstartScenarios": quickstartScenarioIDs(), }, "version": versionInfo(), } diff --git a/internal/cli/local_project.go b/internal/cli/local_project.go index 43a0a14..148850e 100644 --- a/internal/cli/local_project.go +++ b/internal/cli/local_project.go @@ -20,6 +20,7 @@ type localProjectBinding struct { ProjectType string `json:"projectType,omitempty"` Template string `json:"template,omitempty"` Recipe string `json:"recipe,omitempty"` + Scenario string `json:"scenario,omitempty"` EnvPath string `json:"envPath,omitempty"` } diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index 3c4ec26..d10dc02 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -244,12 +244,13 @@ func mcpTools() []map[string]any { mcpTool("agora.quickstart.create", "Clone a quickstart with a project or explicitly as template-only", map[string]string{ "name": "string", "template": "string", + "scenario": "string", "project": "string", "templateOnly": "boolean", "ref": "string", "dir": "string", }), - mcpTool("agora.quickstart.env_write", "Write env values into a previously-cloned quickstart", map[string]string{"dir": "string", "template": "string", "project": "string"}), + mcpTool("agora.quickstart.env_write", "Write env values into a previously-cloned quickstart", map[string]string{"dir": "string", "template": "string", "scenario": "string", "project": "string"}), // Recipe catalog mcpTool("agora.recipes.list", "List recipes from the Agora catalog", map[string]string{"type": "string"}), @@ -261,6 +262,7 @@ func mcpTools() []map[string]any { "dir": "string", "template": "string", "recipe": "string", + "scenario": "string", "project": "string", "newProject": "boolean", "rtmDataCenter": "string", @@ -396,7 +398,11 @@ func (a *App) callMCPTool(name string, args map[string]any, progress progressEmi return nil, err } features := stringSliceArg(args, "features") - progress.emit("project:create", "Creating Agora project", map[string]any{"projectName": name, "features": projectCreateFeatures(stringArg(args, "template"), features)}) + plannedFeatures, err := resolveProjectCreateFeatures(stringArg(args, "template"), features) + if err != nil { + return nil, err + } + progress.emit("project:create", "Creating Agora project", map[string]any{"projectName": name, "features": plannedFeatures}) result, err := a.projectCreate( name, stringArg(args, "template"), @@ -416,7 +422,7 @@ func (a *App) callMCPTool(name string, args map[string]any, progress progressEmi return a.projectEnvValues(stringArg(args, "project"), boolArg(args, "withSecrets", false)) case "agora.project.env_write": - return a.quickstartEnvWrite(defaultString(stringArg(args, "workspaceDir"), "."), stringArg(args, "template"), stringArg(args, "project")) + return a.quickstartEnvWrite(defaultString(stringArg(args, "workspaceDir"), "."), stringArg(args, "template"), "", stringArg(args, "project")) case "agora.project.feature.list": target, err := a.resolveProjectTarget(stringArg(args, "project")) @@ -517,23 +523,23 @@ func (a *App) callMCPTool(name string, args map[string]any, progress progressEmi if !template.Available { continue } - items = append(items, map[string]any{"id": template.ID, "title": template.Title, "runtime": template.Runtime, "repoUrl": template.RepoURL, "supportsInit": template.SupportsInit}) + items = append(items, map[string]any{"id": template.ID, "template": template.Template, "scenario": template.Scenario, "requiredFeatures": append([]string{}, template.RequiredFeatures...), "title": template.Title, "runtime": template.Runtime, "repoUrl": template.RepoURL, "supportsInit": template.SupportsInit}) } return map[string]any{"items": items}, nil case "agora.quickstart.create": - template, ok := findQuickstartTemplate(stringArg(args, "template")) - if !ok { - return nil, &cliError{Message: "unknown quickstart template. Run `agora quickstart list`.", Code: "QUICKSTART_TEMPLATE_UNKNOWN"} + template, err := selectQuickstartDefinition(stringArg(args, "template"), stringArg(args, "scenario")) + if err != nil { + return nil, err } target := defaultString(stringArg(args, "dir"), stringArg(args, "name")) if target == "" { return nil, errors.New("name or dir is required") } - return a.quickstartCreate(*template, target, stringArg(args, "project"), boolArg(args, "templateOnly", false), false, io.Discard, bytes.NewReader(nil), stringArg(args, "ref"), progress) + return a.quickstartCreate(template, target, stringArg(args, "project"), boolArg(args, "templateOnly", false), false, io.Discard, bytes.NewReader(nil), stringArg(args, "ref"), progress) case "agora.quickstart.env_write": - return a.quickstartEnvWrite(defaultString(stringArg(args, "dir"), "."), stringArg(args, "template"), stringArg(args, "project")) + return a.quickstartEnvWrite(defaultString(stringArg(args, "dir"), "."), stringArg(args, "template"), stringArg(args, "scenario"), stringArg(args, "project")) case "agora.recipes.list": response, err := a.listRecipes(defaultString(stringArg(args, "type"), "all")) @@ -562,6 +568,10 @@ func (a *App) callMCPTool(name string, args map[string]any, progress progressEmi if templateID == "" && recipeID == "" { return nil, &cliError{Message: "init source is required; pass template or recipe.", Code: "INIT_SOURCE_REQUIRED"} } + scenario := stringArg(args, "scenario") + if recipeID != "" && scenario != "" { + return nil, &cliError{Message: "scenario is only valid with a quickstart template.", Code: "INIT_SOURCE_CONFLICT"} + } // CRITICAL: when serving over stdio, os.Stdin is the JSON-RPC // transport stream and os.Stderr might be observed by the // host. Pass an empty reader and an in-memory writer so a @@ -576,11 +586,11 @@ func (a *App) callMCPTool(name string, args map[string]any, progress progressEmi } return a.initRecipeProject(name, targetDir, recipe, stringArg(args, "project"), stringSliceArg(args, "features"), stringArg(args, "rtmDataCenter"), boolArg(args, "newProject", false), false, &promptOut, bytes.NewReader(nil), progress) } - template, ok := findQuickstartTemplate(templateID) - if !ok { - return nil, &cliError{Message: "unknown quickstart template. Run `agora quickstart list`.", Code: "QUICKSTART_TEMPLATE_UNKNOWN"} + template, err := selectQuickstartDefinition(templateID, scenario) + if err != nil { + return nil, err } - return a.initProject(name, targetDir, *template, stringArg(args, "project"), stringSliceArg(args, "features"), stringArg(args, "rtmDataCenter"), boolArg(args, "newProject", false), false, &promptOut, bytes.NewReader(nil), progress) + return a.initProject(name, targetDir, template, stringArg(args, "project"), stringSliceArg(args, "features"), stringArg(args, "rtmDataCenter"), boolArg(args, "newProject", false), false, &promptOut, bytes.NewReader(nil), progress) default: return nil, fmt.Errorf("unknown MCP tool %q", name) diff --git a/internal/cli/mcp_test.go b/internal/cli/mcp_test.go index 871b7bd..c927c74 100644 --- a/internal/cli/mcp_test.go +++ b/internal/cli/mcp_test.go @@ -185,6 +185,28 @@ func TestMCPInitRequiresExactlyOneSource(t *testing.T) { assertCLIErrorCode(t, err, "INIT_SOURCE_CONFLICT") } +func TestMCPQuickstartToolsExposeScenario(t *testing.T) { + wantScenario := map[string]bool{ + "agora.init": true, + "agora.quickstart.create": true, + "agora.quickstart.env_write": true, + } + for _, tool := range mcpTools() { + name, _ := tool["name"].(string) + if !wantScenario[name] { + continue + } + properties := tool["inputSchema"].(map[string]any)["properties"].(map[string]any) + if scenario, ok := properties["scenario"].(map[string]any); !ok || scenario["type"] != "string" { + t.Fatalf("%s scenario schema = %#v, want string", name, properties["scenario"]) + } + delete(wantScenario, name) + } + if len(wantScenario) != 0 { + t.Fatalf("missing MCP tools: %v", wantScenario) + } +} + func TestMCPProjectWebhookDeleteRequiresConfirm(t *testing.T) { a := newTestApp(t) _, err := a.callMCPTool("agora.project.webhook.delete", map[string]any{ @@ -326,6 +348,7 @@ func TestMCPQuickstartCreateEmitsProgressNotifications(t *testing.T) { } func TestMCPQuickstartCreateRequiresProjectOrTemplateOnly(t *testing.T) { + t.Chdir(t.TempDir()) a := newTestApp(t) target := filepath.Join(t.TempDir(), "demo") frame := []byte(`{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"agora.quickstart.create","arguments":{"template":"nextjs","dir":` + strconv.Quote(target) + `}}}` + "\n") diff --git a/internal/cli/projects.go b/internal/cli/projects.go index 7e7bb4f..1fafbd8 100644 --- a/internal/cli/projects.go +++ b/internal/cli/projects.go @@ -289,7 +289,10 @@ func (a *App) projectCreate(name, template string, features []string, rtmDataCen return nil, err } region := currentRegionFromContext(ctx) - features = projectCreateFeatures(template, features) + features, err = resolveProjectCreateFeatures(template, features) + if err != nil { + return nil, err + } rtmDataCenter, err = rtmDataCenterForFeatures(features, rtmDataCenter) if err != nil { return nil, err @@ -321,7 +324,7 @@ func (a *App) projectCreate(name, template string, features []string, rtmDataCen // command that re-fetches the list. Wipe it so the next completion // triggers a refresh. _ = clearProjectListCache(a.env) - result := map[string]any{"action": "create", "appId": project.AppID, "enabledFeatures": enabled, "projectId": project.ProjectID, "projectName": project.Name, "region": region} + result := map[string]any{"action": "create", "appId": project.AppID, "enabledFeatures": enabled, "projectId": project.ProjectID, "projectName": project.Name, "region": region, "template": template} if rtmDataCenter != "" { result["rtmDataCenter"] = rtmDataCenter } @@ -357,23 +360,75 @@ func rtmDataCenterForFeatures(features []string, value string) (string, error) { return normalized, nil } -func normalizeProjectCreateFeatures(features []string) []string { - if len(features) == 0 { - return defaultInitFeatures() +type projectPreset struct { + ID string + RequiredFeatures []string +} + +var projectPresets = []projectPreset{ + {ID: "video-call", RequiredFeatures: []string{"rtc"}}, + {ID: "voice-agent", RequiredFeatures: []string{"rtc", "rtm", "convoai"}}, +} + +func findProjectPreset(id string) (projectPreset, bool) { + for _, preset := range projectPresets { + if preset.ID == id { + return preset, true + } } - return features + return projectPreset{}, false } -func projectCreateFeatures(template string, features []string) []string { - next := append([]string{}, features...) - if template == "voice-agent" { - next = append(next, featureIDs()...) +func projectPresetIDs() []string { + ids := make([]string, 0, len(projectPresets)) + for _, preset := range projectPresets { + ids = append(ids, preset.ID) } - next = normalizeProjectCreateFeatures(next) - if featureListIncludes(next, "convoai") && !featureListIncludes(next, "rtm") { - next = append([]string{"rtm"}, next...) + return ids +} + +func resolveProjectCreateFeatures(template string, features []string) ([]string, error) { + next := make([]string, 0, len(features)+len(featureCatalog)) + if template != "" { + preset, ok := findProjectPreset(template) + if !ok { + return nil, &cliError{ + Message: fmt.Sprintf("Unknown project template %q. Valid templates: %s.", template, strings.Join(projectPresetIDs(), ", ")), + Code: "PROJECT_TEMPLATE_UNKNOWN", + } + } + next = append(next, preset.RequiredFeatures...) + } + next = append(next, features...) + if len(next) == 0 { + next = defaultInitFeatures() + } + return mergeFeatureRequirements(next) +} + +func mergeFeatureRequirements(featureGroups ...[]string) ([]string, error) { + next := []string{} + for _, features := range featureGroups { + next = append(next, features...) + } + if featureListIncludes(next, "convoai") { + next = append(next, "rtm") + } + + selected := make(map[string]bool, len(next)) + for _, feature := range next { + if err := validateFeatureID(feature); err != nil { + return nil, err + } + selected[feature] = true + } + ordered := make([]string, 0, len(selected)) + for _, feature := range featureIDs() { + if selected[feature] { + ordered = append(ordered, feature) + } } - return next + return ordered, nil } func featureListIncludes(features []string, target string) bool { diff --git a/internal/cli/quickstart.go b/internal/cli/quickstart.go index ce569db..e1fed71 100644 --- a/internal/cli/quickstart.go +++ b/internal/cli/quickstart.go @@ -2,6 +2,7 @@ package cli import ( "bufio" + "encoding/json" "errors" "fmt" "io" @@ -15,12 +16,26 @@ import ( "github.com/spf13/cobra" ) +const quickstartManifestFileName = "agora.quickstart.json" + +var errQuickstartTemplateUndetected = errors.New("could not detect the quickstart type from this directory") + +type quickstartManifest struct { + SchemaVersion int `json:"schemaVersion"` + Template string `json:"template"` + Scenario string `json:"scenario"` +} + type quickstartTemplate struct { - ID string - Title string - Description string - Runtime string - RepoURL string + ID string + Template string + Scenario string + DefaultScenario bool + RequiredFeatures []string + Title string + Description string + Runtime string + RepoURL 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 @@ -52,14 +67,18 @@ type quickstartEnvLayout struct { func quickstartTemplates() []quickstartTemplate { return []quickstartTemplate{ { - ID: "nextjs", - Title: "Conversational AI Next.js Quickstart", - Description: "Clone the official Next.js conversational AI quickstart.", - Runtime: "node", - RepoURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-nextjs", - RepoURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-nextjs", - DocsURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-nextjs", - DocsURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-nextjs", + ID: "nextjs", + Template: "nextjs", + Scenario: "voice-agent", + DefaultScenario: true, + RequiredFeatures: []string{"rtc", "rtm", "convoai"}, + Title: "Conversational AI Next.js Quickstart", + Description: "Clone the official Next.js conversational AI quickstart.", + Runtime: "node", + RepoURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-nextjs", + RepoURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-nextjs", + DocsURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-nextjs", + DocsURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-nextjs", EnvLayouts: []quickstartEnvLayout{{ DetectPaths: []string{"env.local.example", "app"}, EnvExamplePath: "env.local.example", @@ -74,14 +93,18 @@ func quickstartTemplates() []quickstartTemplate { Available: true, }, { - ID: "python", - Title: "Conversational AI Python Quickstart", - Description: "Clone the official Python conversational AI quickstart.", - Runtime: "python", - RepoURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-python", - RepoURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-python", - DocsURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-python", - DocsURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-python", + ID: "python", + Template: "python", + Scenario: "voice-agent", + DefaultScenario: true, + RequiredFeatures: []string{"rtc", "rtm", "convoai"}, + Title: "Conversational AI Python Quickstart", + Description: "Clone the official Python conversational AI quickstart.", + Runtime: "python", + RepoURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-python", + RepoURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-python", + DocsURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-python", + DocsURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-python", EnvLayouts: []quickstartEnvLayout{ { DetectPaths: []string{"server/requirements.txt"}, @@ -105,14 +128,18 @@ func quickstartTemplates() []quickstartTemplate { Available: true, }, { - ID: "go", - Title: "Conversational AI Go Quickstart", - Description: "Clone the official Go conversational AI quickstart.", - Runtime: "go", - RepoURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-go", - RepoURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-go", - DocsURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-go", - DocsURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-go", + ID: "go", + Template: "go", + Scenario: "voice-agent", + DefaultScenario: true, + RequiredFeatures: []string{"rtc", "rtm", "convoai"}, + Title: "Conversational AI Go Quickstart", + Description: "Clone the official Go conversational AI quickstart.", + Runtime: "go", + RepoURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-go", + RepoURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-go", + DocsURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-go", + DocsURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-go", EnvLayouts: []quickstartEnvLayout{ { DetectPaths: []string{"server/go.mod"}, @@ -136,14 +163,18 @@ func quickstartTemplates() []quickstartTemplate { Available: true, }, { - ID: "android", - Title: "Conversational AI Android Quickstart", - Description: "Clone the official Android client and Python server quickstart.", - Runtime: "android", - RepoURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-android", - 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", + ID: "android", + Template: "android", + Scenario: "voice-agent", + DefaultScenario: true, + RequiredFeatures: []string{"rtc", "rtm", "convoai"}, + Title: "Conversational AI Android Quickstart", + Description: "Clone the official Android client and Python server quickstart.", + Runtime: "android", + RepoURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-android", + 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", EnvLayouts: []quickstartEnvLayout{{ DetectPaths: []string{"server/.env.example", "server/requirements-dev.txt", "app/src/main/AndroidManifest.xml"}, EnvExamplePath: "server/.env.example", @@ -162,12 +193,35 @@ func quickstartTemplates() []quickstartTemplate { SupportsInit: true, Available: true, }, + { + ID: "nextjs-video-call", + Template: "nextjs", + Scenario: "video-call", + RequiredFeatures: []string{"rtc"}, + Title: "Next.js Video Call Quickstart", + Description: "Build a one-to-one RTC audio and video call.", + Runtime: "node", + RepoURL: "https://github.com/AgoraIO-Community/agora-rtc-quickstart-nextjs", + DocsURL: "https://github.com/AgoraIO-Community/agora-rtc-quickstart-nextjs", + EnvLayouts: []quickstartEnvLayout{{ + DetectPaths: []string{"agora.quickstart.json", "env.local.example"}, + EnvExamplePath: "env.local.example", + EnvTargetPath: ".env.local", + AppIDKey: "NEXT_PUBLIC_AGORA_APP_ID", + AppCertificateKey: "NEXT_AGORA_APP_CERTIFICATE", + }}, + InstallCommand: "pnpm install", + RunCommand: "pnpm dev", + EnvDocsSummary: "Writes NEXT_PUBLIC_AGORA_APP_ID for the browser and NEXT_AGORA_APP_CERTIFICATE for server-side token generation.", + SupportsInit: true, + Available: true, + }, } } func findQuickstartTemplate(id string) (*quickstartTemplate, bool) { for _, template := range quickstartTemplates() { - if template.ID == id { + if template.ID == id || (template.Template == id && template.DefaultScenario) { copy := template return ©, true } @@ -175,6 +229,62 @@ func findQuickstartTemplate(id string) (*quickstartTemplate, bool) { return nil, false } +func quickstartTemplateIDs() []string { + ids := []string{} + seen := map[string]bool{} + for _, definition := range quickstartTemplates() { + if !seen[definition.Template] { + ids = append(ids, definition.Template) + seen[definition.Template] = true + } + } + return ids +} + +func quickstartScenarioIDs() []string { + ids := []string{} + seen := map[string]bool{} + for _, definition := range quickstartTemplates() { + if !seen[definition.Scenario] { + ids = append(ids, definition.Scenario) + seen[definition.Scenario] = true + } + } + return ids +} + +func selectQuickstartDefinition(templateID, scenario string) (quickstartTemplate, error) { + templateID = strings.TrimSpace(templateID) + scenario = strings.TrimSpace(scenario) + knownTemplate := false + knownScenario := false + for _, definition := range quickstartTemplates() { + if definition.Scenario == scenario { + knownScenario = true + } + if definition.Template != templateID { + continue + } + knownTemplate = true + if scenario == "" && definition.DefaultScenario { + return definition, nil + } + if scenario != "" && definition.Scenario == scenario { + return definition, nil + } + } + if !knownTemplate { + return quickstartTemplate{}, &cliError{Message: fmt.Sprintf("Unknown quickstart template %q. Run `agora quickstart list` to see valid templates.", templateID), Code: "QUICKSTART_TEMPLATE_UNKNOWN"} + } + if scenario == "" { + return quickstartTemplate{}, &cliError{Message: fmt.Sprintf("Quickstart template %q has no default scenario.", templateID), Code: "QUICKSTART_SCENARIO_UNKNOWN"} + } + if knownScenario { + return quickstartTemplate{}, &cliError{Message: fmt.Sprintf("Scenario %q is not supported by template %q. Run `agora quickstart list` to see supported combinations.", scenario, templateID), Code: "QUICKSTART_SCENARIO_UNSUPPORTED"} + } + return quickstartTemplate{}, &cliError{Message: fmt.Sprintf("Unknown quickstart scenario %q. Run `agora quickstart list` to see valid scenarios.", scenario), Code: "QUICKSTART_SCENARIO_UNKNOWN"} +} + func (a *App) buildQuickstartCommand() *cobra.Command { cmd := &cobra.Command{ Use: "quickstart", @@ -221,15 +331,20 @@ func (a *App) buildQuickstartList() *cobra.Command { continue } items = append(items, map[string]any{ - "available": template.Available, - "description": template.Description, - "docsUrl": quickstartDocsURL(template, a.authRegion()), - "envDocs": template.EnvDocsSummary, - "id": template.ID, - "repoUrl": quickstartRepoURLForRegion(template, a.authRegion()), - "runtime": template.Runtime, - "supportsInit": template.SupportsInit, - "title": template.Title, + "available": template.Available, + "description": template.Description, + "docsUrl": quickstartDocsURL(template, a.authRegion()), + "envDocs": template.EnvDocsSummary, + "id": template.ID, + "installCommand": template.InstallCommand, + "repoUrl": quickstartRepoURLForRegion(template, a.authRegion()), + "requiredFeatures": append([]string{}, template.RequiredFeatures...), + "runCommand": template.RunCommand, + "runtime": template.Runtime, + "scenario": template.Scenario, + "supportsInit": template.SupportsInit, + "template": template.Template, + "title": template.Title, }) } return renderResult(cmd, "quickstart list", map[string]any{ @@ -246,6 +361,7 @@ func (a *App) buildQuickstartList() *cobra.Command { func (a *App) buildQuickstartCreate() *cobra.Command { var templateID string + var scenario string var dir string var project string var ref string @@ -267,9 +383,9 @@ If a current project context exists, or if --project is passed, the CLI also wri if len(args) != 1 { return errors.New("quickstart name is required") } - template, ok := findQuickstartTemplate(templateID) - if !ok { - return &cliError{Message: fmt.Sprintf("unknown quickstart template %q. Run `agora quickstart list` to see available templates.", templateID), Code: "QUICKSTART_TEMPLATE_UNKNOWN"} + template, err := selectQuickstartDefinition(templateID, scenario) + if err != nil { + return err } targetDir := dir if strings.TrimSpace(targetDir) == "" { @@ -281,7 +397,7 @@ If a current project context exists, or if --project is passed, the CLI also wri !isCIEnvironment(a.osEnv) && isTTY(os.Stdin) progress := jsonProgressFor(a, cmd, "quickstart create") - result, err := a.quickstartCreate(*template, targetDir, project, templateOnly, promptForProject, cmd.ErrOrStderr(), os.Stdin, ref, progress) + result, err := a.quickstartCreate(template, targetDir, project, templateOnly, promptForProject, cmd.ErrOrStderr(), os.Stdin, ref, progress) if err != nil { return err } @@ -289,6 +405,7 @@ If a current project context exists, or if --project is passed, the CLI also wri }, } cmd.Flags().StringVar(&templateID, "template", "", "quickstart template ID from `agora quickstart list`") + cmd.Flags().StringVar(&scenario, "scenario", "", "quickstart scenario; omitted selects the template default") cmd.Flags().StringVar(&dir, "dir", "", "target directory for the cloned quickstart; defaults to ") cmd.Flags().StringVar(&project, "project", "", "project ID or exact project name to use for env seeding") cmd.Flags().StringVar(&ref, "ref", "", "git branch, tag, or ref to clone for pinned workshops") @@ -296,6 +413,7 @@ If a current project context exists, or if --project is passed, the CLI also wri cmd.MarkFlagsMutuallyExclusive("project", "template-only") _ = cmd.MarkFlagRequired("template") _ = cmd.RegisterFlagCompletionFunc("template", completeQuickstartTemplateIDs) + _ = cmd.RegisterFlagCompletionFunc("scenario", completeQuickstartScenarios) _ = cmd.RegisterFlagCompletionFunc("project", a.completeProjectNames) return cmd } @@ -363,6 +481,7 @@ func chooseQuickstartProject(in io.Reader, out io.Writer, items []projectSummary func (a *App) buildQuickstartEnv() *cobra.Command { var templateID string + var scenario string var project string cmd := &cobra.Command{ Use: "env", @@ -405,7 +524,7 @@ Python and Go quickstarts receive backend AGORA_APP_ID and AGORA_APP_CERTIFICATE if len(args) > 0 && strings.TrimSpace(args[0]) != "" { targetDir = args[0] } - result, err := a.quickstartEnvWrite(targetDir, templateID, project) + result, err := a.quickstartEnvWrite(targetDir, templateID, scenario, project) if err != nil { return err } @@ -413,8 +532,10 @@ Python and Go quickstarts receive backend AGORA_APP_ID and AGORA_APP_CERTIFICATE }, } write.Flags().StringVar(&templateID, "template", "", "quickstart template ID; if omitted, the CLI detects it from the repo layout") + write.Flags().StringVar(&scenario, "scenario", "", "quickstart scenario; if omitted, use binding, manifest, or the template default") write.Flags().StringVar(&project, "project", "", "project ID or exact project name to use for env seeding") _ = write.RegisterFlagCompletionFunc("template", completeQuickstartTemplateIDs) + _ = write.RegisterFlagCompletionFunc("scenario", completeQuickstartScenarios) _ = write.RegisterFlagCompletionFunc("project", a.completeProjectNames) cmd.AddCommand(write) return cmd @@ -470,7 +591,7 @@ func (a *App) quickstartCreate(template quickstartTemplate, targetDir, explicitP if overrideKey != "" { progress.emit("clone:override", fmt.Sprintf("Using repo override from %s", overrideKey), map[string]any{"repoUrl": repoURL, "envVar": overrideKey}) } - if err := cloneScaffoldRepo(repoURL, absTarget, ref, progress); err != nil { + if err := cloneScaffoldRepo(repoURL, absTarget, ref, progress, &template); err != nil { return nil, err } @@ -493,7 +614,8 @@ func (a *App) quickstartCreate(template quickstartTemplate, targetDir, explicitP ProjectID: boundProject.project.ProjectID, ProjectName: boundProject.project.Name, Region: boundProject.region, - Template: template.ID, + Template: template.Template, + Scenario: template.Scenario, EnvPath: writtenPath, }); err != nil { if cleanupErr := os.RemoveAll(absTarget); cleanupErr != nil { @@ -506,24 +628,30 @@ func (a *App) quickstartCreate(template quickstartTemplate, targetDir, explicitP written = append(written, writtenPath, filepath.ToSlash(filepath.Join(localAgoraDirName, localProjectFileName))) } sort.Strings(written) + setup := resolveQuickstartSetup(template, absTarget, probeQuickstartTool) result := map[string]any{ - "action": "create", - "cloneUrl": repoURL, - "docsUrl": quickstartDocsURL(template, a.authRegion()), - "envPath": envPath, - "envStatus": envStatus, - "metadataPath": "", - "path": absTarget, - "projectId": nil, - "projectName": nil, - "runtime": template.Runtime, - "status": "cloned", - "template": template.ID, - "title": template.Title, - "written": written, - "nextSteps": initNextSteps(template, absTarget), - "ref": ref, + "action": "create", + "cloneUrl": repoURL, + "docsUrl": quickstartDocsURL(template, a.authRegion()), + "envPath": envPath, + "envStatus": envStatus, + "metadataPath": "", + "path": absTarget, + "projectId": nil, + "projectName": nil, + "runtime": template.Runtime, + "scenario": template.Scenario, + "status": "cloned", + "template": template.Template, + "requiredFeatures": append([]string{}, template.RequiredFeatures...), + "title": template.Title, + "written": written, + "nextSteps": setup.NextSteps, + "ref": ref, + } + if setup.PackageManager != nil { + result["packageManager"] = setup.PackageManager } if boundProject != nil { result["projectId"] = boundProject.project.ProjectID @@ -546,12 +674,20 @@ func resolveScaffoldTarget(targetDir string) (string, error) { return absTarget, nil } -func cloneScaffoldRepo(repoURL, absTarget, ref string, progress progressEmitter) error { +func cloneScaffoldRepo(repoURL, absTarget, ref string, progress progressEmitter, expected ...*quickstartTemplate) error { progress.emit("clone:start", "Cloning scaffold repository", map[string]any{"repoUrl": repoURL, "targetPath": absTarget, "ref": ref}) if err := cloneQuickstartRepo(repoURL, absTarget, ref); err != nil { return err } progress.emit("clone:complete", "Scaffold repository cloned", map[string]any{"targetPath": absTarget}) + if len(expected) > 0 { + if err := validateRequiredQuickstartManifest(absTarget, *expected[0]); err != nil { + if cleanupErr := os.RemoveAll(absTarget); cleanupErr != nil { + return fmt.Errorf("%w; cleanup also failed for %s: %v", err, absTarget, cleanupErr) + } + return fmt.Errorf("%w; removed %s", err, absTarget) + } + } if err := stripClonedGitMetadata(absTarget); err != nil { if cleanupErr := os.RemoveAll(absTarget); cleanupErr != nil { return fmt.Errorf("failed to remove scaffold git metadata after clone: %v; cleanup also failed for %s: %v", err, absTarget, cleanupErr) @@ -562,7 +698,7 @@ func cloneScaffoldRepo(repoURL, absTarget, ref string, progress progressEmitter) return nil } -func (a *App) quickstartEnvWrite(targetDir, templateID, explicitProject string) (map[string]any, error) { +func (a *App) quickstartEnvWrite(targetDir, templateID, scenario, explicitProject string) (map[string]any, error) { absTarget, err := filepath.Abs(targetDir) if err != nil { return nil, err @@ -575,7 +711,7 @@ func (a *App) quickstartEnvWrite(targetDir, templateID, explicitProject string) return nil, fmt.Errorf("%s is not a directory.", absTarget) } - template, layout, err := resolveQuickstartEnvWriteTarget(absTarget, templateID) + template, layout, err := resolveQuickstartEnvWriteTarget(absTarget, templateID, scenario) if err != nil { return nil, err } @@ -595,21 +731,24 @@ func (a *App) quickstartEnvWrite(targetDir, templateID, explicitProject string) ProjectID: target.project.ProjectID, ProjectName: target.project.Name, Region: target.region, - Template: template.ID, + Template: template.Template, + Scenario: template.Scenario, EnvPath: envPath, }); err != nil { return nil, err } return map[string]any{ - "action": "env-write", - "envPath": envPath, - "metadataPath": filepath.ToSlash(filepath.Join(localAgoraDirName, localProjectFileName)), - "path": absTarget, - "projectId": target.project.ProjectID, - "projectName": target.project.Name, - "status": status, - "template": template.ID, - "title": template.Title, + "action": "env-write", + "envPath": envPath, + "metadataPath": filepath.ToSlash(filepath.Join(localAgoraDirName, localProjectFileName)), + "path": absTarget, + "projectId": target.project.ProjectID, + "projectName": target.project.Name, + "status": status, + "template": template.Template, + "scenario": template.Scenario, + "requiredFeatures": append([]string{}, template.RequiredFeatures...), + "title": template.Title, }, nil } @@ -763,15 +902,117 @@ func (a *App) resolveOptionalProjectTarget(explicitProject, startPath string) (p return target, true, nil } -func resolveQuickstartTemplateForPath(root, explicitTemplate string) (quickstartTemplate, error) { - if strings.TrimSpace(explicitTemplate) != "" { - template, ok := findQuickstartTemplate(explicitTemplate) - if !ok { - return quickstartTemplate{}, &cliError{Message: fmt.Sprintf("unknown quickstart template %q. Run `agora quickstart list` to see available templates.", explicitTemplate), Code: "QUICKSTART_TEMPLATE_UNKNOWN"} +func readQuickstartManifest(root string) (quickstartManifest, bool, error) { + raw, err := os.ReadFile(filepath.Join(root, quickstartManifestFileName)) + if errors.Is(err, os.ErrNotExist) { + return quickstartManifest{}, false, nil + } + if err != nil { + return quickstartManifest{}, false, err + } + var manifest quickstartManifest + if err := json.Unmarshal(raw, &manifest); err != nil { + return quickstartManifest{}, true, &cliError{Message: fmt.Sprintf("Invalid %s: %v.", quickstartManifestFileName, err), Code: "QUICKSTART_MANIFEST_INVALID"} + } + if manifest.SchemaVersion != 1 || strings.TrimSpace(manifest.Template) == "" || strings.TrimSpace(manifest.Scenario) == "" { + return quickstartManifest{}, true, &cliError{Message: fmt.Sprintf("Invalid %s: schemaVersion must be 1 and template/scenario are required.", quickstartManifestFileName), Code: "QUICKSTART_MANIFEST_INVALID"} + } + if _, err := selectQuickstartDefinition(manifest.Template, manifest.Scenario); err != nil { + return quickstartManifest{}, true, &cliError{Message: fmt.Sprintf("Invalid %s selection %s + %s: %v", quickstartManifestFileName, manifest.Template, manifest.Scenario, err), Code: "QUICKSTART_MANIFEST_INVALID"} + } + return manifest, true, nil +} + +func validateRequiredQuickstartManifest(root string, expected quickstartTemplate) error { + if expected.DefaultScenario { + return nil + } + manifest, found, err := readQuickstartManifest(root) + if err != nil { + return err + } + if !found { + return &cliError{ + Message: fmt.Sprintf("Required %s is missing for %s + %s.", quickstartManifestFileName, expected.Template, expected.Scenario), + Code: "QUICKSTART_MANIFEST_INVALID", } - return *template, nil } + actual, err := selectQuickstartDefinition(manifest.Template, manifest.Scenario) + if err != nil { + return err + } + if !sameQuickstartSelection(expected, actual) { + return quickstartSelectionMismatch("selected quickstart", expected, quickstartManifestFileName, actual) + } + return nil +} + +func sameQuickstartSelection(a, b quickstartTemplate) bool { + return a.Template == b.Template && a.Scenario == b.Scenario +} + +func quickstartSelectionMismatch(leftName string, left quickstartTemplate, rightName string, right quickstartTemplate) error { + return &cliError{ + Message: fmt.Sprintf("Quickstart selection mismatch: %s is %s + %s, but %s is %s + %s.", leftName, left.Template, left.Scenario, rightName, right.Template, right.Scenario), + Code: "QUICKSTART_SELECTION_MISMATCH", + } +} + +func resolveQuickstartTemplateForPath(root, explicitTemplate, explicitScenario string) (quickstartTemplate, error) { + manifest, foundManifest, err := readQuickstartManifest(root) + if err != nil { + return quickstartTemplate{}, err + } + binding, foundBinding, bindingRoot, err := detectLocalProjectBindingFrom(root) + if err != nil { + return quickstartTemplate{}, err + } + foundBinding = foundBinding && bindingRoot == root && strings.TrimSpace(binding.Template) != "" + + // Keep missing fields unset until all sources have been reconciled. A + // template-only flag or legacy binding must not invent a default scenario + // that conflicts with the scenario already declared by the workspace. + type namedSelection struct { + name, template, scenario string + } + selections := []namedSelection{} + if foundBinding { + selections = append(selections, namedSelection{".agora/project.json", strings.TrimSpace(binding.Template), strings.TrimSpace(binding.Scenario)}) + } + if foundManifest { + selections = append(selections, namedSelection{quickstartManifestFileName, strings.TrimSpace(manifest.Template), strings.TrimSpace(manifest.Scenario)}) + } + explicitTemplate = strings.TrimSpace(explicitTemplate) + explicitScenario = strings.TrimSpace(explicitScenario) + if explicitTemplate == "" && explicitScenario != "" && len(selections) == 0 { + return quickstartTemplate{}, &cliError{Message: "--scenario requires --template when the repository has no binding or manifest.", Code: "QUICKSTART_TEMPLATE_REQUIRED"} + } + if explicitTemplate != "" || explicitScenario != "" { + selections = append([]namedSelection{{"explicit flags", explicitTemplate, explicitScenario}}, selections...) + } + if len(selections) > 0 { + var templateID, scenario, templateSource, scenarioSource string + for _, selection := range selections { + if selection.template != "" { + if templateID != "" && templateID != selection.template { + return quickstartTemplate{}, &cliError{Message: fmt.Sprintf("Quickstart template mismatch: %s declares %q, but %s declares %q.", templateSource, templateID, selection.name, selection.template), Code: "QUICKSTART_SELECTION_MISMATCH"} + } + templateID, templateSource = selection.template, selection.name + } + if selection.scenario != "" { + if scenario != "" && scenario != selection.scenario { + return quickstartTemplate{}, &cliError{Message: fmt.Sprintf("Quickstart scenario mismatch: %s declares %q, but %s declares %q.", scenarioSource, scenario, selection.name, selection.scenario), Code: "QUICKSTART_SELECTION_MISMATCH"} + } + scenario, scenarioSource = selection.scenario, selection.name + } + } + return selectQuickstartDefinition(templateID, scenario) + } + for _, template := range quickstartTemplates() { + if !template.DefaultScenario { + continue + } if matchesQuickstartTemplate(root, template) { return template, nil } @@ -785,8 +1026,8 @@ func resolveQuickstartTemplateForPath(root, explicitTemplate string) (quickstart ids = append(ids, t.ID) } return quickstartTemplate{}, fmt.Errorf( - "could not detect the quickstart type from this directory (looked for %s). Pass --template %s to specify explicitly.", - strings.Join(hints, ", "), + "%w (looked for %s). Pass --template %s to specify explicitly.", + errQuickstartTemplateUndetected, strings.Join(hints, ", "), strings.Join(ids, "|"), ) } @@ -835,25 +1076,15 @@ func quickstartEnvLayoutForEnvPath(template quickstartTemplate, envPath string) return quickstartEnvLayout{}, false } -func resolveQuickstartEnvWriteTarget(root, explicitTemplate string) (quickstartTemplate, quickstartEnvLayout, error) { +func resolveQuickstartEnvWriteTarget(root, explicitTemplate, explicitScenario string) (quickstartTemplate, quickstartEnvLayout, error) { binding, foundBinding, bindingRoot, err := detectLocalProjectBindingFrom(root) if err != nil { return quickstartTemplate{}, quickstartEnvLayout{}, err } - var template quickstartTemplate - if strings.TrimSpace(explicitTemplate) == "" && foundBinding && bindingRoot == root && strings.TrimSpace(binding.Template) != "" { - found, ok := findQuickstartTemplate(binding.Template) - if !ok { - return quickstartTemplate{}, quickstartEnvLayout{}, &cliError{Message: fmt.Sprintf("unknown quickstart template %q. Run `agora quickstart list` to see available templates.", binding.Template), Code: "QUICKSTART_TEMPLATE_UNKNOWN"} - } - template = *found - } else { - resolved, resolveErr := resolveQuickstartTemplateForPath(root, explicitTemplate) - if resolveErr != nil { - return quickstartTemplate{}, quickstartEnvLayout{}, resolveErr - } - template = resolved + template, err := resolveQuickstartTemplateForPath(root, explicitTemplate, explicitScenario) + if err != nil { + return quickstartTemplate{}, quickstartEnvLayout{}, err } if foundBinding && bindingRoot == root && strings.TrimSpace(binding.EnvPath) != "" { diff --git a/internal/cli/quickstart_setup.go b/internal/cli/quickstart_setup.go new file mode 100644 index 0000000..52bb591 --- /dev/null +++ b/internal/cli/quickstart_setup.go @@ -0,0 +1,122 @@ +package cli + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" +) + +var pinnedPackageManagerPattern = regexp.MustCompile(`^(pnpm)@([0-9]+\.[0-9]+\.[0-9]+)$`) + +type quickstartPackageManagerResult struct { + Name string `json:"name"` + RequiredVersion string `json:"requiredVersion"` + DetectedVersion string `json:"detectedVersion,omitempty"` + SelectedName string `json:"selectedName,omitempty"` + SelectedVersion string `json:"selectedVersion,omitempty"` + Strategy string `json:"strategy"` + Ready bool `json:"ready"` + Message string `json:"message,omitempty"` +} + +type quickstartSetup struct { + NextSteps []string + PackageManager *quickstartPackageManagerResult +} + +type quickstartToolProbe func(root, command string) (version string, available bool) + +func quickstartPackageManagerSummary(packageManager *quickstartPackageManagerResult) string { + if packageManager == nil { + return "" + } + base := packageManager.Name + " " + packageManager.RequiredVersion + if packageManager.Strategy == "unavailable" { + return base + " unavailable" + } + if packageManager.SelectedName == packageManager.Name { + return base + } + return base + " via " + packageManager.SelectedName + " " + packageManager.SelectedVersion +} + +func probeQuickstartTool(root, command string) (string, bool) { + path, err := exec.LookPath(command) + if err != nil { + return "", false + } + cmd := exec.Command(path, "--version") + cmd.Dir = root + output, err := cmd.Output() + if err != nil { + return "", false + } + return strings.TrimSpace(string(output)), true +} + +func resolveQuickstartSetup(template quickstartTemplate, targetDir string, probe quickstartToolProbe) quickstartSetup { + setup := quickstartSetup{NextSteps: initNextSteps(template, targetDir)} + if template.ID != "nextjs-video-call" { + return setup + } + name, version, ok := readQuickstartPackageManager(targetDir) + if !ok || name != "pnpm" { + return setup + } + detectedVersion, available := probe(targetDir, name) + packageManager := &quickstartPackageManagerResult{ + Name: name, + RequiredVersion: version, + DetectedVersion: detectedVersion, + } + setup.PackageManager = packageManager + if available && detectedVersion == version { + setup.NextSteps = []string{ + "cd " + filepath.Base(targetDir), + "pnpm install --frozen-lockfile", + template.RunCommand, + } + packageManager.Strategy = "native" + packageManager.SelectedName = name + packageManager.SelectedVersion = detectedVersion + packageManager.Ready = true + return setup + } + if npmVersion, npmAvailable := probe(targetDir, "npm"); npmAvailable { + setup.NextSteps = []string{ + "cd " + filepath.Base(targetDir), + "npm install --package-lock=false", + "npm run dev", + } + packageManager.Strategy = "npm" + packageManager.SelectedName = "npm" + packageManager.SelectedVersion = npmVersion + packageManager.Ready = true + return setup + } + setup.NextSteps = []string{"cd " + filepath.Base(targetDir)} + packageManager.Strategy = "unavailable" + packageManager.Message = "pnpm " + version + " is unavailable and npm was not found; install Node.js with npm or install pnpm, then rerun the setup commands." + return setup +} + +func readQuickstartPackageManager(root string) (name, version string, ok bool) { + raw, err := os.ReadFile(filepath.Join(root, "package.json")) + if err != nil { + return "", "", false + } + var manifest struct { + PackageManager string `json:"packageManager"` + } + if json.Unmarshal(raw, &manifest) != nil { + return "", "", false + } + parts := pinnedPackageManagerPattern.FindStringSubmatch(strings.TrimSpace(manifest.PackageManager)) + if len(parts) != 3 { + return "", "", false + } + return parts[1], parts[2], true +} diff --git a/internal/cli/quickstart_setup_test.go b/internal/cli/quickstart_setup_test.go new file mode 100644 index 0000000..477cb56 --- /dev/null +++ b/internal/cli/quickstart_setup_test.go @@ -0,0 +1,155 @@ +package cli + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestResolveQuickstartSetupUsesMatchingPNPM(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"packageManager":"pnpm@9.15.9"}`), 0o644); err != nil { + t.Fatal(err) + } + template, err := selectQuickstartDefinition("nextjs", "video-call") + if err != nil { + t.Fatal(err) + } + + setup := resolveQuickstartSetup(template, root, func(_ string, command string) (string, bool) { + if command == "pnpm" { + return "9.15.9", true + } + return "", false + }) + + wantSteps := []string{"cd " + filepath.Base(root), "pnpm install --frozen-lockfile", "pnpm dev"} + if !reflect.DeepEqual(setup.NextSteps, wantSteps) { + t.Fatalf("next steps:\n got: %#v\nwant: %#v", setup.NextSteps, wantSteps) + } + if setup.PackageManager == nil || setup.PackageManager.Strategy != "native" || !setup.PackageManager.Ready || setup.PackageManager.RequiredVersion != "9.15.9" || setup.PackageManager.DetectedVersion != "9.15.9" || setup.PackageManager.SelectedName != "pnpm" || setup.PackageManager.SelectedVersion != "9.15.9" { + t.Fatalf("unexpected package manager result: %+v", setup.PackageManager) + } +} + +func TestResolveQuickstartSetupFallsBackToNPMWhenPNPMIsMissing(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"packageManager":"pnpm@9.15.9"}`), 0o644); err != nil { + t.Fatal(err) + } + template, err := selectQuickstartDefinition("nextjs", "video-call") + if err != nil { + t.Fatal(err) + } + + setup := resolveQuickstartSetup(template, root, func(_ string, command string) (string, bool) { + if command == "npm" { + return "10.9.4", true + } + return "", false + }) + + wantSteps := []string{ + "cd " + filepath.Base(root), + "npm install --package-lock=false", + "npm run dev", + } + if !reflect.DeepEqual(setup.NextSteps, wantSteps) { + t.Fatalf("next steps:\n got: %#v\nwant: %#v", setup.NextSteps, wantSteps) + } + if setup.PackageManager == nil || setup.PackageManager.Strategy != "npm" || !setup.PackageManager.Ready || setup.PackageManager.DetectedVersion != "" || setup.PackageManager.SelectedName != "npm" || setup.PackageManager.SelectedVersion != "10.9.4" { + t.Fatalf("unexpected package manager result: %+v", setup.PackageManager) + } +} + +func TestResolveQuickstartSetupFallsBackToNPMWhenPNPMVersionDiffers(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"packageManager":"pnpm@9.15.9"}`), 0o644); err != nil { + t.Fatal(err) + } + template, err := selectQuickstartDefinition("nextjs", "video-call") + if err != nil { + t.Fatal(err) + } + + setup := resolveQuickstartSetup(template, root, func(_ string, command string) (string, bool) { + if command == "pnpm" { + return "8.15.9", true + } + if command == "npm" { + return "10.9.4", true + } + return "", false + }) + + if setup.PackageManager == nil || setup.PackageManager.Strategy != "npm" || setup.PackageManager.DetectedVersion != "8.15.9" || setup.PackageManager.SelectedName != "npm" || setup.PackageManager.SelectedVersion != "10.9.4" { + t.Fatalf("unexpected package manager result: %+v", setup.PackageManager) + } + if got := setup.NextSteps[1]; got != "npm install --package-lock=false" { + t.Fatalf("install step = %q", got) + } +} + +func TestResolveQuickstartSetupReportsUnavailableWithoutPNPMOrNPM(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"packageManager":"pnpm@9.15.9"}`), 0o644); err != nil { + t.Fatal(err) + } + template, err := selectQuickstartDefinition("nextjs", "video-call") + if err != nil { + t.Fatal(err) + } + + setup := resolveQuickstartSetup(template, root, func(_ string, _ string) (string, bool) { + return "", false + }) + + wantSteps := []string{"cd " + filepath.Base(root)} + if !reflect.DeepEqual(setup.NextSteps, wantSteps) { + t.Fatalf("next steps:\n got: %#v\nwant: %#v", setup.NextSteps, wantSteps) + } + if setup.PackageManager == nil || setup.PackageManager.Strategy != "unavailable" || setup.PackageManager.Ready || setup.PackageManager.Message == "" { + t.Fatalf("unexpected package manager result: %+v", setup.PackageManager) + } +} + +func TestResolveQuickstartSetupDoesNotInterpolateMalformedPackageManager(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"packageManager":"pnpm@9.15.9 && echo unsafe"}`), 0o644); err != nil { + t.Fatal(err) + } + template, err := selectQuickstartDefinition("nextjs", "video-call") + if err != nil { + t.Fatal(err) + } + + setup := resolveQuickstartSetup(template, root, func(_ string, command string) (string, bool) { + return "", command == "npm" + }) + + wantSteps := []string{"cd " + filepath.Base(root), "pnpm install", "pnpm dev"} + if !reflect.DeepEqual(setup.NextSteps, wantSteps) || setup.PackageManager != nil { + t.Fatalf("malformed declaration must use safe catalog steps, got %+v", setup) + } +} + +func TestResolveQuickstartSetupLeavesOtherQuickstartsUnchanged(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"packageManager":"pnpm@10.23.0"}`), 0o644); err != nil { + t.Fatal(err) + } + template, err := selectQuickstartDefinition("go", "voice-agent") + if err != nil { + t.Fatal(err) + } + + setup := resolveQuickstartSetup(template, root, func(_ string, _ string) (string, bool) { + return "10.23.0", true + }) + + wantSteps := []string{"cd " + filepath.Base(root), "make setup", "make dev"} + if !reflect.DeepEqual(setup.NextSteps, wantSteps) || setup.PackageManager != nil { + t.Fatalf("non-RTC quickstart setup changed: %+v", setup) + } +} diff --git a/internal/cli/quickstart_test.go b/internal/cli/quickstart_test.go index 449c2cb..2ee7b78 100644 --- a/internal/cli/quickstart_test.go +++ b/internal/cli/quickstart_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "reflect" + "slices" "strings" "testing" ) @@ -77,7 +78,7 @@ func TestResolveQuickstartEnvWriteTargetSupportsCurrentAndLegacyLayouts(t *testi } } - template, layout, err := resolveQuickstartEnvWriteTarget(root, "") + template, layout, err := resolveQuickstartEnvWriteTarget(root, "", "") if err != nil { t.Fatal(err) } @@ -100,7 +101,7 @@ func TestResolveQuickstartEnvWriteTargetPreservesBoundLegacyLayout(t *testing.T) t.Fatal(err) } - template, layout, err := resolveQuickstartEnvWriteTarget(root, "") + template, layout, err := resolveQuickstartEnvWriteTarget(root, "", "") if err != nil { t.Fatal(err) } @@ -206,6 +207,77 @@ func TestGoVoiceAgentSkillUsesQuickstartWorkflow(t *testing.T) { t.Fatal("Go voice agent skill not found") } +func TestScaffoldSkillsUseConsistentDiscoveryTags(t *testing.T) { + wantTags := map[string][]string{ + "create-nextjs-video-app": {"nextjs", "rtc", "video", "video-call", "init"}, + "create-nextjs-voice-agent": {"nextjs", "convoai", "voice", "voice-agent", "init"}, + "create-python-voice-agent": {"python", "convoai", "voice", "voice-agent", "init"}, + "create-go-voice-agent": {"go", "convoai", "voice", "voice-agent", "init"}, + } + + for _, skill := range skillsCatalog() { + want, ok := wantTags[skill.ID] + if !ok { + continue + } + if !reflect.DeepEqual(skill.Tags, want) { + t.Fatalf("unexpected tags for %s:\n got: %#v\nwant: %#v", skill.ID, skill.Tags, want) + } + delete(wantTags, skill.ID) + } + if len(wantTags) != 0 { + t.Fatalf("missing scaffold skills: %#v", wantTags) + } +} + +func TestNextJSVideoAppSkillUsesVideoCallQuickstartWorkflow(t *testing.T) { + for _, skill := range skillsCatalog() { + if skill.ID != "create-nextjs-video-app" { + continue + } + wantSteps := []string{ + "agora login", + "agora init my-nextjs-demo --template nextjs --scenario video-call --new-project --json", + "Run the nextSteps returned by agora init exactly; they select exact pnpm or the native npm fallback for this machine.", + } + if !reflect.DeepEqual(skill.Steps, wantSteps) { + t.Fatalf("unexpected Next.js video app steps:\n got: %#v\nwant: %#v", skill.Steps, wantSteps) + } + if skill.Description != "Create a runnable Next.js one-to-one RTC audio and video call." || !slices.Contains(skill.Tags, "video-call") { + t.Fatalf("unexpected Next.js video app discovery metadata: description=%q tags=%#v", skill.Description, skill.Tags) + } + if !slices.Contains(skill.NextSteps, "Run agora project doctor --feature rtc --deep --json to validate the project, manifest, binding, and env.") { + t.Fatalf("Next.js video app skill is missing RTC deep doctor guidance: %#v", skill.NextSteps) + } + return + } + t.Fatal("Next.js video app skill not found") +} + +func TestNextJSVoiceAgentSkillUsesVoiceAgentQuickstartWorkflow(t *testing.T) { + for _, skill := range skillsCatalog() { + if skill.ID != "create-nextjs-voice-agent" { + continue + } + wantSteps := []string{ + "agora login", + "agora init my-nextjs-voice-agent --template nextjs --scenario voice-agent --new-project --json", + "cd my-nextjs-voice-agent && pnpm install && pnpm dev", + } + if !reflect.DeepEqual(skill.Steps, wantSteps) { + t.Fatalf("unexpected Next.js voice agent steps:\n got: %#v\nwant: %#v", skill.Steps, wantSteps) + } + if skill.Description != "Create a runnable Next.js conversational AI voice agent." || !slices.Contains(skill.Tags, "voice-agent") { + t.Fatalf("unexpected Next.js voice agent discovery metadata: description=%q tags=%#v", skill.Description, skill.Tags) + } + if !slices.Contains(skill.NextSteps, "Run agora project doctor --feature convoai --deep --json to validate project and quickstart readiness.") { + t.Fatalf("Next.js voice agent skill is missing ConvoAI deep doctor guidance: %#v", skill.NextSteps) + } + return + } + t.Fatal("Next.js voice agent skill not found") +} + func TestGitQuickstartCloneArgs(t *testing.T) { args := gitQuickstartCloneArgs("https://github.com/AgoraIO/example", "/tmp/example", "") want := []string{"-c", "credential.helper=", "clone", "--depth", "1", "--", "https://github.com/AgoraIO/example", "/tmp/example"} @@ -348,6 +420,55 @@ func TestQuickstartRepoOverrideKey(t *testing.T) { } } +func TestSelectQuickstartDefinitionUsesScenarioAndPreservesTemplateDefaults(t *testing.T) { + rtc, err := selectQuickstartDefinition("nextjs", "video-call") + if err != nil { + t.Fatal(err) + } + if rtc.ID != "nextjs-video-call" || rtc.Template != "nextjs" || rtc.Scenario != "video-call" || !reflect.DeepEqual(rtc.RequiredFeatures, []string{"rtc"}) { + t.Fatalf("unexpected rtc quickstart definition: %+v", rtc) + } + + legacy, err := selectQuickstartDefinition("nextjs", "") + if err != nil { + t.Fatal(err) + } + if legacy.Template != "nextjs" || legacy.Scenario != "voice-agent" || !legacy.DefaultScenario { + t.Fatalf("unexpected default nextjs quickstart definition: %+v", legacy) + } + + _, err = selectQuickstartDefinition("go", "video-call") + assertCLIErrorCode(t, err, "QUICKSTART_SCENARIO_UNSUPPORTED") + _, err = selectQuickstartDefinition("nextjs", "not-a-scenario") + assertCLIErrorCode(t, err, "QUICKSTART_SCENARIO_UNKNOWN") +} + +func TestResolveQuickstartDefinitionUsesManifestAndRejectsConflicts(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, quickstartManifestFileName), []byte(`{"schemaVersion":1,"template":"nextjs","scenario":"video-call"}`), 0o644); err != nil { + t.Fatal(err) + } + definition, err := resolveQuickstartTemplateForPath(root, "", "") + if err != nil { + t.Fatal(err) + } + if definition.ID != "nextjs-video-call" { + t.Fatalf("manifest resolved to %+v", definition) + } + + if err := writeLocalProjectBinding(root, localProjectBinding{ProjectID: "prj_1", Template: "nextjs", Scenario: "voice-agent"}); err != nil { + t.Fatal(err) + } + _, err = resolveQuickstartTemplateForPath(root, "", "") + assertCLIErrorCode(t, err, "QUICKSTART_SELECTION_MISMATCH") + + if err := os.WriteFile(filepath.Join(root, quickstartManifestFileName), []byte(`{"schemaVersion":2,"template":"nextjs","scenario":"video-call"}`), 0o644); err != nil { + t.Fatal(err) + } + _, err = resolveQuickstartTemplateForPath(root, "", "") + assertCLIErrorCode(t, err, "QUICKSTART_MANIFEST_INVALID") +} + func TestQuickstartRepoURLOverride(t *testing.T) { tmpl := quickstartTemplate{ ID: "nextjs", @@ -451,3 +572,39 @@ func TestQuickstartTemplatesIncludeAndroid(t *testing.T) { t.Fatalf("unexpected Android next steps:\n got: %#v\nwant: %#v", got, wantSteps) } } + +func TestResolveQuickstartScenarioInheritance(t *testing.T) { + for _, tc := range []struct { + name string + binding *localProjectBinding + manifest bool + want string + }{ + {name: "manifest", manifest: true, want: "video-call"}, + {name: "binding", binding: &localProjectBinding{ProjectID: "prj_1", Template: "nextjs", Scenario: "video-call"}, want: "video-call"}, + {name: "legacy binding and manifest", binding: &localProjectBinding{ProjectID: "prj_1", Template: "nextjs"}, manifest: true, want: "video-call"}, + {name: "legacy default", binding: &localProjectBinding{ProjectID: "prj_1", Template: "nextjs"}, want: "voice-agent"}, + {name: "no metadata", want: "voice-agent"}, + } { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + if tc.binding != nil { + if err := writeLocalProjectBinding(root, *tc.binding); err != nil { + t.Fatal(err) + } + } + if tc.manifest { + if err := os.WriteFile(filepath.Join(root, quickstartManifestFileName), []byte(`{"schemaVersion":1,"template":"nextjs","scenario":"video-call"}`), 0600); err != nil { + t.Fatal(err) + } + } + definition, err := resolveQuickstartTemplateForPath(root, "nextjs", "") + if err != nil { + t.Fatal(err) + } + if definition.Scenario != tc.want { + t.Fatalf("scenario=%s want=%s", definition.Scenario, tc.want) + } + }) + } +} diff --git a/internal/cli/recipes.go b/internal/cli/recipes.go index c179b15..49c7e47 100644 --- a/internal/cli/recipes.go +++ b/internal/cli/recipes.go @@ -218,7 +218,13 @@ func (a *App) initRecipeProject(name, targetDir string, recipe recipeDetail, exi if err != nil { return nil, err } - resolution, err := a.resolveInitProjectForScaffold(name, existingProject, features, rtmDataCenter, newProject, promptForReuse, promptOut, promptIn, progress) + // Recipe defaults only control new project creation. They are not + // requirements for reusing an existing project. + createFeatures, err := resolveProjectCreateFeatures("", features) + if err != nil { + return nil, err + } + resolution, err := a.resolveInitProjectForScaffold(name, createFeatures, existingProject, rtmDataCenter, newProject, promptForReuse, promptOut, promptIn, progress) if err != nil { return nil, err } diff --git a/internal/cli/render.go b/internal/cli/render.go index d310176..b347d96 100644 --- a/internal/cli/render.go +++ b/internal/cli/render.go @@ -49,7 +49,7 @@ func renderResult(cmd *cobra.Command, command string, data any) error { if list, ok := m["enabledFeatures"].([]string); ok { features = strings.Join(list, ", ") } - printBlock(out, "Project", [][2]string{{"Name", asString(m["projectName"])}, {"Project ID", asString(m["projectId"])}, {"App ID", asString(m["appId"])}, {"Region", asString(m["region"])}, {"Features", features}}) + printBlock(out, "Project", [][2]string{{"Name", asString(m["projectName"])}, {"Project ID", asString(m["projectId"])}, {"App ID", asString(m["appId"])}, {"Region", asString(m["region"])}, {"Template", asString(m["template"])}, {"Features", features}}) case "project use": m := data.(map[string]any) printBlock(out, "Current Project", [][2]string{{"Name", asString(m["projectName"])}, {"Project ID", asString(m["projectId"])}, {"Region", asString(m["region"])}}) @@ -70,10 +70,16 @@ func renderResult(cmd *cobra.Command, command string, data any) error { fmt.Fprintln(out, "Quickstarts") if items, ok := m["items"].([]map[string]any); ok { for _, item := range items { - fmt.Fprintf(out, "- %s: %s\n", asString(item["id"]), asString(item["title"])) + fmt.Fprintf(out, "- %s + %s: %s\n", asString(item["template"]), asString(item["scenario"]), asString(item["title"])) if details, _ := m["details"].(bool); details { + features := "-" + if list, ok := item["requiredFeatures"].([]string); ok { + features = strings.Join(list, ", ") + } + fmt.Fprintf(out, " ID: %s\n", asString(item["id"])) fmt.Fprintf(out, " Available: %s\n", asString(item["available"])) fmt.Fprintf(out, " Runtime: %s\n", asString(item["runtime"])) + fmt.Fprintf(out, " Required Features: %s\n", features) fmt.Fprintf(out, " Supports Init: %s\n", asString(item["supportsInit"])) fmt.Fprintf(out, " Env: %s\n", asString(item["envDocs"])) fmt.Fprintf(out, " Repo: %s\n", asString(item["repoUrl"])) @@ -82,7 +88,14 @@ func renderResult(cmd *cobra.Command, command string, data any) error { } case "quickstart create": m := data.(map[string]any) - printBlock(out, "Quickstart", [][2]string{{"Template", asString(m["template"])}, {"Path", asString(m["path"])}, {"Project", asString(m["projectName"])}, {"Env", asString(m["envStatus"])}, {"Metadata", asString(m["metadataPath"])}, {"Status", asString(m["status"])}}) + fields := [][2]string{{"Template", asString(m["template"])}, {"Scenario", asString(m["scenario"])}, {"Path", asString(m["path"])}, {"Project", asString(m["projectName"])}, {"Env", asString(m["envStatus"])}, {"Metadata", asString(m["metadataPath"])}, {"Status", asString(m["status"])}} + if packageManager, ok := m["packageManager"].(*quickstartPackageManagerResult); ok { + fields = append(fields, [2]string{"Package Manager", quickstartPackageManagerSummary(packageManager)}) + } + printBlock(out, "Quickstart", fields) + if packageManager, ok := m["packageManager"].(*quickstartPackageManagerResult); ok && packageManager.Message != "" { + fmt.Fprintf(out, "Setup: %s\n", packageManager.Message) + } if steps, ok := m["nextSteps"].([]string); ok && len(steps) > 0 { fmt.Fprintln(out) fmt.Fprintln(out, "Next Steps") @@ -92,7 +105,7 @@ func renderResult(cmd *cobra.Command, command string, data any) error { } case "quickstart env write": m := data.(map[string]any) - printBlock(out, "Quickstart Env", [][2]string{{"Template", asString(m["template"])}, {"Project", asString(m["projectName"])}, {"Path", asString(m["path"])}, {"Env Path", asString(m["envPath"])}, {"Metadata", asString(m["metadataPath"])}, {"Status", asString(m["status"])}}) + printBlock(out, "Quickstart Env", [][2]string{{"Template", asString(m["template"])}, {"Scenario", asString(m["scenario"])}, {"Project", asString(m["projectName"])}, {"Path", asString(m["path"])}, {"Env Path", asString(m["envPath"])}, {"Metadata", asString(m["metadataPath"])}, {"Status", asString(m["status"])}}) case "recipes list": m := data.(map[string]any) fmt.Fprintln(out, "Recipes") @@ -115,7 +128,17 @@ func renderResult(cmd *cobra.Command, command string, data any) error { if list, ok := m["enabledFeatures"].([]string); ok && len(list) > 0 { features = strings.Join(list, ", ") } - printBlock(out, "Init", [][2]string{{"Source", asString(m["sourceType"])}, {"Source ID", asString(m["sourceId"])}, {"Project", asString(m["projectName"])}, {"Project ID", asString(m["projectId"])}, {"Project Action", asString(m["projectAction"])}, {"Region", asString(m["region"])}, {"Path", asString(m["path"])}, {"Env Path", asString(m["envPath"])}, {"Metadata", asString(m["metadataPath"])}, {"Features", features}, {"Status", asString(m["status"])}}) + fields := [][2]string{{"Source", asString(m["sourceType"])}, {"Source ID", asString(m["sourceId"])}, {"Project", asString(m["projectName"])}, {"Project ID", asString(m["projectId"])}, {"Project Action", asString(m["projectAction"])}, {"Region", asString(m["region"])}, {"Path", asString(m["path"])}, {"Env Path", asString(m["envPath"])}, {"Metadata", asString(m["metadataPath"])}, {"Features", features}, {"Status", asString(m["status"])}} + if asString(m["scenario"]) != "" { + fields = append(fields, [2]string{"Scenario", asString(m["scenario"])}) + } + if packageManager, ok := m["packageManager"].(*quickstartPackageManagerResult); ok { + fields = append(fields, [2]string{"Package Manager", quickstartPackageManagerSummary(packageManager)}) + } + printBlock(out, "Init", fields) + if packageManager, ok := m["packageManager"].(*quickstartPackageManagerResult); ok && packageManager.Message != "" { + fmt.Fprintf(out, "Setup: %s\n", packageManager.Message) + } if steps, ok := m["nextSteps"].([]string); ok && len(steps) > 0 { fmt.Fprintln(out) fmt.Fprintln(out, "Next Steps") diff --git a/internal/cli/render_test.go b/internal/cli/render_test.go index f1d9871..024a629 100644 --- a/internal/cli/render_test.go +++ b/internal/cli/render_test.go @@ -59,3 +59,14 @@ func TestTerminalValueWidthZeroWhenWidthUnknown(t *testing.T) { t.Fatalf("terminalValueWidth = %d, want >= 0", got) } } + +func TestQuickstartPackageManagerSummary(t *testing.T) { + ready := &quickstartPackageManagerResult{Name: "pnpm", RequiredVersion: "9.15.9", SelectedName: "npm", SelectedVersion: "10.9.4", Strategy: "npm", Ready: true} + if got := quickstartPackageManagerSummary(ready); got != "pnpm 9.15.9 via npm 10.9.4" { + t.Fatalf("ready summary = %q", got) + } + unavailable := &quickstartPackageManagerResult{Name: "pnpm", RequiredVersion: "9.15.9", Strategy: "unavailable"} + if got := quickstartPackageManagerSummary(unavailable); got != "pnpm 9.15.9 unavailable" { + t.Fatalf("unavailable summary = %q", got) + } +} diff --git a/internal/cli/skills.go b/internal/cli/skills.go index b6c47da..2fb69f6 100644 --- a/internal/cli/skills.go +++ b/internal/cli/skills.go @@ -34,26 +34,42 @@ func skillsCatalog() []skill { { ID: "create-nextjs-video-app", Title: "Create a Next.js video app", - Description: "Scaffold a runnable Next.js video app bound to an Agora project, with credentials wired into .env.local.", + Description: "Create a runnable Next.js one-to-one RTC audio and video call.", Category: "scaffold", - Tags: []string{"nextjs", "rtc", "video", "init"}, + Tags: []string{"nextjs", "rtc", "video", "video-call", "init"}, Steps: []string{ "agora login", - "agora init my-nextjs-demo --template nextjs --new-project --json", - "cd my-nextjs-demo && npm install && npm run dev", + "agora init my-nextjs-demo --template nextjs --scenario video-call --new-project --json", + "Run the nextSteps returned by agora init exactly; they select exact pnpm or the native npm fallback for this machine.", }, NextSteps: []string{ "Open http://localhost:3000 to verify the app boots.", - "Run agora project doctor --json to confirm RTC is enabled.", + "Run agora project doctor --feature rtc --deep --json to validate the project, manifest, binding, and env.", }, DocsURL: "https://agoraio.github.io/cli/install.html", }, + { + ID: "create-nextjs-voice-agent", + Title: "Create a Next.js voice agent (ConvoAI)", + Description: "Create a runnable Next.js conversational AI voice agent.", + Category: "scaffold", + Tags: []string{"nextjs", "convoai", "voice", "voice-agent", "init"}, + Steps: []string{ + "agora login", + "agora init my-nextjs-voice-agent --template nextjs --scenario voice-agent --new-project --json", + "cd my-nextjs-voice-agent && pnpm install && pnpm dev", + }, + NextSteps: []string{ + "Open http://localhost:3000 to verify the app boots.", + "Run agora project doctor --feature convoai --deep --json to validate project and quickstart readiness.", + }, + }, { ID: "create-python-voice-agent", Title: "Create a Python voice agent (ConvoAI)", Description: "Bootstrap a Python ConvoAI voice agent with project metadata and env wiring.", Category: "scaffold", - Tags: []string{"python", "convoai", "voice", "init"}, + Tags: []string{"python", "convoai", "voice", "voice-agent", "init"}, Steps: []string{ "agora login", "agora init my-voice-agent --template python --new-project --feature convoai --json", @@ -69,7 +85,7 @@ func skillsCatalog() []skill { Title: "Create a Go voice agent (ConvoAI)", Description: "Bootstrap a Go ConvoAI voice agent with project metadata and env wiring.", Category: "scaffold", - Tags: []string{"go", "convoai", "voice", "backend", "init"}, + Tags: []string{"go", "convoai", "voice", "voice-agent", "init"}, Steps: []string{ "agora login", "agora init my-go-voice-agent --template go --new-project --feature convoai --json", diff --git a/internal/cli/testdata/golden/introspect-enums.json b/internal/cli/testdata/golden/introspect-enums.json index 4108b61..7b2b9f9 100644 --- a/internal/cli/testdata/golden/introspect-enums.json +++ b/internal/cli/testdata/golden/introspect-enums.json @@ -14,6 +14,20 @@ "pretty", "json" ], + "projectTemplates": [ + "video-call", + "voice-agent" + ], + "quickstartScenarios": [ + "voice-agent", + "video-call" + ], + "quickstartTemplates": [ + "nextjs", + "python", + "go", + "android" + ], "recipeTypes": [ "all", "ai",