From a18c4b9bfd3b775628799ad8a038783edb0351e7 Mon Sep 17 00:00:00 2001 From: digitallysavvy Date: Thu, 30 Apr 2026 13:30:16 -0400 Subject: [PATCH 01/26] feat(cli): default features, RTM data center, and init project selection Default new projects to rtc, rtm, and convoai (convoai implies rtm) on init and project create, with --rtm-data-center (CN/NA/EU/AP, default NA). Refine agora init when neither --project nor --new-project is set: bind to an exact "Default Project" when present; otherwise in interactive sessions offer create-new plus existing projects oldest-to-newest with the newest as the default, while JSON/CI/non-TTY reuse still picks the most recent project. Empty accounts still create a project. Update docs (automation, generated commands), CHANGELOG [Unreleased], and integration/unit tests. --- CHANGELOG.md | 5 + docs/automation.md | 20 +- docs/commands.md | 6 +- internal/cli/commands.go | 27 ++- internal/cli/init.go | 233 +++++++++++++++-------- internal/cli/init_test.go | 53 +++++- internal/cli/integration_init_test.go | 17 +- internal/cli/integration_project_test.go | 77 ++++++++ internal/cli/integration_test.go | 6 + internal/cli/projects.go | 95 +++++++-- 10 files changed, 432 insertions(+), 107 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59c0a9b..f01b41d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ Earlier entries pre-date this convention and only carry their version's compare ## [Unreleased] +### Changed + +- Default newly created projects to enable `rtc`, `rtm`, and `convoai`, make `convoai` imply `rtm` during project creation, and add `--rtm-data-center` for `init` / `project create` when RTM should be configured for a specific data center. +- Refine `agora init` project selection so `--project` binds explicitly, `--new-project` creates explicitly, `"Default Project"` auto-selects by exact name, and interactive sessions without a default show existing projects plus a create-new option. + ## [0.1.9] - 2026-04-30 ### Changed diff --git a/docs/automation.md b/docs/automation.md index 38d165c..79af007 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -230,7 +230,7 @@ Example: ./agora init my-nextjs-demo --template nextjs --new-project --json ``` -By default `init` reuses an existing project — preferring one named `"Default Project"`, then the project with the latest `createdAt` value from the current results page. Pass `--new-project` to force creation. Use `--project ` to bind to a specific project. +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. Required `data` fields: - `action` @@ -251,12 +251,16 @@ 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` 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. 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. - `nextSteps` Ordered list of suggested follow-up commands for the selected template. - `status` Currently `ready`. +Optional fields: +- `rtmDataCenter` + RTM data center configured on the new project when RTM was enabled. Defaults to `NA` when `--rtm-data-center` is omitted. + Display-oriented fields: - `title` @@ -277,6 +281,8 @@ Automation notes: Example: ```bash +./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 ``` @@ -288,7 +294,11 @@ Required `data` fields (success): - `appId` - `region` - `enabledFeatures` - Array of features that were enabled on the new project (e.g. `["rtc", "convoai"]`). + 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`. + +Optional fields: +- `rtmDataCenter` + RTM data center configured when RTM was enabled. Defaults to `NA` when `--rtm-data-center` is omitted. Required `data` fields (`--dry-run`): - `action` @@ -305,6 +315,10 @@ Required `data` fields (`--dry-run`): - `idempotencyKey` Echoes the caller-provided `--idempotency-key` value (empty when not provided). +Optional fields (`--dry-run`): +- `rtmDataCenter` + Uppercased data center value (`CN`, `NA`, `EU`, or `AP`) when RTM is enabled. Defaults to `NA` when omitted. + Safe branch fields: - `projectId` (success only) - `projectName` diff --git a/docs/commands.md b/docs/commands.md index 6c1ad85..015aa97 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -94,10 +94,11 @@ Create a project, clone a quickstart, and write env in one flow | Flag | Type | Default | Description | |------|------|---------|-------------| | `--dir` | `string` | — | target directory for the cloned quickstart; defaults to | -| `--feature` | `stringArray` | `[]` | enable a feature on the newly created project (repeatable); defaults to rtc and convoai | +| `--feature` | `stringArray` | `[]` | enable a feature on the newly created project (repeatable); defaults to rtc, rtm, and convoai; 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 | | `--region` | `string` | — | control plane region for newly created projects (global or cn) | +| `--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 | | `--template` | `string` | — | quickstart template ID to use | ### `agora introspect` @@ -143,9 +144,10 @@ Create a new remote Agora project | Flag | Type | Default | Description | |------|------|---------|-------------| | `--dry-run` | `bool` | — | return the planned project create result without creating remote resources | -| `--feature` | `stringArray` | `[]` | enable one or more features after creation | +| `--feature` | `stringArray` | `[]` | enable one or more features after creation; defaults to rtc, rtm, and convoai; convoai also enables rtm | | `--idempotency-key` | `string` | — | caller-provided key for safe retries when supported by the API | | `--region` | `string` | — | control plane region for the project context (global or cn) | +| `--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 | ### `agora project doctor` diff --git a/internal/cli/commands.go b/internal/cli/commands.go index f33c8a1..768b63a 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -544,7 +544,7 @@ These commands do not clone local application code. Use "agora quickstart" for s } func (a *App) buildProjectCreate() *cobra.Command { - var region, template string + var region, rtmDataCenter, template string var features []string var dryRun bool var idempotencyKey string @@ -555,25 +555,39 @@ func (a *App) buildProjectCreate() *cobra.Command { Example: example(` agora project create my-app agora project create my-agent-demo --region global --feature rtc --feature convoai + agora project create my-rtm-demo --rtm-data-center EU agora project create my-voice-agent --template voice-agent `), RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 || strings.TrimSpace(args[0]) == "" { return errors.New("project name is required") } + normalizedRTMDataCenter, err := normalizeRTMDataCenter(rtmDataCenter) + if err != nil { + return err + } if dryRun { - return renderResult(cmd, "project create", map[string]any{ + plannedFeatures := projectCreateFeatures(template, features) + plannedRTMDataCenter, err := rtmDataCenterForFeatures(plannedFeatures, normalizedRTMDataCenter) + if err != nil { + return err + } + result := map[string]any{ "action": "create", "dryRun": true, - "enabledFeatures": features, + "enabledFeatures": plannedFeatures, "idempotencyKey": idempotencyKey, "projectName": args[0], "region": region, "status": "planned", "template": template, - }) + } + if plannedRTMDataCenter != "" { + result["rtmDataCenter"] = plannedRTMDataCenter + } + return renderResult(cmd, "project create", result) } - data, err := a.projectCreate(args[0], region, template, features, idempotencyKey) + data, err := a.projectCreate(args[0], region, template, features, normalizedRTMDataCenter, idempotencyKey) if err != nil { return err } @@ -581,8 +595,9 @@ func (a *App) buildProjectCreate() *cobra.Command { }, } cmd.Flags().StringVar(®ion, "region", "", "control plane region for the project context (global or cn)") + 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().StringArrayVar(&features, "feature", nil, "enable one or more features after creation") + cmd.Flags().StringArrayVar(&features, "feature", nil, "enable one or more features after creation; defaults to rtc, rtm, and convoai; convoai also enables rtm") 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") return cmd diff --git a/internal/cli/init.go b/internal/cli/init.go index 81c66e3..5d4f2d2 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "sort" "strconv" "strings" "time" @@ -15,7 +16,7 @@ import ( ) func defaultInitFeatures() []string { - return []string{"rtc", "convoai"} + return []string{"rtc", "rtm", "convoai"} } func initNextSteps(template quickstartTemplate, targetDir string) []string { @@ -35,6 +36,7 @@ func (a *App) buildInitCommand() *cobra.Command { var dir string var existingProject string var region string + var rtmDataCenter string var features []string var newProject bool cmd := &cobra.Command{ @@ -42,7 +44,7 @@ func (a *App) buildInitCommand() *cobra.Command { Short: "Create a project, clone a quickstart, and write env in one flow", Long: `Init is the recommended onboarding command. -By default it reuses your existing Agora project — preferring one named "Default Project", then falling back to the most recent project. A new project is only created when no projects exist yet or when --new-project is passed. +By default it reuses your existing Agora project — preferring one named "Default Project". In interactive sessions without a Default Project, init shows your existing projects and a create-new option. Non-interactive runs fall back to the most recent project. A new project is created when no projects exist yet or when --new-project is passed. Use --project to bind to a specific existing project by name or ID. Use --new-project to always create a fresh project regardless of existing ones. @@ -51,6 +53,7 @@ Use --feature to specify which features to enable on a newly created project (re agora init my-nextjs-demo --template nextjs agora init my-python-demo --template python agora init my-go-demo --template go --project my-existing-project + agora init my-rtm-demo --template nextjs --new-project --rtm-data-center AP agora init my-rtm-demo --template nextjs --new-project --feature rtc --feature rtm `), RunE: func(cmd *cobra.Command, args []string) error { @@ -81,7 +84,7 @@ Use --feature to specify which features to enable on a newly created project (re !isCIEnvironment(a.osEnv) && isTTY(os.Stdin) progress := jsonProgressFor(a, cmd, "init") - result, err := a.initProject(args[0], targetDir, *template, existingProject, region, features, newProject, promptForReuse, cmd.ErrOrStderr(), os.Stdin, progress) + result, err := a.initProject(args[0], targetDir, *template, existingProject, region, features, rtmDataCenter, newProject, promptForReuse, cmd.ErrOrStderr(), os.Stdin, progress) if err != nil { return err } @@ -92,7 +95,8 @@ Use --feature to specify which features to enable on a newly created project (re 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(®ion, "region", "", "control plane region for newly created projects (global or cn)") - cmd.Flags().StringArrayVar(&features, "feature", nil, "enable a feature on the newly created project (repeatable); defaults to rtc and convoai") + 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, "enable a feature on the newly created project (repeatable); defaults to rtc, rtm, and convoai; convoai also enables rtm") cmd.Flags().BoolVar(&newProject, "new-project", false, "always create a new Agora project instead of reusing an existing one") return cmd } @@ -150,10 +154,8 @@ func selectInitProjectFromList(items []projectSummary) (projectSummary, bool) { if len(items) == 0 { return projectSummary{}, false } - for _, item := range items { - if item.Name == "Default Project" { - return item, true - } + if item, ok := selectDefaultInitProjectFromList(items); ok { + return item, true } selected := items[0] selectedCreated, selectedCreatedOK := parseInitProjectTimestamp(selected.CreatedAt) @@ -198,31 +200,114 @@ func selectInitProjectFromList(items []projectSummary) (projectSummary, bool) { return selected, true } -// findDefaultProject returns the user's preferred existing project: "Default Project" if it -// exists, otherwise the most recently created project in the current results page. Returns -// found=false when the account has no projects yet. The total count returned is the number -// of projects in the listing, so callers can decide whether silent reuse is risky enough to -// warrant a confirmation prompt. -func (a *App) findDefaultProject() (target projectTarget, found bool, total int, err error) { +func selectDefaultInitProjectFromList(items []projectSummary) (projectSummary, bool) { + for _, item := range items { + if item.Name == "Default Project" { + return item, true + } + } + return projectSummary{}, false +} + +func initProjectChoiceItems(items []projectSummary) []projectSummary { + choices := append([]projectSummary{}, items...) + sort.SliceStable(choices, func(i, j int) bool { + leftCreated, leftCreatedOK := parseInitProjectTimestamp(choices[i].CreatedAt) + rightCreated, rightCreatedOK := parseInitProjectTimestamp(choices[j].CreatedAt) + switch { + case leftCreatedOK && rightCreatedOK && !leftCreated.Equal(rightCreated): + return leftCreated.Before(rightCreated) + case leftCreatedOK != rightCreatedOK: + return !leftCreatedOK + } + leftUpdated, leftUpdatedOK := parseInitProjectTimestamp(choices[i].UpdatedAt) + rightUpdated, rightUpdatedOK := parseInitProjectTimestamp(choices[j].UpdatedAt) + switch { + case leftUpdatedOK && rightUpdatedOK && !leftUpdated.Equal(rightUpdated): + return leftUpdated.Before(rightUpdated) + case leftUpdatedOK != rightUpdatedOK: + return !leftUpdatedOK + } + return choices[i].ProjectID < choices[j].ProjectID + }) + return choices +} + +// chooseInitProject prompts a human user to either create a fresh project or bind +// the new quickstart to one of the existing projects. The default selection is +// the most recently created project, displayed last. +func chooseInitProject(in io.Reader, out io.Writer, items []projectSummary) (projectSummary, string, error) { + choices := initProjectChoiceItems(items) + if len(choices) == 0 { + return projectSummary{}, "new", nil + } + defaultIndex := len(choices) + 1 + reader := bufio.NewReader(in) + for { + if _, err := fmt.Fprintln(out, "Choose an Agora project:"); err != nil { + return projectSummary{}, "", err + } + if _, err := fmt.Fprintln(out, " 1. Create a new project"); err != nil { + return projectSummary{}, "", err + } + for index, item := range choices { + suffix := "" + if index == len(choices)-1 { + suffix = " (most recent)" + } + if _, err := fmt.Fprintf(out, " %d. %s (%s)%s\n", index+2, item.Name, item.ProjectID, suffix); err != nil { + return projectSummary{}, "", err + } + } + if _, err := fmt.Fprintf(out, "Project [%d]: ", defaultIndex); err != nil { + return projectSummary{}, "", err + } + answer, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return projectSummary{}, "", err + } + trimmed := strings.ToLower(strings.TrimSpace(answer)) + switch trimmed { + case "": + return choices[len(choices)-1], "reuse", nil + case "1", "new", "c", "create": + return projectSummary{}, "new", nil + case "n", "no", "abort", "q", "quit": + return projectSummary{}, "abort", nil + } + if index, parseErr := strconv.Atoi(trimmed); parseErr == nil && index >= 2 && index <= len(choices)+1 { + return choices[index-2], "reuse", nil + } + for _, item := range choices { + if strings.EqualFold(item.ProjectID, strings.TrimSpace(answer)) || strings.EqualFold(item.Name, strings.TrimSpace(answer)) { + return item, "reuse", nil + } + } + if _, err := fmt.Fprintf(out, "Please choose 1-%d, enter a project name/id, or type new.\n", len(choices)+1); err != nil { + return projectSummary{}, "", err + } + if errors.Is(err, io.EOF) { + return projectSummary{}, "abort", nil + } + } +} + +func (a *App) listInitProjects() (projectContext, []projectSummary, error) { ctx, err := loadContext(a.env) if err != nil { - return projectTarget{}, false, 0, err + return projectContext{}, nil, err } list, err := a.listProjects("", 1, 100) if err != nil { - return projectTarget{}, false, 0, err - } - total = len(list.Items) - if total == 0 { - return projectTarget{}, false, 0, nil - } - item, ok := selectInitProjectFromList(list.Items) - if !ok { - return projectTarget{}, false, total, nil + return projectContext{}, nil, err } + return ctx, list.Items, nil +} + +func (a *App) resolveInitProject(ctx projectContext, item projectSummary) (projectTarget, error) { project, err := a.getProject(item.ProjectID) if err != nil { - return projectTarget{}, false, total, err + return projectTarget{}, err } region := ctx.CurrentRegion if region == "" { @@ -234,45 +319,15 @@ func (a *App) findDefaultProject() (target projectTarget, found bool, total int, if project.Region != nil && *project.Region != "" { region = *project.Region } - return projectTarget{project: project, region: region}, true, total, nil + return projectTarget{project: project, region: region}, nil } -// confirmProjectReuse prompts the user before silently binding a new repo to a -// reused project. Returns one of "reuse", "new", or "abort". Empty input or 'y' -// defaults to "reuse"; 'n' aborts; 'new' or 'c' creates a fresh project. -func confirmProjectReuse(in io.Reader, out io.Writer, name, projectID string) (string, error) { - reader := bufio.NewReader(in) - prompt := fmt.Sprintf("Use existing project %q (%s)? [Y/n/new]: ", name, projectID) - for { - if _, err := fmt.Fprint(out, prompt); err != nil { - return "", err - } - answer, err := reader.ReadString('\n') - if err != nil && !errors.Is(err, io.EOF) { - return "", err - } - switch strings.ToLower(strings.TrimSpace(answer)) { - case "", "y", "yes": - return "reuse", nil - case "n", "no": - return "abort", nil - case "new", "c", "create": - return "new", nil - } - if _, err := fmt.Fprintln(out, "Please answer y (reuse), n (abort), or new (create a fresh project)."); err != nil { - return "", err - } - if errors.Is(err, io.EOF) { - return "abort", nil - } - } -} - -func (a *App) initProject(name, targetDir string, template quickstartTemplate, existingProject, region string, features []string, newProject bool, promptForReuse bool, promptOut io.Writer, promptIn io.Reader, progress progressEmitter) (map[string]any, error) { +func (a *App) initProject(name, targetDir string, template quickstartTemplate, existingProject, region string, features []string, rtmDataCenter string, newProject bool, promptForReuse bool, promptOut io.Writer, promptIn io.Reader, progress progressEmitter) (map[string]any, error) { var target projectTarget projectAction := "existing" enabledFeatures := []string{} needsCreate := false + createdRTMDataCenter := "" switch { case strings.TrimSpace(existingProject) != "": @@ -284,41 +339,57 @@ func (a *App) initProject(name, targetDir string, template quickstartTemplate, e case newProject: needsCreate = true default: - resolved, found, total, err := a.findDefaultProject() + ctx, items, err := a.listInitProjects() if err != nil { return nil, err } - if found { - // Confirm reuse only if more than one project exists; if the - // account has exactly one project, silent reuse is unambiguous. - if promptForReuse && total > 1 { - choice, err := confirmProjectReuse(promptIn, promptOut, resolved.project.Name, resolved.project.ProjectID) + if len(items) == 0 { + needsCreate = true + break + } + if item, ok := selectDefaultInitProjectFromList(items); ok { + resolved, err := a.resolveInitProject(ctx, item) + if err != nil { + return nil, err + } + target = resolved + break + } + if promptForReuse { + selected, action, err := chooseInitProject(promptIn, promptOut, items) + if err != nil { + return nil, err + } + switch action { + case "abort": + return nil, &cliError{Message: "init aborted by user.", Code: "INIT_ABORTED"} + case "new": + needsCreate = true + default: + resolved, err := a.resolveInitProject(ctx, selected) if err != nil { return nil, err } - switch choice { - case "abort": - return nil, &cliError{Message: "init aborted by user.", Code: "INIT_ABORTED"} - case "new": - needsCreate = true - default: - target = resolved - } - } else { target = resolved } } else { - needsCreate = true + item, ok := selectInitProjectFromList(items) + if !ok { + needsCreate = true + break + } + resolved, err := a.resolveInitProject(ctx, item) + if err != nil { + return nil, err + } + target = resolved } } if needsCreate { - featuresToEnable := features - if len(featuresToEnable) == 0 { - featuresToEnable = defaultInitFeatures() - } + featuresToEnable := normalizeProjectCreateFeatures(features) progress.emit("project:create", "Creating Agora project", map[string]any{"projectName": name, "features": featuresToEnable}) - projectResult, err := a.projectCreate(name, region, "", featuresToEnable, "") + projectResult, err := a.projectCreate(name, region, "", featuresToEnable, rtmDataCenter, "") if err != nil { return nil, err } @@ -326,6 +397,7 @@ func (a *App) initProject(name, targetDir string, template quickstartTemplate, e if list, ok := projectResult["enabledFeatures"].([]string); ok { enabledFeatures = list } + createdRTMDataCenter = asString(projectResult["rtmDataCenter"]) resolved, err := a.resolveProjectTarget(asString(projectResult["projectId"])) if err != nil { return nil, err @@ -371,5 +443,8 @@ func (a *App) initProject(name, targetDir string, template quickstartTemplate, e "template": template.ID, "title": template.Title, } + if createdRTMDataCenter != "" { + result["rtmDataCenter"] = createdRTMDataCenter + } return result, nil } diff --git a/internal/cli/init_test.go b/internal/cli/init_test.go index a9a972b..9017f0f 100644 --- a/internal/cli/init_test.go +++ b/internal/cli/init_test.go @@ -1,6 +1,9 @@ package cli -import "testing" +import ( + "strings" + "testing" +) func TestSelectInitProjectFromList(t *testing.T) { t.Run("empty list", func(t *testing.T) { @@ -52,3 +55,51 @@ func TestSelectInitProjectFromList(t *testing.T) { } }) } + +func TestChooseInitProject(t *testing.T) { + items := []projectSummary{ + {ProjectID: "prj_old", Name: "Older", CreatedAt: "2026-04-01T10:00:00Z", UpdatedAt: "2026-04-01T10:00:00Z"}, + {ProjectID: "prj_new", Name: "Newest", CreatedAt: "2026-04-12T10:00:00Z", UpdatedAt: "2026-04-12T10:00:00Z"}, + {ProjectID: "prj_mid", Name: "Middle", CreatedAt: "2026-04-08T10:00:00Z", UpdatedAt: "2026-04-08T10:00:00Z"}, + } + + t.Run("enter selects most recent displayed last", func(t *testing.T) { + var out strings.Builder + project, action, err := chooseInitProject(strings.NewReader("\n"), &out, items) + if err != nil { + t.Fatal(err) + } + if action != "reuse" || project.ProjectID != "prj_new" { + t.Fatalf("expected newest project reuse, got action=%s project=%+v", action, project) + } + output := out.String() + createIndex := strings.Index(output, "Create a new project") + olderIndex := strings.Index(output, "Older") + newestIndex := strings.Index(output, "Newest") + if createIndex < 0 || olderIndex < 0 || newestIndex < 0 || !(createIndex < olderIndex && olderIndex < newestIndex) { + t.Fatalf("expected create option above projects displayed oldest-to-newest, got:\n%s", output) + } + }) + + t.Run("new creates a fresh project", func(t *testing.T) { + var out strings.Builder + _, action, err := chooseInitProject(strings.NewReader("new\n"), &out, items) + if err != nil { + t.Fatal(err) + } + if action != "new" { + t.Fatalf("expected new action, got %s", action) + } + }) + + t.Run("number selects project", func(t *testing.T) { + var out strings.Builder + project, action, err := chooseInitProject(strings.NewReader("3\n"), &out, items) + if err != nil { + t.Fatal(err) + } + if action != "reuse" || project.ProjectID != "prj_mid" { + t.Fatalf("expected middle project, got action=%s project=%+v", action, project) + } + }) +} diff --git a/internal/cli/integration_init_test.go b/internal/cli/integration_init_test.go index b5c0a65..cf81224 100644 --- a/internal/cli/integration_init_test.go +++ b/internal/cli/integration_init_test.go @@ -24,7 +24,7 @@ func TestCLIInitCreatesProjectAndQuickstart(t *testing.T) { "app/page.tsx": "export default function Page() { return null }\n", }) - initResult := runCLI(t, []string{"init", "starter-demo", "--template", "nextjs", "--dir", filepath.Join(rootDir, "starter-demo"), "--json"}, cliRunOptions{ + initResult := runCLI(t, []string{"init", "starter-demo", "--template", "nextjs", "--dir", filepath.Join(rootDir, "starter-demo"), "--rtm-data-center", "ap", "--json"}, cliRunOptions{ env: map[string]string{ "XDG_CONFIG_HOME": configHome, "AGORA_API_BASE_URL": api.baseURL, @@ -36,8 +36,19 @@ func TestCLIInitCreatesProjectAndQuickstart(t *testing.T) { if initResult.exitCode != 0 || !strings.Contains(initResult.stdout, `"action":"init"`) || !strings.Contains(initResult.stdout, `"projectAction":"created"`) || !strings.Contains(initResult.stdout, `"template":"nextjs"`) { t.Fatalf("unexpected init result: %+v", initResult) } - if !strings.Contains(initResult.stdout, `"enabledFeatures":["rtc","convoai"]`) && !strings.Contains(initResult.stdout, `"enabledFeatures":["convoai","rtc"]`) { - t.Fatalf("expected default features in init result: %+v", initResult) + for _, feature := range []string{`"rtc"`, `"rtm"`, `"convoai"`} { + if !strings.Contains(initResult.stdout, feature) { + t.Fatalf("expected default feature %s in init result: %+v", feature, initResult) + } + } + if !strings.Contains(initResult.stdout, `"rtmDataCenter":"AP"`) { + t.Fatalf("expected RTM data center in init result: %+v", initResult) + } + api.mu.Lock() + initProject := api.projects["prj_0001"] + api.mu.Unlock() + if initProject == nil || initProject.FeatureState.RTMRegion != "AP" { + t.Fatalf("expected init to configure RTM data center AP, got %+v", initProject) } localEnv, err := os.ReadFile(filepath.Join(rootDir, "starter-demo", ".env.local")) if err != nil { diff --git a/internal/cli/integration_project_test.go b/internal/cli/integration_project_test.go index 052ac85..8532c8a 100644 --- a/internal/cli/integration_project_test.go +++ b/internal/cli/integration_project_test.go @@ -96,6 +96,83 @@ func TestCLIProjectAndEnvAndDoctorParity(t *testing.T) { } } +func TestCLIProjectCreateDefaultsToCoreFeatures(t *testing.T) { + configHome := t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + persistSessionForIntegration(t, configHome) + + dryRun := runCLI(t, []string{"project", "create", "Project Dry Run", "--dry-run", "--json"}, cliRunOptions{env: map[string]string{ + "XDG_CONFIG_HOME": configHome, + "AGORA_API_BASE_URL": api.baseURL, + "AGORA_LOG_LEVEL": "error", + }}) + if dryRun.exitCode != 0 { + t.Fatalf("unexpected dry-run result: %+v", dryRun) + } + for _, feature := range []string{`"rtc"`, `"rtm"`, `"convoai"`} { + if !strings.Contains(dryRun.stdout, feature) { + t.Fatalf("expected default feature %s in dry-run result: %+v", feature, dryRun) + } + } + + create := runCLI(t, []string{"project", "create", "Project Gamma", "--json"}, cliRunOptions{env: map[string]string{ + "XDG_CONFIG_HOME": configHome, + "AGORA_API_BASE_URL": api.baseURL, + "AGORA_LOG_LEVEL": "error", + }}) + if create.exitCode != 0 { + t.Fatalf("unexpected create result: %+v", create) + } + if !strings.Contains(create.stdout, `"rtmDataCenter":"NA"`) { + t.Fatalf("expected default RTM data center in create result: %+v", create) + } + for _, feature := range []string{`"rtc"`, `"rtm"`, `"convoai"`} { + if !strings.Contains(create.stdout, feature) { + t.Fatalf("expected default feature %s in create result: %+v", feature, create) + } + } + api.mu.Lock() + defaultProject := api.projects["prj_0001"] + api.mu.Unlock() + if defaultProject == nil || defaultProject.FeatureState.RTMRegion != "NA" { + t.Fatalf("expected omitted RTM data center to default to NA, got %+v", defaultProject) + } + + withDataCenter := runCLI(t, []string{"project", "create", "Project Delta", "--rtm-data-center", "eu", "--json"}, cliRunOptions{env: map[string]string{ + "XDG_CONFIG_HOME": configHome, + "AGORA_API_BASE_URL": api.baseURL, + "AGORA_LOG_LEVEL": "error", + }}) + if withDataCenter.exitCode != 0 || !strings.Contains(withDataCenter.stdout, `"rtmDataCenter":"EU"`) { + t.Fatalf("unexpected create with data center result: %+v", withDataCenter) + } + api.mu.Lock() + dataCenterProject := api.projects["prj_0002"] + api.mu.Unlock() + if dataCenterProject == nil || dataCenterProject.FeatureState.RTMRegion != "EU" { + t.Fatalf("expected RTM data center EU, got %+v", dataCenterProject) + } + + rtcOnly := runCLI(t, []string{"project", "create", "Project RTC Only", "--feature", "rtc", "--dry-run", "--json"}, cliRunOptions{env: map[string]string{ + "XDG_CONFIG_HOME": configHome, + "AGORA_API_BASE_URL": api.baseURL, + "AGORA_LOG_LEVEL": "error", + }}) + if rtcOnly.exitCode != 0 || strings.Contains(rtcOnly.stdout, `"rtmDataCenter"`) || strings.Contains(rtcOnly.stdout, `"rtm"`) { + t.Fatalf("unexpected rtc-only dry-run result: %+v", rtcOnly) + } + + convoAIOnly := runCLI(t, []string{"project", "create", "Project ConvoAI", "--feature", "convoai", "--dry-run", "--json"}, cliRunOptions{env: map[string]string{ + "XDG_CONFIG_HOME": configHome, + "AGORA_API_BASE_URL": api.baseURL, + "AGORA_LOG_LEVEL": "error", + }}) + if convoAIOnly.exitCode != 0 || !strings.Contains(convoAIOnly.stdout, `"convoai"`) || !strings.Contains(convoAIOnly.stdout, `"rtm"`) || !strings.Contains(convoAIOnly.stdout, `"rtmDataCenter":"NA"`) { + t.Fatalf("expected convoai dry-run to include rtm dependency: %+v", convoAIOnly) + } +} + func TestCLIProjectUseShowFeatureAndDoctorHappyPath(t *testing.T) { configHome := t.TempDir() api := newFakeCLIBFF() diff --git a/internal/cli/integration_test.go b/internal/cli/integration_test.go index 9e5a40f..9169007 100644 --- a/internal/cli/integration_test.go +++ b/internal/cli/integration_test.go @@ -344,6 +344,7 @@ type fakeProject struct { FeatureState struct { ConvoAIEnabled bool `json:"convoaiEnabled"` RTMEnabled bool `json:"rtmEnabled"` + RTMRegion string } `json:"-"` Name string `json:"name"` ProjectID string `json:"projectId"` @@ -497,7 +498,12 @@ func newFakeCLIBFF() *fakeCLIBFF { "projectId": projectID, }) case http.MethodPut: + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) project.FeatureState.RTMEnabled = true + if region, _ := body["region"].(string); region != "" { + project.FeatureState.RTMRegion = region + } _ = json.NewEncoder(w).Encode(map[string]any{ "enabled": true, "projectId": projectID, diff --git a/internal/cli/projects.go b/internal/cli/projects.go index 50f4a8c..0b904cf 100644 --- a/internal/cli/projects.go +++ b/internal/cli/projects.go @@ -171,11 +171,13 @@ func (a *App) setRTM2Config(projectID, region string) error { "interval": "30", "lockEnabled": false, "occupancy": "50", - "region": region, "storageEnabled": false, "streamChannelEnabled": false, "userSubscribeEnabled": false, } + if strings.TrimSpace(region) != "" { + body["region"] = region + } out := map[string]any{} return a.apiRequest("PUT", "/api/cli/v1/projects/"+projectID+"/rtm2-config", nil, body, &out) } @@ -239,16 +241,20 @@ func (a *App) listProjectFeatures(project projectDetail, region string) ([]featu return items, nil } -func (a *App) enableProjectFeature(feature string, project projectDetail, region string) (map[string]any, error) { +func (a *App) enableProjectFeature(feature string, project projectDetail, region, rtmDataCenter string) (map[string]any, error) { switch feature { case "rtc": return map[string]any{"action": "feature-enable", "feature": "rtc", "message": "rtc is included with the project", "projectId": project.ProjectID, "projectName": project.Name, "status": "included"}, nil case "rtm": - rtmRegion := "NA" - if region == "cn" { - rtmRegion = "CN" + var err error + rtmDataCenter, err = normalizeRTMDataCenter(rtmDataCenter) + if err != nil { + return nil, err + } + if rtmDataCenter == "" { + rtmDataCenter = "NA" } - if err := a.setRTM2Config(project.ProjectID, rtmRegion); err != nil { + if err := a.setRTM2Config(project.ProjectID, rtmDataCenter); err != nil { return nil, err } return map[string]any{"action": "feature-enable", "feature": "rtm", "message": "rtm enabled", "projectId": project.ProjectID, "projectName": project.Name, "status": "enabled"}, nil @@ -266,7 +272,7 @@ func (a *App) enableProjectFeature(feature string, project projectDetail, region } } -func (a *App) projectCreate(name, region, template string, features []string, idempotencyKey string) (map[string]any, error) { +func (a *App) projectCreate(name, region, template string, features []string, rtmDataCenter string, idempotencyKey string) (map[string]any, error) { ctx, err := loadContext(a.env) if err != nil { return nil, err @@ -277,12 +283,14 @@ func (a *App) projectCreate(name, region, template string, features []string, id region = "global" } } - project, err := a.createProject(name, idempotencyKey) + features = projectCreateFeatures(template, features) + rtmDataCenter, err = rtmDataCenterForFeatures(features, rtmDataCenter) if err != nil { return nil, err } - if template == "voice-agent" { - features = append(features, "rtc", "rtm", "convoai") + project, err := a.createProject(name, idempotencyKey) + if err != nil { + return nil, err } seen := map[string]bool{} enabled := []string{} @@ -291,7 +299,7 @@ func (a *App) projectCreate(name, region, template string, features []string, id continue } seen[feature] = true - if _, err := a.enableProjectFeature(feature, project, region); err != nil { + if _, err := a.enableProjectFeature(feature, project, region, rtmDataCenter); err != nil { return nil, err } enabled = append(enabled, feature) @@ -303,7 +311,68 @@ func (a *App) projectCreate(name, region, template string, features []string, id if err := saveContext(a.env, ctx); err != nil { return nil, err } - return map[string]any{"action": "create", "appId": project.AppID, "enabledFeatures": enabled, "projectId": project.ProjectID, "projectName": project.Name, "region": region}, nil + result := map[string]any{"action": "create", "appId": project.AppID, "enabledFeatures": enabled, "projectId": project.ProjectID, "projectName": project.Name, "region": region} + if rtmDataCenter != "" { + result["rtmDataCenter"] = rtmDataCenter + } + return result, nil +} + +func normalizeRTMDataCenter(value string) (string, error) { + normalized := strings.ToUpper(strings.TrimSpace(value)) + switch normalized { + case "": + return "", nil + case "CN", "NA", "EU", "AP": + return normalized, nil + default: + return "", fmt.Errorf("--rtm-data-center must be one of: CN, NA, EU, AP") + } +} + +func rtmDataCenterForFeatures(features []string, value string) (string, error) { + normalized, err := normalizeRTMDataCenter(value) + if err != nil { + return "", err + } + if !featureListIncludes(features, "rtm") { + if normalized != "" { + return "", fmt.Errorf("--rtm-data-center can only be used when rtm is enabled") + } + return "", nil + } + if normalized == "" { + return "NA", nil + } + return normalized, nil +} + +func normalizeProjectCreateFeatures(features []string) []string { + if len(features) == 0 { + return defaultInitFeatures() + } + return features +} + +func projectCreateFeatures(template string, features []string) []string { + next := append([]string{}, features...) + if template == "voice-agent" { + next = append(next, "rtc", "rtm", "convoai") + } + next = normalizeProjectCreateFeatures(next) + if featureListIncludes(next, "convoai") && !featureListIncludes(next, "rtm") { + next = append([]string{"rtm"}, next...) + } + return next +} + +func featureListIncludes(features []string, target string) bool { + for _, feature := range features { + if feature == target { + return true + } + } + return false } func (a *App) projectUse(projectArg string) (map[string]any, error) { @@ -747,5 +816,5 @@ func (a *App) projectFeatureEnable(feature, projectArg string) (map[string]any, if err != nil { return nil, err } - return a.enableProjectFeature(feature, target.project, target.region) + return a.enableProjectFeature(feature, target.project, target.region, "") } From 860e84a38227580b13ffd4e4ba2d7d957b038658 Mon Sep 17 00:00:00 2001 From: digitallysavvy Date: Thu, 30 Apr 2026 18:04:55 -0400 Subject: [PATCH 02/26] chore(release): prepare v0.1.10 docs and version references --- .github/ISSUE_TEMPLATE/bug_report.yml | 8 +-- CHANGELOG.md | 34 ++++++++++- RELEASING.md | 48 +++++++-------- docs/install.md | 87 ++++++++++++++++----------- install.ps1 | 30 ++++++++- internal/cli/version.go | 2 +- 6 files changed, 138 insertions(+), 71 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 352ef3f..ef79390 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -10,7 +10,7 @@ body: can reproduce and triage the issue quickly. - **Do not** include credentials, App Certificates, or session tokens - in any field. Run `agora doctor --json` redacted if needed. + in any field. Run `agora project doctor --json` redacted if needed. - For security issues, email instead. - type: input @@ -18,7 +18,7 @@ body: attributes: label: CLI version description: Output of `agora --version` - placeholder: "e.g. agora-cli-go 0.1.9 (commit abc1234, built 2026-04-30)" + placeholder: "e.g. agora-cli-go 0.1.10 (commit abc1234, built 2026-04-30)" validations: required: true @@ -84,9 +84,9 @@ body: - type: textarea id: doctor attributes: - label: `agora doctor --json` output + label: `agora project doctor --json` output description: | - Optional but very helpful. Run `agora doctor --json` and paste the output here. Redact anything sensitive first. + Optional but very helpful. Run `agora project doctor --json` and paste the output here. Redact anything sensitive first. render: json validations: required: false diff --git a/CHANGELOG.md b/CHANGELOG.md index f01b41d..e83da25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). When tagging a new release, rename the `[Unreleased]` section to the new version -(e.g. `[0.1.9] - 2026-04-30`), add a fresh empty `[Unreleased]` heading at the top, +(e.g. `[0.1.10] - 2026-04-30`), add a fresh empty `[Unreleased]` heading at the top, and update the link references at the bottom of this file. When adding a new entry, link the change to the PR or commit that introduced it @@ -15,10 +15,37 @@ Earlier entries pre-date this convention and only carry their version's compare ## [Unreleased] +### Added + +- Add GitHub Pages publishing for generated CLI docs and route `agora open --target docs` to the CLI docs site, with `product-docs` available for Agora product docs. +- Add global `--yes` / `-y` and `AGORA_NO_INPUT=1` support to accept defaults and suppress prompts. +- Add pretty-mode progress status lines for long-running clone, OAuth, and project creation work. +- Add dynamic shell completions for project names, quickstart templates, and project features. +- Add `agora mcp serve --transport stdio` so MCP-capable agents can use local Agora CLI tools. +- Add drop-in agent rule snippets under `docs/agents/` and `agora init --add-agent-rules`. +- Add `install.sh --uninstall` and `install.ps1 -Uninstall`. +- Add CODEOWNERS, Dependabot, and a scheduled `govulncheck` workflow. +- Infer coarse agent labels for API `User-Agent` when `AGORA_AGENT` is unset; explicit `AGORA_AGENT` still takes precedence. + +### Changed + +- Switch npm platform package wiring from scoped `@agoraio/cli-*` packages to unscoped `agoraio-cli-*` packages. +- Standardize README command examples on the installed `agora` command. +- Standardize contributor contact email on `devrel@agora.io`. + +### Fixed + +- Fix bug report template references to use `agora project doctor --json`. +- Return structured `INIT_NAME_REQUIRED`, `AUTH_OAUTH_EXCHANGE_FAILED`, and `AUTH_OAUTH_RESPONSE_INVALID` errors for previously unclassified paths. + +## [0.1.10] - 2026-04-30 + ### Changed - Default newly created projects to enable `rtc`, `rtm`, and `convoai`, make `convoai` imply `rtm` during project creation, and add `--rtm-data-center` for `init` / `project create` when RTM should be configured for a specific data center. - Refine `agora init` project selection so `--project` binds explicitly, `--new-project` creates explicitly, `"Default Project"` auto-selects by exact name, and interactive sessions without a default show existing projects plus a create-new option. +- `agora project env write` detects Next.js workspaces and writes `NEXT_PUBLIC_AGORA_APP_ID` / `NEXT_AGORA_APP_CERTIFICATE`, with `--template nextjs|standard` to override auto-detection. +- `project env write` now creates or updates repo-local `.agora/project.json` for the selected project, recording `projectType` (framework/language detection such as `nextjs`, `go`, `python`, `node`, `standard`) and `envPath`, while quickstart-bound repos continue using a single `template` field for template lineage. ## [0.1.9] - 2026-04-30 @@ -41,7 +68,7 @@ Earlier entries pre-date this convention and only carry their version's compare - Auto-detect CI environments (`CI`, `GITHUB_ACTIONS`, `GITLAB_CI`, `BUILDKITE`, `CIRCLECI`, `JENKINS_URL`, `TF_BUILD`) and automatically default `--output` to `json`, suppress the first-run config banner, and short-circuit interactive prompts. Explicit `--output` flags, user-set `AGORA_OUTPUT`, and `AGORA_DISABLE_CI_DETECT=1` always take precedence. - Add a `.golangci.yml` ruleset (errcheck, govet, staticcheck, ineffassign, unused, gosec, bodyclose, errorlint, misspell, unconvert) and wire `golangci-lint v1.64.8` into the Linux CI matrix. The `make lint` target now runs `gofmt`, `golangci-lint`, and the error-code coverage audit together. - Add an interactive sign-in prompt for human CLI sessions when an account connection is required and no local session exists. The prompt defaults to yes on Enter and launches the existing OAuth login flow. -- Re-enable the npm distribution channel (`agoraio-cli` wrapper plus six `@agoraio/cli-{os}-{arch}` platform packages). The release workflow now downloads the GitHub release archives, verifies them against `checksums.txt` (SHA-256), stages binaries into platform packages, stamps the tag version into every `package.json`, and publishes all packages with `npm publish --provenance` (sigstore-backed supply-chain attestations). +- Re-enable the npm distribution channel (`agoraio-cli` wrapper plus six platform packages). The release workflow now downloads the GitHub release archives, verifies them against `checksums.txt` (SHA-256), stages binaries into platform packages, stamps the tag version into every `package.json`, and publishes all packages with `npm publish --provenance` (sigstore-backed supply-chain attestations). - Add a post-publish smoke test that runs `npx --yes agoraio-cli@ --version` with retry/backoff to catch registry-propagation or platform-package-mismatch bugs before users hit them. - Add a `workflow_dispatch` trigger to the release workflow with a `dry_run` input so maintainers can validate npm packaging end-to-end without minting a real release. - Enrich every npm `package.json` (wrapper + 6 platform packages) with `repository`, `homepage`, `bugs`, `license`, `author`, `keywords`, and `publishConfig.provenance` for a higher-quality npmjs.com listing and supply-chain attestation. @@ -124,7 +151,8 @@ Earlier entries pre-date this convention and only carry their version's compare - 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.1.9...HEAD +[Unreleased]: https://github.com/AgoraIO/cli/compare/v0.1.10...HEAD +[0.1.10]: https://github.com/AgoraIO/cli/compare/v0.1.9...v0.1.10 [0.1.9]: https://github.com/AgoraIO/cli/compare/v0.1.8...v0.1.9 [0.1.8]: https://github.com/AgoraIO/cli/compare/v0.1.7...v0.1.8 [0.1.7]: https://github.com/AgoraIO/cli/compare/v0.1.6...v0.1.7 diff --git a/RELEASING.md b/RELEASING.md index e1947a4..816c7e8 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -5,8 +5,8 @@ Releases are fully automated via GoReleaser. Pushing a `v*` tag is the only manu ## Release ```bash -git tag v0.1.9 -git push origin v0.1.9 +git tag v0.1.10 +git push origin v0.1.10 ``` The release workflow (`.github/workflows/release.yml`) then: @@ -20,12 +20,12 @@ The release workflow (`.github/workflows/release.yml`) then: 2. **npm publish** job (active): - Downloads the release archives, verifies them against `checksums.txt` (SHA-256), and refuses to publish on mismatch - - Stages the per-platform binary into each `@agoraio/cli-{os}-{arch}` package + - Stages the per-platform binary into each unscoped `agoraio-cli-{os}-{arch}` package - Stamps the tag version into all package.json files (wrapper + 6 platform packages) - Publishes the six per-platform packages with `npm publish --provenance` - Publishes the wrapper package (`agoraio-cli`) with `npm publish --provenance` - Runs a post-publish smoke test: `npx --yes agoraio-cli@ --version` with retry/backoff to handle registry propagation - - Requires `NPM_TOKEN` secret with publish access to `agoraio-cli` and `@agoraio/*` + - Requires `NPM_TOKEN` secret with publish access to `agoraio-cli` and `agoraio-cli-*` - Requires `id-token: write` workflow permission for sigstore-backed npm provenance attestations 3. **Apt repository** job (triggered by the published release): @@ -59,36 +59,35 @@ The release workflow exposes a `workflow_dispatch` trigger that runs the npm pub Before tagging the first real release that ships npm, confirm: -- [ ] `NPM_TOKEN` secret is set in the repo (Settings → Secrets and variables → Actions). Token must have publish access to `agoraio-cli` (unscoped) and the `@agoraio` scope. -- [ ] `agoraio-cli` package name on npmjs.com is owned by the Agora npm org / publisher and not squatted. -- [ ] `@agoraio` scope on npmjs.com is owned by the Agora npm org and the `NPM_TOKEN` user is a member with publish access. +- [ ] `NPM_TOKEN` secret is set in the repo (Settings → Secrets and variables → Actions). Token must have publish access to `agoraio-cli` and all unscoped `agoraio-cli-*` platform packages. +- [ ] `agoraio-cli` and `agoraio-cli-*` package names on npmjs.com are owned by the Agora npm org / publisher and not squatted. - [ ] The workflow has `id-token: write` permission (already set in `release.yml`); npm provenance requires it. - [ ] A `workflow_dispatch` dry-run on the current `main` succeeds end-to-end (validates packaging, scripts, provenance). - [ ] First publish should be a release-candidate tag (e.g. `v0.1.x-rc.1`) so an unexpected failure does not affect a "latest" tag in the registry. ## Required Secrets and Variables -| Name | Type | Required for | -|------|------|-------------| -| `NPM_TOKEN` | secret | npm publish (active) | -| `APT_SIGNING_KEY` | secret | Signed apt repo on GitHub Pages | +| Name | Type | Required for | +| -------------------- | -------- | ------------------------------- | +| `NPM_TOKEN` | secret | npm publish (active) | +| `APT_SIGNING_KEY` | secret | Signed apt repo on GitHub Pages | | `APT_SIGNING_KEY_ID` | variable | Signed apt repo on GitHub Pages | Homebrew and Scoop are not part of the current GoReleaser config. Add `brews:` / `scoops:` blocks before documenting them as automated channels. ## Distribution Channels -| Channel | How | -|---------|-----| -| Homebrew | Coming soon; direct installer is current primary macOS path | -| npm (convenience) | Active; published with provenance from `release.yml` | -| apt/deb (Debian/Ubuntu) | apt-repo.yml → GitHub Pages | -| rpm (RHEL/Fedora) | Release artifact (.rpm via GoReleaser) | -| apk (Alpine/Docker) | Release artifact (.apk via GoReleaser) | -| Scoop (Windows) | Coming soon | -| Docker (GHCR) | GoReleaser dockers block | -| Shell install script | `install.sh` downloads from GitHub Releases | -| Winget (Windows) | Manual: submit PR to microsoft/winget-pkgs | +| Channel | How | +| ----------------------- | ----------------------------------------------------------- | +| Homebrew | Coming soon; direct installer is current primary macOS path | +| npm (convenience) | Active; published with provenance from `release.yml` | +| apt/deb (Debian/Ubuntu) | apt-repo.yml → GitHub Pages | +| rpm (RHEL/Fedora) | Release artifact (.rpm via GoReleaser) | +| apk (Alpine/Docker) | Release artifact (.apk via GoReleaser) | +| Scoop (Windows) | Coming soon | +| Docker (GHCR) | GoReleaser dockers block | +| Shell install script | `install.sh` downloads from GitHub Releases | +| Winget (Windows) | Manual: submit PR to microsoft/winget-pkgs | ## Rollback (npm) @@ -100,10 +99,9 @@ If a published version is bad: ## One-Time Setup Checklist -- [ ] Enable GitHub Pages on this repo (Settings → Pages → Source: `gh-pages` branch) +- [ ] Enable GitHub Pages on this repo (Settings → Pages → Source: GitHub Actions) - [ ] Generate GPG key for apt signing; set `APT_SIGNING_KEY` and `APT_SIGNING_KEY_ID` -- [ ] Set `NPM_TOKEN` with publish access to `agoraio-cli` and `@agoraio/*` -- [ ] Verify `@agoraio` scope ownership on npmjs.com +- [ ] Set `NPM_TOKEN` with publish access to `agoraio-cli` and all `agoraio-cli-*` packages - [ ] Run a `workflow_dispatch` dry-run of the release workflow to validate npm packaging - [ ] Add Homebrew and Scoop GoReleaser blocks before announcing those channels - [ ] Submit first Winget manifest PR to `microsoft/winget-pkgs` after the first release diff --git a/docs/install.md b/docs/install.md index c7e5a2c..9af5689 100644 --- a/docs/install.md +++ b/docs/install.md @@ -16,7 +16,7 @@ agora --help Install a pinned version: ```bash -curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh | sh -s -- --version 0.1.9 --add-to-path +curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh | sh -s -- --version 0.1.10 --add-to-path agora --help ``` @@ -50,7 +50,7 @@ agora --help Install a pinned version and add the default install directory to your user PATH: ```powershell -$env:VERSION = "0.1.9" +$env:VERSION = "0.1.10" & ([scriptblock]::Create((irm https://raw.githubusercontent.com/AgoraIO/cli/main/install.ps1))) -AddToPath agora --help ``` @@ -72,6 +72,7 @@ If your PowerShell execution policy blocks inline scripts, download `install.ps1 --add-to-path Append INSTALL_DIR to your shell rc file (bash, zsh, fish, or .profile). --dry-run Show what would happen without writing any files. +--uninstall Remove the installer-managed binary and receipt. --no-color Disable colored output. -q, --quiet Suppress non-error output. -v, --verbose Verbose debug output. @@ -81,12 +82,28 @@ If your PowerShell execution policy blocks inline scripts, download `install.ps1 If another managed `agora` install is detected, the installer refuses by default to avoid creating two installs that shadow each other on PATH. Pass `--force` to install alongside it. +## Uninstall + +Direct installer installs can be removed with: + +```bash +curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh | sh -s -- --uninstall +``` + +On Windows PowerShell: + +```powershell +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/AgoraIO/cli/main/install.ps1))) -Uninstall +``` + +Uninstall removes the binary and `agora.install.json` receipt from the install directory. It preserves config, session, context, and logs. + ## Supported Environment Variables Both direct installers support these core overrides: - `GITHUB_REPO`: install from a fork or alternate repository. -- `VERSION`: install a specific version. Both `0.1.9` and `v0.1.9` are accepted. +- `VERSION`: install a specific version. Both `0.1.10` and `v0.1.10` are accepted. - `INSTALL_DIR`: install to a custom directory. - `GITHUB_TOKEN` or `GH_TOKEN`: optional GitHub token to avoid API rate limits when resolving the latest release. @@ -107,17 +124,17 @@ Advanced or test overrides supported by both direct installers: Both `install.sh` and `install.ps1` use the same stable exit-code contract for scripted callers: -| Code | Meaning | -| ---- | ------------------------------------------------------------------------------------------------------------------ | -| 0 | success (or already at target version on idempotent re-run) | -| 1 | generic / unknown error | -| 2 | invalid arguments | -| 3 | missing prerequisite (`curl`/`wget`, `tar`/`unzip`, `sha256sum`, ... — Unix only) | -| 4 | unsupported platform / architecture | -| 5 | network or download failure | -| 6 | checksum verification failed | +| Code | Meaning | +| ---- | ------------------------------------------------------------------------------------------------------------------------------ | +| 0 | success (or already at target version on idempotent re-run) | +| 1 | generic / unknown error | +| 2 | invalid arguments | +| 3 | missing prerequisite (`curl`/`wget`, `tar`/`unzip`, `sha256sum`, ... — Unix only) | +| 4 | unsupported platform / architecture | +| 5 | network or download failure | +| 6 | checksum verification failed | | 7 | install or permission failure (non-writable dir, sudo, or refused to overwrite a managed install without `--force` / `-Force`) | -| 8 | post-install verification failed | +| 8 | post-install verification failed | ### Idempotent re-runs @@ -127,13 +144,13 @@ Both installers short-circuit with exit `0` when the target install path already Both installers refuse to overwrite an `agora` binary that came from a package manager and exit `7` with a recommended upgrade command. Override with `--force` / `-Force`. -| Manager | Detected by | Recommended upgrade | -|---------|-------------|---------------------| -| Homebrew (Unix) | binary path under `brew --prefix` | `brew upgrade agora` | -| npm (Unix and Windows) | binary path under `npm prefix -g` | `npm update -g agoraio-cli` | -| Scoop (Windows) | `$env:SCOOP` or path contains `\scoop\shims\` | `scoop update agora` | -| Chocolatey (Windows) | `$env:ChocolateyInstall` or path contains `\chocolatey\bin\` | `choco upgrade agora` | -| winget (Windows) | path contains `\WinGet\Packages\` | `winget upgrade Agora.Cli` | +| Manager | Detected by | Recommended upgrade | +| ---------------------- | ------------------------------------------------------------ | --------------------------- | +| Homebrew (Unix) | binary path under `brew --prefix` | `brew upgrade agora` | +| npm (Unix and Windows) | binary path under `npm prefix -g` | `npm update -g agoraio-cli` | +| Scoop (Windows) | `$env:SCOOP` or path contains `\scoop\shims\` | `scoop update agora` | +| Chocolatey (Windows) | `$env:ChocolateyInstall` or path contains `\chocolatey\bin\` | `choco upgrade agora` | +| winget (Windows) | path contains `\WinGet\Packages\` | `winget upgrade Agora.Cli` | ### Install receipt and upgrades @@ -162,15 +179,15 @@ go build -o agora . ## Package Channels -| Channel | Status | Command | -|---------|--------|---------| -| Shell installer | Available | `curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh \| sh -s -- --add-to-path` | -| Windows PowerShell | Available | `irm https://raw.githubusercontent.com/AgoraIO/cli/main/install.ps1 \| iex` | -| Linux `.deb` / `.rpm` / `.apk` artifacts | Available on GitHub releases | Download the package for your distro from the release page. | -| apt repository | Available when `apt-repo.yml` publishes the release | Use the signed repository documented by the release. | -| Docker / GHCR | Available when release images publish | `docker run --rm ghcr.io/agoraio/agora-cli:latest --help` | -| npm wrapper | Available | `npm install -g agoraio-cli` | -| Homebrew / Scoop | Coming soon | Use the direct installer until package-manager taps are published. | +| Channel | Status | Command | +| ---------------------------------------- | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| Shell installer | Available | `curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh \| sh -s -- --add-to-path` | +| Windows PowerShell | Available | `irm https://raw.githubusercontent.com/AgoraIO/cli/main/install.ps1 \| iex` | +| Linux `.deb` / `.rpm` / `.apk` artifacts | Available on GitHub releases | Download the package for your distro from the release page. | +| apt repository | Available when `apt-repo.yml` publishes the release | Use the signed repository documented by the release. | +| Docker / GHCR | Available when release images publish | `docker run --rm ghcr.io/agoraio/agora-cli:latest --help` | +| npm wrapper | Available | `npm install -g agoraio-cli` | +| Homebrew / Scoop | Coming soon | Use the direct installer until package-manager taps are published. | ### npm wrapper @@ -185,7 +202,7 @@ agora --help npx agoraio-cli --help # Pin a specific version -npm install -g agoraio-cli@0.1.9 +npm install -g agoraio-cli@0.1.10 # Update to the latest published version npm update -g agoraio-cli @@ -213,12 +230,12 @@ For one-off shell sessions, source the generated script according to your shell' If latest-version resolution fails, retry with a pinned version or provide `GITHUB_TOKEN` / `GH_TOKEN`: ```bash -GITHUB_TOKEN=your-token-here VERSION=0.1.9 sh install.sh +GITHUB_TOKEN=your-token-here VERSION=0.1.10 sh install.sh ``` ```powershell $env:GITHUB_TOKEN = "your-token-here" -$env:VERSION = "0.1.9" +$env:VERSION = "0.1.10" & ([scriptblock]::Create((irm https://raw.githubusercontent.com/AgoraIO/cli/main/install.ps1))) ``` @@ -264,7 +281,7 @@ For CI, automation, and reproducible environments, pin `VERSION` explicitly inst Every release is signed with [Cosign](https://docs.sigstore.dev/cosign/overview/) using GitHub Actions OIDC (keyless mode) and ships an [SPDX 2.3](https://spdx.dev/) SBOM per archive and per Linux package. To verify the `checksums.txt` file before trusting any artifact: ```bash -TAG=v0.1.9 +TAG=v0.1.10 ASSET_BASE="https://github.com/AgoraIO/cli/releases/download/${TAG}" curl -fsSLO "${ASSET_BASE}/checksums.txt" curl -fsSLO "${ASSET_BASE}/checksums.txt.sig" @@ -288,8 +305,8 @@ cosign verify "ghcr.io/agoraio/agora-cli:${TAG#v}" \ --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' ``` -To audit dependencies, download the `*.spdx.json` SBOM that ships next to each archive (e.g. `agora-cli-go_v0.1.9_linux_amd64.tar.gz.spdx.json`) and feed it to a scanner such as [Grype](https://github.com/anchore/grype): +To audit dependencies, download the `*.spdx.json` SBOM that ships next to each archive (e.g. `agora-cli-go_v0.1.10_linux_amd64.tar.gz.spdx.json`) and feed it to a scanner such as [Grype](https://github.com/anchore/grype): ```bash -grype sbom:agora-cli-go_v0.1.9_linux_amd64.tar.gz.spdx.json +grype sbom:agora-cli-go_v0.1.10_linux_amd64.tar.gz.spdx.json ``` diff --git a/install.ps1 b/install.ps1 index b9ad67c..d3b541c 100644 --- a/install.ps1 +++ b/install.ps1 @@ -12,7 +12,7 @@ # irm https://raw.githubusercontent.com/AgoraIO/cli/main/install.ps1 | iex # # Pin a version: -# $env:VERSION = '0.1.9'; & ([scriptblock]::Create((irm .../install.ps1))) +# $env:VERSION = '0.1.10'; & ([scriptblock]::Create((irm .../install.ps1))) # [CmdletBinding()] param( @@ -21,7 +21,8 @@ param( [string]$GitHubRepo = $(if ($env:GITHUB_REPO) { $env:GITHUB_REPO } else { 'AgoraIO/cli' }), [switch]$AddToPath, [switch]$Force, - [switch]$NoColor + [switch]$NoColor, + [switch]$Uninstall ) Set-StrictMode -Version Latest @@ -314,11 +315,34 @@ function Show-ExistingInstall { } } +function Uninstall-Agora { + $destinationBinary = Join-Path $InstallDir 'agora.exe' + $receiptPath = Join-Path $InstallDir $InstallReceiptFileName + + Write-Info "Uninstalling Agora CLI from $InstallDir" + if (Test-Path -LiteralPath $destinationBinary) { + Remove-Item -LiteralPath $destinationBinary -Force + Write-Info "Removed $destinationBinary" + } else { + Write-Info "No agora binary found at $destinationBinary" + } + if (Test-Path -LiteralPath $receiptPath) { + Remove-Item -LiteralPath $receiptPath -Force + Write-Info "Removed $receiptPath" + } + Write-Info "Config, session, context, and logs are preserved under the Agora CLI config directory." +} + # ---- Main ------------------------------------------------------------------ +$destinationBinary = Join-Path $InstallDir 'agora.exe' +if ($Uninstall) { + Uninstall-Agora + exit $EXIT_OK +} + $Version = Normalize-Version $Version $arch = Resolve-Architecture $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("agora-install-" + [System.Guid]::NewGuid().ToString('N')) -$destinationBinary = Join-Path $InstallDir 'agora.exe' try { Resolve-Version diff --git a/internal/cli/version.go b/internal/cli/version.go index 52d37cc..9bb1f4b 100644 --- a/internal/cli/version.go +++ b/internal/cli/version.go @@ -5,7 +5,7 @@ import "fmt" // Build-time injected version variables. These are populated by ldflags at // release time: // -// go build -ldflags '-X github.com/.../internal/cli.version=v0.1.9 +// go build -ldflags '-X github.com/.../internal/cli.version=v0.1.10 // -X github.com/.../internal/cli.commit=abc1234 // -X github.com/.../internal/cli.date=2026-04-30' // From 96b462ff18e1c3206296e3fb95faccbc27ac7799 Mon Sep 17 00:00:00 2001 From: digitallysavvy Date: Thu, 30 Apr 2026 18:07:47 -0400 Subject: [PATCH 03/26] feat(cli): detect framework for project env write and sync local binding Infer Next.js vs standard credential env keys, add --template override, resolve merge conflicts with legacy keys, and create or update .agora/project.json with projectType and envPath after writes. Align quickstart bindings on a single template field. Document JSON output, detection rules, and PROJECT_ENV_TEMPLATE_UNKNOWN. --- docs/automation.md | 28 ++- docs/commands.md | 19 +- docs/error-codes.md | 4 + internal/cli/app.go | 52 +++-- internal/cli/app_test.go | 24 +-- internal/cli/commands.go | 72 ++++++- internal/cli/integration_project_test.go | 73 ++++++- internal/cli/local_project.go | 1 + internal/cli/project_env_layout_test.go | 179 +++++++++++++++++ internal/cli/projects.go | 246 +++++++++++++++++++++-- internal/cli/quickstart.go | 4 + 11 files changed, 646 insertions(+), 56 deletions(-) create mode 100644 internal/cli/project_env_layout_test.go diff --git a/docs/automation.md b/docs/automation.md index 79af007..2bc4cdf 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -33,9 +33,11 @@ Use this guide for: - Use `--json --pretty` when a human needs to inspect JSON directly. Scripts should keep the default single-line JSON. - Use `--quiet` to suppress the success envelope in **both** pretty and JSON modes; the exit code becomes the only result. Errors are still printed on stderr (and as a JSON envelope on stdout when `--json` is set without `--quiet`). NDJSON progress events are still emitted because they are observability, not results. - Use `--verbose` (equivalent to `AGORA_VERBOSE=1`) to echo structured log records to stderr. The flag does not change exit codes, JSON envelope shape, or NDJSON progress events; it only mirrors the entries that would normally be written to the log file. Pair with `--json` for fully machine-parseable runs that also surface internal events to your CI logs. -- Interactive login prompts only appear in interactive pretty-mode runs. Automation should still authenticate up front with `agora login`; `--json`, `AGORA_OUTPUT=json`, and detected CI environments never prompt. +- Use `--yes` (or `-y`) / `AGORA_NO_INPUT=1` to accept default choices and suppress prompts in automation. +- Interactive login prompts only appear in interactive pretty-mode runs. Automation should still authenticate up front with `agora login`; `--json`, `AGORA_OUTPUT=json`, `--yes`, `AGORA_NO_INPUT=1`, and detected CI environments never prompt. - Output mode precedence is: explicit CLI flag (`--json` or `--output`) first, user-set `AGORA_OUTPUT` second, then user-customized config file value, then **CI auto-detect → JSON** (see below), then pretty. -- Set `AGORA_AGENT=` in automated environments so support and analytics can distinguish agent traffic in the API `User-Agent`. +- Set `AGORA_AGENT=` in automated environments to explicitly label agent traffic in the API `User-Agent`. When unset, the CLI may infer a coarse label such as `cursor`, `claude-code`, `cline`, `windsurf`, `codex`, or `aider` from known agent environment markers. Set `AGORA_AGENT_DISABLE_INFER=1` to disable inference. +- Use `agora mcp serve --transport stdio` to expose local Agora CLI tools to MCP-capable agents. ### CI auto-detect @@ -389,6 +391,10 @@ Example: ./agora project env write apps/web/.env.local --json ``` +Optional `data` fields: +- `credentialLayout` + Either `standard` (AGORA_* keys) or `nextjs` (`NEXT_PUBLIC_AGORA_APP_ID` and `NEXT_AGORA_APP_CERTIFICATE`) when the workspace is detected or overridden as Next.js. + Required `data` fields: - `action` Always `env-write`. @@ -396,10 +402,20 @@ Required `data` fields: - `projectName` - `path` Absolute path to the written dotenv file. +- `projectType` + Detected workspace type used for future repo metadata (`nextjs`, `go`, `python`, `node`, or `standard`). - `status` One of `created`, `updated`, `appended`, or `overwritten`. - `keysWritten` - Ordered list of credential keys that were written. `project env write` writes only `AGORA_APP_ID` and `AGORA_APP_CERTIFICATE`; non-secret project metadata is stored in `.agora/project.json` for repo-bound quickstarts instead of dotenv files. + Ordered list of credential keys that were written. By default `project env write` uses `AGORA_APP_ID` and `AGORA_APP_CERTIFICATE`. Next.js workspaces (detected via `package.json` / `next.config.*` / `env.local.example` / repo `.agora` `projectType` / `template: nextjs`, or forced with `--template nextjs`) use `NEXT_PUBLIC_AGORA_APP_ID` and `NEXT_AGORA_APP_CERTIFICATE` instead. Non-secret project metadata stays in `.agora/project.json`. + +Optional `data` fields (present when the CLI updates or creates repo metadata): +- `metadataUpdated` + `true` when `.agora/project.json` was created or updated for the selected project (including `projectType` and `envPath` when missing). +- `metadataPath` + Relative path `.agora/project.json` from the repo root when `metadataUpdated` is true. + +Pass `--template standard` to force AGORA_* keys when auto-detection would pick Next.js. Write behavior: - existing `.env` and `.env.local` files are preserved; missing credential keys are appended and existing credential keys are updated @@ -411,6 +427,10 @@ Safe branch fields: - `path` - `status` - `keysWritten` +- `credentialLayout` +- `projectType` +- `metadataUpdated` (when repo binding was updated) +- `metadataPath` (when `metadataUpdated` is true) ### `project env` @@ -547,7 +567,7 @@ Env write behavior: - 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 `APP_ID` and `APP_CERTIFICATE` -- project metadata such as project ID, project name, region, template, and env path is stored in `.agora/project.json` +- project metadata such as project ID, project name, region, template, projectType, and env path is stored in `.agora/project.json` - existing quickstart env files are preserved; missing credential keys are appended and existing credential keys are updated - stale Agora credential aliases for another runtime are commented out to avoid ambiguous dotenv resolution; for example, a Next.js quickstart prefers `NEXT_PUBLIC_AGORA_APP_ID` and comments out old `AGORA_APP_ID` / `APP_ID` entries when replacing them diff --git a/docs/commands.md b/docs/commands.md index 015aa97..2129a1c 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -15,6 +15,7 @@ This page lists every enumerable command and its local flags. For long descripti | `--quiet` | `bool` | — | suppress success output (both pretty and JSON envelopes); rely on exit code. Errors still print on stderr. | | `--upgrade-check` | `bool` | — | print non-interactive upgrade guidance and exit | | `--verbose` | `bool` | — | echo structured logs to stderr (equivalent to AGORA_VERBOSE=1); does not change exit codes or JSON envelopes | +| `--yes` | `bool` | — | accept default answers and suppress interactive prompts (equivalent to AGORA_NO_INPUT=1) | ## Pseudo Commands @@ -93,6 +94,7 @@ Create a project, clone a quickstart, and write env in one flow | Flag | Type | Default | Description | |------|------|---------|-------------| +| `--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, and convoai; convoai also enables rtm | | `--new-project` | `bool` | — | always create a new Agora project instead of reusing an existing one | @@ -122,6 +124,20 @@ Clear the local Agora session _No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ +### `agora mcp` + +Run Agora CLI as a local MCP server + +_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ + +### `agora mcp serve` + +Serve Agora CLI tools over MCP + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--transport` | `string` | `stdio` | MCP transport: stdio | + ### `agora open` Open Agora Console or CLI docs @@ -129,7 +145,7 @@ Open Agora Console or CLI docs | Flag | Type | Default | Description | |------|------|---------|-------------| | `--no-browser` | `bool` | — | print the URL without opening a browser | -| `--target` | `string` | `console` | target to open: console or docs | +| `--target` | `string` | `console` | target to open: console, docs, or product-docs | ### `agora project` @@ -178,6 +194,7 @@ Write project environment variables to a dotenv file |------|------|---------|-------------| | `--append` | `bool` | — | append Agora App ID and App Certificate values when no existing values are present | | `--overwrite` | `bool` | — | replace the target file with only Agora App ID and App Certificate values | +| `--template` | `string` | — | credential key layout: nextjs or standard; if omitted, detect Next.js from the workspace | ### `agora project feature` diff --git a/docs/error-codes.md b/docs/error-codes.md index b6765d6..a87aaa8 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -12,6 +12,8 @@ This catalog is the source of truth for stable codes. CI runs `make snapshot-err |------|------|---------|----------| | `AUTH_UNAUTHENTICATED` | 3 | No usable local session exists. | Run `agora login`. | | `AUTH_SESSION_EXPIRED` | 3 | The stored session is expired or rejected after refresh. | Run `agora login` again. | +| `AUTH_OAUTH_EXCHANGE_FAILED` | 1 | The OAuth token endpoint rejected the authorization-code or refresh-token exchange. | Retry login; if persistent, file a bug with the HTTP status and request ID. | +| `AUTH_OAUTH_RESPONSE_INVALID` | 1 | The OAuth token endpoint returned a response missing required token fields. | Retry login; if persistent, file a bug. | ### Project resolution @@ -21,6 +23,7 @@ This catalog is the source of truth for stable codes. CI runs `make snapshot-err | `PROJECT_NOT_FOUND` | 1 | The requested project ID or exact name was not found. | Run `agora project list` and retry with the project ID. | | `PROJECT_AMBIGUOUS` | 1 | A project name matched multiple projects. | Retry with the project ID. | | `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. | ### Quickstart / init @@ -32,6 +35,7 @@ This catalog is the source of truth for stable codes. CI runs `make snapshot-err | `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_TARGET_EXISTS` | 1 | The clone target already exists. | Choose a new directory. | +| `INIT_NAME_REQUIRED` | 1 | `agora init` was run without the required target directory name. | Pass a directory name, for example `agora init my-nextjs-demo --template nextjs`. | | `INIT_ABORTED` | 1 | The interactive `agora init` reuse prompt was answered "no". | Re-run with `--project `, `--new-project`, or accept the prompt. | ### Self-update (`agora upgrade`) diff --git a/internal/cli/app.go b/internal/cli/app.go index 841dc1a..176be2f 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -18,10 +18,13 @@ package cli import ( + "context" "fmt" "net/http" "os" + "os/signal" "strings" + "syscall" "time" "github.com/spf13/cobra" @@ -136,23 +139,25 @@ type projectDoctorResult struct { // owns the shared HTTP client, the loaded config, the env snapshots, and the // fully-built cobra root command. type App struct { - root *cobra.Command - env map[string]string - osEnv map[string]string // raw OS env snapshot before applyConfigToEnv; used for CI detection & user-set env precedence - cfg appConfig - cfgState configState - rootOutput string - rootJSON bool - rootPrettyJSON bool - rootQuiet bool - rootNoColor bool - rootUpgradeCheck bool - rootVerbose bool - httpClient *http.Client - projectEnvProject string - projectEnvFormat string - projectEnvShell bool - projectEnvSecrets bool + root *cobra.Command + env map[string]string + osEnv map[string]string // raw OS env snapshot before applyConfigToEnv; used for CI detection & user-set env precedence + cfg appConfig + cfgState configState + rootOutput string + rootJSON bool + rootPrettyJSON bool + rootQuiet bool + rootNoColor bool + rootUpgradeCheck bool + rootVerbose bool + rootYes bool + httpClient *http.Client + projectEnvProject string + projectEnvFormat string + projectEnvShell bool + projectEnvSecrets bool + projectEnvWriteTemplate string } // NewApp boots the App: snapshot env (before any mutation), load or migrate @@ -186,6 +191,9 @@ func NewApp() (*App, error) { // stderr path depending on the mode. Returns *exitError or *renderedError // for the cmd/main.go shim to translate into the process exit code. func (a *App) Execute() error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + a.root.SetContext(ctx) rawOutput := readRawFlagValue(os.Args[1:], "--output") if rawOutput != "json" && rawOutput != "pretty" { rawOutput = "" @@ -199,7 +207,7 @@ func (a *App) Execute() error { fmt.Fprintln(os.Stderr, banner) } } - if err := a.root.Execute(); err != nil { + if err := a.root.ExecuteContext(ctx); err != nil { if _, ok := ExitCode(err); ok { return err } @@ -360,6 +368,14 @@ func snapshotEnv() map[string]string { return env } +func (a *App) noInput() bool { + if a.rootYes { + return true + } + value := strings.ToLower(strings.TrimSpace(a.env["AGORA_NO_INPUT"])) + return value == "1" || value == "true" || value == "yes" || value == "y" +} + // isTTY reports whether the given file is connected to a terminal. Used for // the first-run banner, interactive prompts, and color decisions. func isTTY(file *os.File) bool { diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index 4198c48..cce384a 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -33,7 +33,7 @@ func TestDetectInstallProvenanceUsesReceiptThenExecutablePath(t *testing.T) { Tool: "agora", InstallMethod: "installer", InstallPath: installerPath, - Version: "0.1.9", + Version: "0.1.10", InstalledAt: "2026-04-30T11:00:00Z", Source: "install.sh", } @@ -67,7 +67,7 @@ func TestDetectInstallProvenanceFallsBackToExecutablePath(t *testing.T) { { name: "homebrew detected from resolved Cellar path", env: map[string]string{"HOMEBREW_PREFIX": "/usr/local"}, - exePath: "/usr/local/Cellar/agora-cli/0.1.9/bin/agora", + exePath: "/usr/local/Cellar/agora-cli/0.1.10/bin/agora", wantMethod: "homebrew", }, { @@ -79,7 +79,7 @@ func TestDetectInstallProvenanceFallsBackToExecutablePath(t *testing.T) { { name: "npm detected from node_modules path", env: map[string]string{"npm_config_prefix": "/usr/local"}, - exePath: "/usr/local/lib/node_modules/@agoraio/cli-darwin-arm64/bin/agora", + exePath: "/usr/local/lib/node_modules/agoraio-cli-darwin-arm64/bin/agora", wantMethod: "npm", }, { @@ -107,7 +107,7 @@ func TestDetectInstallProvenanceIgnoresStaleReceipt(t *testing.T) { Tool: "agora", InstallMethod: "npm", InstallPath: filepath.Join(dir, "old-agora"), - Version: "0.1.9", + Version: "0.1.10", InstalledAt: "2026-04-30T11:00:00Z", Source: "test", } @@ -127,7 +127,7 @@ func TestDetectInstallProvenanceIgnoresStaleReceipt(t *testing.T) { func TestWriteInstallReceiptRoundTrips(t *testing.T) { exePath := filepath.Join(t.TempDir(), "agora") - receiptPath, err := writeInstallReceipt(exePath, "v0.1.9", "agora upgrade") + receiptPath, err := writeInstallReceipt(exePath, "v0.1.10", "agora upgrade") if err != nil { t.Fatal(err) } @@ -138,7 +138,7 @@ func TestWriteInstallReceiptRoundTrips(t *testing.T) { if !receipt.validForPath(exePath) { t.Fatalf("expected valid receipt for %s: %+v", exePath, receipt) } - if receipt.Version != "0.1.9" || receipt.Source != "agora upgrade" { + if receipt.Version != "0.1.10" || receipt.Source != "agora upgrade" { t.Fatalf("unexpected receipt contents: %+v", receipt) } } @@ -172,7 +172,7 @@ func TestWriteProjectEnvFileWritesOnlyCredentials(t *testing.T) { first, err := writeProjectEnvFile(path, map[string]any{ "AGORA_APP_ID": "app_1", "AGORA_APP_CERTIFICATE": "cert_1", - }, false, false) + }, false, false, nil, false) if err != nil { t.Fatal(err) } @@ -182,7 +182,7 @@ func TestWriteProjectEnvFileWritesOnlyCredentials(t *testing.T) { second, err := writeProjectEnvFile(path, map[string]any{ "AGORA_APP_ID": "app_2", "AGORA_APP_CERTIFICATE": "cert_2", - }, false, false) + }, false, false, nil, false) if err != nil { t.Fatal(err) } @@ -218,7 +218,7 @@ func TestWriteProjectEnvFileReplacesOldManagedBlockWithCredentials(t *testing.T) result, err := writeProjectEnvFile(path, map[string]any{ "AGORA_APP_ID": "app_1", "AGORA_APP_CERTIFICATE": "cert_1", - }, false, false) + }, false, false, nil, false) if err != nil { t.Fatal(err) } @@ -387,7 +387,7 @@ func TestWriteProjectEnvFileRequiresAppendOrOverwriteForExplicitFile(t *testing. _, err := writeProjectEnvFile(path, map[string]any{ "AGORA_APP_ID": "app_1", "AGORA_APP_CERTIFICATE": "cert_1", - }, false, false) + }, false, false, nil, false) if err == nil || !strings.Contains(err.Error(), "--append") || !strings.Contains(err.Error(), "--overwrite") { t.Fatalf("expected explicit file error, got %v", err) } @@ -399,7 +399,7 @@ func TestWriteProjectEnvFileCreatesNestedDirectories(t *testing.T) { result, err := writeProjectEnvFile(path, map[string]any{ "AGORA_APP_ID": "app_1", "AGORA_APP_CERTIFICATE": "cert_1", - }, false, false) + }, false, false, nil, false) if err != nil { t.Fatal(err) } @@ -718,7 +718,7 @@ func TestExchangeAuthorizationCodeFailureAndScopeArray(t *testing.T) { defer failServer.Close() app := &App{httpClient: failServer.Client(), env: map[string]string{}} - if _, err := app.exchangeAuthorizationCode(failServer.URL, "cli_demo", "bad-code", "code-verifier", "http://localhost/callback"); err == nil || !strings.Contains(err.Error(), "OAuth token exchange failed with status 400") { + if _, err := app.exchangeAuthorizationCode(failServer.URL, "cli_demo", "bad-code", "code-verifier", "http://localhost/callback"); err == nil || !strings.Contains(err.Error(), "OAuth token exchange failed (HTTP 400)") { t.Fatalf("unexpected auth-code failure error: %v", err) } diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 768b63a..6433142 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "os" + "path/filepath" "strings" "github.com/spf13/cobra" @@ -74,7 +76,8 @@ Use "agora --help --all --json" for a machine-readable command tree (agent tooli root.PersistentFlags().BoolVar(&a.rootPrettyJSON, "pretty", false, "pretty-print JSON output when used with --json") root.PersistentFlags().BoolVar(&a.rootQuiet, "quiet", false, "suppress success output (both pretty and JSON envelopes); rely on exit code. Errors still print on stderr.") root.PersistentFlags().BoolVar(&a.rootNoColor, "no-color", false, "disable ANSI color in pretty output") - root.PersistentFlags().BoolVar(&a.rootVerbose, "verbose", false, "echo structured logs to stderr (equivalent to AGORA_VERBOSE=1); does not change exit codes or JSON envelopes") + root.PersistentFlags().BoolVarP(&a.rootVerbose, "verbose", "v", false, "echo structured logs to stderr (equivalent to AGORA_VERBOSE=1); does not change exit codes or JSON envelopes") + root.PersistentFlags().BoolVarP(&a.rootYes, "yes", "y", false, "accept default answers and suppress interactive prompts (equivalent to AGORA_NO_INPUT=1)") root.PersistentFlags().Bool("all", false, "show the full command tree in help output") root.PersistentFlags().BoolVar(&a.rootUpgradeCheck, "upgrade-check", false, "print non-interactive upgrade guidance and exit") root.AddCommand(a.buildLoginCommand("login")) @@ -90,6 +93,7 @@ Use "agora --help --all --json" for a machine-readable command tree (agent tooli root.AddCommand(a.buildTelemetryCommand()) root.AddCommand(a.buildUpgradeCommand()) root.AddCommand(a.buildOpenCommand()) + root.AddCommand(a.buildMCPCommand()) // Keep "add" unregistered until it has a real implementation. Calls to // `agora add` should behave as unknown command for now. defaultHelp := root.HelpFunc() @@ -253,11 +257,18 @@ func (a *App) buildOpenCommand() *cobra.Command { Example: example(` agora open --target console agora open --target docs + agora open --target product-docs `), RunE: func(cmd *cobra.Command, _ []string) error { url := "https://console.agora.io" - if target == "docs" { + switch target { + case "docs": + url = "https://agoraio.github.io/cli/" + case "product-docs": url = "https://docs.agora.io" + case "console": + default: + return fmt.Errorf("unknown open target %q. Use console, docs, or product-docs.", target) } status := "printed" if !noBrowser && a.resolveOutputMode(cmd) != outputJSON && openBrowser(url) { @@ -266,7 +277,7 @@ func (a *App) buildOpenCommand() *cobra.Command { return renderResult(cmd, "open", map[string]any{"action": "open", "status": status, "target": target, "url": url}) }, } - cmd.Flags().StringVar(&target, "target", "console", "target to open: console or docs") + cmd.Flags().StringVar(&target, "target", "console", "target to open: console, docs, or product-docs") cmd.Flags().BoolVar(&noBrowser, "no-browser", false, "print the URL without opening a browser") return cmd } @@ -648,6 +659,7 @@ func (a *App) buildProjectUse() *cobra.Command { } return renderResult(cmd, "project use", data) }, + ValidArgsFunction: a.completeProjectNames, } } @@ -672,6 +684,7 @@ func (a *App) buildProjectShow() *cobra.Command { } return renderResult(cmd, "project show", data) }, + ValidArgsFunction: a.completeProjectNames, } } @@ -727,7 +740,11 @@ Use "project env write" when you want to persist the values into a managed doten Short: "Write project environment variables to a dotenv file", Long: `Write Agora App ID and App Certificate values to a dotenv file. -If no path is provided, the CLI chooses the default target using the existing env files in the working directory.`, +If no path is provided, the CLI chooses the default target using the existing env files in the working directory. + +Next.js apps are detected from package.json (a next dependency), a next.config file, env.local.example, or repo .agora project.json fields template (quickstart) or projectType; those workspaces receive NEXT_PUBLIC_AGORA_APP_ID and NEXT_AGORA_APP_CERTIFICATE. Use --template nextjs or --template standard to override detection. + +When .agora/project.json exists, this command updates it for the selected project and records projectType/envPath when missing. If no repo-local binding exists yet, it creates .agora/project.json in the current working directory.`, Example: example(` agora project env write agora project env write .env.local @@ -752,19 +769,57 @@ If no path is provided, the CLI chooses the default target using the existing en if err != nil { return err } - values, err := projectCredentialEnvValues(target.project) + cwd, err := os.Getwd() + if err != nil { + return err + } + pathFromUser := strings.TrimSpace(path) + absPath, err := resolveProjectEnvWriteAbsolutePath(cwd, pathFromUser) + if err != nil { + return err + } + workspaceDir := filepath.Dir(absPath) + projectType, err := detectProjectType(workspaceDir, a.projectEnvWriteTemplate) + if err != nil { + return err + } + layout, err := detectProjectEnvCredentialLayout(workspaceDir, a.projectEnvWriteTemplate) + if err != nil { + return err + } + values, err := projectCredentialEnvValuesForLayout(target.project, layout) + if err != nil { + return err + } + conflicting := conflictingKeysForProjectEnvLayout(layout) + file, err := writeProjectEnvFile(absPath, values, appendFlag, overwriteFlag, conflicting, pathFromUser == "") if err != nil { return err } - file, err := writeProjectEnvFile(path, values, appendFlag, overwriteFlag) + metaUpdated, metadataPath, err := syncLocalProjectBindingAfterEnvWrite(workspaceDir, cwd, absPath, target, projectType) if err != nil { return err } - return renderResult(cmd, "project env write", map[string]any{"action": "env-write", "keysWritten": projectEnvKeys(values), "path": file.Path, "projectId": target.project.ProjectID, "projectName": target.project.Name, "status": file.Status}) + payload := map[string]any{ + "action": "env-write", + "credentialLayout": credentialLayoutLabel(layout), + "keysWritten": projectEnvKeys(values), + "projectType": projectType, + "path": file.Path, + "projectId": target.project.ProjectID, + "projectName": target.project.Name, + "status": file.Status, + } + if metaUpdated { + payload["metadataUpdated"] = true + payload["metadataPath"] = metadataPath + } + return renderResult(cmd, "project env write", payload) }, } write.Flags().Bool("overwrite", false, "replace the target file with only Agora App ID and App Certificate values") write.Flags().Bool("append", false, "append Agora App ID and App Certificate values when no existing values are present") + write.Flags().StringVar(&a.projectEnvWriteTemplate, "template", "", "credential key layout: nextjs or standard; if omitted, detect Next.js from the workspace") cmd.AddCommand(write) return cmd } @@ -808,6 +863,7 @@ func (a *App) buildProjectFeature() *cobra.Command { } return renderResult(cmd, "project feature list", map[string]any{"action": "feature-list", "items": items, "projectId": target.project.ProjectID, "projectName": target.project.Name}) }, + ValidArgsFunction: a.completeProjectNames, }) cmd.AddCommand(&cobra.Command{ Use: "status [project]", @@ -830,6 +886,7 @@ func (a *App) buildProjectFeature() *cobra.Command { } return renderResult(cmd, "project feature status", data) }, + ValidArgsFunction: a.completeFeatureThenProject, }) cmd.AddCommand(&cobra.Command{ Use: "enable [project]", @@ -852,6 +909,7 @@ func (a *App) buildProjectFeature() *cobra.Command { } return renderResult(cmd, "project feature enable", data) }, + ValidArgsFunction: a.completeFeatureThenProject, }) return cmd } diff --git a/internal/cli/integration_project_test.go b/internal/cli/integration_project_test.go index 8532c8a..daed21e 100644 --- a/internal/cli/integration_project_test.go +++ b/internal/cli/integration_project_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "os" "path/filepath" + "regexp" "strings" "testing" ) @@ -374,6 +375,13 @@ func TestCLIProjectEnvFormatsAndWriteRules(t *testing.T) { t.Fatalf("unexpected append result: %+v", appendResult) } + if err := os.MkdirAll(filepath.Join(projectDir, "apps", "web"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projectDir, "apps", "web", "package.json"), []byte(`{"dependencies":{"next":"15.0.0"}}`), 0o644); err != nil { + t.Fatal(err) + } + nestedResult := runCLI(t, []string{"project", "env", "write", "apps/web/.env.local", "--json"}, cliRunOptions{ env: map[string]string{ "XDG_CONFIG_HOME": configHome, @@ -392,8 +400,10 @@ func TestCLIProjectEnvFormatsAndWriteRules(t *testing.T) { if err != nil { t.Fatal(err) } - if !strings.Contains(string(nestedEnv), "AGORA_APP_ID=app_9999") || !strings.Contains(string(nestedEnv), "AGORA_APP_CERTIFICATE=") || strings.Contains(string(nestedEnv), "AGORA_PROJECT_ID=") || strings.Contains(string(nestedEnv), "# BEGIN AGORA CLI") { - t.Fatalf("unexpected nested env contents: %s", string(nestedEnv)) + nextLegacyKeys := regexp.MustCompile(`(?m)^\s*(?:export\s+)?AGORA_APP_ID=|^\s*(?:export\s+)?AGORA_APP_CERTIFICATE=`) + if !strings.Contains(string(nestedEnv), "NEXT_PUBLIC_AGORA_APP_ID=") || !strings.Contains(string(nestedEnv), "NEXT_AGORA_APP_CERTIFICATE=") || + nextLegacyKeys.MatchString(string(nestedEnv)) || strings.Contains(string(nestedEnv), "AGORA_PROJECT_ID=") || strings.Contains(string(nestedEnv), "# BEGIN AGORA CLI") { + t.Fatalf("unexpected nested env contents (expected Next.js credential names): %s", string(nestedEnv)) } explicitDefaultPath := filepath.Join(projectDir, ".env.local") @@ -420,6 +430,65 @@ func TestCLIProjectEnvFormatsAndWriteRules(t *testing.T) { } } +func TestCLIProjectEnvWriteRecordsProjectTypeInBinding(t *testing.T) { + configHome := t.TempDir() + repoRoot := t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + + project := buildFakeProject("Project Gamma", "prj_bindmeta", "app_bindmeta", "global") + project.FeatureState.ConvoAIEnabled = true + project.FeatureState.RTMEnabled = true + api.projects[project.ProjectID] = &project + persistSessionForIntegration(t, configHome) + if err := saveContext(map[string]string{"XDG_CONFIG_HOME": configHome}, projectContext{ + CurrentProjectID: &project.ProjectID, + CurrentProjectName: &project.Name, + CurrentRegion: "global", + PreferredRegion: "global", + }); err != nil { + t.Fatal(err) + } + + if err := writeLocalProjectBinding(repoRoot, localProjectBinding{ + ProjectID: project.ProjectID, + ProjectName: project.Name, + Region: "global", + }); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repoRoot, "package.json"), []byte(`{"dependencies":{"next":"15.0.0"}}`), 0o644); err != nil { + t.Fatal(err) + } + + result := runCLI(t, []string{"project", "env", "write", ".env.local", "--json"}, cliRunOptions{ + env: map[string]string{ + "XDG_CONFIG_HOME": configHome, + "AGORA_API_BASE_URL": api.baseURL, + "AGORA_LOG_LEVEL": "error", + }, + workdir: repoRoot, + }) + if result.exitCode != 0 || !strings.Contains(result.stdout, `"metadataUpdated":true`) || !strings.Contains(result.stdout, `"metadataPath":".agora/project.json"`) { + t.Fatalf("expected metadata update in result: %+v", result) + } + + raw, err := os.ReadFile(filepath.Join(repoRoot, ".agora", "project.json")) + if err != nil { + t.Fatal(err) + } + var meta map[string]any + if err := json.Unmarshal(raw, &meta); err != nil { + t.Fatal(err) + } + if meta["projectType"] != "nextjs" { + t.Fatalf("expected projectType nextjs in binding, got %s", string(raw)) + } + if meta["envPath"] != ".env.local" { + t.Fatalf("expected envPath .env.local, got %s", string(raw)) + } +} + func TestCLIFeatureEnableAndDoctorAuthError(t *testing.T) { configHome := t.TempDir() api := newFakeCLIBFF() diff --git a/internal/cli/local_project.go b/internal/cli/local_project.go index ec5b3dd..787dba5 100644 --- a/internal/cli/local_project.go +++ b/internal/cli/local_project.go @@ -17,6 +17,7 @@ type localProjectBinding struct { ProjectID string `json:"projectId"` ProjectName string `json:"projectName"` Region string `json:"region"` + ProjectType string `json:"projectType,omitempty"` Template string `json:"template,omitempty"` EnvPath string `json:"envPath,omitempty"` } diff --git a/internal/cli/project_env_layout_test.go b/internal/cli/project_env_layout_test.go new file mode 100644 index 0000000..d220f13 --- /dev/null +++ b/internal/cli/project_env_layout_test.go @@ -0,0 +1,179 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDetectProjectEnvCredentialLayout(t *testing.T) { + t.Run("explicit nextjs", func(t *testing.T) { + layout, err := detectProjectEnvCredentialLayout(t.TempDir(), "nextjs") + if err != nil || layout != projectEnvLayoutNextjs { + t.Fatalf("expected nextjs layout, got %v err %v", layout, err) + } + }) + t.Run("explicit standard", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"dependencies":{"next":"15.0.0"}}`), 0o644); err != nil { + t.Fatal(err) + } + layout, err := detectProjectEnvCredentialLayout(dir, "standard") + if err != nil || layout != projectEnvLayoutStandard { + t.Fatalf("expected standard layout, got %v err %v", layout, err) + } + }) + t.Run("unknown template", func(t *testing.T) { + _, err := detectProjectEnvCredentialLayout(t.TempDir(), "rails") + if err == nil { + t.Fatal("expected error") + } + }) + t.Run("package.json next dependency", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"dependencies":{"next":"15.0.0","react":"19.0.0"}}`), 0o644); err != nil { + t.Fatal(err) + } + layout, err := detectProjectEnvCredentialLayout(dir, "") + if err != nil || layout != projectEnvLayoutNextjs { + t.Fatalf("expected nextjs, got %v err %v", layout, err) + } + }) + t.Run("next.config without package in same dir", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "next.config.mjs"), []byte("export default {}\n"), 0o644); err != nil { + t.Fatal(err) + } + layout, err := detectProjectEnvCredentialLayout(dir, "") + if err != nil || layout != projectEnvLayoutNextjs { + t.Fatalf("expected nextjs from next.config, got %v err %v", layout, err) + } + }) + t.Run("parent package.json next app", func(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"dependencies":{"next":"15.0.0"}}`), 0o644); err != nil { + t.Fatal(err) + } + nested := filepath.Join(root, "apps", "web") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + layout, err := detectProjectEnvCredentialLayout(nested, "") + if err != nil || layout != projectEnvLayoutNextjs { + t.Fatalf("expected nextjs from parent package.json, got %v err %v", layout, err) + } + }) + t.Run("local binding projectType", func(t *testing.T) { + dir := t.TempDir() + if err := writeLocalProjectBinding(dir, localProjectBinding{ProjectType: "standard"}); err != nil { + t.Fatal(err) + } + layout, err := detectProjectEnvCredentialLayout(dir, "") + if err != nil || layout != projectEnvLayoutStandard { + t.Fatalf("expected standard from projectType, got %v err %v", layout, err) + } + }) + t.Run("local binding projectType nextjs without package", func(t *testing.T) { + dir := t.TempDir() + if err := writeLocalProjectBinding(dir, localProjectBinding{ProjectType: "nextjs"}); err != nil { + t.Fatal(err) + } + layout, err := detectProjectEnvCredentialLayout(dir, "") + if err != nil || layout != projectEnvLayoutNextjs { + t.Fatalf("expected nextjs from projectType, got %v err %v", layout, err) + } + }) + t.Run("local binding template nextjs", func(t *testing.T) { + dir := t.TempDir() + if err := writeLocalProjectBinding(dir, localProjectBinding{Template: "nextjs"}); err != nil { + t.Fatal(err) + } + layout, err := detectProjectEnvCredentialLayout(dir, "") + if err != nil || layout != projectEnvLayoutNextjs { + t.Fatalf("expected nextjs from binding, got %v err %v", layout, err) + } + }) + t.Run("env.local.example at root", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "env.local.example"), []byte("NEXT_PUBLIC_AGORA_APP_ID=\n"), 0o644); err != nil { + t.Fatal(err) + } + layout, err := detectProjectEnvCredentialLayout(dir, "") + if err != nil || layout != projectEnvLayoutNextjs { + t.Fatalf("expected nextjs from env.local.example, got %v err %v", layout, err) + } + }) +} + +func TestProjectCredentialEnvValuesForLayout(t *testing.T) { + cert := "signkey" + p := projectDetail{Name: "P", ProjectID: "prj", AppID: "app_x", SignKey: &cert} + stdVals, err := projectCredentialEnvValuesForLayout(p, projectEnvLayoutStandard) + if err != nil { + t.Fatal(err) + } + if stdVals["AGORA_APP_ID"] != "app_x" || stdVals["AGORA_APP_CERTIFICATE"] != cert { + t.Fatalf("unexpected standard values: %+v", stdVals) + } + nextVals, err := projectCredentialEnvValuesForLayout(p, projectEnvLayoutNextjs) + if err != nil { + t.Fatal(err) + } + if nextVals["NEXT_PUBLIC_AGORA_APP_ID"] != "app_x" || nextVals["NEXT_AGORA_APP_CERTIFICATE"] != cert { + t.Fatalf("unexpected nextjs values: %+v", nextVals) + } +} + +func TestSyncLocalProjectBindingAfterEnvWrite(t *testing.T) { + dir := t.TempDir() + cert := "cert_val" + p := projectDetail{ProjectID: "prj_x", Name: "Proj", AppID: "app_x", SignKey: &cert} + target := projectTarget{project: p, region: "global"} + if err := writeLocalProjectBinding(dir, localProjectBinding{ProjectID: "prj_x", ProjectName: "Proj", Region: "global"}); err != nil { + t.Fatal(err) + } + envFile := filepath.Join(dir, ".env.local") + updated, metadataPath, err := syncLocalProjectBindingAfterEnvWrite(dir, dir, envFile, target, "nextjs") + if err != nil || !updated { + t.Fatalf("expected metadata update, err=%v updated=%v", err, updated) + } + if metadataPath != ".agora/project.json" { + t.Fatalf("expected metadata path, got %q", metadataPath) + } + binding, err := loadLocalProjectBinding(dir) + if err != nil { + t.Fatal(err) + } + if binding.ProjectType != "nextjs" || binding.EnvPath != ".env.local" { + t.Fatalf("unexpected binding: %+v", binding) + } + again, _, err := syncLocalProjectBindingAfterEnvWrite(dir, dir, envFile, target, "nextjs") + if err != nil || again { + t.Fatalf("expected idempotent skip, err=%v again=%v", err, again) + } + + otherDir := t.TempDir() + if err := writeLocalProjectBinding(otherDir, localProjectBinding{ProjectID: "prj_x", Template: "python"}); err != nil { + t.Fatal(err) + } + skip, _, err := syncLocalProjectBindingAfterEnvWrite(otherDir, otherDir, filepath.Join(otherDir, "server", ".env"), target, "standard") + if err != nil || !skip { + t.Fatalf("expected binding project details refresh even when template set, err=%v updated=%v", err, skip) + } + + createDir := t.TempDir() + created, createdPath, err := syncLocalProjectBindingAfterEnvWrite(createDir, createDir, filepath.Join(createDir, "apps", "web", ".env.local"), target, "nextjs") + if err != nil || !created { + t.Fatalf("expected missing binding create, err=%v updated=%v", err, created) + } + if createdPath != ".agora/project.json" { + t.Fatalf("unexpected created metadata path: %q", createdPath) + } + createdBinding, err := loadLocalProjectBinding(createDir) + if err != nil { + t.Fatal(err) + } + if createdBinding.ProjectID != "prj_x" || createdBinding.ProjectType != "nextjs" || createdBinding.EnvPath != "apps/web/.env.local" { + t.Fatalf("unexpected created binding: %+v", createdBinding) + } +} diff --git a/internal/cli/projects.go b/internal/cli/projects.go index 0b904cf..b57489e 100644 --- a/internal/cli/projects.go +++ b/internal/cli/projects.go @@ -476,14 +476,229 @@ func (a *App) projectEnvValues(projectArg string, withSecrets bool) (map[string] return values, nil } +type projectEnvCredentialLayout int + +const ( + projectEnvLayoutStandard projectEnvCredentialLayout = iota + projectEnvLayoutNextjs +) + +func credentialLayoutLabel(layout projectEnvCredentialLayout) string { + if layout == projectEnvLayoutNextjs { + return "nextjs" + } + return "standard" +} + +func credentialLayoutFromProjectType(projectType string) projectEnvCredentialLayout { + switch strings.ToLower(strings.TrimSpace(projectType)) { + case "nextjs": + return projectEnvLayoutNextjs + default: + return projectEnvLayoutStandard + } +} + func projectCredentialEnvValues(project projectDetail) (map[string]any, error) { + return projectCredentialEnvValuesForLayout(project, projectEnvLayoutStandard) +} + +func projectCredentialEnvValuesForLayout(project projectDetail, layout projectEnvCredentialLayout) (map[string]any, error) { if project.SignKey == nil || *project.SignKey == "" { return nil, &cliError{Message: fmt.Sprintf("project %q does not have an app certificate. Enable one in Agora Console or use a different project with `agora project use`.", project.Name), Code: "PROJECT_NO_CERTIFICATE"} } - return map[string]any{ - "AGORA_APP_ID": project.AppID, - "AGORA_APP_CERTIFICATE": *project.SignKey, - }, nil + switch layout { + case projectEnvLayoutNextjs: + return map[string]any{ + "NEXT_PUBLIC_AGORA_APP_ID": project.AppID, + "NEXT_AGORA_APP_CERTIFICATE": *project.SignKey, + }, nil + default: + return map[string]any{ + "AGORA_APP_ID": project.AppID, + "AGORA_APP_CERTIFICATE": *project.SignKey, + }, nil + } +} + +func conflictingKeysForProjectEnvLayout(layout projectEnvCredentialLayout) []string { + switch layout { + case projectEnvLayoutNextjs: + return []string{"AGORA_APP_ID", "AGORA_APP_CERTIFICATE", "APP_ID", "APP_CERTIFICATE"} + default: + return nil + } +} + +func detectProjectType(workspaceDir, explicitTemplate string) (string, error) { + if t := strings.TrimSpace(strings.ToLower(explicitTemplate)); t != "" { + switch t { + case "nextjs": + return "nextjs", nil + case "standard", "default", "agora": + return "standard", nil + default: + return "", &cliError{ + Message: fmt.Sprintf("unknown env template %q (use nextjs or standard)", strings.TrimSpace(explicitTemplate)), + Code: "PROJECT_ENV_TEMPLATE_UNKNOWN", + } + } + } + + absWorkspace, err := filepath.Abs(workspaceDir) + if err != nil { + return "", err + } + if _, statErr := os.Stat(filepath.Join(absWorkspace, "env.local.example")); statErr == nil { + return "nextjs", nil + } + + cur := absWorkspace + for step := 0; step < 8; step++ { + if packageJSONDeclaresNext(filepath.Join(cur, "package.json")) || nextConfigPresent(cur) { + return "nextjs", nil + } + if _, err := os.Stat(filepath.Join(cur, "go.mod")); err == nil { + return "go", nil + } + if _, err := os.Stat(filepath.Join(cur, "pyproject.toml")); err == nil { + return "python", nil + } + if _, err := os.Stat(filepath.Join(cur, "requirements.txt")); err == nil { + return "python", nil + } + if _, err := os.Stat(filepath.Join(cur, "package.json")); err == nil { + return "node", nil + } + parent := filepath.Dir(cur) + if parent == cur { + break + } + cur = parent + } + + if binding, ok, _, err := detectLocalProjectBindingFrom(absWorkspace); err == nil && ok { + if projectType := strings.ToLower(strings.TrimSpace(binding.ProjectType)); projectType != "" { + return projectType, nil + } + if template := strings.ToLower(strings.TrimSpace(binding.Template)); template != "" { + return template, nil + } + } + return "standard", nil +} + +// syncLocalProjectBindingAfterEnvWrite updates or creates repo-local .agora/project.json +// so non-template repos keep a durable projectType signal for later env/framework decisions. +func syncLocalProjectBindingAfterEnvWrite(workspaceDir, cwd, envFileAbs string, target projectTarget, projectType string) (bool, string, error) { + projectType = strings.TrimSpace(projectType) + if projectType == "" { + projectType = "standard" + } + resolveEnvPath := func(root string) string { + if rel, relErr := filepath.Rel(root, envFileAbs); relErr == nil && rel != "" && !strings.HasPrefix(rel, "..") { + return filepath.ToSlash(rel) + } + return "" + } + + root, hasRoot, err := detectLocalProjectRoot(workspaceDir) + if err != nil { + return false, "", err + } + if !hasRoot { + root = cwd + binding := localProjectBinding{ + ProjectID: target.project.ProjectID, + ProjectName: target.project.Name, + Region: target.region, + ProjectType: projectType, + EnvPath: resolveEnvPath(root), + } + if err := writeLocalProjectBinding(root, binding); err != nil { + return false, "", err + } + return true, filepath.ToSlash(filepath.Join(localAgoraDirName, localProjectFileName)), nil + } + + binding, err := loadLocalProjectBinding(root) + if err != nil { + return false, "", err + } + changed := false + if binding.ProjectID != target.project.ProjectID { + binding.ProjectID = target.project.ProjectID + changed = true + } + if binding.ProjectName != target.project.Name { + binding.ProjectName = target.project.Name + changed = true + } + if strings.TrimSpace(binding.Region) == "" || !strings.EqualFold(binding.Region, target.region) { + binding.Region = target.region + changed = true + } + // Respect quickstart template bindings; only set projectType when template is unset. + if strings.TrimSpace(binding.Template) == "" && strings.TrimSpace(binding.ProjectType) == "" { + binding.ProjectType = projectType + changed = true + } + if strings.TrimSpace(binding.EnvPath) == "" { + if envPath := resolveEnvPath(root); envPath != "" { + binding.EnvPath = envPath + changed = true + } + } + if !changed { + return false, "", nil + } + if err := writeLocalProjectBinding(root, binding); err != nil { + return false, "", err + } + return true, filepath.ToSlash(filepath.Join(localAgoraDirName, localProjectFileName)), nil +} + +func detectProjectEnvCredentialLayout(workspaceDir, explicitTemplate string) (projectEnvCredentialLayout, error) { + projectType, err := detectProjectType(workspaceDir, explicitTemplate) + if err != nil { + return projectEnvLayoutStandard, err + } + return credentialLayoutFromProjectType(projectType), nil +} + +func packageJSONDeclaresNext(packageJSONPath string) bool { + raw, err := os.ReadFile(packageJSONPath) + if err != nil { + return false + } + var meta struct { + Dependencies map[string]any `json:"dependencies"` + DevDependencies map[string]any `json:"devDependencies"` + PeerDependencies map[string]any `json:"peerDependencies"` + } + if err := json.Unmarshal(raw, &meta); err != nil { + return false + } + return depMapHasPackage(meta.Dependencies, "next") || + depMapHasPackage(meta.DevDependencies, "next") || + depMapHasPackage(meta.PeerDependencies, "next") +} + +func depMapHasPackage(section map[string]any, name string) bool { + if section == nil { + return false + } + _, ok := section[name] + return ok +} + +func nextConfigPresent(dir string) bool { + for _, name := range []string{"next.config.js", "next.config.mjs", "next.config.ts", "next.config.mts"} { + if _, err := os.Stat(filepath.Join(dir, name)); err == nil { + return true + } + } + return false } func enabledFeatures(features map[string]bool) []string { @@ -579,15 +794,22 @@ type envWriteResult struct { Status string } -func writeProjectEnvFile(path string, values map[string]any, appendMode, overwrite bool) (envWriteResult, error) { - usedDefaultTarget := path == "" - if path == "" { - resolved, err := resolveDefaultTargetPath(".") +func resolveProjectEnvWriteAbsolutePath(cwd, pathArg string) (string, error) { + if strings.TrimSpace(pathArg) == "" { + rel, err := resolveDefaultTargetPath(cwd) if err != nil { - return envWriteResult{}, err + return "", err } - path = resolved + return filepath.Abs(filepath.Join(cwd, rel)) + } + p := pathArg + if !filepath.IsAbs(p) { + p = filepath.Join(cwd, p) } + return filepath.Abs(p) +} + +func writeProjectEnvFile(path string, values map[string]any, appendMode, overwrite bool, conflictingKeys []string, defaultPathChosen bool) (envWriteResult, error) { filePath, err := filepath.Abs(path) if err != nil { return envWriteResult{}, err @@ -609,8 +831,8 @@ func writeProjectEnvFile(path string, values map[string]any, appendMode, overwri existing = []byte(assignments + "\n") status = "overwritten" default: - merged, mergeStatus := mergeEnvAssignments(string(existing), values, [][2]string{{"# BEGIN AGORA CLI", "# END AGORA CLI"}}, nil) - if mergeStatus == "appended" && !appendMode && !usedDefaultTarget && !isDefaultEnvPath(filePath) { + merged, mergeStatus := mergeEnvAssignments(string(existing), values, [][2]string{{"# BEGIN AGORA CLI", "# END AGORA CLI"}}, conflictingKeys) + if mergeStatus == "appended" && !appendMode && !defaultPathChosen && !isDefaultEnvPath(filePath) { return envWriteResult{}, fmt.Errorf("%s already exists. Use --append to append it or --overwrite to replace it.", path) } existing = []byte(merged) diff --git a/internal/cli/quickstart.go b/internal/cli/quickstart.go index 00e3cba..1dd6b3b 100644 --- a/internal/cli/quickstart.go +++ b/internal/cli/quickstart.go @@ -202,6 +202,8 @@ If a current project context exists, or if --project is passed, the CLI also wri 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") _ = cmd.MarkFlagRequired("template") + _ = cmd.RegisterFlagCompletionFunc("template", completeQuickstartTemplateIDs) + _ = cmd.RegisterFlagCompletionFunc("project", a.completeProjectNames) return cmd } @@ -256,6 +258,8 @@ Python and Go quickstarts receive backend APP_ID and APP_CERTIFICATE values.`, } write.Flags().StringVar(&templateID, "template", "", "quickstart template ID; if omitted, the CLI detects it from the repo layout") write.Flags().StringVar(&project, "project", "", "project ID or exact project name to use for env seeding") + _ = write.RegisterFlagCompletionFunc("template", completeQuickstartTemplateIDs) + _ = write.RegisterFlagCompletionFunc("project", a.completeProjectNames) cmd.AddCommand(write) return cmd } From 7a819c2c4e7664030bccc408dcbda64ad304273f Mon Sep 17 00:00:00 2001 From: digitallysavvy Date: Thu, 30 Apr 2026 18:12:57 -0400 Subject: [PATCH 04/26] feat(cli): refine init project selection and refresh introspect golden Honor explicit --project/--new-project, auto-bind "Default Project" when present, and present an interactive list ordered for create-new first and newest projects last. Update the introspect golden for the global --yes flag. --- internal/cli/init.go | 20 ++++++++++++++++++- .../golden/introspect-global-flags.json | 5 +++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/internal/cli/init.go b/internal/cli/init.go index 5d4f2d2..5af6b80 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -38,6 +38,7 @@ func (a *App) buildInitCommand() *cobra.Command { var region string var rtmDataCenter string var features []string + var agentRules []string var newProject bool cmd := &cobra.Command{ Use: "init ", @@ -58,7 +59,7 @@ Use --feature to specify which features to enable on a newly created project (re `), RunE: func(cmd *cobra.Command, args []string) error { if len(args) != 1 || strings.TrimSpace(args[0]) == "" { - return fmt.Errorf("project name is required") + return &cliError{Message: "directory name is required", Code: "INIT_NAME_REQUIRED"} } if strings.TrimSpace(templateID) == "" { selected, err := a.selectInitTemplate(cmd) @@ -80,6 +81,7 @@ Use --feature to specify which features to enable on a newly created project (re // default for --json / CI / non-TTY agent runs. promptForReuse := strings.TrimSpace(existingProject) == "" && !newProject && + !a.noInput() && a.resolveOutputMode(cmd) != outputJSON && !isCIEnvironment(a.osEnv) && isTTY(os.Stdin) @@ -88,6 +90,13 @@ Use --feature to specify which features to enable on a newly created project (re if err != nil { return err } + if len(agentRules) > 0 { + written, err := writeAgentRules(asString(result["path"]), agentRules) + if err != nil { + return err + } + result["agentRules"] = written + } return renderResult(cmd, "init", result) }, } @@ -97,11 +106,20 @@ Use --feature to specify which features to enable on a newly created project (re cmd.Flags().StringVar(®ion, "region", "", "control plane region for newly created projects (global or cn)") 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, "enable a feature on the newly created project (repeatable); defaults to rtc, rtm, and convoai; 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") return cmd } func (a *App) selectInitTemplate(cmd *cobra.Command) (string, error) { + if a.noInput() { + for _, template := range quickstartTemplates() { + if template.Available && template.SupportsInit { + return template.ID, nil + } + } + return "", &cliError{Message: "no init-compatible quickstart templates are available.", Code: "QUICKSTART_TEMPLATE_UNAVAILABLE"} + } if a.resolveOutputMode(cmd) == outputJSON || isCIEnvironment(a.osEnv) || !isTTY(os.Stdin) { return "", &cliError{Message: "quickstart template is required. Pass `--template` or run `agora quickstart list`.", Code: "QUICKSTART_TEMPLATE_REQUIRED"} } diff --git a/internal/cli/testdata/golden/introspect-global-flags.json b/internal/cli/testdata/golden/introspect-global-flags.json index 6595091..9ff4000 100644 --- a/internal/cli/testdata/golden/introspect-global-flags.json +++ b/internal/cli/testdata/golden/introspect-global-flags.json @@ -33,5 +33,10 @@ "name": "verbose", "type": "bool", "usage": "echo structured logs to stderr (equivalent to AGORA_VERBOSE=1); does not change exit codes or JSON envelopes" + }, + { + "name": "yes", + "type": "bool", + "usage": "accept default answers and suppress interactive prompts (equivalent to AGORA_NO_INPUT=1)" } ] From 2bb22e2869d71770e32b88253164c658bf1a4707 Mon Sep 17 00:00:00 2001 From: digitallysavvy Date: Thu, 30 Apr 2026 18:25:17 -0400 Subject: [PATCH 05/26] fix(cli): harden Windows OAuth and improve interactive output Open the auth URL safely on Windows, listen on IPv4 and IPv6 loopback for the OAuth callback, emit compact progress on TTY stderr outside JSON mode, and truncate wide pretty-printed values. Clarify global --yes semantics and refresh the introspect golden file. --- internal/cli/auth.go | 48 ++++++++++++++++--- internal/cli/commands.go | 2 +- internal/cli/progress.go | 30 ++++++++++-- internal/cli/render.go | 19 +++++++- .../golden/introspect-global-flags.json | 2 +- 5 files changed, 86 insertions(+), 15 deletions(-) diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 1da1da6..c7369a0 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -326,7 +326,16 @@ func (a *App) exchangeToken(tokenURL string, values url.Values) (session, error) defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return session{}, fmt.Errorf("OAuth token exchange failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + detail := strings.TrimSpace(string(body)) + if detail == "" { + detail = http.StatusText(resp.StatusCode) + } + return session{}, &cliError{ + Message: fmt.Sprintf("OAuth token exchange failed (HTTP %d): %s", resp.StatusCode, detail), + Code: "AUTH_OAUTH_EXCHANGE_FAILED", + HTTPStatus: resp.StatusCode, + RequestID: responseRequestID(resp.Header), + } } var token tokenResponse if err := json.Unmarshal(body, &token); err != nil { @@ -339,7 +348,7 @@ func (a *App) exchangeToken(tokenURL string, values url.Values) (session, error) "response": raw, "responseKeys": sortedMapKeys(raw), }) - return session{}, errors.New("OAuth token response was missing required fields.") + return session{}, &cliError{Message: "OAuth token response was missing required fields.", Code: "AUTH_OAUTH_RESPONSE_INVALID"} } now := time.Now().UTC() return session{ @@ -381,14 +390,30 @@ func currentOutputModeFromArgs(env map[string]string) outputMode { return mode } +// shouldPromptForLogin reports whether `promptForLogin` may engage the user +// interactively (or auto-confirm via --yes / AGORA_NO_INPUT). Industry +// convention for `-y` / `--yes` flags is "assume yes to confirmation +// prompts" — NOT "spawn brand-new interactive flows in non-interactive +// contexts". So JSON, CI, and non-TTY runs always fail fast with the +// existing AUTH_UNAUTHENTICATED error, regardless of --yes. func (a *App) shouldPromptForLogin() bool { - if currentOutputModeFromArgs(a.env) == outputJSON { + return decideShouldPromptForLogin(currentOutputModeFromArgs(a.env), isCIEnvironment(a.osEnv), isTTY(os.Stdin)) +} + +// decideShouldPromptForLogin is the pure-function form of +// shouldPromptForLogin so tests can drive every code path without having +// to forge os.Args, os.Stdin, or the CI env. The fix here is that +// `--yes` / AGORA_NO_INPUT no longer short-circuits the JSON/CI/non-TTY +// guards: those contexts always fail with AUTH_UNAUTHENTICATED so we +// never silently launch an OAuth browser flow in CI. +func decideShouldPromptForLogin(mode outputMode, ci bool, stdinIsTTY bool) bool { + if mode == outputJSON { return false } - if isCIEnvironment(a.osEnv) { + if ci { return false } - return isTTY(os.Stdin) + return stdinIsTTY } func readConfirmYesDefault(in io.Reader, out io.Writer, prompt string) (bool, error) { @@ -432,6 +457,15 @@ func (a *App) promptForLogin() error { if !a.shouldPromptForLogin() { return noLocalSessionError() } + // Interactive context: either auto-confirm via --yes / AGORA_NO_INPUT + // (the "yes to the confirmation prompt" semantic) or ask the user. + if a.noInput() { + if _, err := fmt.Fprintln(os.Stderr, "This command requires an Agora account. Continuing without prompting because --yes is set."); err != nil { + return err + } + _, err := a.login(false, a.loginPromptRegion(), nil) + return err + } if _, err := fmt.Fprintln(os.Stderr, "This command requires an Agora account."); err != nil { return err } @@ -513,7 +547,7 @@ func (a *App) apiRequest(method, pathname string, query map[string]string, body return nil, err } req.Header.Set("Authorization", token.TokenType+" "+token.AccessToken) - req.Header.Set("User-Agent", agoraUserAgent(a.env)) + req.Header.Set("User-Agent", agoraUserAgent(a.osEnv)) if body != nil { req.Header.Set("content-type", "application/json") } @@ -559,7 +593,7 @@ func (a *App) apiRequest(method, pathname string, query map[string]string, body func agoraUserAgent(env map[string]string) string { base := "agora-cli/" + version - if agent := strings.TrimSpace(env["AGORA_AGENT"]); agent != "" { + if agent := agentLabelFromOSEnv(env); agent != "" { return base + " agent/" + sanitizeUserAgentToken(agent) } return base diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 6433142..fa8927f 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -77,7 +77,7 @@ Use "agora --help --all --json" for a machine-readable command tree (agent tooli root.PersistentFlags().BoolVar(&a.rootQuiet, "quiet", false, "suppress success output (both pretty and JSON envelopes); rely on exit code. Errors still print on stderr.") root.PersistentFlags().BoolVar(&a.rootNoColor, "no-color", false, "disable ANSI color in pretty output") root.PersistentFlags().BoolVarP(&a.rootVerbose, "verbose", "v", false, "echo structured logs to stderr (equivalent to AGORA_VERBOSE=1); does not change exit codes or JSON envelopes") - root.PersistentFlags().BoolVarP(&a.rootYes, "yes", "y", false, "accept default answers and suppress interactive prompts (equivalent to AGORA_NO_INPUT=1)") + root.PersistentFlags().BoolVarP(&a.rootYes, "yes", "y", false, "assume the default answer to confirmation prompts (equivalent to AGORA_NO_INPUT=1); never starts new interactive flows in JSON/CI/non-TTY contexts") root.PersistentFlags().Bool("all", false, "show the full command tree in help output") root.PersistentFlags().BoolVar(&a.rootUpgradeCheck, "upgrade-check", false, "print non-interactive upgrade guidance and exit") root.AddCommand(a.buildLoginCommand("login")) diff --git a/internal/cli/progress.go b/internal/cli/progress.go index 960d6e0..c898a48 100644 --- a/internal/cli/progress.go +++ b/internal/cli/progress.go @@ -2,23 +2,27 @@ package cli import ( "encoding/json" + "fmt" "io" + "os" + "strings" "sync" "time" "github.com/spf13/cobra" ) -// jsonProgressFor returns a progressEmitter wired to cmd.OutOrStdout() when -// the command is producing JSON output, and nil otherwise. Callers should -// pass the returned emitter through to long-running operations so agents -// watching --json can see step-by-step progress events ahead of the final -// envelope. +// jsonProgressFor returns a progressEmitter for long-running operations. +// JSON mode emits NDJSON on stdout for agents/scripts. Pretty TTY mode emits +// compact status lines on stderr so humans can see that work is progressing. func jsonProgressFor(a *App, cmd *cobra.Command, command string) progressEmitter { if a == nil || cmd == nil { return nil } if a.resolveOutputMode(cmd) != outputJSON { + if isTTY(os.Stderr) { + return makePrettyProgressEmitter(cmd.ErrOrStderr()) + } return nil } return makeJSONProgressEmitter(cmd.OutOrStdout(), command) @@ -72,6 +76,22 @@ func makeJSONProgressEmitter(out io.Writer, command string) progressEmitter { } } +func makePrettyProgressEmitter(out io.Writer) progressEmitter { + if out == nil { + return nil + } + var mu sync.Mutex + return func(_, message string, _ map[string]any) { + message = strings.TrimSpace(message) + if message == "" { + return + } + mu.Lock() + defer mu.Unlock() + _, _ = fmt.Fprintf(out, "- %s\n", message) + } +} + // emit is a small convenience that no-ops when the emitter is nil. Use this // from any path that may or may not have a real emitter wired in. func (e progressEmitter) emit(stage, message string, fields map[string]any) { diff --git a/internal/cli/render.go b/internal/cli/render.go index 3e92012..3b32d86 100644 --- a/internal/cli/render.go +++ b/internal/cli/render.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "strconv" "strings" "github.com/spf13/cobra" @@ -223,8 +224,24 @@ func printBlock(out io.Writer, title string, rows [][2]string) { fmt.Fprintln(out, title) } for _, row := range rows { - fmt.Fprintf(out, "%-*s : %s\n", width, row[0], row[1]) + value := row[1] + if max := terminalValueWidth(width); max > 0 && len(value) > max { + value = value[:max-1] + "..." + } + fmt.Fprintf(out, "%-*s : %s\n", width, row[0], value) + } +} + +func terminalValueWidth(labelWidth int) int { + columns, err := strconv.Atoi(strings.TrimSpace(os.Getenv("COLUMNS"))) + if err != nil || columns <= 0 { + return 0 + } + available := columns - labelWidth - len(" : ") + if available < 20 { + return 0 } + return available } // printDoctor prints a structured diagnostic report including per-category diff --git a/internal/cli/testdata/golden/introspect-global-flags.json b/internal/cli/testdata/golden/introspect-global-flags.json index 9ff4000..6801208 100644 --- a/internal/cli/testdata/golden/introspect-global-flags.json +++ b/internal/cli/testdata/golden/introspect-global-flags.json @@ -37,6 +37,6 @@ { "name": "yes", "type": "bool", - "usage": "accept default answers and suppress interactive prompts (equivalent to AGORA_NO_INPUT=1)" + "usage": "assume the default answer to confirmation prompts (equivalent to AGORA_NO_INPUT=1); never starts new interactive flows in JSON/CI/non-TTY contexts" } ] From 25fa505407750bebb1178c7bd22243ae66be2ccf Mon Sep 17 00:00:00 2001 From: digitallysavvy Date: Thu, 30 Apr 2026 18:30:20 -0400 Subject: [PATCH 06/26] chore(release): rename npm platform packages and add install.sh uninstall Publish optional platform tarballs as agoraio-cli-* instead of @agoraio/cli-*, update the release workflow paths, and teach install.sh --uninstall to remove the managed binary and agora.install.json while keeping config under AGORA_HOME. --- .github/workflows/release.yml | 38 +++++++++---------- install.sh | 35 +++++++++++++++++ .../package.json | 4 +- .../package.json | 4 +- .../package.json | 4 +- .../package.json | 4 +- .../package.json | 4 +- .../package.json | 4 +- packaging/npm/agoraio-cli/package.json | 12 +++--- 9 files changed, 72 insertions(+), 37 deletions(-) rename packaging/npm/{@agoraio/cli-darwin-arm64 => agoraio-cli-darwin-arm64}/package.json (85%) rename packaging/npm/{@agoraio/cli-darwin-x64 => agoraio-cli-darwin-x64}/package.json (85%) rename packaging/npm/{@agoraio/cli-linux-arm64 => agoraio-cli-linux-arm64}/package.json (85%) rename packaging/npm/{@agoraio/cli-linux-x64 => agoraio-cli-linux-x64}/package.json (85%) rename packaging/npm/{@agoraio/cli-win32-arm64 => agoraio-cli-win32-arm64}/package.json (85%) rename packaging/npm/{@agoraio/cli-win32-x64 => agoraio-cli-win32-x64}/package.json (85%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a1a6e3e..95a1ef7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -210,7 +210,7 @@ jobs: stage_platform() { local goos="$1" goarch="$2" npm_pkg="$3" bin_name="$4" archive_ext="$5" - local pkg_dir="packaging/npm/@agoraio/${npm_pkg}" + local pkg_dir="packaging/npm/${npm_pkg}" local archive="agora-cli-go_${tag}_${goos}_${goarch}.${archive_ext}" mkdir -p "${pkg_dir}/bin" @@ -223,12 +223,12 @@ jobs: echo " staged: ${pkg_dir}/bin/${bin_name}" } - stage_platform darwin arm64 cli-darwin-arm64 agora tar.gz - stage_platform darwin amd64 cli-darwin-x64 agora tar.gz - stage_platform linux arm64 cli-linux-arm64 agora tar.gz - stage_platform linux amd64 cli-linux-x64 agora tar.gz - stage_platform windows amd64 cli-win32-x64 agora.exe zip - stage_platform windows arm64 cli-win32-arm64 agora.exe zip + stage_platform darwin arm64 agoraio-cli-darwin-arm64 agora tar.gz + stage_platform darwin amd64 agoraio-cli-darwin-x64 agora tar.gz + stage_platform linux arm64 agoraio-cli-linux-arm64 agora tar.gz + stage_platform linux amd64 agoraio-cli-linux-x64 agora tar.gz + stage_platform windows amd64 agoraio-cli-win32-x64 agora.exe zip + stage_platform windows arm64 agoraio-cli-win32-arm64 agora.exe zip - name: Stamp version into all package.json files shell: bash @@ -238,12 +238,12 @@ jobs: echo "Stamping version: $version" for pkg in \ - packaging/npm/@agoraio/cli-darwin-arm64 \ - packaging/npm/@agoraio/cli-darwin-x64 \ - packaging/npm/@agoraio/cli-linux-arm64 \ - packaging/npm/@agoraio/cli-linux-x64 \ - packaging/npm/@agoraio/cli-win32-x64 \ - packaging/npm/@agoraio/cli-win32-arm64; do + packaging/npm/agoraio-cli-darwin-arm64 \ + packaging/npm/agoraio-cli-darwin-x64 \ + packaging/npm/agoraio-cli-linux-arm64 \ + packaging/npm/agoraio-cli-linux-x64 \ + packaging/npm/agoraio-cli-win32-x64 \ + packaging/npm/agoraio-cli-win32-arm64; do tmp=$(mktemp) jq --arg v "$version" '.version = $v' "${pkg}/package.json" > "$tmp" mv "$tmp" "${pkg}/package.json" @@ -265,12 +265,12 @@ jobs: run: | set -euo pipefail for pkg in \ - packaging/npm/@agoraio/cli-darwin-arm64 \ - packaging/npm/@agoraio/cli-darwin-x64 \ - packaging/npm/@agoraio/cli-linux-arm64 \ - packaging/npm/@agoraio/cli-linux-x64 \ - packaging/npm/@agoraio/cli-win32-x64 \ - packaging/npm/@agoraio/cli-win32-arm64; do + packaging/npm/agoraio-cli-darwin-arm64 \ + packaging/npm/agoraio-cli-darwin-x64 \ + packaging/npm/agoraio-cli-linux-arm64 \ + packaging/npm/agoraio-cli-linux-x64 \ + packaging/npm/agoraio-cli-win32-x64 \ + packaging/npm/agoraio-cli-win32-arm64; do echo "Publishing $pkg ${PUBLISH_ARGS}" # --provenance is honored by publishConfig.provenance; passing here for clarity. # When PUBLISH_ARGS contains --dry-run, no provenance attestation is created. diff --git a/install.sh b/install.sh index 56e90d9..cfe9501 100755 --- a/install.sh +++ b/install.sh @@ -51,6 +51,7 @@ PRERELEASE=0 QUIET=0 VERBOSE=0 NO_COLOR_FLAG=0 +UNINSTALL=0 # ---- Exit codes ------------------------------------------------------------ EXIT_OK=0 @@ -187,6 +188,7 @@ ${BOLD}Options:${RESET} or proceed past a Homebrew/npm-managed install warning. --add-to-path Append the install directory to your shell rc file. --dry-run Show what would happen without making changes. + --uninstall Remove the installer-managed binary and receipt. --no-color Disable colored output. -q, --quiet Suppress non-error output. -v, --verbose Verbose debug output. @@ -270,6 +272,10 @@ parse_args() { DRY_RUN=1 shift ;; + --uninstall) + UNINSTALL=1 + shift + ;; --no-color) NO_COLOR_FLAG=1 shift @@ -299,6 +305,30 @@ parse_args() { done } +# ---- Uninstall -------------------------------------------------------------- +uninstall() { + ensure_install_dir_default + binary_path="${INSTALL_DIR}/${BINARY_NAME}" + receipt_path="${INSTALL_DIR}/${INSTALL_RECEIPT_FILE}" + say_step "Uninstalling Agora CLI from ${INSTALL_DIR}" + if [ "$DRY_RUN" = "1" ]; then + say "[dry-run] Would remove ${binary_path}" + say "[dry-run] Would remove ${receipt_path}" + return 0 + fi + if [ -e "$binary_path" ]; then + rm -f "$binary_path" 2>/dev/null || run_elevated rm -f "$binary_path" + say_ok "Removed ${binary_path}" + else + say "No agora binary found at ${binary_path}." + fi + if [ -e "$receipt_path" ]; then + rm -f "$receipt_path" 2>/dev/null || run_elevated rm -f "$receipt_path" + say_ok "Removed ${receipt_path}" + fi + say "Config, session, context, and logs are preserved under the Agora CLI config directory." +} + # ---- Helpers --------------------------------------------------------------- need_cmd() { if ! command -v "$1" >/dev/null 2>&1; then @@ -910,6 +940,11 @@ main() { list_versions_and_exit fi + if [ "$UNINSTALL" = "1" ]; then + uninstall + exit "$EXIT_OK" + fi + print_banner normalize_version diff --git a/packaging/npm/@agoraio/cli-darwin-arm64/package.json b/packaging/npm/agoraio-cli-darwin-arm64/package.json similarity index 85% rename from packaging/npm/@agoraio/cli-darwin-arm64/package.json rename to packaging/npm/agoraio-cli-darwin-arm64/package.json index bdc125f..bf0f142 100644 --- a/packaging/npm/@agoraio/cli-darwin-arm64/package.json +++ b/packaging/npm/agoraio-cli-darwin-arm64/package.json @@ -1,5 +1,5 @@ { - "name": "@agoraio/cli-darwin-arm64", + "name": "agoraio-cli-darwin-arm64", "version": "0.0.0-dev", "description": "Agora CLI native binary — macOS arm64", "homepage": "https://github.com/AgoraIO/cli#readme", @@ -9,7 +9,7 @@ "repository": { "type": "git", "url": "git+https://github.com/AgoraIO/cli.git", - "directory": "agora-cli-go/packaging/npm/@agoraio/cli-darwin-arm64" + "directory": "agora-cli-go/packaging/npm/agoraio-cli-darwin-arm64" }, "license": "MIT", "author": "Agora DevRel ", diff --git a/packaging/npm/@agoraio/cli-darwin-x64/package.json b/packaging/npm/agoraio-cli-darwin-x64/package.json similarity index 85% rename from packaging/npm/@agoraio/cli-darwin-x64/package.json rename to packaging/npm/agoraio-cli-darwin-x64/package.json index 72dcfeb..c7b466d 100644 --- a/packaging/npm/@agoraio/cli-darwin-x64/package.json +++ b/packaging/npm/agoraio-cli-darwin-x64/package.json @@ -1,5 +1,5 @@ { - "name": "@agoraio/cli-darwin-x64", + "name": "agoraio-cli-darwin-x64", "version": "0.0.0-dev", "description": "Agora CLI native binary — macOS x64", "homepage": "https://github.com/AgoraIO/cli#readme", @@ -9,7 +9,7 @@ "repository": { "type": "git", "url": "git+https://github.com/AgoraIO/cli.git", - "directory": "agora-cli-go/packaging/npm/@agoraio/cli-darwin-x64" + "directory": "agora-cli-go/packaging/npm/agoraio-cli-darwin-x64" }, "license": "MIT", "author": "Agora DevRel ", diff --git a/packaging/npm/@agoraio/cli-linux-arm64/package.json b/packaging/npm/agoraio-cli-linux-arm64/package.json similarity index 85% rename from packaging/npm/@agoraio/cli-linux-arm64/package.json rename to packaging/npm/agoraio-cli-linux-arm64/package.json index d8b01eb..43ac514 100644 --- a/packaging/npm/@agoraio/cli-linux-arm64/package.json +++ b/packaging/npm/agoraio-cli-linux-arm64/package.json @@ -1,5 +1,5 @@ { - "name": "@agoraio/cli-linux-arm64", + "name": "agoraio-cli-linux-arm64", "version": "0.0.0-dev", "description": "Agora CLI native binary — Linux arm64", "homepage": "https://github.com/AgoraIO/cli#readme", @@ -9,7 +9,7 @@ "repository": { "type": "git", "url": "git+https://github.com/AgoraIO/cli.git", - "directory": "agora-cli-go/packaging/npm/@agoraio/cli-linux-arm64" + "directory": "agora-cli-go/packaging/npm/agoraio-cli-linux-arm64" }, "license": "MIT", "author": "Agora DevRel ", diff --git a/packaging/npm/@agoraio/cli-linux-x64/package.json b/packaging/npm/agoraio-cli-linux-x64/package.json similarity index 85% rename from packaging/npm/@agoraio/cli-linux-x64/package.json rename to packaging/npm/agoraio-cli-linux-x64/package.json index 3f2c320..2d87151 100644 --- a/packaging/npm/@agoraio/cli-linux-x64/package.json +++ b/packaging/npm/agoraio-cli-linux-x64/package.json @@ -1,5 +1,5 @@ { - "name": "@agoraio/cli-linux-x64", + "name": "agoraio-cli-linux-x64", "version": "0.0.0-dev", "description": "Agora CLI native binary — Linux x64", "homepage": "https://github.com/AgoraIO/cli#readme", @@ -9,7 +9,7 @@ "repository": { "type": "git", "url": "git+https://github.com/AgoraIO/cli.git", - "directory": "agora-cli-go/packaging/npm/@agoraio/cli-linux-x64" + "directory": "agora-cli-go/packaging/npm/agoraio-cli-linux-x64" }, "license": "MIT", "author": "Agora DevRel ", diff --git a/packaging/npm/@agoraio/cli-win32-arm64/package.json b/packaging/npm/agoraio-cli-win32-arm64/package.json similarity index 85% rename from packaging/npm/@agoraio/cli-win32-arm64/package.json rename to packaging/npm/agoraio-cli-win32-arm64/package.json index d202ea4..1870875 100644 --- a/packaging/npm/@agoraio/cli-win32-arm64/package.json +++ b/packaging/npm/agoraio-cli-win32-arm64/package.json @@ -1,5 +1,5 @@ { - "name": "@agoraio/cli-win32-arm64", + "name": "agoraio-cli-win32-arm64", "version": "0.0.0-dev", "description": "Agora CLI native binary — Windows arm64", "homepage": "https://github.com/AgoraIO/cli#readme", @@ -9,7 +9,7 @@ "repository": { "type": "git", "url": "git+https://github.com/AgoraIO/cli.git", - "directory": "agora-cli-go/packaging/npm/@agoraio/cli-win32-arm64" + "directory": "agora-cli-go/packaging/npm/agoraio-cli-win32-arm64" }, "license": "MIT", "author": "Agora DevRel ", diff --git a/packaging/npm/@agoraio/cli-win32-x64/package.json b/packaging/npm/agoraio-cli-win32-x64/package.json similarity index 85% rename from packaging/npm/@agoraio/cli-win32-x64/package.json rename to packaging/npm/agoraio-cli-win32-x64/package.json index acccd15..6183981 100644 --- a/packaging/npm/@agoraio/cli-win32-x64/package.json +++ b/packaging/npm/agoraio-cli-win32-x64/package.json @@ -1,5 +1,5 @@ { - "name": "@agoraio/cli-win32-x64", + "name": "agoraio-cli-win32-x64", "version": "0.0.0-dev", "description": "Agora CLI native binary — Windows x64", "homepage": "https://github.com/AgoraIO/cli#readme", @@ -9,7 +9,7 @@ "repository": { "type": "git", "url": "git+https://github.com/AgoraIO/cli.git", - "directory": "agora-cli-go/packaging/npm/@agoraio/cli-win32-x64" + "directory": "agora-cli-go/packaging/npm/agoraio-cli-win32-x64" }, "license": "MIT", "author": "Agora DevRel ", diff --git a/packaging/npm/agoraio-cli/package.json b/packaging/npm/agoraio-cli/package.json index f1cd8a5..accd4a3 100644 --- a/packaging/npm/agoraio-cli/package.json +++ b/packaging/npm/agoraio-cli/package.json @@ -33,12 +33,12 @@ "bin/agora.js" ], "optionalDependencies": { - "@agoraio/cli-darwin-arm64": "0.0.0-dev", - "@agoraio/cli-darwin-x64": "0.0.0-dev", - "@agoraio/cli-linux-arm64": "0.0.0-dev", - "@agoraio/cli-linux-x64": "0.0.0-dev", - "@agoraio/cli-win32-x64": "0.0.0-dev", - "@agoraio/cli-win32-arm64": "0.0.0-dev" + "agoraio-cli-darwin-arm64": "0.0.0-dev", + "agoraio-cli-darwin-x64": "0.0.0-dev", + "agoraio-cli-linux-arm64": "0.0.0-dev", + "agoraio-cli-linux-x64": "0.0.0-dev", + "agoraio-cli-win32-x64": "0.0.0-dev", + "agoraio-cli-win32-arm64": "0.0.0-dev" }, "publishConfig": { "access": "public", From 2a74b0a59fa3a719518c6542b19002a847495165 Mon Sep 17 00:00:00 2001 From: digitallysavvy Date: Thu, 30 Apr 2026 18:32:55 -0400 Subject: [PATCH 07/26] feat(cli): add MCP server, feature catalog, and init agent rule drops Expose the CLI as an MCP stdio server, centralize rtc/rtm/convoai in features.go for validation and introspect enums, derive init defaults from that catalog, infer AGORA_USER_AGENT labels from common agent env markers, and add init --add-agent-rules with managed snippets for Cursor, Claude, and Windsurf. Document MCP usage, agent docs layout, and refresh README/AGENTS. --- AGENTS.md | 24 +- README.md | 82 +++-- docs/agents/README.md | 36 +++ docs/agents/claude.md | 10 + docs/agents/cursor.mdc | 16 + docs/agents/windsurf.md | 10 + docs/automation.md | 4 +- docs/index.md | 21 ++ internal/cli/agent_infer.go | 46 +++ internal/cli/agent_infer_test.go | 100 +++++++ internal/cli/agent_rules.go | 202 +++++++++++++ internal/cli/agent_rules_test.go | 130 ++++++++ internal/cli/completion.go | 58 ++++ internal/cli/doctor.go | 11 +- internal/cli/features.go | 76 +++++ internal/cli/init.go | 8 +- internal/cli/introspect.go | 2 +- internal/cli/mcp.go | 499 +++++++++++++++++++++++++++++++ internal/cli/mcp_test.go | 215 +++++++++++++ internal/cli/projects.go | 13 +- 20 files changed, 1500 insertions(+), 63 deletions(-) create mode 100644 docs/agents/README.md create mode 100644 docs/agents/claude.md create mode 100644 docs/agents/cursor.mdc create mode 100644 docs/agents/windsurf.md create mode 100644 docs/index.md create mode 100644 internal/cli/agent_infer.go create mode 100644 internal/cli/agent_infer_test.go create mode 100644 internal/cli/agent_rules.go create mode 100644 internal/cli/agent_rules_test.go create mode 100644 internal/cli/completion.go create mode 100644 internal/cli/features.go create mode 100644 internal/cli/mcp.go create mode 100644 internal/cli/mcp_test.go diff --git a/AGENTS.md b/AGENTS.md index a0bf754..dac0517 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,6 @@ internal/cli/ docs/ automation.md Stable JSON output contract — machine-consumption source of truth install.md Direct installer, platform, CI, and security guidance -brew_tap_walkthrough.md Homebrew tap setup and verification notes .github/workflows/ ci.yml Push/PR matrix: Ubuntu, macOS, Windows release.yml Tag-driven cross-platform release @@ -133,7 +132,7 @@ The full stable contract with all result shapes is in [`docs/automation.md`](doc `auth status`, `whoami`, and API-touching commands return exit code `3` plus `ok: false` with `error.code == "AUTH_UNAUTHENTICATED"` when no local session exists. Treat that as the unauthenticated state and run `agora login` before commands that require a session. -Set `AGORA_AGENT=` in agent runs so API requests include agent context in `User-Agent`. +Set `AGORA_AGENT=` in agent runs to explicitly label API requests in `User-Agent`. If it is unset, the CLI infers a coarse label from known agent environment markers (Cursor, Claude Code, Cline, Windsurf, Codex, Aider) unless `AGORA_AGENT_DISABLE_INFER=1` is set. ## Testing @@ -205,20 +204,19 @@ packaging/npm/ agoraio-cli/ ← the published npm package (Node shim only) bin/agora.js ← entry point: resolves platform binary and spawns it package.json ← optionalDependencies for all 6 platforms - @agoraio/ - cli-darwin-arm64/ ← one package per platform - cli-darwin-x64/ - cli-linux-arm64/ - cli-linux-x64/ - cli-win32-x64/ - cli-win32-arm64/ - package.json ← os/cpu fields restrict install to matching platform - bin/ ← .gitignored; populated by CI at release time + agoraio-cli-darwin-arm64/ ← one unscoped package per platform + agoraio-cli-darwin-x64/ + agoraio-cli-linux-arm64/ + agoraio-cli-linux-x64/ + agoraio-cli-win32-x64/ + agoraio-cli-win32-arm64/ + package.json ← os/cpu fields restrict install to matching platform + bin/ ← .gitignored; populated by CI at release time ``` **How it works:** 1. `npm install -g agoraio-cli` installs the shim + the matching platform package via `optionalDependencies` -2. `bin/agora.js` resolves `@agoraio/cli-/bin/agora` and `spawnSync`s it with all args inherited +2. `bin/agora.js` resolves `agoraio-cli-/bin/agora` and `spawnSync`s it with all args inherited 3. If the platform package is missing, the shim prints a helpful error pointing to Homebrew or GitHub releases **Release flow (automated and active):** the `publish-npm` job in `release.yml`: @@ -230,7 +228,7 @@ packaging/npm/ 6. Smoke-tests the published wrapper with `npx --yes agoraio-cli@ --version` (retry/backoff for registry propagation) **Prerequisites:** -- `NPM_TOKEN` secret in the repo, with publish access to the `@agoraio` scope and `agoraio-cli`. +- `NPM_TOKEN` secret in the repo, with publish access to `agoraio-cli` and all unscoped `agoraio-cli-*` platform packages. - `id-token: write` workflow permission (already set in `release.yml`) — required for npm provenance. **Manual dry-run:** the workflow exposes `workflow_dispatch` with a `dry_run` input that runs `npm publish --dry-run` against a synthetic version, validating packaging without publishing. diff --git a/README.md b/README.md index a4e509e..fd79aeb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,24 @@ # Agora CLI -Native Agora CLI for authentication, project management, quickstart setup, and developer onboarding. +[![CI](https://github.com/AgoraIO/cli/actions/workflows/ci.yml/badge.svg)](https://github.com/AgoraIO/cli/actions/workflows/ci.yml) +[![Release](https://img.shields.io/github/v/release/AgoraIO/cli?label=release)](https://github.com/AgoraIO/cli/releases) +[![npm](https://img.shields.io/npm/v/agoraio-cli?label=npm)](https://www.npmjs.com/package/agoraio-cli) +[![License](https://img.shields.io/github/license/AgoraIO/cli)](LICENSE) + +Native Agora CLI for authentication, project management, quickstart setup, and developer onboarding. Use it to go from an Agora account to a runnable app with one command. + +```bash +agora login +agora init my-nextjs-demo --template nextjs +``` + +## What You Can Build Quickly + +| Goal | Command | What You Get | +|------|---------|--------------| +| Next.js video app | `agora init my-nextjs-demo --template nextjs` | A cloned Next.js quickstart, project binding, and `.env.local` | +| Python voice agent | `agora init my-python-demo --template python` | A Python quickstart with Agora credentials written for the backend | +| Go token service | `agora init my-go-demo --template go` | A Go server quickstart with project metadata and env wiring | ## Install @@ -48,7 +66,7 @@ agora init my-nextjs-demo --template nextjs - Telemetry controls: [docs/telemetry.md](docs/telemetry.md) - Contributor and agent guide: [AGENTS.md](AGENTS.md) -Command examples use `agora` for an installed CLI. Use `./agora` when running a local binary built from this repository with `go build -o agora .`. +Command examples use `agora` for the installed CLI. Local source builds are covered in [Build From Source](#build-from-source). ## Command Model @@ -65,9 +83,9 @@ The command model is intentionally layered: Discover the full command tree: ```bash -./agora --help -./agora --help --all -./agora introspect --json +agora --help +agora --help --all +agora introspect --json ``` ### `init` @@ -116,43 +134,43 @@ Prints build metadata. Release binaries include version, commit, and build date. ### Onboard a new demo ```bash -./agora login -./agora init my-nextjs-demo --template nextjs +agora login +agora init my-nextjs-demo --template nextjs ``` ### Use an existing project with a quickstart ```bash -./agora quickstart create my-go-demo --template go --project my-existing-project -./agora quickstart env write my-go-demo --project my-existing-project +agora quickstart create my-go-demo --template go --project my-existing-project +agora quickstart env write my-go-demo --project my-existing-project ``` ### Update env after changing projects ```bash -./agora project use my-agent-demo -./agora quickstart env write my-go-demo +agora project use my-agent-demo +agora quickstart env write my-go-demo ``` ### Inspect project readiness ```bash -./agora project doctor -./agora project doctor --json +agora project doctor +agora project doctor --json ``` ### Use low-level commands directly ```bash -./agora project create my-agent-demo --feature rtc --feature convoai -./agora quickstart create my-go-demo --template go --project my-agent-demo -./agora quickstart env write my-go-demo --project my-agent-demo +agora project create my-agent-demo --feature rtc --feature convoai +agora quickstart create my-go-demo --template go --project my-agent-demo +agora quickstart env write my-go-demo --project my-agent-demo ``` ### Inspect the full command tree ```bash -./agora --help --all +agora --help --all ``` ## Quickstart Env Conventions @@ -202,13 +220,13 @@ Examples: ```bash # Inside a bound quickstart repo -./agora project show --json +agora project show --json # From any directory, target a repo path directly -./agora quickstart env write /abs/path/to/my-go-demo --json +agora quickstart env write /abs/path/to/my-go-demo --json # Rebind a repo to a different project -./agora quickstart env write /abs/path/to/my-go-demo --project my-other-project --json +agora quickstart env write /abs/path/to/my-go-demo --project my-other-project --json ``` ## Automation / Agent Usage @@ -219,7 +237,7 @@ For scripts, CI, and agentic workflows: - set `AGORA_HOME` to an isolated temporary directory in CI or multi-agent runs - prefer `init` for end-to-end setup - use low-level commands when the workflow must be decomposed or resumed in stages -- use `./agora --help --all` to inspect the full command tree +- use `agora --help --all` to inspect the full command tree - use `quickstart env write` to re-sync env files after changing project selection - use `project doctor --json` for readiness checks - rely on the same JSON envelope for both success and failure @@ -228,11 +246,11 @@ Examples: ```bash export AGORA_HOME="$(mktemp -d)" -./agora init my-nextjs-demo --template nextjs --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 -./agora auth status --json +agora init my-nextjs-demo --template nextjs --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 +agora auth status --json ``` The JSON envelope and stable result shapes are documented in [docs/automation.md](docs/automation.md). `auth status --json` exits `3` with `error.code` set to `AUTH_UNAUTHENTICATED` when no local session exists. @@ -258,8 +276,8 @@ The CLI stores config, session, context, and logs under the Agora CLI config dir Useful commands: ```bash -./agora config path -./agora config get +agora config path +agora config get ``` Built-in default config values are documented in [config.example.json](config.example.json). @@ -271,13 +289,13 @@ Built-in default config values are documented in [config.example.json](config.ex Try: ```bash -./agora login --no-browser +agora login --no-browser ``` You can also inspect the current auth state: ```bash -./agora whoami +agora whoami ``` ### `git` is missing @@ -301,8 +319,8 @@ Quickstart env injection requires a project with an app certificate. If the sele If a command needs a project and none is currently selected, either: ```bash -./agora quickstart env write my-go-demo --project my-project -./agora project use my-project +agora quickstart env write my-go-demo --project my-project +agora project use my-project ``` or run it inside a repo that already has `.agora/project.json`. diff --git a/docs/agents/README.md b/docs/agents/README.md new file mode 100644 index 0000000..a9fb5a5 --- /dev/null +++ b/docs/agents/README.md @@ -0,0 +1,36 @@ +# Agent Rules For Agora Projects + +These snippets help AI coding agents use Agora CLI safely and consistently in app repositories. + +Use the CLI to scaffold rules into a new quickstart: + +```bash +agora init my-nextjs-demo --template nextjs --add-agent-rules cursor +agora init my-python-demo --template python --add-agent-rules claude +agora init my-go-demo --template go --add-agent-rules windsurf +``` + +Available rule targets: + +- [Cursor](cursor.mdc) +- [Claude Code](claude.md) +- [Windsurf](windsurf.md) + +## Safety contract + +`--add-agent-rules` never destroys your existing agent configuration: + +- If the destination file (for example `CLAUDE.md`) does not exist yet, the CLI creates it with the Agora rules block. +- If the destination file exists and does **not** contain a previously-managed block, the CLI **appends** a new block to the end of the file, preserving every prior line you (or another tool) wrote. +- If the destination file already contains a previously-managed block, the CLI replaces only the contents between the markers — content before and after the markers stays exactly as you left it. + +Each managed block is wrapped in HTML-comment sentinels: + +```text + +...rules... + +``` + +You can move the block around inside the file or wrap it with your own commentary; subsequent runs will still recognize and refresh it. + diff --git a/docs/agents/claude.md b/docs/agents/claude.md new file mode 100644 index 0000000..5c5eb15 --- /dev/null +++ b/docs/agents/claude.md @@ -0,0 +1,10 @@ +# Agora CLI Agent Rules + +- Use `agora --help --all --json` or `agora introspect --json` for command discovery. +- Prefer `--json` for commands whose output you need to parse. +- Set `AGORA_HOME` to an isolated temporary directory in CI or multi-agent runs. +- Use explicit `--project ` when working outside a bound quickstart. +- Run `agora project doctor --json` before assuming a project is ready. +- Do not parse pretty output; it is for humans. +- Do not print App Certificates, session tokens, or `.env` secret values. + diff --git a/docs/agents/cursor.mdc b/docs/agents/cursor.mdc new file mode 100644 index 0000000..ca092eb --- /dev/null +++ b/docs/agents/cursor.mdc @@ -0,0 +1,16 @@ +--- +description: Use Agora CLI safely in this project +globs: "**/*" +alwaysApply: true +--- + +# Agora CLI Agent Rules + +- Use `agora --help --all --json` or `agora introspect --json` for command discovery. +- Prefer `--json` for commands whose output you need to parse. +- Set `AGORA_HOME` to an isolated temporary directory in CI or multi-agent runs. +- Use explicit `--project ` when working outside a bound quickstart. +- Run `agora project doctor --json` before assuming a project is ready. +- Do not parse pretty output; it is for humans. +- Do not print App Certificates, session tokens, or `.env` secret values. + diff --git a/docs/agents/windsurf.md b/docs/agents/windsurf.md new file mode 100644 index 0000000..5c5eb15 --- /dev/null +++ b/docs/agents/windsurf.md @@ -0,0 +1,10 @@ +# Agora CLI Agent Rules + +- Use `agora --help --all --json` or `agora introspect --json` for command discovery. +- Prefer `--json` for commands whose output you need to parse. +- Set `AGORA_HOME` to an isolated temporary directory in CI or multi-agent runs. +- Use explicit `--project ` when working outside a bound quickstart. +- Run `agora project doctor --json` before assuming a project is ready. +- Do not parse pretty output; it is for humans. +- Do not print App Certificates, session tokens, or `.env` secret values. + diff --git a/docs/automation.md b/docs/automation.md index 2bc4cdf..3b2731e 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -33,8 +33,8 @@ Use this guide for: - Use `--json --pretty` when a human needs to inspect JSON directly. Scripts should keep the default single-line JSON. - Use `--quiet` to suppress the success envelope in **both** pretty and JSON modes; the exit code becomes the only result. Errors are still printed on stderr (and as a JSON envelope on stdout when `--json` is set without `--quiet`). NDJSON progress events are still emitted because they are observability, not results. - Use `--verbose` (equivalent to `AGORA_VERBOSE=1`) to echo structured log records to stderr. The flag does not change exit codes, JSON envelope shape, or NDJSON progress events; it only mirrors the entries that would normally be written to the log file. Pair with `--json` for fully machine-parseable runs that also surface internal events to your CI logs. -- Use `--yes` (or `-y`) / `AGORA_NO_INPUT=1` to accept default choices and suppress prompts in automation. -- Interactive login prompts only appear in interactive pretty-mode runs. Automation should still authenticate up front with `agora login`; `--json`, `AGORA_OUTPUT=json`, `--yes`, `AGORA_NO_INPUT=1`, and detected CI environments never prompt. +- Use `--yes` (or `-y`) / `AGORA_NO_INPUT=1` to assume the default answer to confirmation prompts. Following industry convention for `-y` (apt-style), the flag never starts brand-new interactive flows: in JSON, CI, or non-TTY contexts the CLI still fails fast with the same `AUTH_UNAUTHENTICATED` error you would have seen without `--yes`, instead of silently launching an OAuth browser flow. +- Interactive login prompts only appear in interactive pretty-mode TTY runs. Automation should authenticate up front with `agora login`; `--json`, `AGORA_OUTPUT=json`, detected CI environments, and non-TTY stdin all skip the prompt and fail with `AUTH_UNAUTHENTICATED`. - Output mode precedence is: explicit CLI flag (`--json` or `--output`) first, user-set `AGORA_OUTPUT` second, then user-customized config file value, then **CI auto-detect → JSON** (see below), then pretty. - Set `AGORA_AGENT=` in automated environments to explicitly label agent traffic in the API `User-Agent`. When unset, the CLI may infer a coarse label such as `cursor`, `claude-code`, `cline`, `windsurf`, `codex`, or `aider` from known agent environment markers. Set `AGORA_AGENT_DISABLE_INFER=1` to disable inference. - Use `agora mcp serve --transport stdio` to expose local Agora CLI tools to MCP-capable agents. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..f2e2268 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,21 @@ +# Agora CLI Docs + +Agora CLI is the native command-line tool for Agora authentication, project management, quickstart setup, and developer onboarding. + +## Start Here + +- [Install options](install.md) +- [Command reference](commands.md) +- [Automation and JSON contract](automation.md) +- [Stable error codes](error-codes.md) +- [Telemetry controls](telemetry.md) + +## Common Commands + +```bash +agora login +agora init my-nextjs-demo --template nextjs +agora project doctor --json +agora --help --all +``` + diff --git a/internal/cli/agent_infer.go b/internal/cli/agent_infer.go new file mode 100644 index 0000000..bffd71d --- /dev/null +++ b/internal/cli/agent_infer.go @@ -0,0 +1,46 @@ +package cli + +import "strings" + +func agentLabelFromOSEnv(env map[string]string) string { + if explicit := strings.TrimSpace(env["AGORA_AGENT"]); explicit != "" { + return explicit + } + if truthyEnv(env, "AGORA_AGENT_DISABLE_INFER") { + return "" + } + switch { + case nonEmptyEnv(env, "CURSOR_AGENT"): + return "cursor" + case nonEmptyEnv(env, "CLAUDE_CODE"): + return "claude-code" + case hasEnvPrefix(env, "CLINE_"): + return "cline" + case hasEnvPrefix(env, "WINDSURF_"): + return "windsurf" + case hasEnvPrefix(env, "OPENAI_CODEX_"): + return "codex" + case hasEnvPrefix(env, "AIDER_"): + return "aider" + default: + return "" + } +} + +func nonEmptyEnv(env map[string]string, key string) bool { + return strings.TrimSpace(env[key]) != "" +} + +func hasEnvPrefix(env map[string]string, prefix string) bool { + for key, value := range env { + if strings.HasPrefix(key, prefix) && strings.TrimSpace(value) != "" { + return true + } + } + return false +} + +func truthyEnv(env map[string]string, key string) bool { + value := strings.ToLower(strings.TrimSpace(env[key])) + return value == "1" || value == "true" || value == "yes" || value == "y" +} diff --git a/internal/cli/agent_infer_test.go b/internal/cli/agent_infer_test.go new file mode 100644 index 0000000..6b7da93 --- /dev/null +++ b/internal/cli/agent_infer_test.go @@ -0,0 +1,100 @@ +package cli + +import "testing" + +func TestAgentLabelFromOSEnv(t *testing.T) { + tests := []struct { + name string + env map[string]string + want string + }{ + {name: "explicit wins", env: map[string]string{"AGORA_AGENT": "custom", "CURSOR_AGENT": "1"}, want: "custom"}, + {name: "disabled", env: map[string]string{"AGORA_AGENT_DISABLE_INFER": "1", "CURSOR_AGENT": "1"}, want: ""}, + {name: "cursor", env: map[string]string{"CURSOR_AGENT": "1"}, want: "cursor"}, + {name: "claude code", env: map[string]string{"CLAUDE_CODE": "1"}, want: "claude-code"}, + {name: "cline", env: map[string]string{"CLINE_SESSION": "abc"}, want: "cline"}, + {name: "windsurf", env: map[string]string{"WINDSURF_SESSION": "abc"}, want: "windsurf"}, + {name: "codex", env: map[string]string{"OPENAI_CODEX_SESSION": "abc"}, want: "codex"}, + {name: "aider", env: map[string]string{"AIDER_AUTO_COMMITS": "false"}, want: "aider"}, + {name: "empty", env: map[string]string{}, want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := agentLabelFromOSEnv(tt.env); got != tt.want { + t.Fatalf("agentLabelFromOSEnv() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestAgoraUserAgentInfersCursorAgent(t *testing.T) { + got := agoraUserAgent(map[string]string{"CURSOR_AGENT": "1"}) + if got != "agora-cli/"+version+" agent/cursor" { + t.Fatalf("unexpected user agent: %q", got) + } +} + +// TestDecideShouldPromptForLoginYesFlagDoesNotBypassGuards is the +// regression test for the bug where `--yes` / AGORA_NO_INPUT used to +// short-circuit the JSON/CI/non-TTY guards in shouldPromptForLogin and +// silently launch an OAuth browser flow in CI. The new contract: --yes +// only auto-confirms an *already-interactive* prompt. JSON/CI/non-TTY +// runs always return false and let the caller surface the existing +// AUTH_UNAUTHENTICATED error. +func TestDecideShouldPromptForLoginYesFlagDoesNotBypassGuards(t *testing.T) { + tests := []struct { + name string + mode outputMode + ci bool + stdinIsTTY bool + want bool + }{ + {name: "interactive pretty TTY", mode: outputPretty, ci: false, stdinIsTTY: true, want: true}, + {name: "json mode never prompts", mode: outputJSON, ci: false, stdinIsTTY: true, want: false}, + {name: "CI never prompts", mode: outputPretty, ci: true, stdinIsTTY: true, want: false}, + {name: "non-TTY never prompts", mode: outputPretty, ci: false, stdinIsTTY: false, want: false}, + {name: "json and CI", mode: outputJSON, ci: true, stdinIsTTY: false, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := decideShouldPromptForLogin(tt.mode, tt.ci, tt.stdinIsTTY) + if got != tt.want { + t.Fatalf("decideShouldPromptForLogin(%v, %v, %v) = %v, want %v", + tt.mode, tt.ci, tt.stdinIsTTY, got, tt.want) + } + }) + } +} + +// TestPromptForLoginNoInputDoesNotPromptInJSONMode verifies that a +// non-interactive caller that sets AGORA_NO_INPUT=1 still gets the +// structured AUTH_UNAUTHENTICATED error in JSON mode instead of +// triggering an OAuth flow. +func TestPromptForLoginNoInputDoesNotPromptInJSONMode(t *testing.T) { + t.Setenv("AGORA_NO_INPUT", "1") + t.Setenv("AGORA_OUTPUT", "json") + t.Setenv("CI", "true") + app := &App{ + env: map[string]string{"AGORA_NO_INPUT": "1", "AGORA_OUTPUT": "json"}, + osEnv: map[string]string{"CI": "true"}, + } + err := app.promptForLogin() + if err == nil { + t.Fatal("expected error, got nil") + } + var structured *cliError + if !asCliError(err, &structured) || structured.Code != "AUTH_UNAUTHENTICATED" { + t.Fatalf("expected AUTH_UNAUTHENTICATED cliError, got %T %v", err, err) + } +} + +func asCliError(err error, target **cliError) bool { + if err == nil { + return false + } + if c, ok := err.(*cliError); ok { + *target = c + return true + } + return false +} diff --git a/internal/cli/agent_rules.go b/internal/cli/agent_rules.go new file mode 100644 index 0000000..82ea048 --- /dev/null +++ b/internal/cli/agent_rules.go @@ -0,0 +1,202 @@ +package cli + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// agentRuleSentinel is a unique marker the CLI writes alongside the +// rule body so we can recognize an Agora-managed block on subsequent +// runs and update it in place without duplicating content. The marker +// is wrapped in HTML comments so it stays invisible in rendered +// Markdown views (Cursor, GitHub, etc.). +const ( + agentRuleStartMarker = "" + agentRuleEndMarker = "" +) + +// agentRuleBody is the shared rule body written into every supported +// agent's configuration file. The body lives between sentinel markers +// inside the destination file so subsequent `agora init --add-agent-rules` +// runs can update only the Agora-managed block. +const agentRuleBody = "# Agora CLI Agent Rules\n\n" + + "- Use `agora --help --all --json` or `agora introspect --json` for command discovery.\n" + + "- Prefer `--json` for commands whose output you need to parse.\n" + + "- Set `AGORA_HOME` to an isolated temporary directory in CI or multi-agent runs.\n" + + "- Use explicit `--project ` when working outside a bound quickstart.\n" + + "- Run `agora project doctor --json` before assuming a project is ready.\n" + + "- Do not parse pretty output; it is for humans.\n" + + "- Do not print App Certificates, session tokens, or `.env` secret values.\n" + +// agentRuleWriteResult records what writeAgentRules did for one target +// so callers can surface accurate, non-destructive feedback to the user. +type agentRuleWriteResult struct { + Target string `json:"target"` + Path string `json:"path"` + // Status is one of: "created", "appended", "updated". + // created — file did not exist and we created it + // appended — file existed and did not contain an Agora-managed + // block; we appended a new block, preserving every + // prior line of the user's content + // updated — file existed and already contained an Agora-managed + // block from a previous run; we replaced only the + // block between the markers + Status string `json:"status"` +} + +// writeAgentRules writes (or refreshes) the Agora rules block in the +// configured destination for each requested agent target. It NEVER +// destroys pre-existing user content: existing files are appended to, +// and previously-managed blocks are updated in place between sentinel +// markers. Unknown targets return an error before any file is touched. +func writeAgentRules(root string, targets []string) ([]agentRuleWriteResult, error) { + results := []agentRuleWriteResult{} + for _, target := range targets { + target = strings.ToLower(strings.TrimSpace(target)) + if target == "" { + continue + } + path, body, err := agentRuleTargetSpec(root, target) + if err != nil { + return results, err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return results, err + } + status, err := writeOrAppendAgentRuleBlock(path, body, target) + if err != nil { + return results, err + } + rel, err := filepath.Rel(root, path) + if err != nil { + rel = path + } + results = append(results, agentRuleWriteResult{Target: target, Path: rel, Status: status}) + } + return results, nil +} + +// agentRuleTargetSpec returns the destination path and the full block +// body (including any front-matter) for a given target. +func agentRuleTargetSpec(root, target string) (string, string, error) { + switch target { + case "cursor": + path := filepath.Join(root, ".cursor", "rules", "agora.mdc") + body := "---\n" + + "description: Use Agora CLI safely in this project\n" + + "globs: \"**/*\"\n" + + "alwaysApply: true\n" + + "---\n\n" + + agentRuleBody + return path, body, nil + case "claude": + return filepath.Join(root, "CLAUDE.md"), agentRuleBody, nil + case "windsurf": + return filepath.Join(root, ".windsurf", "rules", "agora.md"), agentRuleBody, nil + default: + return "", "", fmt.Errorf("unknown agent rules target %q. Use cursor, claude, or windsurf.", target) + } +} + +// writeOrAppendAgentRuleBlock writes the Agora-managed rule block to +// path, taking three possible paths depending on the file's prior state: +// +// 1. The file does not exist → create with the marked block. +// 2. The file exists but has no markers → append a separator and the +// marked block. We preserve every line of the user's content. +// 3. The file exists and has prior markers → replace ONLY the content +// between the markers. Everything before the start marker and after +// the end marker (including any user notes the agent added in the +// same file) survives unchanged. +// +// In every case the destination ends with the canonical Agora-managed +// block surrounded by `agentRuleStartMarker` / `agentRuleEndMarker`, +// which is what the next `agora init --add-agent-rules` run looks for. +func writeOrAppendAgentRuleBlock(path, body, target string) (string, error) { + managed := buildManagedAgentRuleBlock(body, target) + existing, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + // Cursor's `.mdc` files require front-matter at the very top + // of the file, so we write the managed block as the entire + // file when the file did not exist. + if err := os.WriteFile(path, []byte(managed), 0o644); err != nil { + return "", err + } + return "created", nil + } + if err != nil { + return "", err + } + if start, end, ok := locateManagedAgentRuleBlock(existing); ok { + next := append([]byte{}, existing[:start]...) + next = append(next, []byte(managed)...) + next = append(next, existing[end:]...) + if err := os.WriteFile(path, next, 0o644); err != nil { + return "", err + } + return "updated", nil + } + separator := "\n" + if len(existing) > 0 && existing[len(existing)-1] != '\n' { + separator = "\n\n" + } else if len(existing) >= 2 && string(existing[len(existing)-2:]) != "\n\n" { + separator = "\n" + } else { + separator = "" + } + next := append([]byte{}, existing...) + next = append(next, []byte(separator)...) + next = append(next, []byte(managed)...) + if err := os.WriteFile(path, next, 0o644); err != nil { + return "", err + } + return "appended", nil +} + +// buildManagedAgentRuleBlock wraps the raw rule body with stable +// sentinel markers and a generation header. The header is +// human-readable so users browsing the file understand who owns this +// block and how to refresh it. Cursor `.mdc` front-matter must stay at +// the very top of the file, so the start marker comes after the front +// matter when present. +func buildManagedAgentRuleBlock(body, target string) string { + const note = "\n" + if strings.HasPrefix(body, "---\n") { + // Split off the leading YAML front-matter so the start marker + // stays inside the body of the file. + end := strings.Index(body[4:], "\n---") + if end >= 0 { + split := 4 + end + len("\n---\n") + if split <= len(body) { + front := body[:split] + rest := body[split:] + return front + agentRuleStartMarker + "\n" + note + rest + agentRuleEndMarker + "\n" + } + } + } + return agentRuleStartMarker + "\n" + note + body + agentRuleEndMarker + "\n" +} + +// locateManagedAgentRuleBlock returns the byte offsets of the prior +// Agora-managed block, including its surrounding markers, so the caller +// can splice a refreshed block in place. +func locateManagedAgentRuleBlock(content []byte) (int, int, bool) { + startIdx := strings.Index(string(content), agentRuleStartMarker) + if startIdx < 0 { + return 0, 0, false + } + endIdx := strings.Index(string(content[startIdx:]), agentRuleEndMarker) + if endIdx < 0 { + return 0, 0, false + } + endIdx += startIdx + len(agentRuleEndMarker) + if endIdx < len(content) && content[endIdx] == '\n' { + endIdx++ + } + return startIdx, endIdx, true +} diff --git a/internal/cli/agent_rules_test.go b/internal/cli/agent_rules_test.go new file mode 100644 index 0000000..34c282c --- /dev/null +++ b/internal/cli/agent_rules_test.go @@ -0,0 +1,130 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWriteAgentRulesCreatesFileWhenMissing(t *testing.T) { + root := t.TempDir() + results, err := writeAgentRules(root, []string{"claude"}) + if err != nil { + t.Fatal(err) + } + if len(results) != 1 || results[0].Status != "created" { + t.Fatalf("expected one created result, got %+v", results) + } + body, err := os.ReadFile(filepath.Join(root, "CLAUDE.md")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), agentRuleStartMarker) || !strings.Contains(string(body), agentRuleEndMarker) { + t.Fatalf("expected managed markers in created file, got:\n%s", body) + } + if !strings.Contains(string(body), "Agora CLI Agent Rules") { + t.Fatalf("expected rule body in created file, got:\n%s", body) + } +} + +func TestWriteAgentRulesAppendsToExistingFileWithoutClobber(t *testing.T) { + root := t.TempDir() + dest := filepath.Join(root, "CLAUDE.md") + prior := "# Project Notes\n\nThese are critical instructions from the user.\n" + if err := os.WriteFile(dest, []byte(prior), 0o644); err != nil { + t.Fatal(err) + } + results, err := writeAgentRules(root, []string{"claude"}) + if err != nil { + t.Fatal(err) + } + if len(results) != 1 || results[0].Status != "appended" { + t.Fatalf("expected one appended result, got %+v", results) + } + body, err := os.ReadFile(dest) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(body), prior) { + t.Fatalf("user content was modified or removed, got:\n%s", body) + } + if !strings.Contains(string(body), agentRuleStartMarker) { + t.Fatalf("expected managed marker after append, got:\n%s", body) + } +} + +func TestWriteAgentRulesUpdatesExistingManagedBlockInPlace(t *testing.T) { + root := t.TempDir() + dest := filepath.Join(root, "CLAUDE.md") + header := "# Project Notes\n\nThese are critical instructions from the user.\n" + footer := "\n\n## After-block notes\n\nRemember to run tests.\n" + staleBlock := agentRuleStartMarker + "\nOLD AGORA BODY\n" + agentRuleEndMarker + "\n" + prior := header + staleBlock + footer + if err := os.WriteFile(dest, []byte(prior), 0o644); err != nil { + t.Fatal(err) + } + results, err := writeAgentRules(root, []string{"claude"}) + if err != nil { + t.Fatal(err) + } + if len(results) != 1 || results[0].Status != "updated" { + t.Fatalf("expected updated result, got %+v", results) + } + body, err := os.ReadFile(dest) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), header) { + t.Fatalf("header content missing, got:\n%s", body) + } + if !strings.Contains(string(body), footer) { + t.Fatalf("footer content missing, got:\n%s", body) + } + if strings.Contains(string(body), "OLD AGORA BODY") { + t.Fatalf("stale block was not replaced, got:\n%s", body) + } + if !strings.Contains(string(body), "Agora CLI Agent Rules") { + t.Fatalf("expected fresh rule body, got:\n%s", body) + } + if strings.Count(string(body), agentRuleStartMarker) != 1 { + t.Fatalf("expected exactly one start marker, got:\n%s", body) + } +} + +func TestWriteAgentRulesCursorPreservesFrontmatter(t *testing.T) { + root := t.TempDir() + results, err := writeAgentRules(root, []string{"cursor"}) + if err != nil { + t.Fatal(err) + } + if len(results) != 1 || results[0].Status != "created" { + t.Fatalf("expected one created result, got %+v", results) + } + body, err := os.ReadFile(filepath.Join(root, ".cursor", "rules", "agora.mdc")) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(body), "---\n") { + t.Fatalf("Cursor .mdc must start with YAML frontmatter, got:\n%s", body) + } + frontMatterEnd := strings.Index(string(body), "\n---\n") + if frontMatterEnd <= 0 { + t.Fatalf("Cursor .mdc must close YAML frontmatter, got:\n%s", body) + } + startMarkerPos := strings.Index(string(body), agentRuleStartMarker) + if startMarkerPos <= frontMatterEnd { + t.Fatalf("start marker must come after YAML frontmatter, body:\n%s", body) + } +} + +func TestWriteAgentRulesUnknownTargetReturnsError(t *testing.T) { + root := t.TempDir() + _, err := writeAgentRules(root, []string{"copilot"}) + if err == nil { + t.Fatal("expected error for unknown target") + } + if !strings.Contains(err.Error(), "unknown agent rules target") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/cli/completion.go b/internal/cli/completion.go new file mode 100644 index 0000000..52c19bb --- /dev/null +++ b/internal/cli/completion.go @@ -0,0 +1,58 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +func (a *App) completeProjectNames(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + list, err := a.listProjects(toComplete, 1, 100) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + results := make([]string, 0, len(list.Items)*2) + for _, item := range list.Items { + if item.Name != "" && strings.HasPrefix(strings.ToLower(item.Name), strings.ToLower(toComplete)) { + results = append(results, fmt.Sprintf("%s\t%s", item.Name, item.ProjectID)) + } + if item.ProjectID != "" && strings.HasPrefix(strings.ToLower(item.ProjectID), strings.ToLower(toComplete)) { + results = append(results, fmt.Sprintf("%s\t%s", item.ProjectID, item.Name)) + } + } + return results, cobra.ShellCompDirectiveNoFileComp +} + +func completeQuickstartTemplateIDs(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + results := []string{} + 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)) + } + } + return results, cobra.ShellCompDirectiveNoFileComp +} + +func completeFeatureIDs(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + results := []string{} + for _, feature := range featureIDs() { + if strings.HasPrefix(strings.ToLower(feature), strings.ToLower(toComplete)) { + results = append(results, feature) + } + } + return results, cobra.ShellCompDirectiveNoFileComp +} + +func (a *App) completeFeatureThenProject(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return completeFeatureIDs(cmd, args, toComplete) + } + if len(args) == 1 { + return a.completeProjectNames(cmd, args, toComplete) + } + return nil, cobra.ShellCompDirectiveNoFileComp +} diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 4ab1dfb..61a3783 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -1,19 +1,16 @@ package cli import ( - "fmt" "os" "path/filepath" "strings" ) +// validateDoctorFeature defers to the canonical feature catalog so the +// list of accepted `--feature` values stays in lockstep with the rest +// of the CLI. func validateDoctorFeature(feature string) error { - switch feature { - case "rtc", "rtm", "convoai": - return nil - default: - return fmt.Errorf("%q must be one of: rtc, rtm, convoai.", feature) - } + return validateFeatureID(feature) } func doctorFeatureDependencies(feature string) map[string]bool { diff --git a/internal/cli/features.go b/internal/cli/features.go new file mode 100644 index 0000000..709547f --- /dev/null +++ b/internal/cli/features.go @@ -0,0 +1,76 @@ +package cli + +import ( + "fmt" + "strings" +) + +// featureCatalog is the single source of truth for the supported +// product features the CLI knows how to enable, inspect, and report on. +// Adding a new product feature requires only a new entry here: +// +// - command-line completions (`completeFeatureIDs`), +// - the `init`/`project create` default-features list, +// - `project doctor`'s feature validator, +// - `project feature {list,status,enable}`'s iteration order, and +// - the MCP tool surface +// +// all read from this slice. Order is significant: it controls the +// stable ordering of `project feature list` output, the deterministic +// ordering of `project create --feature` defaults, and the suggestion +// order in shell completions. +var featureCatalog = []featureSpec{ + {ID: "rtc", Title: "Real-Time Communication"}, + {ID: "rtm", Title: "Real-Time Messaging"}, + {ID: "convoai", Title: "Conversational AI"}, +} + +// featureSpec describes one product feature the CLI knows about. +// Future fields (Beta, GA timestamp, MCP exposure flag, etc.) belong +// here so the catalog stays the single source of truth. +type featureSpec struct { + ID string + Title string +} + +// featureIDs returns the canonical list of feature IDs in the +// catalog's stable order. Callers must NOT mutate the returned slice; +// it is freshly allocated on every call so accidental aliasing is +// safe. +func featureIDs() []string { + out := make([]string, 0, len(featureCatalog)) + for _, f := range featureCatalog { + out = append(out, f.ID) + } + return out +} + +// isKnownFeature reports whether id is one of the catalog's known +// feature IDs. +func isKnownFeature(id string) bool { + id = strings.TrimSpace(id) + for _, f := range featureCatalog { + if f.ID == id { + return true + } + } + return false +} + +// featureListString is the human-readable representation of the +// catalog used in error messages (e.g. "rtc, rtm, convoai"). Keeping +// it derived from the catalog means error messages stay correct as +// new features are added. +func featureListString() string { + return strings.Join(featureIDs(), ", ") +} + +// validateFeatureID returns a stable error matching the historical +// `project doctor --feature` shape. The wording is intentionally +// preserved so existing scripted error parsers do not break. +func validateFeatureID(id string) error { + if isKnownFeature(id) { + return nil + } + return fmt.Errorf("%q must be one of: %s.", id, featureListString()) +} diff --git a/internal/cli/init.go b/internal/cli/init.go index 5af6b80..f637e44 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -15,8 +15,12 @@ import ( "github.com/spf13/cobra" ) +// defaultInitFeatures returns the features enabled on a freshly created +// Agora project when no `--feature` flags are passed. Sourced from the +// canonical feature catalog so adding a new feature in features.go +// flows here automatically. func defaultInitFeatures() []string { - return []string{"rtc", "rtm", "convoai"} + return featureIDs() } func initNextSteps(template quickstartTemplate, targetDir string) []string { @@ -105,7 +109,7 @@ Use --feature to specify which features to enable on a newly created project (re cmd.Flags().StringVar(&existingProject, "project", "", "existing project ID or exact project name to bind to") cmd.Flags().StringVar(®ion, "region", "", "control plane region for newly created projects (global or cn)") 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, "enable a feature on the newly created project (repeatable); defaults to rtc, rtm, and convoai; convoai also enables rtm") + 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(&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") return cmd diff --git a/internal/cli/introspect.go b/internal/cli/introspect.go index ad412f2..c6be818 100644 --- a/internal/cli/introspect.go +++ b/internal/cli/introspect.go @@ -77,7 +77,7 @@ func buildIntrospectionData(root *cobra.Command) map[string]any { "globalFlags": globalFlags, "pseudoCommands": buildPseudoCommands(), "enums": map[string][]string{ - "features": {"rtc", "rtm", "convoai"}, + "features": featureIDs(), "outputModes": {"pretty", "json"}, "doctorStatus": {"healthy", "warning", "not_ready", "auth_error"}, }, diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go new file mode 100644 index 0000000..3c53b2d --- /dev/null +++ b/internal/cli/mcp.go @@ -0,0 +1,499 @@ +package cli + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + "github.com/spf13/cobra" +) + +// JSON-RPC framing for the MCP stdio transport. We accept and emit one +// JSON object per line. The buffer is sized so a single large +// `tools/call` frame (e.g. an agent passing a long quickstart payload) +// does not trip bufio.Scanner's default 64 KiB line cap. +const ( + mcpScannerInitialBuffer = 64 * 1024 + mcpScannerMaxBuffer = 4 * 1024 * 1024 // 4 MiB + mcpProtocolVersion = "2024-11-05" +) + +type mcpRequest struct { + JSONRPC string `json:"jsonrpc"` + ID any `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +type mcpResponse struct { + JSONRPC string `json:"jsonrpc"` + ID any `json:"id,omitempty"` + Result any `json:"result,omitempty"` + Error *mcpRPCError `json:"error,omitempty"` +} + +type mcpRPCError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type mcpToolCall struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` +} + +func (a *App) buildMCPCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "mcp", + Short: "Run Agora CLI as a local MCP server", + Long: `Expose Agora CLI tools to MCP-capable agents over stdio. + +Use this when an MCP client (Cursor, Claude Code, Windsurf, custom) wants to drive Agora workflows directly. The full Agora command surface is exposed as MCP tools so agents can authenticate, discover, manage projects, scaffold quickstarts, and run readiness checks without shelling out. + +Notes for agents: +- Long-running tools (init, quickstart create, project create) emit no NDJSON progress over MCP. The result payload is returned as a single tool response. +- ` + "`agora.auth.login`" + ` is intentionally not exposed because OAuth requires an interactive browser. Run ` + "`agora login`" + ` once on the host before starting the MCP server. +- All tools return JSON-stringified payloads in the standard MCP ` + "`content[0].text`" + ` slot.`, + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + var transport string + serve := &cobra.Command{ + Use: "serve", + Short: "Serve Agora CLI tools over MCP", + Example: example(` + agora mcp serve + agora mcp serve --transport stdio +`), + RunE: func(cmd *cobra.Command, _ []string) error { + if transport != "stdio" { + return fmt.Errorf("unsupported MCP transport %q; use stdio", transport) + } + return a.serveMCP(cmd.InOrStdin(), cmd.OutOrStdout()) + }, + } + serve.Flags().StringVar(&transport, "transport", "stdio", "MCP transport: stdio") + cmd.AddCommand(serve) + return cmd +} + +func (a *App) serveMCP(in io.Reader, out io.Writer) error { + scanner := bufio.NewScanner(in) + // Default Scanner has a 64 KiB line cap. Larger payloads (a quickstart + // create with custom args, a doctor result echoed back to the client) + // can exceed that, so widen the cap up to 4 MiB. + scanner.Buffer(make([]byte, mcpScannerInitialBuffer), mcpScannerMaxBuffer) + encoder := json.NewEncoder(out) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var req mcpRequest + if err := json.Unmarshal([]byte(line), &req); err != nil { + _ = encoder.Encode(mcpResponse{JSONRPC: "2.0", Error: &mcpRPCError{Code: -32700, Message: err.Error()}}) + continue + } + // JSON-RPC 2.0: a frame without an ID is a notification and + // MUST NOT receive a response. We respect that for any method, + // not just notifications/*. + isNotification := req.ID == nil + result, err := a.handleMCPRequest(req) + if isNotification { + continue + } + resp := mcpResponse{JSONRPC: "2.0", ID: req.ID, Result: result} + if err != nil { + resp.Result = nil + resp.Error = &mcpRPCError{Code: -32000, Message: err.Error()} + } + if err := encoder.Encode(resp); err != nil { + return err + } + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("mcp transport read error: %w", err) + } + return nil +} + +func (a *App) handleMCPRequest(req mcpRequest) (any, error) { + switch req.Method { + case "initialize": + return map[string]any{ + "protocolVersion": mcpProtocolVersion, + "capabilities": map[string]any{ + "tools": map[string]any{}, + }, + "serverInfo": map[string]any{"name": "agora-cli", "version": version}, + }, nil + case "tools/list": + return map[string]any{"tools": mcpTools()}, nil + case "tools/call": + var call mcpToolCall + if err := json.Unmarshal(req.Params, &call); err != nil { + return nil, err + } + data, err := a.callMCPTool(call.Name, call.Arguments) + if err != nil { + return nil, err + } + raw, _ := json.MarshalIndent(data, "", " ") + return map[string]any{"content": []map[string]string{{"type": "text", "text": string(raw)}}}, nil + case "ping": + return map[string]any{}, nil + default: + return nil, fmt.Errorf("unsupported MCP method %q", req.Method) + } +} + +// mcpTools enumerates the full tool surface. New tools should be added +// here AND wired in callMCPTool below. Keep names dot-namespaced under +// agora.[.] to match the CLI command tree. +func mcpTools() []map[string]any { + return []map[string]any{ + // Discovery and metadata + mcpTool("agora.version", "Show CLI build information", nil), + mcpTool("agora.introspect", "Emit machine-readable command metadata for the entire CLI", nil), + + // Authentication + mcpTool("agora.auth.status", "Inspect local Agora authentication state", nil), + mcpTool("agora.auth.logout", "Clear the local Agora session", nil), + + // Configuration + mcpTool("agora.config.path", "Show the path to the persisted CLI config file", nil), + mcpTool("agora.config.get", "Read persisted CLI defaults", nil), + + // Telemetry + mcpTool("agora.telemetry.status", "Show telemetry status and DO_NOT_TRACK detection", nil), + + // Upgrade guidance + mcpTool("agora.upgrade.check", "Resolve the latest release and report what would happen", nil), + + // Projects + mcpTool("agora.project.list", "List Agora projects", map[string]string{"keyword": "string", "page": "number", "pageSize": "number"}), + mcpTool("agora.project.show", "Show one Agora project", map[string]string{"project": "string"}), + mcpTool("agora.project.use", "Select the current Agora project", map[string]string{"project": "string"}), + mcpTool("agora.project.create", "Create a new Agora project and optionally enable features", map[string]string{ + "name": "string", + "region": "string", + "template": "string", + "features": "array", + "rtmDataCenter": "string", + "idempotencyKey": "string", + }), + mcpTool("agora.project.doctor", "Run project readiness diagnostics", map[string]string{"project": "string", "feature": "string", "deep": "boolean"}), + mcpTool("agora.project.env", "Render project environment variable values", map[string]string{"project": "string", "withSecrets": "boolean"}), + mcpTool("agora.project.env_write", "Write project env values to a dotenv file in a workspace", map[string]string{"workspaceDir": "string", "project": "string", "template": "string"}), + + // Project features + mcpTool("agora.project.feature.list", "List feature status for a project", map[string]string{"project": "string"}), + mcpTool("agora.project.feature.status", "Show one feature status", map[string]string{"feature": "string", "project": "string"}), + mcpTool("agora.project.feature.enable", "Enable one feature for a project", map[string]string{"feature": "string", "project": "string"}), + + // Quickstart + mcpTool("agora.quickstart.list", "List quickstart templates", nil), + mcpTool("agora.quickstart.create", "Clone a quickstart and optionally bind a project", map[string]string{ + "name": "string", + "template": "string", + "project": "string", + "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"}), + + // Init: the recommended end-to-end flow + mcpTool("agora.init", "Create or bind a project, clone a quickstart, and write env in one call", map[string]string{ + "name": "string", + "template": "string", + "project": "string", + "newProject": "boolean", + "region": "string", + "rtmDataCenter": "string", + "features": "array", + }), + } +} + +// mcpTool builds an MCP tool descriptor with a JSON-Schema-ish input +// shape. Properties is a {key: type} map; pass nil for tools that take +// no arguments. +func mcpTool(name, description string, properties map[string]string) map[string]any { + schemaProps := map[string]any{} + for key, typ := range properties { + schemaProps[key] = map[string]any{"type": typ} + } + return map[string]any{ + "name": name, + "description": description, + "inputSchema": map[string]any{ + "type": "object", + "properties": schemaProps, + "additionalProperties": false, + }, + } +} + +// callMCPTool dispatches a tool name to the underlying App method. New +// tools must be added here in addition to mcpTools(); the switch +// returns an explicit error for unknown names so agents see a clear +// `unknown MCP tool` response. +// +// Important: every path here that calls into a long-running command +// (init, quickstart create) MUST pass non-os.Stdin / non-os.Stderr +// readers and writers so the MCP transport stream (which IS os.Stdin +// when serving over stdio) is never read as if it were user input. We +// pass bytes.NewReader(nil) and io.Discard explicitly to enforce the +// no-stdin contract at the call site. +func (a *App) callMCPTool(name string, args map[string]any) (any, error) { + switch name { + + case "agora.version": + return versionInfo(), nil + + case "agora.introspect": + return buildIntrospectionData(a.root), nil + + case "agora.auth.status": + return a.authStatus() + + case "agora.auth.logout": + return a.logout() + + case "agora.config.path": + path, err := resolveConfigFilePath(a.env) + if err != nil { + return nil, err + } + return map[string]any{"path": path}, nil + + case "agora.config.get": + return a.cfg, nil + + case "agora.telemetry.status": + path, err := resolveConfigFilePath(a.env) + if err != nil { + return nil, err + } + return map[string]any{ + "action": "status", + "configPath": path, + "doNotTrack": strings.TrimSpace(a.env["DO_NOT_TRACK"]) != "", + "enabled": a.cfg.TelemetryEnabled && strings.TrimSpace(a.env["DO_NOT_TRACK"]) == "", + }, nil + + case "agora.upgrade.check": + return a.performSelfUpdate(true) + + case "agora.project.list": + page := intArg(args, "page", 1) + pageSize := intArg(args, "pageSize", 20) + res, err := a.listProjects(stringArg(args, "keyword"), page, pageSize) + if err != nil { + return nil, err + } + return map[string]any{"items": res.Items, "page": res.Page, "pageSize": res.PageSize, "total": res.Total}, nil + + case "agora.project.show": + return a.projectShow(stringArg(args, "project")) + + case "agora.project.use": + project := stringArg(args, "project") + if project == "" { + return nil, errors.New("project is required") + } + return a.projectUse(project) + + case "agora.project.create": + name := stringArg(args, "name") + if name == "" { + return nil, &cliError{Message: "project name is required", Code: "PROJECT_NAME_REQUIRED"} + } + rtmDataCenter, err := normalizeRTMDataCenter(stringArg(args, "rtmDataCenter")) + if err != nil { + return nil, err + } + return a.projectCreate( + name, + stringArg(args, "region"), + stringArg(args, "template"), + stringSliceArg(args, "features"), + rtmDataCenter, + stringArg(args, "idempotencyKey"), + ) + + case "agora.project.doctor": + return a.projectDoctor(stringArg(args, "project"), defaultString(stringArg(args, "feature"), "convoai"), boolArg(args, "deep", false)), nil + + case "agora.project.env": + 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")) + + case "agora.project.feature.list": + target, err := a.resolveProjectTarget(stringArg(args, "project")) + if err != nil { + return nil, err + } + items, err := a.listProjectFeatures(target.project, target.region) + if err != nil { + return nil, err + } + return map[string]any{ + "action": "feature-list", + "items": items, + "projectId": target.project.ProjectID, + "projectName": target.project.Name, + }, nil + + case "agora.project.feature.status": + feature := stringArg(args, "feature") + if feature == "" { + return nil, errors.New("feature is required") + } + if err := validateDoctorFeature(feature); err != nil { + return nil, err + } + return a.projectFeatureStatus(feature, stringArg(args, "project")) + + case "agora.project.feature.enable": + feature := stringArg(args, "feature") + if feature == "" { + return nil, errors.New("feature is required") + } + if err := validateDoctorFeature(feature); err != nil { + return nil, err + } + return a.projectFeatureEnable(feature, stringArg(args, "project")) + + case "agora.quickstart.list": + items := []map[string]any{} + for _, template := range quickstartTemplates() { + 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}) + } + 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"} + } + 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"), stringArg(args, "ref"), nil) + + case "agora.quickstart.env_write": + return a.quickstartEnvWrite(defaultString(stringArg(args, "dir"), "."), stringArg(args, "template"), stringArg(args, "project")) + + case "agora.init": + name := stringArg(args, "name") + if name == "" { + return nil, &cliError{Message: "directory name is required", Code: "INIT_NAME_REQUIRED"} + } + template, ok := findQuickstartTemplate(stringArg(args, "template")) + if !ok { + return nil, &cliError{Message: "unknown quickstart template. Run `agora quickstart list`.", Code: "QUICKSTART_TEMPLATE_UNKNOWN"} + } + // 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 + // future change to initProject can NEVER consume MCP frames or + // scribble onto the host's stderr. + var promptOut bytes.Buffer + return a.initProject( + name, + defaultString(stringArg(args, "dir"), name), + *template, + stringArg(args, "project"), + stringArg(args, "region"), + stringSliceArg(args, "features"), + stringArg(args, "rtmDataCenter"), + boolArg(args, "newProject", false), + false, + &promptOut, + bytes.NewReader(nil), + nil, + ) + + default: + return nil, fmt.Errorf("unknown MCP tool %q", name) + } +} + +func stringArg(args map[string]any, key string) string { + if value, ok := args[key].(string); ok { + return strings.TrimSpace(value) + } + return "" +} + +func intArg(args map[string]any, key string, fallback int) int { + switch value := args[key].(type) { + case float64: + return int(value) + case int: + return value + default: + return fallback + } +} + +func boolArg(args map[string]any, key string, fallback bool) bool { + if value, ok := args[key].(bool); ok { + return value + } + return fallback +} + +// stringSliceArg coerces an "array of string" MCP argument into a Go +// slice. Accepts either a real []any payload (the JSON-RPC default) or +// a single comma-separated string for shells that flatten arrays. +func stringSliceArg(args map[string]any, key string) []string { + value, ok := args[key] + if !ok || value == nil { + return nil + } + switch v := value.(type) { + case []any: + out := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok && strings.TrimSpace(s) != "" { + out = append(out, strings.TrimSpace(s)) + } + } + return out + case []string: + return v + case string: + if strings.TrimSpace(v) == "" { + return nil + } + parts := strings.Split(v, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if t := strings.TrimSpace(p); t != "" { + out = append(out, t) + } + } + return out + } + return nil +} + +func defaultString(value, fallback string) string { + if strings.TrimSpace(value) != "" { + return value + } + return fallback +} diff --git a/internal/cli/mcp_test.go b/internal/cli/mcp_test.go new file mode 100644 index 0000000..1f07ecf --- /dev/null +++ b/internal/cli/mcp_test.go @@ -0,0 +1,215 @@ +package cli + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +func newTestApp(t *testing.T) *App { + t.Helper() + t.Setenv("AGORA_HOME", t.TempDir()) + a, err := NewApp() + if err != nil { + t.Fatalf("NewApp: %v", err) + } + return a +} + +// TestServeMCPHandlesLargeFramesAboveDefaultBuffer guards the regression +// where bufio.Scanner's 64 KiB default cap would silently terminate the +// MCP loop on large `tools/call` payloads. We feed a frame whose tool +// arguments exceed 256 KiB and assert the server still emits a single +// JSON-RPC response with `id` echoed back. +func TestServeMCPHandlesLargeFramesAboveDefaultBuffer(t *testing.T) { + a := newTestApp(t) + bigKeyword := strings.Repeat("a", 256*1024) + req := map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": map[string]any{ + "name": "agora.version", + "arguments": map[string]any{"keyword": bigKeyword}, + }, + } + frame, _ := json.Marshal(req) + in := bytes.NewReader(append(frame, '\n')) + var out bytes.Buffer + if err := a.serveMCP(in, &out); err != nil { + t.Fatalf("serveMCP: %v", err) + } + if out.Len() == 0 { + t.Fatalf("expected a response, got nothing") + } + var resp mcpResponse + if err := json.Unmarshal(out.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response: %v\nbody: %q", err, out.String()) + } + if resp.Error != nil { + t.Fatalf("expected success, got error: %+v", resp.Error) + } + idFloat, ok := resp.ID.(float64) + if !ok || int(idFloat) != 1 { + t.Fatalf("expected id=1, got %v (%T)", resp.ID, resp.ID) + } +} + +// TestServeMCPNotificationsReturnNoResponse covers the JSON-RPC 2.0 +// rule: any frame without an id is a notification and MUST NOT receive +// a response. +func TestServeMCPNotificationsReturnNoResponse(t *testing.T) { + a := newTestApp(t) + frame := []byte(`{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}` + "\n") + var out bytes.Buffer + if err := a.serveMCP(bytes.NewReader(frame), &out); err != nil { + t.Fatalf("serveMCP: %v", err) + } + if out.Len() != 0 { + t.Fatalf("expected no response for notification, got: %q", out.String()) + } +} + +// TestServeMCPInitializeAdvertisesProtocolVersion confirms the +// initialize handshake emits the documented protocol version and a +// stable serverInfo object. +func TestServeMCPInitializeAdvertisesProtocolVersion(t *testing.T) { + a := newTestApp(t) + frame := []byte(`{"jsonrpc":"2.0","id":42,"method":"initialize","params":{}}` + "\n") + var out bytes.Buffer + if err := a.serveMCP(bytes.NewReader(frame), &out); err != nil { + t.Fatalf("serveMCP: %v", err) + } + var resp mcpResponse + if err := json.Unmarshal(out.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v\nbody: %q", err, out.String()) + } + if resp.Error != nil { + t.Fatalf("unexpected error: %+v", resp.Error) + } + result, ok := resp.Result.(map[string]any) + if !ok { + t.Fatalf("expected map result, got %T", resp.Result) + } + if result["protocolVersion"] != mcpProtocolVersion { + t.Fatalf("protocolVersion = %v, want %v", result["protocolVersion"], mcpProtocolVersion) + } + if info, ok := result["serverInfo"].(map[string]any); !ok || info["name"] != "agora-cli" { + t.Fatalf("expected serverInfo.name=agora-cli, got %+v", result["serverInfo"]) + } +} + +// TestMCPToolsListCoversFullSurface guards the contract that the MCP +// surface enumerates every supported tool (so the schema matches the +// CLI command tree). When new commands are added to mcpTools(), update +// this expected list. +func TestMCPToolsListCoversFullSurface(t *testing.T) { + expected := []string{ + "agora.auth.logout", + "agora.auth.status", + "agora.config.get", + "agora.config.path", + "agora.init", + "agora.introspect", + "agora.project.create", + "agora.project.doctor", + "agora.project.env", + "agora.project.env_write", + "agora.project.feature.enable", + "agora.project.feature.list", + "agora.project.feature.status", + "agora.project.list", + "agora.project.show", + "agora.project.use", + "agora.quickstart.create", + "agora.quickstart.env_write", + "agora.quickstart.list", + "agora.telemetry.status", + "agora.upgrade.check", + "agora.version", + } + got := map[string]bool{} + for _, tool := range mcpTools() { + name, _ := tool["name"].(string) + got[name] = true + } + for _, name := range expected { + if !got[name] { + t.Errorf("missing MCP tool: %q", name) + } + } + if len(got) != len(expected) { + extra := []string{} + for name := range got { + found := false + for _, e := range expected { + if name == e { + found = true + break + } + } + if !found { + extra = append(extra, name) + } + } + if len(extra) > 0 { + t.Errorf("unexpected MCP tools (update test or remove): %v", extra) + } + } +} + +// TestMCPVersionToolReturnsBuildInfo runs end-to-end through serveMCP +// for a no-arg, no-network tool to verify the request/response loop +// works, including the content[0].text envelope. +func TestMCPVersionToolReturnsBuildInfo(t *testing.T) { + a := newTestApp(t) + frame := []byte(`{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"agora.version","arguments":{}}}` + "\n") + var out bytes.Buffer + if err := a.serveMCP(bytes.NewReader(frame), &out); err != nil { + t.Fatalf("serveMCP: %v", err) + } + var resp mcpResponse + if err := json.Unmarshal(out.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v\nbody: %q", err, out.String()) + } + if resp.Error != nil { + t.Fatalf("unexpected error: %+v", resp.Error) + } + result := resp.Result.(map[string]any) + contentArr := result["content"].([]any) + first := contentArr[0].(map[string]any) + if !strings.Contains(first["text"].(string), `"version"`) { + t.Fatalf("expected version payload, got: %v", first["text"]) + } +} + +// TestStringSliceArgShapes verifies the MCP slice coercion handles the +// three real-world payload shapes: native JSON array, comma-string, +// and missing key. +func TestStringSliceArgShapes(t *testing.T) { + tests := []struct { + name string + args map[string]any + want []string + }{ + {name: "json array", args: map[string]any{"features": []any{"rtc", "rtm"}}, want: []string{"rtc", "rtm"}}, + {name: "comma string", args: map[string]any{"features": "rtc, rtm , convoai"}, want: []string{"rtc", "rtm", "convoai"}}, + {name: "missing", args: map[string]any{}, want: nil}, + {name: "empty string", args: map[string]any{"features": ""}, want: nil}, + {name: "nil value", args: map[string]any{"features": nil}, want: nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := stringSliceArg(tt.args, "features") + if len(got) != len(tt.want) { + t.Fatalf("got %v, want %v", got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Fatalf("[%d] got %q, want %q", i, got[i], tt.want[i]) + } + } + }) + } +} diff --git a/internal/cli/projects.go b/internal/cli/projects.go index b57489e..982d851 100644 --- a/internal/cli/projects.go +++ b/internal/cli/projects.go @@ -225,13 +225,14 @@ func (a *App) getFeatureItem(feature string, project projectDetail, region strin } return featureItem{Feature: "convoai", Message: "convoai not enabled", Status: "disabled"}, nil default: - return featureItem{}, fmt.Errorf("%q must be one of: rtc, rtm, convoai.", feature) + return featureItem{}, validateFeatureID(feature) } } func (a *App) listProjectFeatures(project projectDetail, region string) ([]featureItem, error) { - items := make([]featureItem, 0, 3) - for _, feature := range []string{"rtc", "rtm", "convoai"} { + ids := featureIDs() + items := make([]featureItem, 0, len(ids)) + for _, feature := range ids { item, err := a.getFeatureItem(feature, project, region) if err != nil { return nil, err @@ -268,7 +269,7 @@ func (a *App) enableProjectFeature(feature string, project projectDetail, region } return map[string]any{"action": "feature-enable", "feature": "convoai", "message": "convoai enabled", "projectId": project.ProjectID, "projectName": project.Name, "status": "enabled"}, nil default: - return nil, fmt.Errorf("%q must be one of: rtc, rtm, convoai.", feature) + return nil, validateFeatureID(feature) } } @@ -357,7 +358,7 @@ func normalizeProjectCreateFeatures(features []string) []string { func projectCreateFeatures(template string, features []string) []string { next := append([]string{}, features...) if template == "voice-agent" { - next = append(next, "rtc", "rtm", "convoai") + next = append(next, featureIDs()...) } next = normalizeProjectCreateFeatures(next) if featureListIncludes(next, "convoai") && !featureListIncludes(next, "rtm") { @@ -703,7 +704,7 @@ func nextConfigPresent(dir string) bool { func enabledFeatures(features map[string]bool) []string { out := []string{} - for _, name := range []string{"rtc", "rtm", "convoai"} { + for _, name := range featureIDs() { if features[name] { out = append(out, name) } From 330059a00e1575edb5f4892dd4e8343e999140cb Mon Sep 17 00:00:00 2001 From: digitallysavvy Date: Thu, 30 Apr 2026 21:01:30 -0400 Subject: [PATCH 08/26] feat(cli): cache project lists, harden completion, and enrich agora open Add AGORA_HOME/cache projects list with TTL and tests; serve completion from cache when the session is valid, clear cache on logout, and prune stale cache on startup. Add project list --refresh-cache and cacheRefreshed in JSON. Refactor agora open through open_targets (docs-md, env URL overrides). Derive feature flag help text from featureListString() and add catalog tests. --- internal/cli/app.go | 7 + internal/cli/app_test.go | 10 +- internal/cli/auth.go | 28 +++ internal/cli/cache.go | 253 +++++++++++++++++++++++++++ internal/cli/cache_test.go | 272 ++++++++++++++++++++++++++++++ internal/cli/commands.go | 33 ++-- internal/cli/completion.go | 65 ++++++- internal/cli/features_test.go | 51 ++++++ internal/cli/open_targets.go | 106 ++++++++++++ internal/cli/open_targets_test.go | 138 +++++++++++++++ internal/cli/projects.go | 17 ++ 11 files changed, 955 insertions(+), 25 deletions(-) create mode 100644 internal/cli/cache.go create mode 100644 internal/cli/cache_test.go create mode 100644 internal/cli/features_test.go create mode 100644 internal/cli/open_targets.go create mode 100644 internal/cli/open_targets_test.go diff --git a/internal/cli/app.go b/internal/cli/app.go index 176be2f..0b4620c 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -194,6 +194,13 @@ func (a *App) Execute() error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() a.root.SetContext(ctx) + // Best-effort cache hygiene on every startup. Anything older than + // projectListCacheMaxAge (24h) is removed so we never accumulate + // unbounded data under /cache and so a stale cache + // from a prior auth session can never silently shape today's CLI + // output. Errors are intentionally ignored: cache cleanup must + // never block a user's command. + _ = pruneStaleCaches(a.env) rawOutput := readRawFlagValue(os.Args[1:], "--output") if rawOutput != "json" && rawOutput != "pretty" { rawOutput = "" diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index cce384a..7c0e61c 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -33,7 +33,7 @@ func TestDetectInstallProvenanceUsesReceiptThenExecutablePath(t *testing.T) { Tool: "agora", InstallMethod: "installer", InstallPath: installerPath, - Version: "0.1.10", + Version: "0.2.0", InstalledAt: "2026-04-30T11:00:00Z", Source: "install.sh", } @@ -67,7 +67,7 @@ func TestDetectInstallProvenanceFallsBackToExecutablePath(t *testing.T) { { name: "homebrew detected from resolved Cellar path", env: map[string]string{"HOMEBREW_PREFIX": "/usr/local"}, - exePath: "/usr/local/Cellar/agora-cli/0.1.10/bin/agora", + exePath: "/usr/local/Cellar/agora-cli/0.2.0/bin/agora", wantMethod: "homebrew", }, { @@ -107,7 +107,7 @@ func TestDetectInstallProvenanceIgnoresStaleReceipt(t *testing.T) { Tool: "agora", InstallMethod: "npm", InstallPath: filepath.Join(dir, "old-agora"), - Version: "0.1.10", + Version: "0.2.0", InstalledAt: "2026-04-30T11:00:00Z", Source: "test", } @@ -127,7 +127,7 @@ func TestDetectInstallProvenanceIgnoresStaleReceipt(t *testing.T) { func TestWriteInstallReceiptRoundTrips(t *testing.T) { exePath := filepath.Join(t.TempDir(), "agora") - receiptPath, err := writeInstallReceipt(exePath, "v0.1.10", "agora upgrade") + receiptPath, err := writeInstallReceipt(exePath, "v0.2.0", "agora upgrade") if err != nil { t.Fatal(err) } @@ -138,7 +138,7 @@ func TestWriteInstallReceiptRoundTrips(t *testing.T) { if !receipt.validForPath(exePath) { t.Fatalf("expected valid receipt for %s: %+v", exePath, receipt) } - if receipt.Version != "0.1.10" || receipt.Source != "agora upgrade" { + if receipt.Version != "0.2.0" || receipt.Source != "agora upgrade" { t.Fatalf("unexpected receipt contents: %+v", receipt) } } diff --git a/internal/cli/auth.go b/internal/cli/auth.go index c7369a0..d8ec006 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -101,6 +101,10 @@ func (a *App) logout() (map[string]any, error) { if err := clearContext(a.env); err != nil { return nil, err } + // The on-disk completion cache assumes an active session; once the + // user is logged out it would be misleading to keep serving cached + // project names from a previous identity. + _ = clearProjectListCache(a.env) return map[string]any{"action": "logout", "clearedSession": cleared, "status": "logged-out"}, nil } @@ -379,6 +383,30 @@ func noLocalSessionError() error { return &cliError{Message: noLocalSessionErrorMessage, Code: "AUTH_UNAUTHENTICATED"} } +// hasPersistedNonEmptySession reports whether session.json exists, parses, +// and contains a non-empty access token. Shell completion consults this +// before serving the on-disk project list cache so we never show +// API-derived project names when the user has no local session (logout +// already clears the cache; this also covers stray cache files without a +// matching session). +func hasPersistedNonEmptySession(env map[string]string) bool { + s, err := loadSession(env) + if err != nil || s == nil { + return false + } + if strings.TrimSpace(s.AccessToken) == "" { + return false + } + if strings.TrimSpace(s.ExpiresAt) == "" { + return true + } + expiresAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(s.ExpiresAt)) + if err != nil { + return false + } + return time.Now().Before(expiresAt) +} + func currentOutputModeFromArgs(env map[string]string) outputMode { mode := resolveConfiguredOutputMode("", env) if output := readRawFlagValue(os.Args[1:], "--output"); output == "json" || output == "pretty" { diff --git a/internal/cli/cache.go b/internal/cli/cache.go new file mode 100644 index 0000000..c66a517 --- /dev/null +++ b/internal/cli/cache.go @@ -0,0 +1,253 @@ +package cli + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +// Cache layer for read-only API responses that are safe to serve from +// disk for a short window. Today this powers shell tab-completion of +// project names so `agora project use ` does not hit the API on +// every keystroke. The same machinery is reusable for any "list-y" +// API response we want to amortize. +// +// Storage layout (under the resolved Agora directory, honoring +// AGORA_HOME / XDG_CONFIG_HOME / ~/.agora-cli on macOS): +// +// / +// cache/ +// projects.json — see projectListCachePayload below +// +// Files are written with 0o600 because they may include project IDs +// the user considers private. The directory is 0o700 to match the +// rest of the Agora config tree. + +const ( + // cacheDirName is the on-disk subdirectory under the Agora + // config directory that holds all transient API caches. + cacheDirName = "cache" + + // projectListCacheFile is the projects-list cache filename. + projectListCacheFile = "projects.json" + + // projectListCacheTTL is the longest age a cache file may be + // served from. After this window the cache is considered stale + // and ignored (and proactively removed on the next startup + // sweep). + projectListCacheTTL = 5 * time.Minute + + // projectListCacheMaxAge is the cutoff used by + // pruneStaleCaches: anything older than this is removed + // regardless of whether it is being read. + projectListCacheMaxAge = 24 * time.Hour + + // projectListCacheSchemaVersion is bumped whenever the on-disk + // shape changes incompatibly. Older versions are ignored + // (treated as cache-miss) instead of crashing the CLI. + projectListCacheSchemaVersion = 1 +) + +// projectListCachePayload is the persisted shape of the projects-list +// cache. The TTLSeconds field is recorded alongside FetchedAt so a +// future TTL change does not silently invalidate every existing cache; +// readers honor whichever TTL is shorter (the file's recorded TTL or +// the current process's TTL constant) so we are always conservative. +type projectListCachePayload struct { + SchemaVersion int `json:"schemaVersion"` + FetchedAt string `json:"fetchedAt"` + TTLSeconds int `json:"ttlSeconds"` + Items []projectSummary `json:"items"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int `json:"total"` +} + +// resolveCacheDir is the on-disk root for all Agora CLI read caches. +// It always lives under the resolved Agora config directory so it +// follows the same isolation contract: in CI / multi-agent runs, +// setting AGORA_HOME to a tmpdir transparently isolates the cache too. +func resolveCacheDir(env map[string]string) (string, error) { + dir, err := resolveAgoraDirectory(env) + if err != nil { + return "", err + } + return filepath.Join(dir, cacheDirName), nil +} + +func resolveProjectListCachePath(env map[string]string) (string, error) { + dir, err := resolveCacheDir(env) + if err != nil { + return "", err + } + return filepath.Join(dir, projectListCacheFile), nil +} + +// saveProjectListCache writes a fresh cache entry. Errors are +// non-fatal to the caller: a failure to cache is observability noise, +// not a user-visible error. The function therefore returns an error +// for tests but every production caller is expected to ignore it. +func saveProjectListCache(env map[string]string, list projectListResponse) error { + path, err := resolveProjectListCachePath(env) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + payload := projectListCachePayload{ + SchemaVersion: projectListCacheSchemaVersion, + FetchedAt: time.Now().UTC().Format(time.RFC3339Nano), + TTLSeconds: int(projectListCacheTTL.Seconds()), + Items: list.Items, + Page: list.Page, + PageSize: list.PageSize, + Total: list.Total, + } + data, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o600) +} + +// loadProjectListCache reads the persisted cache. Returns: +// +// (payload, true, nil) — cache is present, schema-current, and fresh +// (zero, false, nil) — cache is missing, expired, or wrong schema +// (zero, false, err) — disk I/O error or parse error +// +// "Fresh" honors whichever TTL is shorter (the file's recorded TTL +// when it was written, or this process's compiled-in TTL). This means +// shortening the TTL in code immediately tightens what counts as +// fresh, while lengthening it does not retroactively extend a file +// the user wrote under a tighter contract. +func loadProjectListCache(env map[string]string) (projectListCachePayload, bool, error) { + path, err := resolveProjectListCachePath(env) + if err != nil { + return projectListCachePayload{}, false, err + } + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return projectListCachePayload{}, false, nil + } + if err != nil { + return projectListCachePayload{}, false, err + } + var payload projectListCachePayload + if err := json.Unmarshal(data, &payload); err != nil { + return projectListCachePayload{}, false, err + } + if payload.SchemaVersion != projectListCacheSchemaVersion { + return projectListCachePayload{}, false, nil + } + fetchedAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(payload.FetchedAt)) + if err != nil { + return projectListCachePayload{}, false, nil + } + effectiveTTL := projectListCacheTTL + if payload.TTLSeconds > 0 { + recorded := time.Duration(payload.TTLSeconds) * time.Second + if recorded < effectiveTTL { + effectiveTTL = recorded + } + } + if time.Since(fetchedAt) > effectiveTTL { + return projectListCachePayload{}, false, nil + } + return payload, true, nil +} + +// clearProjectListCache removes the persisted cache. Called when the +// backing list is known to be stale or the user identity changed: +// `agora logout` (no session should imply no cached API snapshot) and +// `agora project create` (a new project exists server-side but is not +// yet in the cached first page). Also cleared when `AGORA_DISABLE_CACHE=1` +// runs the startup prune path. +func clearProjectListCache(env map[string]string) error { + path, err := resolveProjectListCachePath(env) + if err != nil { + return err + } + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} + +// pruneStaleCaches is the "flush on startup" sweep called from +// App.Execute. Any cache file whose age exceeds projectListCacheMaxAge +// (24 h by default) is removed so we never accumulate unbounded +// disk under the Agora config tree, and so a stale cache from an +// earlier auth session can never silently shape today's CLI output. +// +// This runs at most once per process, never blocks, and is best-effort +// silent: any I/O error is swallowed because cache hygiene is not a +// reason to fail a user's command. Errors are still surfaced to tests +// via the return value. +func pruneStaleCaches(env map[string]string) error { + if isTruthy(env["AGORA_DISABLE_CACHE"]) { + return clearProjectListCache(env) + } + dir, err := resolveCacheDir(env) + if err != nil { + return err + } + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + cutoff := time.Now().Add(-projectListCacheMaxAge) + var firstErr error + for _, entry := range entries { + if entry.IsDir() { + continue + } + info, err := entry.Info() + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + if info.ModTime().Before(cutoff) { + if err := os.Remove(filepath.Join(dir, entry.Name())); err != nil && firstErr == nil { + firstErr = err + } + } + } + return firstErr +} + +// isTruthy parses the standard truthy strings the rest of the CLI +// accepts ("1", "true", "yes", "y", case-insensitive). +func isTruthy(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "1", "true", "yes", "y": + return true + default: + return false + } +} + +// cacheTTLFromEnv lets tests and power users override the read-cache +// TTL via AGORA_PROJECT_CACHE_TTL_SECONDS without recompiling. A +// missing or invalid value falls back to the package-level default. +func cacheTTLFromEnv(env map[string]string) time.Duration { + raw := strings.TrimSpace(env["AGORA_PROJECT_CACHE_TTL_SECONDS"]) + if raw == "" { + return projectListCacheTTL + } + seconds, err := strconv.Atoi(raw) + if err != nil || seconds < 0 { + return projectListCacheTTL + } + return time.Duration(seconds) * time.Second +} diff --git a/internal/cli/cache_test.go b/internal/cli/cache_test.go new file mode 100644 index 0000000..5f30c6b --- /dev/null +++ b/internal/cli/cache_test.go @@ -0,0 +1,272 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +func newTestEnv(t *testing.T) map[string]string { + t.Helper() + dir := t.TempDir() + return map[string]string{"AGORA_HOME": dir} +} + +func TestSaveAndLoadProjectListCacheRoundTrip(t *testing.T) { + env := newTestEnv(t) + list := projectListResponse{ + Items: []projectSummary{ + {ProjectID: "prj_aaa", Name: "Demo One"}, + {ProjectID: "prj_bbb", Name: "Demo Two"}, + }, + Page: 1, + PageSize: 100, + Total: 2, + } + if err := saveProjectListCache(env, list); err != nil { + t.Fatalf("saveProjectListCache: %v", err) + } + payload, fresh, err := loadProjectListCache(env) + if err != nil { + t.Fatalf("loadProjectListCache: %v", err) + } + if !fresh { + t.Fatal("expected fresh=true immediately after write") + } + if payload.SchemaVersion != projectListCacheSchemaVersion { + t.Errorf("schema version = %d, want %d", payload.SchemaVersion, projectListCacheSchemaVersion) + } + if len(payload.Items) != 2 || payload.Items[0].ProjectID != "prj_aaa" { + t.Errorf("unexpected items: %+v", payload.Items) + } + if payload.TTLSeconds <= 0 { + t.Errorf("expected positive TTLSeconds, got %d", payload.TTLSeconds) + } +} + +func TestLoadProjectListCacheTreatsMissingFileAsCacheMiss(t *testing.T) { + env := newTestEnv(t) + _, fresh, err := loadProjectListCache(env) + if err != nil { + t.Fatalf("missing cache should be a cache-miss not an error, got %v", err) + } + if fresh { + t.Fatal("expected fresh=false for missing cache") + } +} + +func TestLoadProjectListCacheStaleByFileAge(t *testing.T) { + env := newTestEnv(t) + if err := saveProjectListCache(env, projectListResponse{Items: []projectSummary{{ProjectID: "prj_old"}}}); err != nil { + t.Fatal(err) + } + path, _ := resolveProjectListCachePath(env) + data, _ := os.ReadFile(path) + var payload projectListCachePayload + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatal(err) + } + payload.FetchedAt = time.Now().Add(-1 * time.Hour).UTC().Format(time.RFC3339Nano) + out, _ := json.MarshalIndent(payload, "", " ") + if err := os.WriteFile(path, out, 0o600); err != nil { + t.Fatal(err) + } + _, fresh, err := loadProjectListCache(env) + if err != nil { + t.Fatalf("expected nil error for stale cache, got %v", err) + } + if fresh { + t.Fatal("stale cache must be reported as fresh=false") + } +} + +func TestLoadProjectListCacheRejectsUnknownSchemaVersion(t *testing.T) { + env := newTestEnv(t) + path, _ := resolveProjectListCachePath(env) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + bogus := []byte(`{"schemaVersion": 9999, "fetchedAt": "` + time.Now().UTC().Format(time.RFC3339Nano) + `", "items": []}`) + if err := os.WriteFile(path, bogus, 0o600); err != nil { + t.Fatal(err) + } + _, fresh, err := loadProjectListCache(env) + if err != nil { + t.Fatalf("future schema must be a soft cache-miss, got %v", err) + } + if fresh { + t.Fatal("future schema must be reported as fresh=false") + } +} + +func TestClearProjectListCacheIsIdempotent(t *testing.T) { + env := newTestEnv(t) + if err := saveProjectListCache(env, projectListResponse{}); err != nil { + t.Fatal(err) + } + if err := clearProjectListCache(env); err != nil { + t.Fatalf("first clear: %v", err) + } + if err := clearProjectListCache(env); err != nil { + t.Fatalf("second clear should be a no-op, got %v", err) + } + _, fresh, _ := loadProjectListCache(env) + if fresh { + t.Fatal("cache should be missing after clear") + } +} + +func TestPruneStaleCachesRemovesFilesOlderThanMaxAge(t *testing.T) { + env := newTestEnv(t) + dir, _ := resolveCacheDir(env) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + freshPath := filepath.Join(dir, "fresh.json") + stalePath := filepath.Join(dir, "stale.json") + if err := os.WriteFile(freshPath, []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(stalePath, []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + old := time.Now().Add(-2 * projectListCacheMaxAge) + if err := os.Chtimes(stalePath, old, old); err != nil { + t.Fatal(err) + } + if err := pruneStaleCaches(env); err != nil { + t.Fatalf("pruneStaleCaches: %v", err) + } + if _, err := os.Stat(freshPath); err != nil { + t.Fatal("fresh file was incorrectly pruned") + } + if _, err := os.Stat(stalePath); !os.IsNotExist(err) { + t.Fatal("stale file was not pruned") + } +} + +func TestPruneStaleCachesIsNoOpWithoutCacheDir(t *testing.T) { + env := newTestEnv(t) + if err := pruneStaleCaches(env); err != nil { + t.Fatalf("pruneStaleCaches with no cache dir should be a no-op, got %v", err) + } +} + +func TestAGORADisableCacheClearsTheCacheOnStartup(t *testing.T) { + env := newTestEnv(t) + if err := saveProjectListCache(env, projectListResponse{Items: []projectSummary{{ProjectID: "prj_x"}}}); err != nil { + t.Fatal(err) + } + env["AGORA_DISABLE_CACHE"] = "1" + if err := pruneStaleCaches(env); err != nil { + t.Fatalf("pruneStaleCaches: %v", err) + } + _, fresh, _ := loadProjectListCache(env) + if fresh { + t.Fatal("AGORA_DISABLE_CACHE should drop the cache on startup") + } +} + +func TestCacheTTLFromEnvHonorsOverride(t *testing.T) { + if got := cacheTTLFromEnv(map[string]string{}); got != projectListCacheTTL { + t.Errorf("default TTL = %v, want %v", got, projectListCacheTTL) + } + if got := cacheTTLFromEnv(map[string]string{"AGORA_PROJECT_CACHE_TTL_SECONDS": "0"}); got != 0 { + t.Errorf("override 0s = %v, want 0", got) + } + if got := cacheTTLFromEnv(map[string]string{"AGORA_PROJECT_CACHE_TTL_SECONDS": "120"}); got != 2*time.Minute { + t.Errorf("override 120s = %v, want 2m", got) + } + if got := cacheTTLFromEnv(map[string]string{"AGORA_PROJECT_CACHE_TTL_SECONDS": "garbage"}); got != projectListCacheTTL { + t.Errorf("invalid override should fall back, got %v", got) + } +} + +func TestHasPersistedNonEmptySessionRequiresToken(t *testing.T) { + env := newTestEnv(t) + if hasPersistedNonEmptySession(env) { + t.Fatal("expected false with no session file") + } + if err := saveSession(env, session{AccessToken: "t"}); err != nil { + t.Fatal(err) + } + if !hasPersistedNonEmptySession(env) { + t.Fatal("expected true with non-empty access token") + } +} + +func TestHasPersistedNonEmptySessionRejectsExpiredSession(t *testing.T) { + env := newTestEnv(t) + expired := time.Now().Add(-time.Minute).UTC().Format(time.RFC3339Nano) + if err := saveSession(env, session{AccessToken: "t", ExpiresAt: expired}); err != nil { + t.Fatal(err) + } + if hasPersistedNonEmptySession(env) { + t.Fatal("expected false with expired session") + } + future := time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano) + if err := saveSession(env, session{AccessToken: "t", ExpiresAt: future}); err != nil { + t.Fatal(err) + } + if !hasPersistedNonEmptySession(env) { + t.Fatal("expected true with unexpired session") + } +} + +func TestCompletionUsesCacheBeforeNetwork(t *testing.T) { + env := newTestEnv(t) + if err := saveSession(env, session{AccessToken: "cached-session-token", TokenType: "Bearer"}); err != nil { + t.Fatal(err) + } + list := projectListResponse{ + Items: []projectSummary{ + {ProjectID: "prj_alpha", Name: "Alpha App"}, + {ProjectID: "prj_beta", Name: "Beta App"}, + }, + } + if err := saveProjectListCache(env, list); err != nil { + t.Fatal(err) + } + app := &App{env: env} + items, ok := app.completionProjectsFromCache() + if !ok { + t.Fatal("expected cache hit") + } + if len(items) != 2 || items[0].Name != "Alpha App" { + t.Fatalf("unexpected cached items: %+v", items) + } + results := filterProjectCompletions(items, "alp") + if len(results) == 0 { + t.Fatal("filter should have matched 'alp' against Alpha App") + } +} + +func TestCompletionCacheIgnoredWithoutLocalSession(t *testing.T) { + env := newTestEnv(t) + if err := saveProjectListCache(env, projectListResponse{ + Items: []projectSummary{{ProjectID: "prj_only_in_cache", Name: "Ghost"}}, + }); err != nil { + t.Fatal(err) + } + app := &App{env: env} + if _, ok := app.completionProjectsFromCache(); ok { + t.Fatal("expected no cache hit without a local session") + } +} + +func TestCompletionCacheRespectsAGORADisableCache(t *testing.T) { + env := newTestEnv(t) + if err := saveSession(env, session{AccessToken: "x", TokenType: "Bearer"}); err != nil { + t.Fatal(err) + } + if err := saveProjectListCache(env, projectListResponse{Items: []projectSummary{{ProjectID: "prj_x"}}}); err != nil { + t.Fatal(err) + } + env["AGORA_PROJECT_CACHE_TTL_SECONDS"] = "0" + app := &App{env: env} + if _, ok := app.completionProjectsFromCache(); ok { + t.Fatal("TTL=0 must disable the completion cache") + } +} diff --git a/internal/cli/commands.go b/internal/cli/commands.go index fa8927f..e9379e4 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -257,18 +257,13 @@ func (a *App) buildOpenCommand() *cobra.Command { Example: example(` agora open --target console agora open --target docs + agora open --target docs-md agora open --target product-docs `), RunE: func(cmd *cobra.Command, _ []string) error { - url := "https://console.agora.io" - switch target { - case "docs": - url = "https://agoraio.github.io/cli/" - case "product-docs": - url = "https://docs.agora.io" - case "console": - default: - return fmt.Errorf("unknown open target %q. Use console, docs, or product-docs.", target) + url, err := resolveOpenTarget(target, a.osEnv) + if err != nil { + return err } status := "printed" if !noBrowser && a.resolveOutputMode(cmd) != outputJSON && openBrowser(url) { @@ -277,7 +272,7 @@ func (a *App) buildOpenCommand() *cobra.Command { return renderResult(cmd, "open", map[string]any{"action": "open", "status": status, "target": target, "url": url}) }, } - cmd.Flags().StringVar(&target, "target", "console", "target to open: console, docs, or product-docs") + cmd.Flags().StringVar(&target, "target", "console", "target to open: console, docs, docs-md, or product-docs") cmd.Flags().BoolVar(&noBrowser, "no-browser", false, "print the URL without opening a browser") return cmd } @@ -608,7 +603,7 @@ func (a *App) buildProjectCreate() *cobra.Command { cmd.Flags().StringVar(®ion, "region", "", "control plane region for the project context (global or cn)") 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().StringArrayVar(&features, "feature", nil, "enable one or more features after creation; defaults to rtc, rtm, and convoai; convoai also enables rtm") + 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") return cmd @@ -617,6 +612,7 @@ func (a *App) buildProjectCreate() *cobra.Command { func (a *App) buildProjectList() *cobra.Command { var page, pageSize int var keyword string + var refreshCache bool cmd := &cobra.Command{ Use: "list", Short: "List projects available to the current account", @@ -625,18 +621,27 @@ func (a *App) buildProjectList() *cobra.Command { agora project list agora project list --keyword demo agora project list --page 2 --page-size 50 + agora project list --refresh-cache `), RunE: func(cmd *cobra.Command, _ []string) error { res, err := a.listProjects(keyword, page, pageSize) if err != nil { return err } - return renderResult(cmd, "project list", map[string]any{"items": res.Items, "page": res.Page, "pageSize": res.PageSize, "total": res.Total}) + cacheRefreshed := false + if refreshCache { + if err := a.refreshProjectListCache(); err != nil { + return err + } + cacheRefreshed = true + } + return renderResult(cmd, "project list", map[string]any{"cacheRefreshed": cacheRefreshed, "items": res.Items, "page": res.Page, "pageSize": res.PageSize, "total": res.Total}) }, } cmd.Flags().IntVar(&page, "page", 1, "page number to request") cmd.Flags().IntVar(&pageSize, "page-size", 20, "number of projects per page") cmd.Flags().StringVar(&keyword, "keyword", "", "filter by exact or partial project name or project ID") + cmd.Flags().BoolVar(&refreshCache, "refresh-cache", false, "force-refresh the unfiltered first-page project completion cache after listing") return cmd } @@ -828,7 +833,7 @@ func (a *App) buildProjectFeature() *cobra.Command { cmd := &cobra.Command{ Use: "feature", Short: "Manage project feature state", - Long: "Inspect and enable product features such as rtc, rtm, and convoai for a remote Agora project.", + Long: fmt.Sprintf("Inspect and enable product features such as %s for a remote Agora project.", featureListString()), Example: example(` agora project feature list agora project feature status convoai @@ -969,7 +974,7 @@ Exit codes: }, } cmd.Flags().BoolVar(&deep, "deep", false, "run deeper repo-local checks for .agora metadata and quickstart env consistency") - cmd.Flags().StringVar(&feature, "feature", "convoai", "target feature readiness to evaluate: rtc, rtm, or convoai") + cmd.Flags().StringVar(&feature, "feature", "convoai", fmt.Sprintf("target feature readiness to evaluate: %s", featureListString())) return cmd } diff --git a/internal/cli/completion.go b/internal/cli/completion.go index 52c19bb..d6b6dc8 100644 --- a/internal/cli/completion.go +++ b/internal/cli/completion.go @@ -3,25 +3,78 @@ package cli import ( "fmt" "strings" + "time" "github.com/spf13/cobra" ) +const projectCompletionPageSize = 100 + +// completeProjectNames is the dynamic completion for `agora project use`, +// `agora project show`, and the project arg of `agora project feature`. +// +// It is cache-first *only when a local session exists*: a fresh on-disk +// cache (under /cache/projects.json) is served instantly so +// the shell never blocks on the network for a TAB. With no session file +// (or empty token), the cache is ignored so Tab never suggests stale +// projects after logout. When the cache is missing or stale, we fall back +// to a single live API call and warm the cache for next time. If the +// network call also fails (e.g. the user is unauthenticated), we +// silently return no completions — never a partial list and never an +// auth-flow side effect, since completion runs from inside the user's +// shell on every keystroke. func (a *App) completeProjectNames(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { - list, err := a.listProjects(toComplete, 1, 100) + if items, ok := a.completionProjectsFromCache(); ok { + return filterProjectCompletions(items, toComplete), cobra.ShellCompDirectiveNoFileComp + } + list, err := a.listProjects("", 1, projectCompletionPageSize) if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp } - results := make([]string, 0, len(list.Items)*2) - for _, item := range list.Items { - if item.Name != "" && strings.HasPrefix(strings.ToLower(item.Name), strings.ToLower(toComplete)) { + return filterProjectCompletions(list.Items, toComplete), cobra.ShellCompDirectiveNoFileComp +} + +// completionProjectsFromCache returns the cached project items when +// the user has a persisted non-empty session, the on-disk cache exists, +// parses, and is younger than the current process's TTL (overridable +// via AGORA_PROJECT_CACHE_TTL_SECONDS). +func (a *App) completionProjectsFromCache() ([]projectSummary, bool) { + if !hasPersistedNonEmptySession(a.env) { + return nil, false + } + payload, fresh, err := loadProjectListCache(a.env) + if err != nil || !fresh { + return nil, false + } + if cacheTTLFromEnv(a.env) == 0 { + return nil, false + } + if payload.FetchedAt == "" { + return nil, false + } + if fetchedAt, parseErr := time.Parse(time.RFC3339Nano, payload.FetchedAt); parseErr == nil { + if time.Since(fetchedAt) > cacheTTLFromEnv(a.env) { + return nil, false + } + } + return payload.Items, true +} + +// filterProjectCompletions emits both name- and ID-prefixed matches +// (with the alternate value as the description), sorted by their +// natural appearance in the cache. +func filterProjectCompletions(items []projectSummary, toComplete string) []string { + results := make([]string, 0, len(items)*2) + prefix := strings.ToLower(toComplete) + for _, item := range items { + if item.Name != "" && strings.HasPrefix(strings.ToLower(item.Name), prefix) { results = append(results, fmt.Sprintf("%s\t%s", item.Name, item.ProjectID)) } - if item.ProjectID != "" && strings.HasPrefix(strings.ToLower(item.ProjectID), strings.ToLower(toComplete)) { + if item.ProjectID != "" && strings.HasPrefix(strings.ToLower(item.ProjectID), prefix) { results = append(results, fmt.Sprintf("%s\t%s", item.ProjectID, item.Name)) } } - return results, cobra.ShellCompDirectiveNoFileComp + return results } func completeQuickstartTemplateIDs(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { diff --git a/internal/cli/features_test.go b/internal/cli/features_test.go new file mode 100644 index 0000000..7151c8c --- /dev/null +++ b/internal/cli/features_test.go @@ -0,0 +1,51 @@ +package cli + +import ( + "strings" + "testing" +) + +func TestFeatureCatalogIsTheSingleSourceOfTruth(t *testing.T) { + ids := featureIDs() + if len(ids) == 0 { + t.Fatal("featureIDs() returned an empty list; the catalog is the source of truth and must never be empty") + } + for _, id := range ids { + if !isKnownFeature(id) { + t.Errorf("isKnownFeature(%q) = false but %q is in featureIDs()", id, id) + } + } + if isKnownFeature("not-a-feature") { + t.Error("isKnownFeature should reject unknown ids") + } + expectedString := strings.Join(ids, ", ") + if got := featureListString(); got != expectedString { + t.Errorf("featureListString() = %q, want %q", got, expectedString) + } +} + +func TestFeatureIDsReturnsFreshSliceEachCall(t *testing.T) { + a := featureIDs() + b := featureIDs() + if len(a) > 0 { + a[0] = "MUTATED" + } + if b[0] == "MUTATED" { + t.Fatal("featureIDs() must return a fresh slice; mutating one return value affected another") + } +} + +func TestValidateFeatureIDIncludesActualCatalog(t *testing.T) { + for _, id := range featureIDs() { + if err := validateFeatureID(id); err != nil { + t.Errorf("validateFeatureID(%q) = %v, want nil", id, err) + } + } + err := validateFeatureID("nope") + if err == nil { + t.Fatal("validateFeatureID(unknown) should return an error") + } + if !strings.Contains(err.Error(), featureListString()) { + t.Errorf("error message should list the catalog, got %q", err.Error()) + } +} diff --git a/internal/cli/open_targets.go b/internal/cli/open_targets.go new file mode 100644 index 0000000..b9c6315 --- /dev/null +++ b/internal/cli/open_targets.go @@ -0,0 +1,106 @@ +package cli + +import ( + "fmt" + "net/url" + "strings" +) + +// Canonical URLs for `agora open --target `. +// +// IMPORTANT — keep these in sync with infrastructure outside this +// package: +// +// - cliDocsURL must match the GitHub Pages site published by +// .github/workflows/pages.yml. If the publishing repo, branch, or +// custom-domain CNAME ever changes, update both here AND in +// pages.yml in the same commit. +// - cliDocsMarkdownURL must point at the raw Markdown copy of the +// same docs tree. Pages publishes these files under /md/ after +// rendering the human HTML site, giving agents stable source URLs +// such as /md/commands.md and /md/automation.md. +// - consoleURL is the public Agora Console front door. +// - productDocsURL is the public product documentation site. +// +// A smoke test in open_targets_test.go validates that each URL +// parses, uses HTTPS, and is non-empty so a typo here surfaces in CI. +// +// Forks and dev/staging environments can override any of these at +// runtime via environment variables (see openTargetEnv) without +// editing or rebuilding the CLI. +const ( + cliDocsURL = "https://agoraio.github.io/cli/" + cliDocsMarkdownURL = "https://agoraio.github.io/cli/md/index.md" + consoleURL = "https://console.agora.io" + productDocsURL = "https://docs.agora.io" +) + +// openTargetEnv maps each open-target name to the environment variable +// that overrides its compiled-in URL. This is the supported escape +// hatch for forks (CLI repo renamed → Pages URL changes), local dev +// against a staging Console, and CI environments that prefer to point +// at preview docs builds. +var openTargetEnv = map[string]string{ + "console": "AGORA_CONSOLE_URL", + "docs": "AGORA_DOCS_URL", + "docs-md": "AGORA_DOCS_MD_URL", + "product-docs": "AGORA_PRODUCT_DOCS_URL", +} + +// resolveOpenTarget returns the URL the `agora open` command should +// open or print for the given target name. Resolution order: +// +// 1. Per-target environment override from openTargetEnv (when set +// and non-empty), e.g. AGORA_DOCS_URL=https://staging-docs.example/ +// 2. Compiled-in canonical URL. +// +// Returns a structured error for unknown targets so the message stays +// consistent with the rest of the CLI's input-validation errors. +func resolveOpenTarget(target string, env map[string]string) (string, error) { + envKey, known := openTargetEnv[target] + if !known { + return "", fmt.Errorf("unknown open target %q. Use console, docs, docs-md, or product-docs.", target) + } + if env != nil { + if override := strings.TrimSpace(env[envKey]); override != "" { + return override, nil + } + } + switch target { + case "console": + return consoleURL, nil + case "docs": + return cliDocsURL, nil + case "docs-md": + return cliDocsMarkdownURL, nil + case "product-docs": + return productDocsURL, nil + } + // Unreachable: openTargetEnv keys and switch cases are kept in sync. + return "", fmt.Errorf("unknown open target %q. Use console, docs, docs-md, or product-docs.", target) +} + +// validateOpenTargetURL is the predicate used by the smoke test (and +// by any future runtime URL-loaded path) to assert that a URL string +// is well-formed enough to hand to a browser: parses, uses HTTPS, +// has a host, and contains no whitespace. Exposed so tests can +// validate both the compiled-in constants and override env vars. +func validateOpenTargetURL(raw string) error { + if strings.TrimSpace(raw) == "" { + return fmt.Errorf("URL must not be empty") + } + if strings.ContainsAny(raw, " \t\r\n") { + return fmt.Errorf("URL must not contain whitespace: %q", raw) + } + parsed, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("URL %q failed to parse: %w", raw, err) + } + if parsed.Scheme != "https" { + return fmt.Errorf("URL %q must use https", raw) + } + if parsed.Host == "" { + return fmt.Errorf("URL %q must include a host", raw) + } + return nil +} diff --git a/internal/cli/open_targets_test.go b/internal/cli/open_targets_test.go new file mode 100644 index 0000000..549f390 --- /dev/null +++ b/internal/cli/open_targets_test.go @@ -0,0 +1,138 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestOpenTargetURLsAreWellFormed is the smoke test guaranteeing that +// every compiled-in `agora open` target points at a syntactically +// valid HTTPS URL. A typo in cliDocsURL / cliDocsMarkdownURL / +// consoleURL / productDocsURL +// fails the test and is caught in CI before shipping. +func TestOpenTargetURLsAreWellFormed(t *testing.T) { + for _, target := range []string{"console", "docs", "docs-md", "product-docs"} { + t.Run(target, func(t *testing.T) { + url, err := resolveOpenTarget(target, nil) + if err != nil { + t.Fatalf("resolveOpenTarget(%q) = error %v", target, err) + } + if err := validateOpenTargetURL(url); err != nil { + t.Fatalf("compiled-in URL for %q is malformed: %v", target, err) + } + }) + } +} + +// TestOpenTargetEnvOverridesWin verifies that AGORA_*_URL overrides +// take precedence over the compiled-in constants. Forks (CLI repo +// renamed → Pages URL changes) and dev/staging environments rely on +// this path; if it ever silently regresses the override becomes a +// no-op and users open the wrong site. +func TestOpenTargetEnvOverridesWin(t *testing.T) { + env := map[string]string{ + "AGORA_CONSOLE_URL": "https://staging-console.example.com", + "AGORA_DOCS_URL": "https://staging-docs.example.com", + "AGORA_DOCS_MD_URL": "https://staging-docs.example.com/md/index.md", + "AGORA_PRODUCT_DOCS_URL": "https://staging-product-docs.example.com", + } + for target, want := range map[string]string{ + "console": env["AGORA_CONSOLE_URL"], + "docs": env["AGORA_DOCS_URL"], + "docs-md": env["AGORA_DOCS_MD_URL"], + "product-docs": env["AGORA_PRODUCT_DOCS_URL"], + } { + t.Run(target, func(t *testing.T) { + got, err := resolveOpenTarget(target, env) + if err != nil { + t.Fatalf("resolveOpenTarget(%q) = error %v", target, err) + } + if got != want { + t.Fatalf("resolveOpenTarget(%q) = %q, want %q", target, got, want) + } + }) + } +} + +// TestOpenTargetUnknownReturnsStructuredError confirms unknown +// targets fail with the documented message rather than fallthrough. +func TestOpenTargetUnknownReturnsStructuredError(t *testing.T) { + _, err := resolveOpenTarget("nope", nil) + if err == nil { + t.Fatal("expected error for unknown target") + } + if !strings.Contains(err.Error(), `unknown open target "nope"`) { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestOpenTargetEmptyOverrideFallsBackToCompiledIn ensures setting +// AGORA_DOCS_URL="" does NOT clear the URL — empty overrides are +// indistinguishable from "unset" from the user's perspective. +func TestOpenTargetEmptyOverrideFallsBackToCompiledIn(t *testing.T) { + env := map[string]string{"AGORA_DOCS_URL": " "} + got, err := resolveOpenTarget("docs", env) + if err != nil { + t.Fatal(err) + } + if got != cliDocsURL { + t.Fatalf("empty override should fall back to compiled-in URL, got %q want %q", got, cliDocsURL) + } +} + +// TestCLIDocsURLMatchesPagesWorkflow guards the cross-file invariant +// that cliDocsURL points at the GitHub Pages site published by +// .github/workflows/pages.yml. The workflow uploads the docs folder +// to a Pages site whose URL must match the constant — if the repo or +// publishing branch is ever changed in the workflow, this test fails +// and the maintainer is forced to update both files together. +// +// We do this with a structural check instead of a network probe so +// the test stays hermetic and fast: we just assert that the workflow +// file exists and that it actually publishes a Pages site (i.e. uses +// the `actions/deploy-pages` action). +func TestCLIDocsURLMatchesPagesWorkflow(t *testing.T) { + repoRoot := findRepoRootForTest(t) + pagesYAML := filepath.Join(repoRoot, ".github", "workflows", "pages.yml") + body, err := os.ReadFile(pagesYAML) + if err != nil { + t.Fatalf("could not read %s: %v", pagesYAML, err) + } + if !strings.Contains(string(body), "actions/deploy-pages") { + t.Fatal("pages.yml does not invoke actions/deploy-pages; cliDocsURL would be unreachable") + } + if !strings.HasSuffix(cliDocsURL, "/") { + t.Fatalf("cliDocsURL must end with a trailing slash to match GitHub Pages canonical URLs, got %q", cliDocsURL) + } + if cliDocsMarkdownURL != cliDocsURL+"md/index.md" { + t.Fatalf("cliDocsMarkdownURL = %q, want %q", cliDocsMarkdownURL, cliDocsURL+"md/index.md") + } + if !strings.Contains(string(body), "Copy raw Markdown docs for agents") || !strings.Contains(string(body), "_site/md") { + t.Fatal("pages.yml does not copy raw Markdown docs into _site/md for agent-facing URLs") + } +} + +// findRepoRootForTest walks up from the current working directory +// until it finds a go.mod, returning the directory holding it. Used +// by TestCLIDocsURLMatchesPagesWorkflow so it doesn't have to encode +// a relative path that breaks when the test is run from somewhere +// other than the package directory. +func findRepoRootForTest(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatalf("could not find go.mod above %s", dir) + } + dir = parent + } +} diff --git a/internal/cli/projects.go b/internal/cli/projects.go index 982d851..7beb3a1 100644 --- a/internal/cli/projects.go +++ b/internal/cli/projects.go @@ -13,9 +13,21 @@ import ( func (a *App) listProjects(keyword string, page, pageSize int) (projectListResponse, error) { var out projectListResponse err := a.apiRequest("GET", "/api/cli/v1/projects", map[string]string{"keyword": keyword, "page": fmt.Sprint(page), "pageSize": fmt.Sprint(pageSize)}, nil, &out) + if err == nil && strings.TrimSpace(keyword) == "" && page <= 1 { + // Best-effort: persist the unfiltered first page so shell tab + // completion can serve project names without a network round + // trip on every keystroke. Failures here are non-fatal. + _ = saveProjectListCache(a.env, out) + } return out, err } +func (a *App) refreshProjectListCache() error { + _ = clearProjectListCache(a.env) + _, err := a.listProjects("", 1, projectCompletionPageSize) + return err +} + func (a *App) createProject(name, idempotencyKey string) (projectDetail, error) { var out projectDetail body := map[string]any{"name": name, "projectType": "paas"} @@ -312,6 +324,11 @@ func (a *App) projectCreate(name, region, template string, features []string, rt if err := saveContext(a.env, ctx); err != nil { return nil, err } + // The completion cache just became stale: a brand-new project + // won't appear in `agora project use ` until the user runs a + // 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} if rtmDataCenter != "" { result["rtmDataCenter"] = rtmDataCenter From 15bf2c443fa7e4150307568b98d5f42d79e4296b Mon Sep 17 00:00:00 2001 From: digitallysavvy Date: Thu, 30 Apr 2026 21:02:16 -0400 Subject: [PATCH 09/26] fix(cli): detect terminal width for pretty block output Use golang.org/x/term (with COLUMNS override) so printBlock can truncate long values sensibly when stderr/stdout exposes a size. Document why pretty progress stays line-based instead of a spinner. Bump the module to Go 1.26.2 and add render tests. --- go.mod | 8 +++-- go.sum | 4 +++ internal/cli/progress.go | 19 ++++++++++++ internal/cli/render.go | 43 ++++++++++++++++++++++++-- internal/cli/render_test.go | 61 +++++++++++++++++++++++++++++++++++++ 5 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 internal/cli/render_test.go diff --git a/go.mod b/go.mod index 4a1b981..d195427 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,14 @@ module github.com/agora/cli-workspace/agora-cli-go -go 1.24 +go 1.26.2 require ( github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 + golang.org/x/term v0.42.0 ) -require github.com/inconshreveable/mousetrap v1.1.0 // indirect +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + golang.org/x/sys v0.43.0 // indirect +) diff --git a/go.sum b/go.sum index ffae55e..310f6da 100644 --- a/go.sum +++ b/go.sum @@ -6,5 +6,9 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cli/progress.go b/internal/cli/progress.go index c898a48..41667e2 100644 --- a/internal/cli/progress.go +++ b/internal/cli/progress.go @@ -76,6 +76,25 @@ func makeJSONProgressEmitter(out io.Writer, command string) progressEmitter { } } +// makePrettyProgressEmitter returns the pretty-mode progress writer. +// +// Design choice: each event becomes a single newline-terminated bullet +// line ("- message"). This is intentionally NOT an animated ANSI +// spinner because: +// +// - Pretty progress lines must remain useful when stderr is being +// captured into a CI build log, redirected to a file, or scraped +// by an editor's terminal pane (none of which honor cursor moves). +// - Spinners produce noisy artifacts on dumb terminals and inside +// `tee` / `script` capture, where each redraw lands as garbage. +// - JSON mode already emits stable NDJSON progress events for +// real-time observability; pretty mode just needs a readable trace. +// +// If a future revision wants a real spinner, gate it on +// term.IsTerminal(stderr) and a TERM != "dumb" check, and keep this +// path as the fallback. The CHANGELOG entry is intentionally worded +// "progress status lines" (not "spinner support") to match this +// implementation. func makePrettyProgressEmitter(out io.Writer) progressEmitter { if out == nil { return nil diff --git a/internal/cli/render.go b/internal/cli/render.go index 3b32d86..de67e5c 100644 --- a/internal/cli/render.go +++ b/internal/cli/render.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/spf13/cobra" + "golang.org/x/term" ) // renderResult is the single dispatch point for command output. In JSON @@ -232,9 +233,31 @@ func printBlock(out io.Writer, title string, rows [][2]string) { } } +// terminalValueWidth returns the maximum number of value-column +// characters that fit on one terminal line, given the label-column +// width plus the " : " separator. +// +// Resolution order: +// +// 1. COLUMNS env var when set and parseable. Lets users and tests +// override the detected width without a real TTY (and lets +// containers/CI runners that *do* export COLUMNS opt in). +// 2. golang.org/x/term.GetSize against stderr, then stdout. Both +// are tried because pretty output goes to stdout but stderr is +// more often a TTY when stdout is being piped (the common case +// for `agora ... | jq`). +// 3. 0, meaning "do not truncate". Honoring "no terminal detected +// => never truncate" is the safest default for log scrapers and +// CI build logs. +// +// A returned 0 (no width info) means the caller MUST NOT truncate. +// A nonzero value is the byte width available for the value column; +// callers should treat values longer than this as truncation +// candidates. Values below 20 characters of available room are +// suppressed because narrower truncation produces unreadable output. func terminalValueWidth(labelWidth int) int { - columns, err := strconv.Atoi(strings.TrimSpace(os.Getenv("COLUMNS"))) - if err != nil || columns <= 0 { + columns := detectTerminalColumns() + if columns <= 0 { return 0 } available := columns - labelWidth - len(" : ") @@ -244,6 +267,22 @@ func terminalValueWidth(labelWidth int) int { return available } +// detectTerminalColumns is the resolution helper for +// terminalValueWidth. Split out so tests can drive it directly. +func detectTerminalColumns() int { + if raw := strings.TrimSpace(os.Getenv("COLUMNS")); raw != "" { + if columns, err := strconv.Atoi(raw); err == nil && columns > 0 { + return columns + } + } + for _, fd := range []uintptr{os.Stderr.Fd(), os.Stdout.Fd()} { + if width, _, err := term.GetSize(int(fd)); err == nil && width > 0 { + return width + } + } + return 0 +} + // printDoctor prints a structured diagnostic report including per-category // items, suggested recovery commands, and a status summary line. noColor // swaps Unicode glyphs for ASCII so the output is safe for log scrapers. diff --git a/internal/cli/render_test.go b/internal/cli/render_test.go new file mode 100644 index 0000000..f1d9871 --- /dev/null +++ b/internal/cli/render_test.go @@ -0,0 +1,61 @@ +package cli + +import ( + "testing" +) + +// TestDetectTerminalColumnsHonorsCOLUMNSEnv covers the user-override +// path. COLUMNS is checked first so users (and tests) can force a +// known width without needing a real TTY. +func TestDetectTerminalColumnsHonorsCOLUMNSEnv(t *testing.T) { + t.Setenv("COLUMNS", "120") + got := detectTerminalColumns() + if got != 120 { + t.Fatalf("detectTerminalColumns() = %d, want 120 from COLUMNS", got) + } +} + +// TestDetectTerminalColumnsIgnoresInvalidCOLUMNS verifies we fall +// through to the term.GetSize / zero path when COLUMNS is garbage, +// rather than coercing it to 0 and accidentally suppressing later +// detection. +func TestDetectTerminalColumnsIgnoresInvalidCOLUMNS(t *testing.T) { + t.Setenv("COLUMNS", "not-a-number") + // We can't assert the exact result because it depends on whether + // stdin/stdout are TTYs in the test runner. The contract is: + // the function must NOT return a parsed-but-bogus value. + got := detectTerminalColumns() + if got < 0 { + t.Fatalf("detectTerminalColumns() = %d, want >= 0", got) + } +} + +// TestTerminalValueWidthSuppressesNarrowResults is a behavioral test +// for the truncation gate. Below 20 chars of available value-column +// room, truncation produces unreadable output, so the helper must +// return 0 (caller should not truncate). +func TestTerminalValueWidthSuppressesNarrowResults(t *testing.T) { + t.Setenv("COLUMNS", "30") + if got := terminalValueWidth(20); got != 0 { + t.Errorf("terminalValueWidth(20) at COLUMNS=30 = %d, want 0 (too narrow)", got) + } + t.Setenv("COLUMNS", "120") + if got := terminalValueWidth(20); got <= 0 { + t.Errorf("terminalValueWidth(20) at COLUMNS=120 = %d, want positive", got) + } +} + +// TestTerminalValueWidthZeroWhenWidthUnknown is the "no terminal +// detected" contract. Returning 0 means "do not truncate", which is +// the safest default for log scrapers and CI build logs. +func TestTerminalValueWidthZeroWhenWidthUnknown(t *testing.T) { + t.Setenv("COLUMNS", "") + // In CI without a TTY this returns 0; on developer machines with + // a TTY this may return a positive value. We only assert + // non-negative because the contract is "no panic, sensible + // fallback". + got := terminalValueWidth(10) + if got < 0 { + t.Fatalf("terminalValueWidth = %d, want >= 0", got) + } +} From 61576e93600e07ac1ae8255bd28a7e00ded1bb3b Mon Sep 17 00:00:00 2001 From: digitallysavvy Date: Thu, 30 Apr 2026 21:03:56 -0400 Subject: [PATCH 10/26] docs(release): prepare v0.2.0 changelog and documentation Document new behavior in automation and commands references, refresh install and releasing notes, align the issue template and PowerShell installer examples with v0.2.0, and update the version.go ldflags comment. --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- CHANGELOG.md | 40 +++++++++++++++++---------- CONTRIBUTING.md | 4 +-- RELEASING.md | 4 +-- docs/automation.md | 21 +++++++++++++- docs/commands.md | 13 +++++---- docs/error-codes.md | 1 + docs/index.md | 12 ++++++++ docs/install.md | 18 ++++++------ install.ps1 | 2 +- internal/cli/version.go | 2 +- 11 files changed, 82 insertions(+), 37 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index ef79390..ff869d0 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -18,7 +18,7 @@ body: attributes: label: CLI version description: Output of `agora --version` - placeholder: "e.g. agora-cli-go 0.1.10 (commit abc1234, built 2026-04-30)" + placeholder: "e.g. agora-cli-go 0.2.0 (commit abc1234, built 2026-04-30)" validations: required: true diff --git a/CHANGELOG.md b/CHANGELOG.md index e83da25..9fca1b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). When tagging a new release, rename the `[Unreleased]` section to the new version -(e.g. `[0.1.10] - 2026-04-30`), add a fresh empty `[Unreleased]` heading at the top, +(e.g. `[0.2.0] - 2026-04-30`), add a fresh empty `[Unreleased]` heading at the top, and update the link references at the bottom of this file. When adding a new entry, link the change to the PR or commit that introduced it @@ -15,16 +15,20 @@ Earlier entries pre-date this convention and only carry their version's compare ## [Unreleased] +## [0.2.0] - 2026-04-30 + ### Added -- Add GitHub Pages publishing for generated CLI docs and route `agora open --target docs` to the CLI docs site, with `product-docs` available for Agora product docs. +- Add GitHub Pages publishing for generated CLI docs and route `agora open --target docs` to the human CLI docs site, `agora open --target docs-md` to the agent-facing raw Markdown docs under `/md/`, and `product-docs` to Agora product docs. - Add global `--yes` / `-y` and `AGORA_NO_INPUT=1` support to accept defaults and suppress prompts. - Add pretty-mode progress status lines for long-running clone, OAuth, and project creation work. -- Add dynamic shell completions for project names, quickstart templates, and project features. -- Add `agora mcp serve --transport stdio` so MCP-capable agents can use local Agora CLI tools. -- Add drop-in agent rule snippets under `docs/agents/` and `agora init --add-agent-rules`. +- Add dynamic shell completions for project names, quickstart templates, and project features, with an on-disk completion cache under `/cache/projects.json` so `agora project use ` is instant on warm caches. Configurable via `AGORA_PROJECT_CACHE_TTL_SECONDS` and disable-able via `AGORA_DISABLE_CACHE=1`. +- Add `agora mcp serve --transport stdio` so MCP-capable agents can use local Agora CLI tools, exposing the full surface (`agora.version`, `agora.introspect`, `agora.auth.*`, `agora.config.*`, `agora.telemetry.status`, `agora.upgrade.check`, `agora.project.*` including `create`/`env`/`feature.{list,status,enable}`, `agora.quickstart.*`, and `agora.init`). +- Add drop-in agent rule snippets under `docs/agents/` and `agora init --add-agent-rules` with safe append-when-exists semantics: subsequent runs update only the Agora-managed block between sentinel markers and never destroy pre-existing user content. - Add `install.sh --uninstall` and `install.ps1 -Uninstall`. - Add CODEOWNERS, Dependabot, and a scheduled `govulncheck` workflow. +- Add `PROJECT_NAME_REQUIRED` error code for `project create` and the equivalent MCP tool. +- Add `agora project list --refresh-cache` to explicitly refresh the unfiltered first page used by project-name shell completion. - Infer coarse agent labels for API `User-Agent` when `AGORA_AGENT` is unset; explicit `AGORA_AGENT` still takes precedence. ### Changed @@ -32,20 +36,28 @@ Earlier entries pre-date this convention and only carry their version's compare - Switch npm platform package wiring from scoped `@agoraio/cli-*` packages to unscoped `agoraio-cli-*` packages. - Standardize README command examples on the installed `agora` command. - Standardize contributor contact email on `devrel@agora.io`. +- Consolidate the `rtc` / `rtm` / `convoai` feature list into a single source of truth (`internal/cli/features.go`); `init`, `project create`, `project doctor`, `project feature {list,status,enable}`, MCP tools, shell completion, and `--help` text all read from the same catalog so future feature additions only need one entry. +- Default newly created projects to enable `rtc`, `rtm`, and `convoai`, make `convoai` imply `rtm` during project creation, and add `--rtm-data-center` for `init` / `project create` when RTM should be configured for a specific data center. +- Refine `agora init` project selection so `--project` binds explicitly, `--new-project` creates explicitly, `"Default Project"` auto-selects by exact name, and interactive sessions without a default show existing projects plus a create-new option. +- `agora project env write` detects Next.js workspaces and writes `NEXT_PUBLIC_AGORA_APP_ID` / `NEXT_AGORA_APP_CERTIFICATE`, with `--template nextjs|standard` to override auto-detection. +- `project env write` now creates or updates repo-local `.agora/project.json` for the selected project, recording `projectType` (framework/language detection such as `nextjs`, `go`, `python`, `node`, `standard`) and `envPath`, while quickstart-bound repos continue using a single `template` field for template lineage. +- Build and release metadata now target Go 1.26.2, matching the current stable Go toolchain for distributed CLI builds. ### Fixed +- Fix `--yes` / `-y` / `AGORA_NO_INPUT=1` so it never silently launches an OAuth browser flow in JSON, CI, or non-TTY contexts. Industry convention for `-y` is "accept the default for confirmation prompts", not "spawn a brand-new interactive flow"; those contexts now consistently fail fast with `AUTH_UNAUTHENTICATED`. +- Fix the MCP server's stdio scanner to allow JSON-RPC frames up to 4 MiB (was 64 KiB) so large `tools/call` payloads no longer truncate the loop. +- Fix the MCP `agora.init` tool to never read from `os.Stdin` (the JSON-RPC transport stream) or write to `os.Stderr`; `initProject` is now invoked with an empty in-memory reader and a discarded prompt writer. +- Fix the MCP server's notification handling to match JSON-RPC 2.0: any frame without an `id` is treated as a notification and produces no response (previously notifications without the `notifications/` method prefix received an `id: null` reply). +- Fix `printBlock` value-column truncation, which used to silently no-op because `COLUMNS` is a shell-internal variable and is rarely exported to child processes. The CLI now consults `COLUMNS` first (so users and tests can override) and falls back to `golang.org/x/term.GetSize` against stderr / stdout, with a "no terminal detected → don't truncate" safe default for log scrapers and CI build logs. +- Fix `agora open --target docs` URL resolution to be configurable: each target now reads from `AGORA_CONSOLE_URL` / `AGORA_DOCS_URL` / `AGORA_PRODUCT_DOCS_URL` (when set), falling back to the compiled-in canonical URLs. A new smoke test asserts every compiled-in URL parses, uses HTTPS, and has a host, and that `cliDocsURL` and `.github/workflows/pages.yml` stay in sync. +- Fix project-name shell completion so the on-disk cache is ignored when the local session is missing, empty, or locally expired. - Fix bug report template references to use `agora project doctor --json`. - Return structured `INIT_NAME_REQUIRED`, `AUTH_OAUTH_EXCHANGE_FAILED`, and `AUTH_OAUTH_RESPONSE_INVALID` errors for previously unclassified paths. -## [0.1.10] - 2026-04-30 - -### Changed +### Documentation -- Default newly created projects to enable `rtc`, `rtm`, and `convoai`, make `convoai` imply `rtm` during project creation, and add `--rtm-data-center` for `init` / `project create` when RTM should be configured for a specific data center. -- Refine `agora init` project selection so `--project` binds explicitly, `--new-project` creates explicitly, `"Default Project"` auto-selects by exact name, and interactive sessions without a default show existing projects plus a create-new option. -- `agora project env write` detects Next.js workspaces and writes `NEXT_PUBLIC_AGORA_APP_ID` / `NEXT_AGORA_APP_CERTIFICATE`, with `--template nextjs|standard` to override auto-detection. -- `project env write` now creates or updates repo-local `.agora/project.json` for the selected project, recording `projectType` (framework/language detection such as `nextjs`, `go`, `python`, `node`, `standard`) and `envPath`, while quickstart-bound repos continue using a single `template` field for template lineage. +- Document the MCP transport caveat that `agora init`, `agora quickstart create`, `agora project create`, and `agora login` collapse their NDJSON progress event stream into the final `tools/call` result over MCP, since stdout is the JSON-RPC transport. ## [0.1.9] - 2026-04-30 @@ -151,8 +163,8 @@ Earlier entries pre-date this convention and only carry their version's compare - 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.1.10...HEAD -[0.1.10]: https://github.com/AgoraIO/cli/compare/v0.1.9...v0.1.10 +[Unreleased]: https://github.com/AgoraIO/cli/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/AgoraIO/cli/compare/v0.1.9...v0.2.0 [0.1.9]: https://github.com/AgoraIO/cli/compare/v0.1.8...v0.1.9 [0.1.8]: https://github.com/AgoraIO/cli/compare/v0.1.7...v0.1.8 [0.1.7]: https://github.com/AgoraIO/cli/compare/v0.1.6...v0.1.7 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 173c12b..1db7c98 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ detailed guidance, and what we expect from contributions. This project adheres to the [Contributor Covenant](CODE_OF_CONDUCT.md). By participating, you agree to uphold its standards. Please report unacceptable -behavior to . +behavior to . ## Where to read first @@ -36,7 +36,7 @@ For end-user behavior and machine-readable contracts, see: Requirements: -- **Go** 1.24+ (see `go.mod`). +- **Go** 1.26.2+ (see `go.mod`). Release builds intentionally track the current stable Go toolchain; this distributed CLI does not target older Go compiler support. - **Git**. - (Optional) `golangci-lint` v1.64.8 — install matches CI; instructions in the next section. diff --git a/RELEASING.md b/RELEASING.md index 816c7e8..43833a5 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -5,8 +5,8 @@ Releases are fully automated via GoReleaser. Pushing a `v*` tag is the only manu ## Release ```bash -git tag v0.1.10 -git push origin v0.1.10 +git tag v0.2.0 +git push origin v0.2.0 ``` The release workflow (`.github/workflows/release.yml`) then: diff --git a/docs/automation.md b/docs/automation.md index 3b2731e..6d7a073 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -37,7 +37,9 @@ Use this guide for: - Interactive login prompts only appear in interactive pretty-mode TTY runs. Automation should authenticate up front with `agora login`; `--json`, `AGORA_OUTPUT=json`, detected CI environments, and non-TTY stdin all skip the prompt and fail with `AUTH_UNAUTHENTICATED`. - Output mode precedence is: explicit CLI flag (`--json` or `--output`) first, user-set `AGORA_OUTPUT` second, then user-customized config file value, then **CI auto-detect → JSON** (see below), then pretty. - Set `AGORA_AGENT=` in automated environments to explicitly label agent traffic in the API `User-Agent`. When unset, the CLI may infer a coarse label such as `cursor`, `claude-code`, `cline`, `windsurf`, `codex`, or `aider` from known agent environment markers. Set `AGORA_AGENT_DISABLE_INFER=1` to disable inference. -- Use `agora mcp serve --transport stdio` to expose local Agora CLI tools to MCP-capable agents. +- Use `agora mcp serve --transport stdio` to expose local Agora CLI tools to MCP-capable agents. The full surface is exposed: `agora.version`, `agora.introspect`, `agora.auth.{status,logout}`, `agora.config.{path,get}`, `agora.telemetry.status`, `agora.upgrade.check`, `agora.project.{list,show,use,create,doctor,env,env_write}`, `agora.project.feature.{list,status,enable}`, `agora.quickstart.{list,create,env_write}`, and `agora.init`. Authentication is intentionally **not** exposed via MCP because OAuth requires an interactive browser; run `agora login` once on the host first. +- Use `agora open --target docs` for the human GitHub Pages docs and `agora open --target docs-md` for the agent-facing raw Markdown index. The Markdown tree is published under predictable `/md/` URLs, for example `/md/commands.md`, `/md/automation.md`, and `/md/error-codes.md`. +- The CLI maintains a short-lived on-disk completion cache for `agora project use ` under `/cache/projects.json`. The cache is only used for completions when a **local unexpired session exists** (`session.json` with a non-empty access token and a future `expiresAt`, when present), so Tab does not suggest stale project names after logout or local session expiry. The cache TTL is 5 minutes by default; override with `AGORA_PROJECT_CACHE_TTL_SECONDS=` (set to `0` to disable). Cache files older than 24 h are pruned at every CLI startup. Set `AGORA_DISABLE_CACHE=1` to drop the cache on the next startup. The cache is invalidated automatically by `agora logout` and `agora project create` (the latter clears the file; it does not embed the new project until the next successful list fetch). To **force-refresh** the cached completion page, run `agora project list --refresh-cache` while authenticated; that command fetches the unfiltered first page used by completion and rewrites `projects.json` when it succeeds. ### CI auto-detect @@ -188,6 +190,20 @@ Additional stage-specific fields may appear (for example `repoUrl`, `projectId`, Stages are stable identifiers; new stages may be added over time. Agents should treat unknown stages as benign progress information rather than failing on them. +### MCP transport caveat: progress events collapse into the tool result + +When the same long-running commands (`agora init`, `agora quickstart create`, `agora project create`, `agora login`) are invoked **through the MCP server** (`agora mcp serve`), the NDJSON progress event stream is **not** surfaced to the MCP client. MCP clients receive the final result payload only — JSON-stringified into the standard `content[0].text` slot of the `tools/call` response. + +Why: MCP's stdio transport uses stdout for JSON-RPC framing, so any extra newline-delimited objects emitted before the final response would corrupt the transport. The progress events are still produced internally but are intentionally swallowed before they reach the client. + +Implications for agent authors: + +- Do not rely on `stage`-based hooks (e.g. progress UIs, mid-flight cancellation prompts) when calling these tools over MCP. Use them only when shelling out to `agora ... --json` directly. +- Plan for higher tail latency on MCP `tools/call` for the affected tools; the user-visible "nothing is happening" gap may be tens of seconds for git clones or project creation. Consider surfacing your own "running tool: agora.init…" UI in the host agent. +- The terminal payload shape returned in `content[0].text` is identical to the CLI's terminal `data` envelope, so existing JSON-shape handlers continue to work unchanged. + +When future MCP transports support server-initiated `notifications/progress` (an open spec area), the CLI can add native MCP progress forwarding without changing the existing tool result shape. + Failure example: ```json @@ -719,6 +735,7 @@ Example: ```bash ./agora project list --json ./agora project list --keyword demo --page 2 --json +./agora project list --refresh-cache --json ``` Required `data` fields: @@ -730,6 +747,8 @@ Required `data` fields: Number of items per page. - `total` Total number of matching projects across all pages. +- `cacheRefreshed` + Boolean. `true` only when `--refresh-cache` successfully refreshed the unfiltered first-page project completion cache. Each item includes: `projectId`, `name`, `appId`, `projectType`, `status`, `region`, `createdAt`, `updatedAt`. diff --git a/docs/commands.md b/docs/commands.md index 2129a1c..3b34806 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1,6 +1,6 @@ # Agora CLI — Command Reference -> Generated from `agora introspect --json` on 2026-04-30. Do not edit by hand — run `make docs-commands` or rely on the release workflow to regenerate. +> Generated from `agora introspect --json` on 2026-05-01. Do not edit by hand — run `make docs-commands` or rely on the release workflow to regenerate. This page lists every enumerable command and its local flags. For long descriptions, examples, and inherited flags, run `agora --help` or read the source in `internal/cli/`. @@ -15,7 +15,7 @@ This page lists every enumerable command and its local flags. For long descripti | `--quiet` | `bool` | — | suppress success output (both pretty and JSON envelopes); rely on exit code. Errors still print on stderr. | | `--upgrade-check` | `bool` | — | print non-interactive upgrade guidance and exit | | `--verbose` | `bool` | — | echo structured logs to stderr (equivalent to AGORA_VERBOSE=1); does not change exit codes or JSON envelopes | -| `--yes` | `bool` | — | accept default answers and suppress interactive prompts (equivalent to AGORA_NO_INPUT=1) | +| `--yes` | `bool` | — | assume the default answer to confirmation prompts (equivalent to AGORA_NO_INPUT=1); never starts new interactive flows in JSON/CI/non-TTY contexts | ## Pseudo Commands @@ -96,7 +96,7 @@ Create a project, clone a quickstart, and write env in one flow |------|------|---------|-------------| | `--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, and convoai; convoai also enables rtm | +| `--feature` | `stringArray` | `[]` | enable a feature on the newly created project (repeatable); defaults to rtc, rtm, convoai; 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 | | `--region` | `string` | — | control plane region for newly created projects (global or cn) | @@ -145,7 +145,7 @@ Open Agora Console or CLI docs | Flag | Type | Default | Description | |------|------|---------|-------------| | `--no-browser` | `bool` | — | print the URL without opening a browser | -| `--target` | `string` | `console` | target to open: console, docs, or product-docs | +| `--target` | `string` | `console` | target to open: console, docs, docs-md, or product-docs | ### `agora project` @@ -160,7 +160,7 @@ Create a new remote Agora project | Flag | Type | Default | Description | |------|------|---------|-------------| | `--dry-run` | `bool` | — | return the planned project create result without creating remote resources | -| `--feature` | `stringArray` | `[]` | enable one or more features after creation; defaults to rtc, rtm, and convoai; convoai also enables rtm | +| `--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 | | `--region` | `string` | — | control plane region for the project context (global or cn) | | `--rtm-data-center` | `string` | — | RTM data center to configure when rtm is enabled (CN, NA, EU, or AP); defaults to NA | @@ -173,7 +173,7 @@ Diagnose whether a project is ready for selected feature development | Flag | Type | Default | Description | |------|------|---------|-------------| | `--deep` | `bool` | — | run deeper repo-local checks for .agora metadata and quickstart env consistency | -| `--feature` | `string` | `convoai` | target feature readiness to evaluate: rtc, rtm, or convoai | +| `--feature` | `string` | `convoai` | target feature readiness to evaluate: rtc, rtm, convoai | ### `agora project env` @@ -229,6 +229,7 @@ List projects available to the current account | `--keyword` | `string` | — | filter by exact or partial project name or project ID | | `--page` | `int` | `1` | page number to request | | `--page-size` | `int` | `20` | number of projects per page | +| `--refresh-cache` | `bool` | — | force-refresh the unfiltered first-page project completion cache after listing | ### `agora project show` diff --git a/docs/error-codes.md b/docs/error-codes.md index a87aaa8..ce13b53 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -37,6 +37,7 @@ This catalog is the source of truth for stable codes. CI runs `make snapshot-err | `QUICKSTART_TARGET_EXISTS` | 1 | The clone target already exists. | Choose a new directory. | | `INIT_NAME_REQUIRED` | 1 | `agora init` was run without the required target directory name. | Pass a directory name, for example `agora init my-nextjs-demo --template nextjs`. | | `INIT_ABORTED` | 1 | The interactive `agora init` reuse prompt was answered "no". | Re-run with `--project `, `--new-project`, or accept the prompt. | +| `PROJECT_NAME_REQUIRED` | 1 | `agora project create` (or the equivalent MCP tool) was called without `name`. | Pass a project name, for example `agora project create my-app`. | ### Self-update (`agora upgrade`) diff --git a/docs/index.md b/docs/index.md index f2e2268..e2e95c1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,6 +10,18 @@ Agora CLI is the native command-line tool for Agora authentication, project mana - [Stable error codes](error-codes.md) - [Telemetry controls](telemetry.md) +## Agent Markdown + +The same docs are published as raw Markdown under predictable `/md/` URLs for agents and scripts: + +- [Markdown index](https://agoraio.github.io/cli/md/index.md) +- [Markdown command reference](https://agoraio.github.io/cli/md/commands.md) +- [Markdown automation contract](https://agoraio.github.io/cli/md/automation.md) +- [Markdown error codes](https://agoraio.github.io/cli/md/error-codes.md) +- [Markdown install guide](https://agoraio.github.io/cli/md/install.md) +- [Markdown telemetry controls](https://agoraio.github.io/cli/md/telemetry.md) +- [Markdown agent rules guide](https://agoraio.github.io/cli/md/agents/README.md) + ## Common Commands ```bash diff --git a/docs/install.md b/docs/install.md index 9af5689..4f6a148 100644 --- a/docs/install.md +++ b/docs/install.md @@ -16,7 +16,7 @@ agora --help Install a pinned version: ```bash -curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh | sh -s -- --version 0.1.10 --add-to-path +curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh | sh -s -- --version 0.2.0 --add-to-path agora --help ``` @@ -50,7 +50,7 @@ agora --help Install a pinned version and add the default install directory to your user PATH: ```powershell -$env:VERSION = "0.1.10" +$env:VERSION = "0.2.0" & ([scriptblock]::Create((irm https://raw.githubusercontent.com/AgoraIO/cli/main/install.ps1))) -AddToPath agora --help ``` @@ -103,7 +103,7 @@ Uninstall removes the binary and `agora.install.json` receipt from the install d Both direct installers support these core overrides: - `GITHUB_REPO`: install from a fork or alternate repository. -- `VERSION`: install a specific version. Both `0.1.10` and `v0.1.10` are accepted. +- `VERSION`: install a specific version. Both `0.2.0` and `v0.2.0` are accepted. - `INSTALL_DIR`: install to a custom directory. - `GITHUB_TOKEN` or `GH_TOKEN`: optional GitHub token to avoid API rate limits when resolving the latest release. @@ -202,7 +202,7 @@ agora --help npx agoraio-cli --help # Pin a specific version -npm install -g agoraio-cli@0.1.10 +npm install -g agoraio-cli@0.2.0 # Update to the latest published version npm update -g agoraio-cli @@ -230,12 +230,12 @@ For one-off shell sessions, source the generated script according to your shell' If latest-version resolution fails, retry with a pinned version or provide `GITHUB_TOKEN` / `GH_TOKEN`: ```bash -GITHUB_TOKEN=your-token-here VERSION=0.1.10 sh install.sh +GITHUB_TOKEN=your-token-here VERSION=0.2.0 sh install.sh ``` ```powershell $env:GITHUB_TOKEN = "your-token-here" -$env:VERSION = "0.1.10" +$env:VERSION = "0.2.0" & ([scriptblock]::Create((irm https://raw.githubusercontent.com/AgoraIO/cli/main/install.ps1))) ``` @@ -281,7 +281,7 @@ For CI, automation, and reproducible environments, pin `VERSION` explicitly inst Every release is signed with [Cosign](https://docs.sigstore.dev/cosign/overview/) using GitHub Actions OIDC (keyless mode) and ships an [SPDX 2.3](https://spdx.dev/) SBOM per archive and per Linux package. To verify the `checksums.txt` file before trusting any artifact: ```bash -TAG=v0.1.10 +TAG=v0.2.0 ASSET_BASE="https://github.com/AgoraIO/cli/releases/download/${TAG}" curl -fsSLO "${ASSET_BASE}/checksums.txt" curl -fsSLO "${ASSET_BASE}/checksums.txt.sig" @@ -305,8 +305,8 @@ cosign verify "ghcr.io/agoraio/agora-cli:${TAG#v}" \ --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' ``` -To audit dependencies, download the `*.spdx.json` SBOM that ships next to each archive (e.g. `agora-cli-go_v0.1.10_linux_amd64.tar.gz.spdx.json`) and feed it to a scanner such as [Grype](https://github.com/anchore/grype): +To audit dependencies, download the `*.spdx.json` SBOM that ships next to each archive (e.g. `agora-cli-go_v0.2.0_linux_amd64.tar.gz.spdx.json`) and feed it to a scanner such as [Grype](https://github.com/anchore/grype): ```bash -grype sbom:agora-cli-go_v0.1.10_linux_amd64.tar.gz.spdx.json +grype sbom:agora-cli-go_v0.2.0_linux_amd64.tar.gz.spdx.json ``` diff --git a/install.ps1 b/install.ps1 index d3b541c..640b3dc 100644 --- a/install.ps1 +++ b/install.ps1 @@ -12,7 +12,7 @@ # irm https://raw.githubusercontent.com/AgoraIO/cli/main/install.ps1 | iex # # Pin a version: -# $env:VERSION = '0.1.10'; & ([scriptblock]::Create((irm .../install.ps1))) +# $env:VERSION = '0.2.0'; & ([scriptblock]::Create((irm .../install.ps1))) # [CmdletBinding()] param( diff --git a/internal/cli/version.go b/internal/cli/version.go index 9bb1f4b..98cea08 100644 --- a/internal/cli/version.go +++ b/internal/cli/version.go @@ -5,7 +5,7 @@ import "fmt" // Build-time injected version variables. These are populated by ldflags at // release time: // -// go build -ldflags '-X github.com/.../internal/cli.version=v0.1.10 +// go build -ldflags '-X github.com/.../internal/cli.version=v0.2.0 // -X github.com/.../internal/cli.commit=abc1234 // -X github.com/.../internal/cli.date=2026-04-30' // From a6bae772e79f161e3ebd290e971013b60e05b9dc Mon Sep 17 00:00:00 2001 From: digitallysavvy Date: Thu, 30 Apr 2026 21:06:18 -0400 Subject: [PATCH 11/26] chore(ci): add Pages, govulncheck, dependabot, and code owners Publish docs on push to main, run govulncheck on a schedule and PRs, enable dependabot for Go and Actions, and record default code owners. --- .github/CODEOWNERS | 2 + .github/dependabot.yml | 20 +++++++++ .github/workflows/govulncheck.yml | 35 +++++++++++++++ .github/workflows/pages.yml | 71 +++++++++++++++++++++++++++++++ 4 files changed, 128 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/govulncheck.yml create mode 100644 .github/workflows/pages.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..a23964e --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +* @AgoraIO/devrel + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..dcf3a96 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + + - package-ecosystem: npm + directory: /packaging/npm/agoraio-cli + schedule: + interval: weekly + open-pull-requests-limit: 5 + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml new file mode 100644 index 0000000..925cea5 --- /dev/null +++ b/.github/workflows/govulncheck.yml @@ -0,0 +1,35 @@ +name: govulncheck + +on: + pull_request: + push: + branches: + - main + schedule: + - cron: "17 9 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + govulncheck: + name: Run govulncheck + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + + - name: Scan + run: govulncheck ./... + diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..aafbff3 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,71 @@ +name: Publish CLI docs + +on: + push: + branches: + - main + paths: + - "docs/**" + - "cmd/gendocs/**" + - "internal/cli/**" + - "scripts/prepare-pages-site.py" + - ".github/workflows/pages.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Build CLI docs + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Verify generated command reference + run: go run ./cmd/gendocs -check + + - name: Configure Pages + uses: actions/configure-pages@v5 + + - name: Build Pages site + uses: actions/jekyll-build-pages@v1 + with: + source: ./docs + destination: ./_site + + - name: Prepare human and agent docs + run: python3 scripts/prepare-pages-site.py --source docs --site _site --env-file docs/site.env + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: ./_site + + deploy: + name: Deploy CLI docs + runs-on: ubuntu-latest + needs: build + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 + From 2f67708a6771bd45c7a697505455b9306c35f77e Mon Sep 17 00:00:00 2001 From: digitallysavvy Date: Thu, 30 Apr 2026 21:09:15 -0400 Subject: [PATCH 12/26] docs: add Pages URL injection script and site.env defaults Introduce prepare-pages-site.py to expand @@CLI_DOCS_*@@ placeholders in the built site, mirror Markdown under /md/, and emit docs.env. Point docs index links at tokens, document the contract in automation.md, extend the changelog, and lock open-target URLs to site.env via tests. --- CHANGELOG.md | 1 + docs/automation.md | 1 + docs/index.md | 14 ++--- docs/site.env | 2 + internal/cli/open_targets_test.go | 22 ++++++- scripts/prepare-pages-site.py | 101 ++++++++++++++++++++++++++++++ 6 files changed, 132 insertions(+), 9 deletions(-) create mode 100644 docs/site.env create mode 100644 scripts/prepare-pages-site.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fca1b0..a64b502 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Earlier entries pre-date this convention and only carry their version's compare ### Added - Add GitHub Pages publishing for generated CLI docs and route `agora open --target docs` to the human CLI docs site, `agora open --target docs-md` to the agent-facing raw Markdown docs under `/md/`, and `product-docs` to Agora product docs. +- Add `docs/site.env` and Pages build-time URL injection so staging docs can publish with different `CLI_DOCS_BASE_URL` / `CLI_DOCS_MD_BASE_URL` values while keeping predictable human and Markdown paths. - Add global `--yes` / `-y` and `AGORA_NO_INPUT=1` support to accept defaults and suppress prompts. - Add pretty-mode progress status lines for long-running clone, OAuth, and project creation work. - Add dynamic shell completions for project names, quickstart templates, and project features, with an on-disk completion cache under `/cache/projects.json` so `agora project use ` is instant on warm caches. Configurable via `AGORA_PROJECT_CACHE_TTL_SECONDS` and disable-able via `AGORA_DISABLE_CACHE=1`. diff --git a/docs/automation.md b/docs/automation.md index 6d7a073..34bbd33 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -39,6 +39,7 @@ Use this guide for: - Set `AGORA_AGENT=` in automated environments to explicitly label agent traffic in the API `User-Agent`. When unset, the CLI may infer a coarse label such as `cursor`, `claude-code`, `cline`, `windsurf`, `codex`, or `aider` from known agent environment markers. Set `AGORA_AGENT_DISABLE_INFER=1` to disable inference. - Use `agora mcp serve --transport stdio` to expose local Agora CLI tools to MCP-capable agents. The full surface is exposed: `agora.version`, `agora.introspect`, `agora.auth.{status,logout}`, `agora.config.{path,get}`, `agora.telemetry.status`, `agora.upgrade.check`, `agora.project.{list,show,use,create,doctor,env,env_write}`, `agora.project.feature.{list,status,enable}`, `agora.quickstart.{list,create,env_write}`, and `agora.init`. Authentication is intentionally **not** exposed via MCP because OAuth requires an interactive browser; run `agora login` once on the host first. - Use `agora open --target docs` for the human GitHub Pages docs and `agora open --target docs-md` for the agent-facing raw Markdown index. The Markdown tree is published under predictable `/md/` URLs, for example `/md/commands.md`, `/md/automation.md`, and `/md/error-codes.md`. +- Docs publishing reads `docs/site.env` for `CLI_DOCS_BASE_URL` and `CLI_DOCS_MD_BASE_URL`; staging Pages builds can override those environment variables at workflow time without changing docs content. The resolved values are published as `/docs.env` for transparency. - The CLI maintains a short-lived on-disk completion cache for `agora project use ` under `/cache/projects.json`. The cache is only used for completions when a **local unexpired session exists** (`session.json` with a non-empty access token and a future `expiresAt`, when present), so Tab does not suggest stale project names after logout or local session expiry. The cache TTL is 5 minutes by default; override with `AGORA_PROJECT_CACHE_TTL_SECONDS=` (set to `0` to disable). Cache files older than 24 h are pruned at every CLI startup. Set `AGORA_DISABLE_CACHE=1` to drop the cache on the next startup. The cache is invalidated automatically by `agora logout` and `agora project create` (the latter clears the file; it does not embed the new project until the next successful list fetch). To **force-refresh** the cached completion page, run `agora project list --refresh-cache` while authenticated; that command fetches the unfiltered first page used by completion and rewrites `projects.json` when it succeeds. ### CI auto-detect diff --git a/docs/index.md b/docs/index.md index e2e95c1..4c148dd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,13 +14,13 @@ Agora CLI is the native command-line tool for Agora authentication, project mana The same docs are published as raw Markdown under predictable `/md/` URLs for agents and scripts: -- [Markdown index](https://agoraio.github.io/cli/md/index.md) -- [Markdown command reference](https://agoraio.github.io/cli/md/commands.md) -- [Markdown automation contract](https://agoraio.github.io/cli/md/automation.md) -- [Markdown error codes](https://agoraio.github.io/cli/md/error-codes.md) -- [Markdown install guide](https://agoraio.github.io/cli/md/install.md) -- [Markdown telemetry controls](https://agoraio.github.io/cli/md/telemetry.md) -- [Markdown agent rules guide](https://agoraio.github.io/cli/md/agents/README.md) +- [Markdown index](@@CLI_DOCS_MD_BASE_URL@@/index.md) +- [Markdown command reference](@@CLI_DOCS_MD_BASE_URL@@/commands.md) +- [Markdown automation contract](@@CLI_DOCS_MD_BASE_URL@@/automation.md) +- [Markdown error codes](@@CLI_DOCS_MD_BASE_URL@@/error-codes.md) +- [Markdown install guide](@@CLI_DOCS_MD_BASE_URL@@/install.md) +- [Markdown telemetry controls](@@CLI_DOCS_MD_BASE_URL@@/telemetry.md) +- [Markdown agent rules guide](@@CLI_DOCS_MD_BASE_URL@@/agents/README.md) ## Common Commands diff --git a/docs/site.env b/docs/site.env new file mode 100644 index 0000000..fe5771c --- /dev/null +++ b/docs/site.env @@ -0,0 +1,2 @@ +CLI_DOCS_BASE_URL=https://agoraio.github.io/cli +CLI_DOCS_MD_BASE_URL=https://agoraio.github.io/cli/md diff --git a/internal/cli/open_targets_test.go b/internal/cli/open_targets_test.go index 549f390..2b33831 100644 --- a/internal/cli/open_targets_test.go +++ b/internal/cli/open_targets_test.go @@ -109,8 +109,26 @@ func TestCLIDocsURLMatchesPagesWorkflow(t *testing.T) { if cliDocsMarkdownURL != cliDocsURL+"md/index.md" { t.Fatalf("cliDocsMarkdownURL = %q, want %q", cliDocsMarkdownURL, cliDocsURL+"md/index.md") } - if !strings.Contains(string(body), "Copy raw Markdown docs for agents") || !strings.Contains(string(body), "_site/md") { - t.Fatal("pages.yml does not copy raw Markdown docs into _site/md for agent-facing URLs") + if !strings.Contains(string(body), "Prepare human and agent docs") || + !strings.Contains(string(body), "scripts/prepare-pages-site.py") || + !strings.Contains(string(body), "docs/site.env") { + t.Fatal("pages.yml does not prepare docs with the env-file driven Pages script") + } +} + +func TestPagesSiteEnvDefaultsMatchOpenTargets(t *testing.T) { + repoRoot := findRepoRootForTest(t) + siteEnv := filepath.Join(repoRoot, "docs", "site.env") + body, err := os.ReadFile(siteEnv) + if err != nil { + t.Fatalf("could not read %s: %v", siteEnv, err) + } + content := string(body) + if !strings.Contains(content, "CLI_DOCS_BASE_URL="+strings.TrimSuffix(cliDocsURL, "/")) { + t.Fatalf("docs/site.env does not match cliDocsURL %q:\n%s", cliDocsURL, content) + } + if !strings.Contains(content, "CLI_DOCS_MD_BASE_URL="+strings.TrimSuffix(cliDocsURL, "/")+"/md") { + t.Fatalf("docs/site.env does not match cliDocsMarkdownURL base %q:\n%s", cliDocsMarkdownURL, content) } } diff --git a/scripts/prepare-pages-site.py b/scripts/prepare-pages-site.py new file mode 100644 index 0000000..29850d2 --- /dev/null +++ b/scripts/prepare-pages-site.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Prepare the GitHub Pages artifact for human and agent docs. + +The Pages workflow renders the human site with Jekyll, then this script: + +1. Reads docs/site.env for default published URLs. +2. Lets workflow/job env vars override those defaults for staging builds. +3. Replaces URL placeholders in rendered site files. +4. Copies raw Markdown and MDC files to _site/md/ with the same replacements. +5. Publishes the resolved URL config at _site/docs.env for transparency. +""" + +from __future__ import annotations + +import argparse +import os +import shutil +from pathlib import Path + + +DEFAULTS = { + "CLI_DOCS_BASE_URL": "https://agoraio.github.io/cli", + "CLI_DOCS_MD_BASE_URL": "https://agoraio.github.io/cli/md", +} + + +def read_env_file(path: Path) -> dict[str, str]: + values: dict[str, str] = {} + if not path.exists(): + return values + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + values[key.strip()] = value.strip().strip('"').strip("'") + return values + + +def resolved_values(env_file: Path) -> dict[str, str]: + values = DEFAULTS | read_env_file(env_file) + for key in DEFAULTS: + override = os.environ.get(key, "").strip() + if override: + values[key] = override + values["CLI_DOCS_BASE_URL"] = values["CLI_DOCS_BASE_URL"].rstrip("/") + values["CLI_DOCS_MD_BASE_URL"] = values["CLI_DOCS_MD_BASE_URL"].rstrip("/") + return values + + +def replace_tokens(text: str, values: dict[str, str]) -> str: + for key, value in values.items(): + text = text.replace(f"@@{key}@@", value) + return text + + +def replace_tokens_in_tree(root: Path, values: dict[str, str]) -> None: + for path in root.rglob("*"): + if not path.is_file() or path.suffix not in {".html", ".md", ".mdc", ".txt", ".xml"}: + continue + original = path.read_text(encoding="utf-8") + updated = replace_tokens(original, values) + if updated != original: + path.write_text(updated, encoding="utf-8") + + +def copy_raw_markdown(source: Path, site: Path, values: dict[str, str]) -> None: + destination = site / "md" + for path in source.rglob("*"): + if not path.is_file() or path.suffix not in {".md", ".mdc"}: + continue + target = destination / path.relative_to(source) + target.parent.mkdir(parents=True, exist_ok=True) + content = path.read_text(encoding="utf-8") + target.write_text(replace_tokens(content, values), encoding="utf-8") + shutil.copystat(path, target) + + +def write_resolved_env(site: Path, values: dict[str, str]) -> None: + body = "".join(f"{key}={value}\n" for key, value in sorted(values.items())) + (site / "docs.env").write_text(body, encoding="utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--source", default="docs", type=Path) + parser.add_argument("--site", default="_site", type=Path) + parser.add_argument("--env-file", default=Path("docs/site.env"), type=Path) + args = parser.parse_args() + + values = resolved_values(args.env_file) + replace_tokens_in_tree(args.site, values) + copy_raw_markdown(args.source, args.site, values) + write_resolved_env(args.site, values) + print(f"prepared Pages docs with CLI_DOCS_BASE_URL={values['CLI_DOCS_BASE_URL']}") + print(f"prepared Pages docs with CLI_DOCS_MD_BASE_URL={values['CLI_DOCS_MD_BASE_URL']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 59dffa22e03380f53cf18a047e018e5077063823 Mon Sep 17 00:00:00 2001 From: digitallysavvy Date: Thu, 30 Apr 2026 21:33:58 -0400 Subject: [PATCH 13/26] docs: add Jekyll theme, local Pages preview, and command reference front matter Ship a default layout and site CSS for GitHub Pages, exclude site.env from the Jekyll build, add make docs-preview via preview-pages-site.sh + URL prep, emit YAML front matter from docgen for the command reference, align Markdown docs with the layout, ignore _local _site output, and record the change in the changelog. --- .gitignore | 3 + CHANGELOG.md | 2 + Makefile | 7 +- docs/_config.yml | 16 ++ docs/_layouts/default.html | 37 ++++ docs/agents/README.md | 4 + docs/agents/claude.md | 4 + docs/agents/windsurf.md | 4 + docs/assets/css/site.css | 321 ++++++++++++++++++++++++++++++++++ docs/automation.md | 4 + docs/commands.md | 4 + docs/error-codes.md | 4 + docs/index.md | 22 ++- docs/install.md | 4 + docs/telemetry.md | 4 + internal/cli/docgen.go | 3 + scripts/preview-pages-site.sh | 57 ++++++ 17 files changed, 494 insertions(+), 6 deletions(-) create mode 100644 docs/_config.yml create mode 100644 docs/_layouts/default.html create mode 100644 docs/assets/css/site.css create mode 100755 scripts/preview-pages-site.sh diff --git a/.gitignore b/.gitignore index 0d93969..1a5cec7 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ agora-cli-go # IDE and editor files .idea/ .vscode/ + +# Local Jekyll / Pages preview output (see `make docs-preview`) +_site/ diff --git a/CHANGELOG.md b/CHANGELOG.md index a64b502..8896c7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ Earlier entries pre-date this convention and only carry their version's compare - Add GitHub Pages publishing for generated CLI docs and route `agora open --target docs` to the human CLI docs site, `agora open --target docs-md` to the agent-facing raw Markdown docs under `/md/`, and `product-docs` to Agora product docs. - Add `docs/site.env` and Pages build-time URL injection so staging docs can publish with different `CLI_DOCS_BASE_URL` / `CLI_DOCS_MD_BASE_URL` values while keeping predictable human and Markdown paths. +- Add a custom GitHub Pages theme for the human docs with responsive layout, system light/dark mode via `prefers-color-scheme`, and no manual theme toggle. +- Add `make docs-preview` for a Ruby/Jekyll local docs preview that builds with localhost-friendly paths, injects localhost docs URLs, and serves both the human site and `/md/` Markdown tree. - Add global `--yes` / `-y` and `AGORA_NO_INPUT=1` support to accept defaults and suppress prompts. - Add pretty-mode progress status lines for long-running clone, OAuth, and project creation work. - Add dynamic shell completions for project names, quickstart templates, and project features, with an on-disk completion cache under `/cache/projects.json` so `agora project use ` is instant on warm caches. Configurable via `AGORA_PROJECT_CACHE_TTL_SECONDS` and disable-able via `AGORA_DISABLE_CACHE=1`. diff --git a/Makefile b/Makefile index 8104c0a..7ba4c84 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test lint lint-go lint-fmt snapshot snapshot-error-codes release release-snapshot docs-commands docs-commands-check +.PHONY: build test lint lint-go lint-fmt snapshot snapshot-error-codes release release-snapshot docs-commands docs-commands-check docs-preview # Build a local agora binary with stripped paths. build: @@ -68,3 +68,8 @@ docs-commands: # commit the result alongside the code change. docs-commands-check: go run ./cmd/gendocs -check + +# Build and serve the GitHub Pages docs locally with the custom theme. +# Uses an empty local baseurl so assets resolve from http://localhost:4000/. +docs-preview: + ./scripts/preview-pages-site.sh diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 0000000..afc1eb8 --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,16 @@ +title: Agora CLI Docs +description: Human and agent documentation for Agora CLI. +url: "https://agoraio.github.io" +baseurl: "/cli" + +markdown: kramdown +highlighter: rouge + +defaults: + - scope: + path: "" + values: + layout: default + +exclude: + - site.env diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html new file mode 100644 index 0000000..422d70c --- /dev/null +++ b/docs/_layouts/default.html @@ -0,0 +1,37 @@ + + + + + + + {% if page.title %}{{ page.title | escape }} · {% endif %}{{ site.title | escape }} + + + + + + + diff --git a/docs/agents/README.md b/docs/agents/README.md index a9fb5a5..59dfbdc 100644 --- a/docs/agents/README.md +++ b/docs/agents/README.md @@ -1,3 +1,7 @@ +--- +title: Agent Rules +--- + # Agent Rules For Agora Projects These snippets help AI coding agents use Agora CLI safely and consistently in app repositories. diff --git a/docs/agents/claude.md b/docs/agents/claude.md index 5c5eb15..daf4e26 100644 --- a/docs/agents/claude.md +++ b/docs/agents/claude.md @@ -1,3 +1,7 @@ +--- +title: Claude Agent Rules +--- + # Agora CLI Agent Rules - Use `agora --help --all --json` or `agora introspect --json` for command discovery. diff --git a/docs/agents/windsurf.md b/docs/agents/windsurf.md index 5c5eb15..99d96a7 100644 --- a/docs/agents/windsurf.md +++ b/docs/agents/windsurf.md @@ -1,3 +1,7 @@ +--- +title: Windsurf Agent Rules +--- + # Agora CLI Agent Rules - Use `agora --help --all --json` or `agora introspect --json` for command discovery. diff --git a/docs/assets/css/site.css b/docs/assets/css/site.css new file mode 100644 index 0000000..bb0983e --- /dev/null +++ b/docs/assets/css/site.css @@ -0,0 +1,321 @@ +:root { + color-scheme: light dark; + --bg: #f6f3ed; + --bg-strong: #ebe5d8; + --panel: rgba(255, 252, 246, 0.86); + --panel-border: rgba(28, 33, 39, 0.12); + --text: #15191f; + --muted: #646a73; + --link: #006d77; + --link-hover: #004f59; + --accent: #ff6b35; + --accent-soft: rgba(255, 107, 53, 0.14); + --code-bg: #141a21; + --code-text: #f7efe2; + --inline-code-bg: rgba(0, 109, 119, 0.1); + --shadow: 0 30px 80px rgba(21, 25, 31, 0.14); + --radius: 28px; + --measure: 76ch; + --font-body: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0b1016; + --bg-strong: #111a24; + --panel: rgba(17, 26, 36, 0.82); + --panel-border: rgba(222, 230, 237, 0.14); + --text: #eef2f6; + --muted: #aab4c0; + --link: #6fe3e8; + --link-hover: #a7f5f7; + --accent: #ff8f5c; + --accent-soft: rgba(255, 143, 92, 0.16); + --code-bg: #05080c; + --code-text: #fbf4e8; + --inline-code-bg: rgba(111, 227, 232, 0.14); + --shadow: 0 32px 90px rgba(0, 0, 0, 0.42); + } +} + +* { + box-sizing: border-box; +} + +html { + min-height: 100%; + background: + radial-gradient(circle at top left, var(--accent-soft), transparent 34rem), + linear-gradient(135deg, var(--bg), var(--bg-strong)); +} + +body { + min-height: 100%; + margin: 0; + color: var(--text); + font-family: var(--font-body); + font-size: 16px; + line-height: 1.65; + text-rendering: optimizeLegibility; +} + +body::before { + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + content: ""; + background-image: + linear-gradient(var(--panel-border) 1px, transparent 1px), + linear-gradient(90deg, var(--panel-border) 1px, transparent 1px); + background-size: 42px 42px; + mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.42), transparent 72%); +} + +a { + color: var(--link); + text-decoration-color: color-mix(in srgb, var(--link) 35%, transparent); + text-decoration-thickness: 0.09em; + text-underline-offset: 0.18em; +} + +a:hover { + color: var(--link-hover); +} + +.page-shell { + width: min(1180px, calc(100% - 32px)); + margin: 0 auto; + padding: 28px 0 56px; +} + +.site-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + margin-bottom: 24px; +} + +.brand { + display: inline-flex; + align-items: center; + gap: 12px; + color: var(--text); + text-decoration: none; +} + +.brand-mark { + display: grid; + width: 44px; + height: 44px; + place-items: center; + border: 1px solid var(--panel-border); + border-radius: 16px; + background: var(--panel); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08); + color: var(--accent); + font-family: var(--font-mono); + font-size: 0.9rem; + font-weight: 800; + letter-spacing: -0.08em; +} + +.brand-title, +.brand-subtitle { + display: block; +} + +.brand-title { + font-weight: 820; + letter-spacing: -0.04em; +} + +.brand-subtitle { + margin-top: -4px; + color: var(--muted); + font-size: 0.82rem; +} + +.site-nav { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.site-nav a { + padding: 8px 12px; + border: 1px solid var(--panel-border); + border-radius: 999px; + background: color-mix(in srgb, var(--panel) 86%, transparent); + color: var(--text); + font-size: 0.88rem; + font-weight: 700; + text-decoration: none; +} + +.site-nav a:hover { + border-color: color-mix(in srgb, var(--accent) 55%, var(--panel-border)); + color: var(--accent); +} + +.content-card { + overflow: hidden; + border: 1px solid var(--panel-border); + border-radius: var(--radius); + background: var(--panel); + box-shadow: var(--shadow); + backdrop-filter: blur(18px); +} + +.doc-content { + max-width: var(--measure); + padding: clamp(28px, 5vw, 72px); +} + +.doc-content > :first-child { + margin-top: 0; +} + +.doc-content > :last-child { + margin-bottom: 0; +} + +h1, +h2, +h3, +h4 { + color: var(--text); + line-height: 1.12; + letter-spacing: -0.045em; +} + +h1 { + max-width: 14ch; + margin: 0 0 0.7em; + font-size: clamp(2.8rem, 9vw, 6.6rem); +} + +h2 { + margin-top: 2.2em; + padding-top: 0.35em; + border-top: 1px solid var(--panel-border); + font-size: clamp(1.55rem, 3vw, 2.45rem); +} + +h3 { + margin-top: 1.8em; + font-size: 1.35rem; +} + +p, +li { + color: var(--text); +} + +p { + margin: 1em 0; +} + +ul, +ol { + padding-left: 1.4rem; +} + +li + li { + margin-top: 0.35rem; +} + +blockquote { + margin: 1.6rem 0; + padding: 1rem 1.2rem; + border-left: 4px solid var(--accent); + border-radius: 0 18px 18px 0; + background: var(--accent-soft); + color: var(--text); +} + +code { + border-radius: 0.45em; + background: var(--inline-code-bg); + color: color-mix(in srgb, var(--text) 90%, var(--accent)); + font-family: var(--font-mono); + font-size: 0.9em; + padding: 0.13em 0.38em; +} + +pre { + overflow-x: auto; + margin: 1.35rem 0; + padding: 1.1rem 1.25rem; + border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent); + border-radius: 20px; + background: var(--code-bg); + color: var(--code-text); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + +pre code { + padding: 0; + background: transparent; + color: inherit; +} + +table { + display: block; + overflow-x: auto; + width: 100%; + margin: 1.5rem 0; + border-collapse: collapse; + border: 1px solid var(--panel-border); + border-radius: 18px; +} + +th, +td { + padding: 0.72rem 0.85rem; + border-bottom: 1px solid var(--panel-border); + text-align: left; + vertical-align: top; +} + +th { + background: color-mix(in srgb, var(--accent-soft) 62%, transparent); + color: var(--text); + font-size: 0.84rem; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +tr:last-child td { + border-bottom: 0; +} + +hr { + height: 1px; + border: 0; + margin: 2rem 0; + background: var(--panel-border); +} + +@media (max-width: 760px) { + .page-shell { + width: min(100% - 20px, 1180px); + padding-top: 16px; + } + + .site-header { + align-items: flex-start; + flex-direction: column; + } + + .site-nav { + justify-content: flex-start; + } + + .doc-content { + padding: 24px; + } +} diff --git a/docs/automation.md b/docs/automation.md index 34bbd33..670598f 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -1,3 +1,7 @@ +--- +title: Automation Contract +--- + # Automation Contract This document defines the machine-consumption contract for Agora CLI. diff --git a/docs/commands.md b/docs/commands.md index 3b34806..0c793bc 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1,3 +1,7 @@ +--- +title: Command Reference +--- + # Agora CLI — Command Reference > Generated from `agora introspect --json` on 2026-05-01. Do not edit by hand — run `make docs-commands` or rely on the release workflow to regenerate. diff --git a/docs/error-codes.md b/docs/error-codes.md index ce13b53..20a6ad7 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -1,3 +1,7 @@ +--- +title: Error Codes +--- + # Agora CLI Error Codes Structured JSON failures include `error.code` when the CLI can classify the recovery path. Agents and scripts should branch on `error.code` first, then `error.message`. diff --git a/docs/index.md b/docs/index.md index 4c148dd..25a80e4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,14 +1,18 @@ +--- +title: Agora CLI Docs +--- + # Agora CLI Docs Agora CLI is the native command-line tool for Agora authentication, project management, quickstart setup, and developer onboarding. ## Start Here -- [Install options](install.md) -- [Command reference](commands.md) -- [Automation and JSON contract](automation.md) -- [Stable error codes](error-codes.md) -- [Telemetry controls](telemetry.md) +- [Install options](install.html) +- [Command reference](commands.html) +- [Automation and JSON contract](automation.html) +- [Stable error codes](error-codes.html) +- [Telemetry controls](telemetry.html) ## Agent Markdown @@ -22,6 +26,14 @@ The same docs are published as raw Markdown under predictable `/md/` URLs for ag - [Markdown telemetry controls](@@CLI_DOCS_MD_BASE_URL@@/telemetry.md) - [Markdown agent rules guide](@@CLI_DOCS_MD_BASE_URL@@/agents/README.md) +## Local Preview + +```bash +make docs-preview +``` + +That builds the themed HTML site locally with system light/dark mode and serves the raw Markdown tree under `/md/`. + ## Common Commands ```bash diff --git a/docs/install.md b/docs/install.md index 4f6a148..f5a6a84 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,3 +1,7 @@ +--- +title: Install Agora CLI +--- + # Install Agora CLI This page lists the supported installation paths for Agora CLI and the direct installers for macOS, Linux, and Windows. diff --git a/docs/telemetry.md b/docs/telemetry.md index 353b2ac..f1bed1c 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -1,3 +1,7 @@ +--- +title: Telemetry +--- + # Telemetry Agora CLI telemetry is limited to operational diagnostics such as command failures and local log metadata. It must never include OAuth tokens, app certificates, dotenv secrets, or project env values. diff --git a/internal/cli/docgen.go b/internal/cli/docgen.go index 116e11d..07ff861 100644 --- a/internal/cli/docgen.go +++ b/internal/cli/docgen.go @@ -31,6 +31,9 @@ func RenderCommandReference(out io.Writer, root *cobra.Command) error { stamp := time.Now().UTC().Format("2006-01-02") var b strings.Builder + b.WriteString("---\n") + b.WriteString("title: Command Reference\n") + b.WriteString("---\n\n") b.WriteString("# Agora CLI — Command Reference\n\n") b.WriteString("> Generated from `agora introspect --json` on ") b.WriteString(stamp) diff --git a/scripts/preview-pages-site.sh b/scripts/preview-pages-site.sh new file mode 100755 index 0000000..8031dde --- /dev/null +++ b/scripts/preview-pages-site.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PORT="${PORT:-4000}" +SITE_DIR="${SITE_DIR:-_site}" + +cd "$ROOT" + +if ! command -v jekyll >/dev/null 2>&1 && command -v gem >/dev/null 2>&1; then + GEM_HOME_BIN="$(gem env home 2>/dev/null)/bin" + if [ -x "${GEM_HOME_BIN}/jekyll" ]; then + export PATH="${GEM_HOME_BIN}:${PATH}" + fi +fi + +if ! command -v jekyll >/dev/null 2>&1; then + cat >&2 <<'EOF' +jekyll was not found on PATH. + +If you installed Ruby with Homebrew, add both Ruby and the gem bin directory: + + echo 'export PATH="/opt/homebrew/opt/ruby/bin:/opt/homebrew/lib/ruby/gems/4.0.0/bin:$PATH"' >> ~/.zshrc + source ~/.zshrc + gem install bundler jekyll + +Then re-run: + + make docs-preview + +EOF + exit 127 +fi + +rm -rf "$SITE_DIR" + +# Local preview serves _site at http://localhost:${PORT}/, so strip the +# production GitHub Pages baseurl (/cli) while building. The production +# workflow still uses docs/_config.yml unchanged. +jekyll build -s docs -d "$SITE_DIR" --baseurl "" + +CLI_DOCS_BASE_URL="http://localhost:${PORT}" \ +CLI_DOCS_MD_BASE_URL="http://localhost:${PORT}/md" \ + python3 scripts/prepare-pages-site.py --source docs --site "$SITE_DIR" --env-file docs/site.env + +cat < Date: Thu, 30 Apr 2026 22:52:58 -0400 Subject: [PATCH 14/26] f --- docs/_layouts/default.html | 14 +- docs/assets/agora-icon.svg | 11 ++ docs/assets/css/site.css | 387 +++++++++++++++++++++++++++++-------- docs/index.md | 100 +++++++--- docs/llms.txt | 79 ++++++++ docs/robots.txt | 38 ++++ docs/sitemap.xml | 58 ++++++ 7 files changed, 571 insertions(+), 116 deletions(-) create mode 100644 docs/assets/agora-icon.svg create mode 100644 docs/llms.txt create mode 100644 docs/robots.txt create mode 100644 docs/sitemap.xml diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index 422d70c..f1e35b9 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -6,13 +6,25 @@ {% if page.title %}{{ page.title | escape }} · {% endif %}{{ site.title | escape }} + + + + + + + + + +
diff --git a/docs/llms.txt b/docs/llms.txt index 029bbd8..a5b8360 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -4,7 +4,7 @@ ## Quick Start -Install: curl -fsSL https://download.agora.io/cli/install.sh | sh +Install: curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh | sh -s -- --add-to-path Login: agora login Initialize project: agora init my-demo --template nextjs From d7149f5cebb9ee6e784bfee09d503daa5b39dd24 Mon Sep 17 00:00:00 2001 From: digitallysavvy Date: Fri, 1 May 2026 07:42:21 -0400 Subject: [PATCH 16/26] chore(release): set v0.2.0 changelog date to 2026-05-01 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align CONTRIBUTING’s release example, CHANGELOG heading and header note, version.go ldflags comment, and bug report placeholder with the May 1 ship date. --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- CHANGELOG.md | 4 ++-- CONTRIBUTING.md | 2 +- internal/cli/version.go | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index ff869d0..e81d61c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -18,7 +18,7 @@ body: attributes: label: CLI version description: Output of `agora --version` - placeholder: "e.g. agora-cli-go 0.2.0 (commit abc1234, built 2026-04-30)" + placeholder: "e.g. agora-cli-go 0.2.0 (commit abc1234, built 2026-05-01)" validations: required: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 8896c7f..5a9ffa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). When tagging a new release, rename the `[Unreleased]` section to the new version -(e.g. `[0.2.0] - 2026-04-30`), add a fresh empty `[Unreleased]` heading at the top, +(e.g. `[0.2.0] - 2026-05-01`), add a fresh empty `[Unreleased]` heading at the top, and update the link references at the bottom of this file. When adding a new entry, link the change to the PR or commit that introduced it @@ -15,7 +15,7 @@ Earlier entries pre-date this convention and only carry their version's compare ## [Unreleased] -## [0.2.0] - 2026-04-30 +## [0.2.0] - 2026-05-01 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f92ad3e..2c8d594 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -143,7 +143,7 @@ change; prefer adding a new code and deprecating the old one over a rename. for user-facing changes (new commands, behavior changes, breaking changes, CLI exit code changes, error code additions). When cutting a release, move those bullets into a dated `## [x.y.z] - YYYY-MM-DD` section per the note at - the top of `CHANGELOG.md`. + the top of `CHANGELOG.md` (for example, v0.2.0 shipped as `## [0.2.0] - 2026-05-01`). - For UI/UX-affecting changes (pretty output, prompts, progress events, errors), include before/after copy-paste samples in the PR description. diff --git a/internal/cli/version.go b/internal/cli/version.go index 98cea08..96dba2f 100644 --- a/internal/cli/version.go +++ b/internal/cli/version.go @@ -7,7 +7,7 @@ import "fmt" // // go build -ldflags '-X github.com/.../internal/cli.version=v0.2.0 // -X github.com/.../internal/cli.commit=abc1234 -// -X github.com/.../internal/cli.date=2026-04-30' +// -X github.com/.../internal/cli.date=2026-05-01' // // Snapshot/local builds keep the placeholder values below. var ( From f4369286e0b297133fe40218e500ed8470ea3277 Mon Sep 17 00:00:00 2001 From: digitallysavvy Date: Fri, 1 May 2026 16:11:51 -0400 Subject: [PATCH 17/26] feat(cli): add install doctor, env-help, skills, and telemetry refactor Introduce top-level agora doctor for local install diagnostics, agora env-help for the env catalog, and agora skills for curated agent recipes; split telemetry behind a small client interface, refresh installers and installer message tests in CI, expand docs (troubleshooting, schema, proposals), and add SECURITY and SUPPORT policies. --- .github/workflows/ci.yml | 5 + AGENTS.md | 9 +- CHANGELOG.md | 33 ++ CONTRIBUTING.md | 34 +- Dockerfile | 25 +- README.md | 83 +-- SECURITY.md | 78 +++ SUPPORT.md | 54 ++ config.example.json | 4 +- docs/_layouts/default.html | 2 + docs/automation.md | 38 +- docs/commands.md | 47 +- docs/error-codes.md | 7 + docs/index.md | 2 +- docs/install.md | 63 ++- docs/llms.txt | 57 ++- docs/proposals/ci-matrix-expansion.md | 161 ++++++ docs/proposals/supply-chain-hardening.md | 265 ++++++++++ docs/proposals/telemetry-sentry-wireup.md | 215 ++++++++ docs/schema/envelope.v1.json | 122 +++++ docs/sitemap.xml | 50 ++ docs/telemetry.md | 62 ++- docs/troubleshooting.md | 186 +++++++ install.ps1 | 163 +++++- install.sh | 356 ++++++++++++- internal/cli/app.go | 23 +- internal/cli/app_test.go | 64 ++- internal/cli/commands.go | 77 ++- internal/cli/config.go | 23 +- internal/cli/env_help.go | 117 +++++ internal/cli/install_doctor.go | 484 ++++++++++++++++++ internal/cli/install_doctor_test.go | 95 ++++ internal/cli/integration_auth_test.go | 6 +- internal/cli/integration_project_test.go | 6 +- internal/cli/mcp.go | 4 + internal/cli/projects.go | 42 +- internal/cli/quickstart.go | 7 +- internal/cli/render.go | 113 +++- internal/cli/runtime_support.go | 10 +- internal/cli/skills.go | 359 +++++++++++++ internal/cli/telemetry.go | 167 ++++++ .../golden/introspect-global-flags.json | 10 +- internal/cli/upgrade.go | 4 +- scripts/test-installer-messages.sh | 126 +++++ 44 files changed, 3675 insertions(+), 183 deletions(-) create mode 100644 SECURITY.md create mode 100644 SUPPORT.md create mode 100644 docs/proposals/ci-matrix-expansion.md create mode 100644 docs/proposals/supply-chain-hardening.md create mode 100644 docs/proposals/telemetry-sentry-wireup.md create mode 100644 docs/schema/envelope.v1.json create mode 100644 docs/troubleshooting.md create mode 100644 internal/cli/env_help.go create mode 100644 internal/cli/install_doctor.go create mode 100644 internal/cli/install_doctor_test.go create mode 100644 internal/cli/skills.go create mode 100644 internal/cli/telemetry.go create mode 100644 scripts/test-installer-messages.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38081d6..dde65b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,6 +83,11 @@ jobs: shell: bash run: sh -n install.sh + - name: Smoke test installer messages + if: runner.os != 'Windows' + shell: bash + run: sh scripts/test-installer-messages.sh + - name: Check PowerShell installer syntax if: runner.os == 'Windows' shell: pwsh diff --git a/AGENTS.md b/AGENTS.md index 9994ec8..3485a18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,10 @@ internal/cli/ quickstart.go quickstart create / env write / list init.go init — one-step: project + quickstart + env doctor.go project doctor — readiness checks, workspace mode + install_doctor.go Top-level agora doctor — install self-test (PATH, network, auth, MCP host) + env_help.go agora env-help — authoritative env-var catalog + skills.go agora skills — curated workflow recipes (in-binary catalog) + telemetry.go telemetryClient interface + noop sink + Sentry placeholder (wire-up scheduled for next release) local_project.go .agora/project.json read/write; repo-local project binding runtime_support.go Template/runtime detection (nextjs, python, go), CI auto-detect, banner rules app_test.go Unit tests for app init and config @@ -70,10 +74,13 @@ agora ├── init Recommended path: reuses existing project (or creates if none); add --new-project to force creation ├── version Build version, commit, and date ├── introspect Machine-readable command metadata for agents +├── doctor Install self-test (PATH, version, network, auth, MCP host); use project doctor for project readiness +├── env-help Catalog of every AGORA_* env var the CLI honors +├── skills Curated workflow recipes for humans and AI agents (list / show / search) ├── open Open Console, CLI docs (human or /md/), or product docs ├── mcp MCP stdio server for agent tool integrations ├── telemetry Telemetry status/enable/disable -├── upgrade (alias: update) Print package-manager-specific upgrade guidance +├── upgrade (alias: update, self-update) In-place self-update on installer-managed installs; otherwise prints upgrade guidance ├── project │ ├── create Create a remote Agora project (control-plane only) │ ├── use Set global project context diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a9ffa6..90a12c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,23 @@ Earlier entries pre-date this convention and only carry their version's compare - Add `PROJECT_NAME_REQUIRED` error code for `project create` and the equivalent MCP tool. - Add `agora project list --refresh-cache` to explicitly refresh the unfiltered first page used by project-name shell completion. - Infer coarse agent labels for API `User-Agent` when `AGORA_AGENT` is unset; explicit `AGORA_AGENT` still takes precedence. +- Add top-level `agora doctor` command for an install self-test (binary path, PATH resolution, version, AGORA_HOME writability, API/OAuth network reachability, auth state, MCP host detection). Distinct from `agora project doctor` which validates a remote project. +- Add `agora env-help` command listing every `AGORA_*` (and `DO_NOT_TRACK`, `NO_COLOR`) environment variable the CLI honors, grouped by category, with defaults and accepted values. JSON envelope returns the catalog plus a category index. +- Add `agora skills` (list / show / search) curated workflow recipes for humans and AI agents. Read-only catalog shipped in the binary today; future releases can move to fetched skills with the same JSON shape. +- Add `--debug` global flag as the canonical name for runtime log echo (mirrors `AGORA_DEBUG=1`). Matches `gh`, `vercel`, `stripe`, and `supabase` conventions. See **Removed** below for the legacy `--verbose` / `AGORA_VERBOSE` cleanup that landed alongside it. +- Add `agora self-update` as an alias for `agora upgrade` / `agora update`. +- Add `--format envelope|json` to `agora project env` so callers can be explicit about the unified JSON envelope shape; `--format dotenv|shell` continue to render raw stdout for shell sourcing. Unknown formats now produce a typed error listing the valid choices. +- Add Cobra typo suggestions (`SuggestionsMinimumDistance: 2`) so `agora projct doctor` prints "Did you mean this? project". Matches `gh`, `kubectl`, `git` UX. +- Add `SECURITY.md` (private disclosure process, supported versions, safe harbor) and `SUPPORT.md` (channel routing for questions, bugs, security, install issues). +- Add `docs/troubleshooting.md` and link it from the README; add `Troubleshooting` and `Telemetry` to the GitHub Pages nav and sitemap. +- Add `docs/schema/envelope.v1.json` — JSON Schema for the unified envelope so wrappers can generate types or validate at runtime. +- Add curated agent rule snippets for the new `doctor`, `env-help`, and `skills` surfaces in the existing `docs/agents/` rule files via the next sync. +- Make installer shell setup auto-on by default and add granular opt-out flags. `install.sh` now adds the install directory to your shell rc when `agora` isn't already on `PATH` and writes a tab-completion script for the detected shell (bash, zsh, fish); `install.ps1` mirrors the behavior for user PATH and a PowerShell `$PROFILE` completion loader. New flags: `--no-path` / `-NoPath` (skip PATH only), `--no-completion` / `-NoCompletion` (skip completion only), and the umbrella `--skip-shell` / `-SkipShell` (binary only, no shell modifications). Matches modern installer DX (bun, fnm, deno, uv, volta). Auto-PATH wiring is **best-effort**: when the user's shell rc is unwritable (root-owned `~/.zshrc`, read-only mount, UAC denial, etc.) the installer never aborts — it uses bun-style branch wording (` is not writable, so the installer can't add agora to your PATH automatically.`) followed by an indented action block that names the installed binary path and the exact `export PATH=...` (POSIX) or `setx PATH ...` (Windows) line to paste. The same block is reused when the user explicitly opts out via `--no-path`, so the message is identical across both paths. +- Polish installer DX to match bun / uv conventions: softer wording when the shell rc is unwritable (no implicit-failure tone), an `exec $SHELL` (POSIX) / `$env:Path += ';...'` (PowerShell) follow-up so the user can use `agora` in the current shell after install, a bash candidate-list walk that chooses the first writable rc among `~/.bashrc`, `~/.bash_profile`, and `~/.profile`, and a docs URL footer in the manual fallback block. Backed by a new shell-based smoke test (`scripts/test-installer-messages.sh`) wired into CI to prevent regression. +- `agora doctor` now suggests the exact shell-aware command for fixing a missing PATH entry. The `path_resolution` failure suggestion now reads `echo 'export PATH=":$PATH"' >> ~/.zshrc && source ~/.zshrc` for zsh, the equivalent for bash and `fish_add_path` for fish, the `setx PATH ...` form on Windows, and a generic `~/.profile` fallback for unknown shells. The doctor derives `` from the running binary's location, so the command is always copy-pasteable. +- Add a curated `Requirements` and `Verifying release artifacts` section to the README, plus links to `SECURITY.md`, `SUPPORT.md`, the new troubleshooting doc, and `docs/schema/envelope.v1.json` from the Docs index. +- Add a telemetry stub (`internal/cli/telemetry.go`) with the `telemetryClient` interface, default no-op sink, redaction helper, and `sentryClient` placeholder so the next release wires Sentry by adding the SDK + replacing one constant. The on/off contract (`agora telemetry`, `AGORA_SENTRY_ENABLED`, `DO_NOT_TRACK`) is fully wired; transport is a no-op until Sentry is connected. +- Add the proposal documents `docs/proposals/supply-chain-hardening.md`, `docs/proposals/ci-matrix-expansion.md`, and `docs/proposals/telemetry-sentry-wireup.md` for the next release. ### Changed @@ -45,6 +62,18 @@ Earlier entries pre-date this convention and only carry their version's compare - `agora project env write` detects Next.js workspaces and writes `NEXT_PUBLIC_AGORA_APP_ID` / `NEXT_AGORA_APP_CERTIFICATE`, with `--template nextjs|standard` to override auto-detection. - `project env write` now creates or updates repo-local `.agora/project.json` for the selected project, recording `projectType` (framework/language detection such as `nextjs`, `go`, `python`, `node`, `standard`) and `envPath`, while quickstart-bound repos continue using a single `template` field for template lineage. - Build and release metadata now target Go 1.26.2, matching the current stable Go toolchain for distributed CLI builds. +- Standardize per-command `Example:` blocks across the entire command tree. Every Cobra command now ships at least one example, including the previously empty telemetry subcommands and the `mcp` parent. +- `agora project env` `--format` is now a typed enum (`dotenv | shell | envelope | json`) with a precedence-aware error message when combined with `--json` / `--shell`. +- Harden the published Docker image: pin Alpine 3.20, run as a non-root `agora` user (uid 10001) with `AGORA_HOME=/home/agora/.agora`, add `tini` as PID 1 for proper signal handling, set OCI labels (`org.opencontainers.image.*`), and default `CMD` to `--help` so `docker run ghcr.io/agoraio/agora-cli:latest` is self-explanatory. +- Rewrite `docs/llms.txt` to fix the outdated command list (`agora init`, `agora project doctor` instead of obsolete labels), document the new `--format envelope` exception for `agora project env`, link the Markdown-mirror docs, and expand the catalog of stable exit codes. +- Rename `agora quickstart list --verbose` to `--details`. The previous flag overloaded the `--verbose` name with a meaning ("show repository, runtime, and env details") completely different from the global `--debug` semantic, so we removed it as part of the broader `--verbose` cleanup. The JSON envelope key emitted under `data` was renamed from `verbose` to `details` to match. +- Rename the `agora config update --verbose` flag to `--debug` and rename the persisted config field from `verbose` to `debug`. Bump the on-disk config schema from version `2` to version `3`. **0.1.x configs auto-migrate**: any existing `verbose` key is silently promoted to `debug` on the first 0.2.0 launch, and the rewritten file no longer contains the legacy key. Users do not need to take any action. + +### Removed + +- **BREAKING**: Drop the legacy `--verbose` / `-v` global flag and the `AGORA_VERBOSE` environment variable. v0.2.0 ships only `--debug` / `AGORA_DEBUG` for the runtime log-echo control. Rationale: maintaining two names for the same flag indefinitely creates exactly the confusing DX the rename was meant to fix; the 0.1.9 → 0.2.0 boundary is the right place to make the break instead of carrying an alias forward as permanent technical debt. Migration: replace `--verbose` / `-v` with `--debug`, and `AGORA_VERBOSE=1` with `AGORA_DEBUG=1`. Persisted configs are auto-migrated (see Changed above). +- **BREAKING (installer)**: Drop the opt-in `--add-to-path` (`install.sh`) and `-AddToPath` (`install.ps1`) flags. Shell setup is now auto-on by default; opt out with the new `--no-path` / `--no-completion` / `--skip-shell` flags (or `-NoPath` / `-NoCompletion` / `-SkipShell` on Windows). Migration: drop `--add-to-path` from any pinned install command — the bare `curl ... | sh` (and `irm ... | iex`) now does the right thing. CI environments that explicitly want only the binary should switch to `--skip-shell`. +- **BREAKING (installer)**: Drop the `--completion auto|skip|bash|zsh|fish|powershell` flag in favor of `--no-completion` (and the umbrella `--skip-shell`). Auto-detect from `$SHELL` is the only supported wiring path; users who want a completion script for a shell different from `$SHELL` should run `agora completion ` directly. ### Fixed @@ -61,6 +90,10 @@ Earlier entries pre-date this convention and only carry their version's compare ### Documentation - Document the MCP transport caveat that `agora init`, `agora quickstart create`, `agora project create`, and `agora login` collapse their NDJSON progress event stream into the final `tools/call` result over MCP, since stdout is the JSON-RPC transport. +- README updates land in logical sections (Install requirements, Verifying releases, Docs index, Command Model additions, Troubleshooting redirect) without disturbing the marketing-first opening. +- `CONTRIBUTING.md` documents the branching model (`main` is releasable, topic branches off `main`), the commit-message convention, the optional DCO sign-off path, and the per-command example requirement for new commands. +- `docs/automation.md` adds a section documenting the `agora project env` raw-stdout exception and links the new envelope JSON Schema. +- `docs/proposals/` introduces a new directory for deferred-implementation proposals (supply-chain hardening, CI matrix expansion, Sentry wire-up). Each proposal carries a `status: proposed`, `target-release: next` front-matter so contributors can see what's planned without bisecting branches. ## [0.1.9] - 2026-04-30 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2c8d594..7d2812e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -128,12 +128,32 @@ change; prefer adding a new code and deprecating the old one over a rename. that is part of the public contract (JSON shape, exit code, stderr text). Use `app_test.go` for isolated helper logic. +## Branching model + +- `main` is always releasable. CI must be green before merge. +- Feature work happens on short-lived topic branches off `main` named + `feat/`, `fix/`, `docs/`, or `chore/` + (matches the conventional-commits prefixes). Avoid long-running + branches; rebase on `main` instead of merging it back into your + topic branch. +- Releases are cut from `main` by tagging `vX.Y.Z`. The release + workflow handles building, signing, publishing, and Homebrew / + Scoop / npm bumps. See [docs/install.md](docs/install.md) for the + release matrix. + ## Commit hygiene - Keep commits focused. One logical change per commit is preferred. - Write present-tense imperative subjects ("Add CI auto-detect", not - "Added CI auto-detect"). + "Added CI auto-detect"). We prefer (but do not strictly require) + the [Conventional Commits](https://www.conventionalcommits.org/) + prefixes (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, + `test:`, `build:`). - Reference issues with `Fixes #123` / `Refs #456` in the body when relevant. +- We do not require [DCO](https://developercertificate.org/) sign-off + today, but contributors are welcome to sign their commits with + `git commit -s`. If we adopt mandatory sign-off in the future, we + will announce it here and add a CI check. ## Pull requests @@ -146,6 +166,10 @@ change; prefer adding a new code and deprecating the old one over a rename. the top of `CHANGELOG.md` (for example, v0.2.0 shipped as `## [0.2.0] - 2026-05-01`). - For UI/UX-affecting changes (pretty output, prompts, progress events, errors), include before/after copy-paste samples in the PR description. +- New commands MUST include a per-command example block in the Cobra + `Example:` field. See "Adding a new command" above and the existing + `agora skills`, `agora doctor`, and `agora env-help` builders for the + current style. ## Reporting bugs and requesting features @@ -154,9 +178,11 @@ Use the GitHub issue templates: - [Bug report](https://github.com/AgoraIO/cli/issues/new?template=bug_report.yml) - [Feature request](https://github.com/AgoraIO/cli/issues/new?template=feature_request.yml) -For security issues, please email rather than filing a -public issue. Do not include credentials or App Certificates in any public -report. +For **support** (questions, "how do I", install help) see [SUPPORT.md](SUPPORT.md). + +For **security** issues, see [SECURITY.md](SECURITY.md). Email + rather than filing a public issue. Do not include +credentials or App Certificates in any public report. ## License diff --git a/Dockerfile b/Dockerfile index 61171b6..7266d97 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,25 @@ FROM alpine:3.20 -# git is required for `quickstart create` and `init` -RUN apk add --no-cache ca-certificates git +RUN apk add --no-cache ca-certificates git tini \ + && addgroup -S agora \ + && adduser -S -G agora -u 10001 -h /home/agora -s /sbin/nologin agora \ + && mkdir -p /home/agora/.agora \ + && chown -R agora:agora /home/agora -COPY agora /usr/local/bin/agora +COPY --chown=root:root agora /usr/local/bin/agora +RUN chmod 0755 /usr/local/bin/agora -ENTRYPOINT ["agora"] +LABEL org.opencontainers.image.title="agora-cli" \ + org.opencontainers.image.description="Native Agora CLI for authentication, project management, quickstart setup, and developer onboarding." \ + org.opencontainers.image.url="https://github.com/AgoraIO/cli" \ + org.opencontainers.image.documentation="https://agoraio.github.io/cli/" \ + org.opencontainers.image.licenses="MIT" \ + org.opencontainers.image.vendor="Agora.io" + +USER agora +WORKDIR /home/agora +ENV HOME=/home/agora \ + AGORA_HOME=/home/agora/.agora + +ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/agora"] +CMD ["--help"] diff --git a/README.md b/README.md index 8ea90f1..dea4d6f 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,17 @@ agora init my-nextjs-demo --template nextjs ## Install +### Requirements + +- macOS 12+, Linux (glibc 2.31+ or musl), or Windows 10+ for the prebuilt binaries. +- `git` on `PATH` for `agora init` and `agora quickstart create` (they shell out to `git clone`). +- For the npm install path, Node.js 18 or newer. +- For the source build, the Go toolchain pinned in [`go.mod`](go.mod). + +### Install the CLI + ```bash -curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh | sh -s -- --add-to-path +curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh | sh ``` Run the CLI: @@ -45,10 +54,22 @@ irm https://raw.githubusercontent.com/AgoraIO/cli/main/install.ps1 | iex Notes: - The shell installer supports macOS, Linux, and Windows POSIX shells such as Git Bash. Use `install.ps1` for native PowerShell installs on Windows. -- If the installer says `agora` is not on your PATH, re-run with `--add-to-path` or add the printed install directory to your shell profile. +- **Shell setup is auto-on**: the installer wires the install directory onto your `PATH` (when needed) and writes a shell completion script for the detected shell (bash, zsh, fish, or PowerShell). Pass `--no-path`, `--no-completion`, or the umbrella `--skip-shell` (PowerShell: `-NoPath` / `-NoCompletion` / `-SkipShell`) to opt out granularly. - Installer help is always available with `curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh | sh -s -- --help`. - Pinned versions, dry runs, custom install directories, npm details, and source builds are documented in [docs/install.md](docs/install.md). +### Verifying release artifacts + +Every release ships with a SHA-256 `checksums.txt` and a Cosign keyless signature. The official installers verify the SHA-256 automatically. You can also verify manually: + +```bash +# Verify SHA-256 against the published checksums.txt +curl -fsSLO https://github.com/AgoraIO/cli/releases/download/vX.Y.Z/checksums.txt +sha256sum -c checksums.txt --ignore-missing +``` + +For Cosign signature verification and the SBOM workflow, see the **Security** section of [docs/install.md](docs/install.md). Vulnerability disclosures: see [SECURITY.md](SECURITY.md). + ## First Run ```bash @@ -59,13 +80,18 @@ agora init my-nextjs-demo --template nextjs ## Docs - Human docs (GitHub Pages): [https://agoraio.github.io/cli/](https://agoraio.github.io/cli/) +- Agent-friendly Markdown mirror: [https://agoraio.github.io/cli/md/](https://agoraio.github.io/cli/md/) - Release notes: [CHANGELOG.md](CHANGELOG.md) - Install options (direct installer, Windows, source): [docs/install.md](docs/install.md) - Full command reference (auto-generated): [docs/commands.md](docs/commands.md) - Automation and JSON contract: [docs/automation.md](docs/automation.md) +- JSON envelope schema (machine-readable): [docs/schema/envelope.v1.json](docs/schema/envelope.v1.json) - Stable error codes: [docs/error-codes.md](docs/error-codes.md) - Telemetry controls: [docs/telemetry.md](docs/telemetry.md) -- Contributor and agent guide: [AGENTS.md](AGENTS.md) +- Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md) +- Security policy: [SECURITY.md](SECURITY.md) +- Support and contact channels: [SUPPORT.md](SUPPORT.md) +- Contributor and agent guide: [AGENTS.md](AGENTS.md), plus [CONTRIBUTING.md](CONTRIBUTING.md) Command examples use `agora` for the installed CLI. Local source builds are covered in [Build From Source](#build-from-source). @@ -79,10 +105,13 @@ The command model is intentionally layered: - `auth` for login and session inspection - `config` for local CLI defaults - `telemetry` for telemetry preferences -- `upgrade` / `update` for package-manager-specific upgrade guidance +- `upgrade` / `update` / `self-update` for in-place upgrade or package-manager-specific guidance - `open` to open the Console, published CLI docs (human or `/md/` Markdown), or product docs in a browser +- `doctor` for an install self-test (PATH, version, network, auth, MCP host) +- `env-help` to list every `AGORA_*` environment variable the CLI honors +- `skills` to browse curated workflow recipes for humans and AI agents - `mcp` to run the CLI as a local MCP server (`agora mcp serve`) for agent integrations -- `completion` for shell completion scripts (standard Cobra completion) +- `completion` for shell completion scripts (auto-installed by the installer; see `agora completion --help` for manual setup) Discover the full command tree: @@ -298,46 +327,22 @@ Built-in default config values are documented in [config.example.json](config.ex ## Troubleshooting -### Login or browser issues - -Try: +For a full troubleshooting guide with diagnostic commands, see [docs/troubleshooting.md](docs/troubleshooting.md). The fastest first step is always: ```bash -agora login --no-browser -``` - -You can also inspect the current auth state: - -```bash -agora whoami +agora doctor --json +agora project doctor --json ``` -### `git` is missing - -`quickstart create` and `init` shell out to `git clone`. Install `git` and retry. - -### Quickstart clone failures - -Check: - -- network access to GitHub -- that the target directory does not already exist -- that the quickstart repo URL is reachable +The most common issues: -### Missing app certificate for env injection - -Quickstart env injection requires a project with an app certificate. If the selected project has no certificate, `quickstart create --project`, `quickstart env write`, and `init` cannot seed the env file. - -### No project selected - -If a command needs a project and none is currently selected, either: - -```bash -agora quickstart env write my-go-demo --project my-project -agora project use my-project -``` +- **`agora` not found after install**: the installer wires PATH automatically by default; if you ran with `--no-path` or `--skip-shell`, re-run without it (or add the install directory to your shell profile manually). +- **OAuth browser does not open**: `agora login --no-browser` prints the URL so you can open it elsewhere; or `agora config update --browser-auto-open=false`. +- **`git` is missing**: `agora init` and `agora quickstart create` shell out to `git clone`. Install `git` and retry. +- **Project has no app certificate**: `quickstart env write`, `init`, and `project env --with-secrets` need a project with an App Certificate. Pick another project or enable one in [Agora Console](https://console.agora.io). +- **No project selected**: pass `--project `, run `agora project use `, or run from a repo that already has `.agora/project.json`. -or run it inside a repo that already has `.agora/project.json`. +Full guide with debug logging, CI tips, completion troubleshooting, and the `--debug` flag: [docs/troubleshooting.md](docs/troubleshooting.md). ## Build From Source diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..de6c452 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,78 @@ +# Security Policy + +We take the security of Agora CLI seriously. Thank you for helping us keep +developers and their Agora projects safe. + +## Supported Versions + +Security fixes are issued against the latest minor release line. Older +minor lines are best-effort only. + +| Version | Supported | +| ------- | ------------------ | +| 0.2.x | Yes | +| < 0.2.0 | Not supported. Please upgrade with `agora upgrade` or reinstall via [docs/install.md](docs/install.md). | + +## Reporting a Vulnerability + +Please do **not** file a public GitHub issue for suspected security +vulnerabilities. Instead: + +1. Email **security@agora.io** with the subject line + `[agora-cli] vulnerability report`. +2. Include: + - A description of the issue and the impact you observed. + - Steps to reproduce, ideally with `agora --version` output and the + `agora project doctor --json` envelope when relevant. + - Any proof-of-concept code or logs (avoid attaching real Agora App + Certificates or OAuth tokens; redact them as `[REDACTED]`). +3. We will acknowledge your report within **3 business days** and aim to + provide a remediation plan or status update within **10 business days**. + +If you do not receive an acknowledgement within 3 business days, please +follow up at **devrel@agora.io** so we can route your report internally. + +## Disclosure Process + +- We coordinate disclosure with reporters. Once a fix is available, we + publish a release with security notes in + [`CHANGELOG.md`](CHANGELOG.md) and credit reporters who wish to be + acknowledged. +- For high-severity issues we may publish a GitHub Security Advisory and + request a CVE. +- Please give us a reasonable window (typically **90 days** or until a + fix ships, whichever is sooner) before public disclosure. + +## Safe Harbor + +We support good-faith security research. As long as you: + +- avoid privacy violations, destruction of data, and interruption or + degradation of Agora services, +- only test against accounts and projects you own (or have explicit + permission to test), and +- give us a reasonable opportunity to respond before public disclosure, + +we will not pursue or support legal action against you for your research. +This safe harbor applies to the Agora CLI binary and the install scripts +distributed from this repository. Other Agora services have their own +disclosure programs at [agora.io/security](https://www.agora.io/en/about-us/security/). + +## Verifying Release Artifacts + +Every release ships with a SHA-256 `checksums.txt` and a Cosign keyless +signature. The official installers verify the SHA-256 automatically. To +verify Cosign attestations manually, see the **Security** section of +[docs/install.md](docs/install.md). + +## Out of Scope + +The following are out of scope for security reports against this repository: + +- Vulnerabilities in third-party Agora services (use the channels listed + at [agora.io/security](https://www.agora.io/en/about-us/security/)). +- Vulnerabilities in user-cloned quickstart applications. Those repos + have their own SECURITY.md files. +- Issues that require physical access to the user's machine, root access + granted by the user, or a compromised package mirror outside the + GitHub Releases / npm / Homebrew channels we publish to. diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..62d3bfc --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,54 @@ +# Support + +Thanks for using Agora CLI. Use the channel that matches your need so we +can route you to the right person quickly. + +## I have a question + +- **How does X work?** Check the docs first: + - Human-readable: [https://agoraio.github.io/cli/](https://agoraio.github.io/cli/) + - Agent-readable Markdown mirror: [https://agoraio.github.io/cli/md/](https://agoraio.github.io/cli/md/) + - Inline help: `agora --help`, `agora --help --all`, `agora introspect --json` +- **Discussion / community questions:** open a [GitHub Discussion](https://github.com/AgoraIO/cli/discussions). +- **Agora product questions** (RTC, RTM, ConvoAI, Console, billing): use + the support links at [agora.io/support](https://www.agora.io/en/support/). + +## I think I found a bug + +1. Run `agora --version` and `agora project doctor --json` and capture + the output. +2. File a bug at + [github.com/AgoraIO/cli/issues/new?template=bug_report.yml](https://github.com/AgoraIO/cli/issues/new?template=bug_report.yml). + +The bug template asks for the install method, OS, and the JSON envelope +so we can reproduce quickly. + +## I want to request a feature + +File a feature request at +[github.com/AgoraIO/cli/issues/new?template=feature_request.yml](https://github.com/AgoraIO/cli/issues/new?template=feature_request.yml). +The template helps us understand the user problem before we discuss +implementation. + +## I think I found a security vulnerability + +**Do not file a public issue.** See [SECURITY.md](SECURITY.md) for the +private disclosure process. + +## I'm having an installation issue + +See the **Troubleshooting** section of the [README](README.md#troubleshooting) +and [docs/install.md](docs/install.md). The shell installer prints exit +codes that map to specific failure modes (network, checksum, missing +prerequisite, etc.). + +## I want to contribute + +See [CONTRIBUTING.md](CONTRIBUTING.md) and the contributor / agent +guide in [AGENTS.md](AGENTS.md). + +## Response time expectations + +- Bug reports: triaged within 5 business days. +- Feature requests: discussed in the next planning cycle (no SLA). +- Security reports: acknowledged within 3 business days. See [SECURITY.md](SECURITY.md). diff --git a/config.example.json b/config.example.json index d135a8b..15915ca 100644 --- a/config.example.json +++ b/config.example.json @@ -1,5 +1,5 @@ { - "version": 2, + "version": 3, "apiBaseUrl": "https://agora-cli.agora.io", "browserAutoOpen": true, "logLevel": "info", @@ -8,5 +8,5 @@ "oauthScope": "basic_info,console", "output": "pretty", "telemetryEnabled": true, - "verbose": false + "debug": false } diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index f1e35b9..0eae2f5 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -35,6 +35,8 @@ Commands Automation Errors + Telemetry + Troubleshooting Agent MD diff --git a/docs/automation.md b/docs/automation.md index 670598f..681e740 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -36,7 +36,7 @@ Use this guide for: - In JSON mode, both success and failure return the same top-level envelope shape. - Use `--json --pretty` when a human needs to inspect JSON directly. Scripts should keep the default single-line JSON. - Use `--quiet` to suppress the success envelope in **both** pretty and JSON modes; the exit code becomes the only result. Errors are still printed on stderr (and as a JSON envelope on stdout when `--json` is set without `--quiet`). NDJSON progress events are still emitted because they are observability, not results. -- Use `--verbose` (equivalent to `AGORA_VERBOSE=1`) to echo structured log records to stderr. The flag does not change exit codes, JSON envelope shape, or NDJSON progress events; it only mirrors the entries that would normally be written to the log file. Pair with `--json` for fully machine-parseable runs that also surface internal events to your CI logs. +- Use `--debug` (equivalent to `AGORA_DEBUG=1`) to echo structured log records to stderr. The flag does not change exit codes, JSON envelope shape, or NDJSON progress events; it only mirrors the entries that would normally be written to the log file. Pair with `--json` for fully machine-parseable runs that also surface internal events to your CI logs. v0.2.0 dropped the legacy `--verbose` / `-v` alias and the `AGORA_VERBOSE` env var; persisted configs that contain a `verbose` key are auto-promoted to `debug` on first load. - Use `--yes` (or `-y`) / `AGORA_NO_INPUT=1` to assume the default answer to confirmation prompts. Following industry convention for `-y` (apt-style), the flag never starts brand-new interactive flows: in JSON, CI, or non-TTY contexts the CLI still fails fast with the same `AUTH_UNAUTHENTICATED` error you would have seen without `--yes`, instead of silently launching an OAuth browser flow. - Interactive login prompts only appear in interactive pretty-mode TTY runs. Automation should authenticate up front with `agora login`; `--json`, `AGORA_OUTPUT=json`, detected CI environments, and non-TTY stdin all skip the prompt and fail with `AUTH_UNAUTHENTICATED`. - Output mode precedence is: explicit CLI flag (`--json` or `--output`) first, user-set `AGORA_OUTPUT` second, then user-customized config file value, then **CI auto-detect → JSON** (see below), then pretty. @@ -140,6 +140,40 @@ Agent guidance: - treat pretty output as human-only - do not parse stderr when `--json` is in use +A **JSON Schema** for this envelope is published at +[`docs/schema/envelope.v1.json`](schema/envelope.v1.json) (also available +at the live URL `https://agoraio.github.io/cli/schema/envelope.v1.json`). +Wrappers that want compile-time type safety can generate types from the +schema with `quicktype`, `datamodel-code-generator`, or any +JSON-Schema-aware tool. + +### One documented exception: `agora project env` + +`agora project env` is the only command whose **default** (non-JSON) +output is raw stdout — without the unified envelope — so it can be used +with shell substitution: + +```bash +source <(agora project env --shell) +eval "$(agora project env --format shell)" +``` + +To explicitly request a specific format, pass `--format`: + +| Flag | Output | +|-----------------------|-------------------------------------------| +| `--format dotenv` | `KEY=value` lines (default; `>> .env`) | +| `--format shell` | shell `export KEY=value` statements | +| `--format envelope` | unified JSON envelope (alias of `--json`) | +| `--format json` | same as `--format envelope` | +| `--shell` | back-compat alias of `--format shell` | +| `--json` | unified JSON envelope | + +For automation, prefer `--json` (or `--format envelope`) so the result +has the same shape as every other command. `agora project env write` +already emits the unified envelope under all output modes — only the +read path has the raw-stdout exception above. + ## Progress Events (NDJSON Stream) Long-running commands emit one or more **progress events** to stdout ahead of the final envelope when `--json` is set. The wire format is **NDJSON** (newline-delimited JSON): one complete JSON object per line, terminated by `\n`. @@ -864,7 +898,7 @@ Returns the current resolved config object. Safe branch fields: - `logLevel` - `browserAutoOpen` - `telemetryEnabled` -- `verbose` +- `debug` (renamed from legacy `verbose` in v0.2.0; legacy key is migrated on first load) ### `config update` diff --git a/docs/commands.md b/docs/commands.md index 0c793bc..f814422 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -12,13 +12,13 @@ This page lists every enumerable command and its local flags. For long descripti | Flag | Type | Default | Description | |------|------|---------|-------------| +| `--debug` | `bool` | — | echo structured logs to stderr (equivalent to AGORA_DEBUG=1); does not change exit codes or JSON envelopes | | `--json` | `bool` | — | shortcut for --output json | | `--no-color` | `bool` | — | disable ANSI color in pretty output | | `--output` | `string` | — | output mode for command results: pretty or json | | `--pretty` | `bool` | — | pretty-print JSON output when used with --json | | `--quiet` | `bool` | — | suppress success output (both pretty and JSON envelopes); rely on exit code. Errors still print on stderr. | | `--upgrade-check` | `bool` | — | print non-interactive upgrade guidance and exit | -| `--verbose` | `bool` | — | echo structured logs to stderr (equivalent to AGORA_VERBOSE=1); does not change exit codes or JSON envelopes | | `--yes` | `bool` | — | assume the default answer to confirmation prompts (equivalent to AGORA_NO_INPUT=1); never starts new interactive flows in JSON/CI/non-TTY contexts | ## Pseudo Commands @@ -84,13 +84,25 @@ Update persisted CLI defaults |------|------|---------|-------------| | `--api-base-url` | `string` | `https://agora-cli.agora.io` | default CLI API base URL | | `--browser-auto-open` | `bool` | — | persist browser auto-open preference; use --browser-auto-open=false to disable | +| `--debug` | `bool` | — | persist the --debug preference (echo structured logs to stderr); use --debug=false to disable | | `--log-level` | `string` | `info` | persist default log level | | `--oauth-base-url` | `string` | `https://sso2.agora.io` | default OAuth base URL | | `--oauth-client-id` | `string` | `agora_web_cli` | default OAuth client ID | | `--oauth-scope` | `string` | `basic_info,console` | default OAuth scope | | `--output` | `output` | `pretty` | persist default output mode (pretty or json) | | `--telemetry-enabled` | `bool` | — | persist telemetry preference; use --telemetry-enabled=false to disable | -| `--verbose` | `bool` | — | persist verbose logging preference; use --verbose=false to disable | + +### `agora doctor` + +Diagnose the local Agora CLI install (PATH, version, network, auth, MCP host) + +_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ + +### `agora env-help` + +List every AGORA_* environment variable the CLI honors + +_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ ### `agora init` @@ -185,7 +197,7 @@ Export project environment variables | Flag | Type | Default | Description | |------|------|---------|-------------| -| `--format` | `string` | — | output format: dotenv or shell; use --json for JSON output | +| `--format` | `string` | — | output format: dotenv \| shell \| envelope \| json (default dotenv; envelope/json emit the unified JSON envelope) | | `--project` | `string` | — | project ID or exact project name; defaults to the current project context | | `--shell` | `bool` | — | render shell export statements instead of dotenv lines | | `--with-secrets` | `bool` | — | include sensitive values such as the app certificate | @@ -285,8 +297,35 @@ List available official quickstarts | Flag | Type | Default | Description | |------|------|---------|-------------| +| `--details` | `bool` | — | show repository, runtime, and env details in pretty output | | `--show-all` | `bool` | — | include upcoming or unavailable templates in the list | -| `--verbose` | `bool` | — | show repository, runtime, and env details in pretty output | + +### `agora skills` + +Browse curated Agora workflows for humans and AI agents + +_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ + +### `agora skills list` + +List available skills + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--category` | `string` | — | filter by category (scaffold, ops, agent) | +| `--tag` | `string` | — | filter by tag (e.g. nextjs, rtc, mcp) | + +### `agora skills search` + +Search skills by id, title, description, or tag + +_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ + +### `agora skills show` + +Show one skill in detail + +_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ ### `agora telemetry` diff --git a/docs/error-codes.md b/docs/error-codes.md index 20a6ad7..77b602b 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -77,6 +77,12 @@ These codes appear inside `data.checks[].issues[].code` and (for blocking issues | `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`). | | `TOKEN_CAPABILITY_DISABLED` | (warning) | The project has token issuance disabled. | Enable token issuance in Console. | +### Skills (curated workflows) + +| Code | Exit | Meaning | Recovery | +|------|------|---------|----------| +| `SKILL_NOT_FOUND` | 1 | `agora skills show ` was given an unknown skill ID. | Run `agora skills list` to see available IDs. | + ## Dynamic code families Some doctor codes are generated from the feature name at runtime. Agents should match by prefix. @@ -85,6 +91,7 @@ Some doctor codes are generated from the feature name at runtime. Agents should |---------|---------|---------|----------| | `FEATURE__PROVISIONING` | `FEATURE_RTC_PROVISIONING` | The named feature is being provisioned (warning). | Wait and re-run `project doctor`. | | `FEATURE__DISABLED` | `FEATURE_CONVOAI_DISABLED` | The named feature is disabled for this project. | Run the command from `suggestedCommand` or enable the feature in Console. | +| `INSTALL_DOCTOR_` | `INSTALL_DOCTOR_NOT_READY` | `agora doctor` (top-level) summary code, where `` is `WARNING`, `NOT_READY`, or `AUTH_ERROR`. The detailed per-check items live in `data.blockingIssues[].code` / `data.warnings[].code` and follow the same dotted naming as `project doctor` codes (e.g. `INSTALL_PATH_RESOLUTION`, `NETWORK_API_DNS`). | Read `data.summary` and the per-check `suggestedCommand`. | `` is uppercased and matches the feature ID set returned by `agora introspect --json` under `data.enums.features` (currently `RTC`, `RTM`, `CONVOAI`). diff --git a/docs/index.md b/docs/index.md index c1b3bb2..9af00d3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,7 +17,7 @@ title: Agora CLI Docs Copy -
curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh | sh -s -- --add-to-path
+
curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh | sh
diff --git a/docs/install.md b/docs/install.md index f5a6a84..3b07a01 100644 --- a/docs/install.md +++ b/docs/install.md @@ -13,25 +13,33 @@ This page lists the supported installation paths for Agora CLI and the direct in Install the latest release: ```bash -curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh | sh -s -- --add-to-path +curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh | sh agora --help ``` +> **Shell setup is auto-on.** The default install adds the install directory to your shell rc when `agora` isn't already on `PATH`, and writes a tab-completion script for the detected shell (bash, zsh, fish). Pass `--no-path`, `--no-completion`, or the umbrella `--skip-shell` to opt out granularly. + Install a pinned version: ```bash -curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh | sh -s -- --version 0.2.0 --add-to-path +curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh | sh -s -- --version 0.2.0 agora --help ``` -Install to a user-writable directory and let the installer add it to your shell rc: +Install to a user-writable directory: ```bash curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh \ - | INSTALL_DIR="$HOME/.local/bin" sh -s -- --add-to-path + | INSTALL_DIR="$HOME/.local/bin" sh agora --help ``` +Install only the binary (no shell modifications): + +```bash +curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh | sh -s -- --skip-shell +``` + Run a dry run before installing: ```bash @@ -51,14 +59,22 @@ irm https://raw.githubusercontent.com/AgoraIO/cli/main/install.ps1 | iex agora --help ``` -Install a pinned version and add the default install directory to your user PATH: +> The PowerShell installer wires the install directory onto your user PATH and writes a completion loader into your `$PROFILE` automatically. Pass `-NoPath`, `-NoCompletion`, or the umbrella `-SkipShell` to opt out granularly. + +Install a pinned version: ```powershell $env:VERSION = "0.2.0" -& ([scriptblock]::Create((irm https://raw.githubusercontent.com/AgoraIO/cli/main/install.ps1))) -AddToPath +irm https://raw.githubusercontent.com/AgoraIO/cli/main/install.ps1 | iex agora --help ``` +Install only the binary (no shell modifications): + +```powershell +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/AgoraIO/cli/main/install.ps1))) -SkipShell +``` + The Windows installer installs `agora.exe` into `%LOCALAPPDATA%\Programs\Agora\bin` by default. If your PowerShell execution policy blocks inline scripts, download `install.ps1` first and run it with `powershell -ExecutionPolicy Bypass -File .\install.ps1`. @@ -73,17 +89,38 @@ If your PowerShell execution policy blocks inline scripts, download `install.ps1 --list-versions Print recent published versions and exit. --force Reinstall even if the requested version is present, or proceed past an existing managed install warning. ---add-to-path Append INSTALL_DIR to your shell rc file (bash, zsh, - fish, or .profile). + +# Shell integration (auto-on; pass an opt-out flag to disable) +--no-path Don't append the install directory to your shell rc file. +--no-completion Don't install shell completion. +--skip-shell Umbrella for --no-path --no-completion. + --dry-run Show what would happen without writing any files. --uninstall Remove the installer-managed binary and receipt. --no-color Disable colored output. -q, --quiet Suppress non-error output. --v, --verbose Verbose debug output. +-v, --verbose Verbose debug output (installer-internal; unrelated to + the agora CLI's --debug flag). --installer-version Print this installer's revision and exit. -h, --help Show full help. ``` +## PowerShell Installer Parameters + +```text +-Version Install a specific version. +-InstallDir Install directory (default: %LOCALAPPDATA%\Programs\Agora\bin). +-GitHubRepo Install from a fork or alternate repository. +-Force Reinstall even if the requested version is present. +-NoColor Disable colored output. +-Uninstall Remove the installer-managed binary and receipt. + +# Shell integration (auto-on; pass an opt-out switch to disable) +-NoPath Don't add the install directory to your user PATH. +-NoCompletion Don't wire completion into your PowerShell $PROFILE. +-SkipShell Umbrella for -NoPath -NoCompletion. +``` + If another managed `agora` install is detected, the installer refuses by default to avoid creating two installs that shadow each other on PATH. Pass `--force` to install alongside it. ## Uninstall @@ -185,7 +222,7 @@ go build -o agora . | Channel | Status | Command | | ---------------------------------------- | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| Shell installer | Available | `curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh \| sh -s -- --add-to-path` | +| Shell installer | Available | `curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh \| sh` | | Windows PowerShell | Available | `irm https://raw.githubusercontent.com/AgoraIO/cli/main/install.ps1 \| iex` | | Linux `.deb` / `.rpm` / `.apk` artifacts | Available on GitHub releases | Download the package for your distro from the release page. | | apt repository | Available when `apt-repo.yml` publishes the release | Use the signed repository documented by the release. | @@ -257,10 +294,10 @@ The shell installer refuses to install over an existing managed `agora` to avoid ### PATH issues -If `agora` installs successfully but is not found: +If `agora` installs successfully but is not found, you most likely ran the installer with `--no-path` / `--skip-shell` (or `-NoPath` / `-SkipShell` on Windows). The default install wires PATH automatically. -- macOS, Linux, and Windows POSIX shells: re-run with `--add-to-path` to update your shell rc automatically, or add `INSTALL_DIR` to your shell profile manually, for example `export PATH="$HOME/.local/bin:$PATH"`. -- Windows: rerun `install.ps1 -AddToPath` or add `%LOCALAPPDATA%\Programs\Agora\bin` to your user PATH manually, then open a new terminal. +- macOS, Linux, and Windows POSIX shells: re-run the installer **without** the `--no-path` / `--skip-shell` flag, or add `INSTALL_DIR` to your shell profile manually, for example `export PATH="$HOME/.local/bin:$PATH"`. +- Windows: re-run `install.ps1` without `-NoPath` / `-SkipShell`, or add `%LOCALAPPDATA%\Programs\Agora\bin` to your user PATH manually, then open a new terminal. ### Checksum failures diff --git a/docs/llms.txt b/docs/llms.txt index a5b8360..86b5871 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -4,7 +4,7 @@ ## Quick Start -Install: curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh | sh -s -- --add-to-path +Install: curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh | sh Login: agora login Initialize project: agora init my-demo --template nextjs @@ -29,14 +29,20 @@ Check project health: agora project doctor --json - Telemetry: /cli/md/telemetry.md - Agent rules: /cli/md/agents/README.md +### Machine-Readable Schema +- JSON envelope schema (v1): /cli/schema/envelope.v1.json +- Live introspection (run locally): agora introspect --json + ## Key Features - **Authentication**: OAuth-based login flow with token management - **Project Management**: Initialize, configure, and validate Agora projects -- **JSON Output**: All commands support --json for automation and scripting +- **JSON Output**: All commands support --json for automation and scripting (see Automation Notes below for one documented exception) - **Stable Exit Codes**: Consistent error codes for CI/CD integration -- **Template System**: Quick-start templates for React, Next.js, React Native, and more +- **Template System**: Quick-start templates for Next.js, Python, and Go (see `agora init --help` for the current catalog) - **Cross-Platform**: macOS, Linux, Windows support +- **Agentic discovery**: `agora introspect --json` and `agora --help --all --json` emit the same machine-readable command tree +- **MCP server**: `agora mcp serve` exposes the CLI as Model Context Protocol tools for agents ## Common Use Cases @@ -45,35 +51,70 @@ Check project health: agora project doctor --json agora login agora whoami agora logout +agora auth status --json ``` ### Project Initialization ``` agora init my-app --template nextjs -agora init my-app --template react-native --yes +agora init my-app --template python --yes +agora init my-app --template go --json ``` ### Project Health Check ``` agora project doctor -agora project doctor --json --silent +agora project doctor --json +agora project doctor --quiet ``` ### Configuration Management ``` agora config get -agora config set telemetry.enabled false +agora config update --telemetry-enabled=false +agora telemetry disable ``` ## Automation -All commands support --json output for programmatic consumption. -Exit codes: 0 (success), 1 (error), 2 (usage error), 130 (user interrupt) +All commands except `agora project env` (raw stdout in dotenv/shell mode for `eval $(...)` ergonomics) emit a uniform JSON envelope on `--json`: + +``` +{ + "ok": true, + "command": "", + "data": { ... }, + "meta": { "outputMode": "json", "exitCode": 0 } +} +``` + +To get the JSON envelope for `project env`, pass `--json` (the raw dotenv/shell formats remain available via `--format dotenv` / `--format shell` for shell sourcing). + +Exit codes (stable contract): +- 0 success +- 1 generic error +- 2 doctor warnings (`project doctor` only) +- 3 auth error (AUTH_UNAUTHENTICATED, AUTH_SESSION_EXPIRED) +- 130 user interrupt (Ctrl+C / SIGINT) Full automation contract: /cli/md/automation.md +Stable error codes: /cli/md/error-codes.md + +## Telemetry + +Telemetry is operational diagnostics only and never includes Agora App Certificates or OAuth tokens. Disable with any of: +``` +agora telemetry disable +agora config update --telemetry-enabled=false +DO_NOT_TRACK=1 agora +``` + +Full telemetry contract (events, fields, sinks): /cli/md/telemetry.md ## Support - GitHub Issues: https://github.com/AgoraIO/cli/issues +- GitHub Discussions: https://github.com/AgoraIO/cli/discussions +- Security disclosure: security@agora.io (see SECURITY.md) - Documentation: https://agoraio.github.io/cli/ - Agora Console: https://console.agora.io diff --git a/docs/proposals/ci-matrix-expansion.md b/docs/proposals/ci-matrix-expansion.md new file mode 100644 index 0000000..938659f --- /dev/null +++ b/docs/proposals/ci-matrix-expansion.md @@ -0,0 +1,161 @@ +--- +title: CI matrix expansion proposal +status: proposed +target-release: next +--- + +# CI matrix expansion proposal + +> Status: **proposed**, awaiting approval. This document outlines the +> required steps and the implications of expanding the CI matrix. +> Implementation is deferred to the next release. + +The current CI workflow (`.github/workflows/ci.yml`) runs `go test` +once per OS (`ubuntu-latest`, `macos-latest`, `windows-latest`) against +the toolchain pinned in `go.mod`. This proposal adds three missing +signals — race detection, coverage, and a Go version matrix — and +documents the runtime, cost, and maintenance implications. + +## Current state + +```yaml +# .github/workflows/ci.yml (current) +strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] +- uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true +- name: Run tests + run: go test -count=1 ./... +``` + +- 3 OS jobs. +- Single Go toolchain per job. +- No `-race`. No `-cover`. No coverage upload. + +## Proposed changes + +### 1. Add `go test -race` on Linux + +The CLI shells out to `git`, parses HTTP responses concurrently in the +project list cache and the OAuth callback server, and serves an MCP +stdio loop. None of this is currently exercised under the race +detector. Linux `-race` is the cheapest, highest-signal addition. + +```yaml +- name: Run tests with race detector (Linux) + if: runner.os == 'Linux' + run: go test -race -count=1 ./... +``` + +**Implication:** ~2x the test runtime on Linux, but no extra job and no +parallelism cost. Existing `go test` line stays unchanged for macOS +and Windows because `-race` requires CGO and is markedly slower on +those runners. + +### 2. Add `go test -cover` and Codecov upload on Linux + +```yaml +- name: Run tests with coverage (Linux) + if: runner.os == 'Linux' + run: go test -coverprofile=coverage.out -covermode=atomic ./... + +- name: Upload coverage to Codecov + if: runner.os == 'Linux' + uses: codecov/codecov-action@v5 + with: + files: coverage.out + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} +``` + +**Implications:** + +- Requires a Codecov account and `CODECOV_TOKEN` secret (Codecov is + free for public repos but the token avoids rate limits). +- Adds a coverage badge to the README. +- Sets up a soft "no coverage regression" PR check (Codecov default). + We can switch to hard-fail later once a baseline stabilizes. +- We will need to either combine coverage from `-race` and non-race + runs, or pick one. Recommendation: run `-race -coverprofile=...` in + a single step so we get both signals from one binary. + +### 3. Add a Go version matrix + +```yaml +strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + go: [stable, oldstable] +- uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go }} + cache: true +``` + +**Implications:** + +- Doubles the matrix cell count from 3 to 6. With the current ~4 + minute per-cell runtime that adds ~12 minutes of CI time per PR + (parallelized across runners, wall-clock impact is closer to ~4 + minutes since they run concurrently). +- `go.mod` keeps its pinned version for production builds; CI matrix + proves the codebase compiles and tests pass against the two toolchain + lines GitHub-hosted runners support out of the box. +- We should add a `go.work` exclude or build constraint if any test + uses Go-version-gated APIs (none today). +- Release workflow continues to use `go-version-file: go.mod` so we + ship one toolchain and one toolchain only. + +## Optional follow-ups + +These are flagged for discussion but not part of the v1 proposal: + +- **Add `go vet -all` as a separate step.** Currently included + implicitly via `golangci-lint`'s `govet` linter. Explicit step would + surface `vet` failures separately from lint. +- **Add `gosec` SARIF upload.** Already enabled in `golangci-lint`, + but standalone SARIF would surface in the Security tab. +- **Run integration tests in a dedicated job with `-tags=integration`.** + Currently mixed with unit tests. +- **Macos `-race`.** Useful but expensive (~3x slower on Apple + Silicon runners); defer until we have a concurrency bug that justifies it. + +## Implications summary + +| Change | New CI minutes / PR (approx) | New required secret | New required action | Risk | +|--------|------------------------------|---------------------|---------------------|------| +| `-race` on Linux | +2 min | none | none | low (catches real bugs) | +| `-cover` + Codecov upload | +1 min | `CODECOV_TOKEN` | Codecov account | low (soft-fail) | +| Go version matrix `[stable, oldstable]` | +12 min total / +4 min wall-clock | none | none | medium (oldstable lag may surface API drift) | + +Total wall-clock CI runtime for a PR moves from roughly 4-5 minutes to +roughly 8-10 minutes. + +## Rollout plan + +1. Land `-race` first as a single-line addition. Watch for new test + failures over a week. +2. Add `-cover` + Codecov in a follow-up once `CODECOV_TOKEN` is + provisioned and the README has space for the badge. +3. Add the Go matrix last. If `oldstable` ever holds back development, + drop back to `[stable]` only and document why in this file. + +## Reasoning + +| # | Why we need it | +|---|----------------| +| 1 | The CLI runs concurrent goroutines (HTTP, OAuth callback, MCP loop, project cache). Without `-race` we will eventually ship a data race that only manifests for users with high CPU concurrency. | +| 2 | Coverage is the single most useful CI signal for a CLI of this size. We do not need a hard threshold; just visibility on which paths are tested. | +| 3 | `go.mod` pins a single toolchain. Users on slightly older Go versions (common in enterprise) will hit confusing errors if we accidentally use a feature they cannot consume. The matrix catches this on PR. | + +## Out of scope + +- Self-hosted runners. +- Cross-compilation matrix beyond what GoReleaser already covers in + the release workflow. +- Mutation testing. diff --git a/docs/proposals/supply-chain-hardening.md b/docs/proposals/supply-chain-hardening.md new file mode 100644 index 0000000..8f24ecb --- /dev/null +++ b/docs/proposals/supply-chain-hardening.md @@ -0,0 +1,265 @@ +--- +title: Supply-chain hardening proposal +status: proposed +target-release: next +--- + +# Supply-chain hardening proposal + +> Status: **proposed**, awaiting approval. Do not implement until the +> proposal is signed off and scheduled for the next release. + +This proposal closes the gap between the current Agora CLI release +pipeline and the supply-chain expectations of flagship Go CLIs (`gh`, +`k9s`, `gitleaks`, `golangci-lint`, Charm tools). The CLI already ships +SBOMs (Syft), Cosign-signed `checksums.txt`, and npm OIDC provenance. +This proposal adds the missing pieces required to pass enterprise +security questionnaires and to give downstream consumers a single, +verifiable trust chain from `git tag` to installed binary. + +## Goals + +1. Every artifact published from this repository — GitHub Release zips, + npm packages, Docker images, deb/rpm/apk packages — has a verifiable + provenance attestation traceable to a tagged commit and a recorded + GitHub Actions run. +2. Pull requests block on dependency vulnerability and code-scanning + findings before merge, not on a weekly cron. +3. Installer scripts (`install.sh`, `install.ps1`) optionally verify + the Cosign signature on `checksums.txt` so users with `cosign` + installed get end-to-end attestation, not just SHA-256 verification. +4. We have a documented, reproducible answer to "how do I verify this + binary?" published at a stable URL. + +## Non-goals + +- Switching to keyed Cosign signing. Keyless OIDC remains the chosen + trust model. +- Adding an internal artifact registry. We continue to publish to + GitHub Releases, npm, and GHCR. +- Reproducible builds beyond what GoReleaser already provides. + +## Proposed changes + +### 1. Add `actions/attest-build-provenance` to release workflow + +Currently `release.yml` produces npm provenance (via `--provenance` and +`id-token: write`) but the GitHub Release zips themselves carry no +attestation. Add a step after GoReleaser that attests every archive, +checksum file, and SBOM. + +```yaml +# .github/workflows/release.yml (sketch) +- name: Attest release artifacts + uses: actions/attest-build-provenance@v1 + with: + subject-path: | + dist/*.tar.gz + dist/*.zip + dist/checksums.txt + dist/*.spdx.json +``` + +Result: every release artifact gets a SLSA-compatible provenance entry +visible in the GitHub UI and verifiable with `gh attestation verify`. + +### 2. Add CodeQL workflow + +```yaml +# .github/workflows/codeql.yml +name: CodeQL +on: + pull_request: + branches: [main] + push: + branches: [main] + schedule: + - cron: '37 9 * * 1' +permissions: + actions: read + contents: read + security-events: write +jobs: + analyze: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: github/codeql-action/init@v3 + with: + languages: go + - uses: github/codeql-action/autobuild@v3 + - uses: github/codeql-action/analyze@v3 +``` + +Result: GitHub Security tab gets Go CodeQL findings; PRs surface +findings as inline annotations. + +### 3. Add Dependency Review on PRs + +```yaml +# .github/workflows/dependency-review.yml +name: Dependency Review +on: + pull_request: +permissions: + contents: read + pull-requests: write +jobs: + review: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/dependency-review-action@v4 + with: + fail-on-severity: high + comment-summary-in-pr: on-failure +``` + +Result: PRs introducing high-severity vulnerable dependencies are +blocked at review time, not at the next weekly govulncheck cron. + +### 4. Add OSV-Scanner SARIF upload + +Augment the existing `govulncheck.yml` with a parallel OSV-Scanner job +that uploads SARIF to the Security tab so non-Go transitive issues +(e.g. JS in npm wrapper) are also visible. + +```yaml +# .github/workflows/osv-scanner.yml (sketch) +jobs: + scan: + permissions: + security-events: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: google/osv-scanner-action/osv-scanner-action@v1 + with: + scan-args: |- + --recursive + --skip-git + --format=sarif + --output=osv.sarif + ./ + - uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: osv.sarif +``` + +### 5. Add gitleaks pre-commit and CI secret scan + +```yaml +# .github/workflows/secret-scan.yml +name: Secret scan +on: + pull_request: + push: + branches: [main] +jobs: + gitleaks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: gitleaks/gitleaks-action@v2 + env: + GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} +``` + +Note: gitleaks-action is free for public repos; no license key needed +for this repo. Document that contributors should also install the +gitleaks pre-commit hook locally. + +### 6. Have installers consume Cosign verification + +`install.sh` already verifies SHA-256. Optionally consume the existing +Cosign signature when the user has `cosign` installed: + +```sh +# install.sh sketch +verify_cosign_optional() { + if command -v cosign >/dev/null 2>&1; then + say_step "Verifying checksums.txt signature with cosign..." + cosign verify-blob \ + --certificate "${CHECKSUMS_PATH}.pem" \ + --signature "${CHECKSUMS_PATH}.sig" \ + --certificate-identity-regexp "https://github.com/AgoraIO/cli/.github/workflows/release.yml@refs/tags/v.*" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + "$CHECKSUMS_PATH" >/dev/null + say_ok "cosign signature verified" + fi +} +``` + +Result: users with cosign get end-to-end OIDC trust verification with +zero extra steps. Users without it keep the SHA-256-only path. + +### 7. Add `make verify-release` target + +A local convenience target so contributors and downstream consumers +can replay the full verification chain (SHA-256 + Cosign + SBOM +inspection) against a published version. + +```make +# Makefile sketch +verify-release: + @scripts/verify-release.sh "$${VERSION:?VERSION required, e.g. VERSION=0.2.0 make verify-release}" +``` + +### 8. Document the trust chain + +Add a `Verifying releases` section to [docs/install.md](../install.md) +that walks through: + +1. `cosign verify-blob` against `checksums.txt`. +2. `sha256sum -c` against an extracted archive. +3. `syft` to confirm the SBOM matches the binary. +4. `gh attestation verify` for the new build provenance. + +## Reasoning per item + +| # | Why we need it | +|---|----------------| +| 1 | npm provenance is a great precedent, but enterprise customers also consume the GitHub Release zips directly. Without attestation those zips are effectively unsigned from the consumer's point of view. | +| 2 | Govulncheck catches Go vulnerabilities in shipped deps but not Go code-quality issues like SQL injection patterns or path traversal. CodeQL is the industry default and surfaces in the Security tab GitHub uses for supply-chain scoring. | +| 3 | A weekly govulncheck cron is too late for security-conscious downstream consumers. Block at PR time. | +| 4 | npm wrapper / install scripts can pull JS/Python tooling into the supply chain over time. OSV-Scanner sees those; govulncheck does not. | +| 5 | We have OAuth flows, a hardcoded Sentry DSN (planned wire-in), and example config snippets. Secret scanning prevents future regressions where a real key lands in Git history. | +| 6 | We already sign `checksums.txt` keyless with Cosign, but no consumer verifies that signature today. Wiring it into the installer (best-effort, optional) raises the trust ceiling at zero UX cost. | +| 7 | Makes the verification story a single command, removing "I tried but the cosign incantation was wrong" friction. | +| 8 | Documents the trust chain so security questionnaires get a published URL instead of a back-and-forth email thread. | + +## Risks / open questions + +- **CodeQL false positives.** Go CodeQL has a high signal-to-noise + ratio compared to JS/Python but we should expect to triage and + suppress 5-15 findings on the first run. +- **OSV-Scanner SARIF noise.** The `packaging/npm/agoraio-cli/` + wrapper has minimal deps but every transitive bump will surface. + Consider scoping the scan to the Go module on day one and adding + npm later. +- **Installer change risk.** The Cosign verify branch must remain + best-effort. A failed verify-blob on a flaky network must not break + the install. Existing SHA-256 path stays mandatory. +- **Workflow runtime.** Adding CodeQL adds ~5 minutes per PR. Acceptable. + +## Rollout plan + +1. Land items 2 (CodeQL), 3 (Dependency Review), 4 (OSV-Scanner), 5 + (gitleaks) in one PR. These are pure-add CI workflows. +2. Land item 1 (attest-build-provenance) in the next release PR so it + exercises against a real tag. +3. Land items 6 (installer cosign), 7 (`make verify-release`), 8 (docs) + together so the documented verification flow matches what the + installer does. +4. Update [docs/install.md](../install.md) "Security" section and add + a callout to the README and `SECURITY.md`. + +## Out of scope + +- Replacing GoReleaser with `slsa-github-generator` for the binary + build step. The current GoReleaser build is well-tuned and + attest-build-provenance gives equivalent SLSA coverage at lower + switching cost. +- Adding signed git tags. Useful but orthogonal. diff --git a/docs/proposals/telemetry-sentry-wireup.md b/docs/proposals/telemetry-sentry-wireup.md new file mode 100644 index 0000000..e7d9a84 --- /dev/null +++ b/docs/proposals/telemetry-sentry-wireup.md @@ -0,0 +1,215 @@ +--- +title: Sentry telemetry wire-up plan +status: proposed +target-release: next +--- + +# Sentry telemetry wire-up plan + +> Status: **proposed**, target release: next. +> +> This document describes how to take the telemetry stub introduced in +> [`internal/cli/telemetry.go`](../../internal/cli/telemetry.go) from a +> no-op interface to a fully wired Sentry-backed client. Until this lands, +> the CLI exposes the full opt-in/opt-out contract (`agora telemetry +> enable|disable`, `AGORA_SENTRY_ENABLED`, `DO_NOT_TRACK`) but does not +> actually transmit any events. Treat this proposal as the contract a +> reviewer should validate against during the next release PR. + +## Context + +The TS predecessor (`agora-cli-ts`) ships Sentry. Concretely: + +- `agora-cli-ts/packages/cli-telemetry/package.json` depends on + `@sentry/node ^10.18.0`. +- `agora-cli-ts/apps/agora-cli/src/telemetry.ts` initializes Sentry + with the project DSN + `https://07bf9b5275eef5259abebe89fa247cec@o4510955723292672.ingest.us.sentry.io/4511189164687360`, + reads the `AGORA_SENTRY_ENABLED` env var, redacts sensitive fields, + and skips `command.failed` to avoid double reporting. +- The Go CLI mirrors the on/off contract in + [`internal/cli/config.go`](../../internal/cli/config.go) (the + `applyConfigToEnv` map sets `AGORA_SENTRY_ENABLED` from + `cfg.TelemetryEnabled`) but had no actual transport. The new + `telemetry.go` adds a `telemetryClient` interface and a noop + default; callsites in `app.go` already invoke `CaptureException` on + error. + +## Goals + +1. The Go CLI reports the same operational diagnostics to the same + Sentry project the TS CLI reports to, so the migration does not lose + error visibility. +2. The contract surface (`agora telemetry`, env vars, log fields) does + not change. Existing wrappers and CI configs keep working. +3. Field redaction is enforced inside the sink (defense in depth) in + addition to whatever the call site does. +4. Telemetry never blocks the CLI from returning control to the shell. + Bounded flush, no panic propagation, no synchronous network calls + on the hot path. + +## Implementation steps + +### Step 1: Add the SDK dependency + +```bash +go get github.com/getsentry/sentry-go@latest +go mod tidy +``` + +Verify the resolved version is `>=v0.27.0` so we get the `WithContext` +helpers and the modern `BeforeSend` signature. + +### Step 2: Replace the `agoraSentryDSN` const + +In [`internal/cli/telemetry.go`](../../internal/cli/telemetry.go): + +```go +const agoraSentryDSN = "https://07bf9b5275eef5259abebe89fa247cec@o4510955723292672.ingest.us.sentry.io/4511189164687360" +``` + +This single change activates the Sentry path through `initTelemetry`. + +### Step 3: Replace the `sentryClient` placeholder methods + +Replace the `sentryClient` struct so it owns a real Sentry hub and +implements the interface against the SDK. The full implementation +should look approximately like: + +```go +import sentry "github.com/getsentry/sentry-go" + +type sentryClient struct { + hub *sentry.Hub +} + +func newSentryClient(dsn string, env map[string]string) *sentryClient { + options := sentry.ClientOptions{ + Dsn: dsn, + Environment: defaultString(env["AGORA_SENTRY_ENVIRONMENT"], "production"), + Release: firstNonEmpty(env["AGORA_RELEASE"], version), + AttachStacktrace: true, + SendDefaultPII: false, + BeforeSend: func(event *sentry.Event, _ *sentry.EventHint) *sentry.Event { + // Defense-in-depth: redact sensitive fields again, in case + // a call site forgot. + if event.Extra != nil { + event.Extra = redactTelemetryFields(event.Extra) + } + return event + }, + } + client, err := sentry.NewClient(options) + if err != nil { + return nil + } + scope := sentry.NewScope() + scope.SetTag("app", "agora-cli") + return &sentryClient{hub: sentry.NewHub(client, scope)} +} + +func (c *sentryClient) CaptureException(err error, fields map[string]any) { + if c == nil || c.hub == nil { + return + } + c.hub.WithScope(func(scope *sentry.Scope) { + for k, v := range redactTelemetryFields(fields) { + scope.SetExtra(k, v) + } + c.hub.CaptureException(err) + }) +} + +func (c *sentryClient) Flush(timeout time.Duration) bool { + if c == nil || c.hub == nil { + return true + } + return c.hub.Flush(timeout) +} +``` + +`newSentryClient` returning nil on init failure means +`(*sentryClient).Enabled` returns false, the CLI drops back to noop +behavior, and nothing else changes. This is the contract `initTelemetry` +already expects. + +### Step 4: Document fields + +Update [`docs/telemetry.md`](../telemetry.md) with the **exact** +field schema we send. Suggested initial event vocabulary (mirrors TS): + +| Field | Type | Example | Notes | +|----------------|--------|----------------------------------|-------| +| `command` | string | `"project create"` | Stable label from `introspect`. | +| `exitCode` | int | `1` | Process exit code at failure. | +| `commitSha` | string | `"abc1234"` | Build-time injected. | +| `os` | string | `"darwin/arm64"` | Runtime, not host-specific. | +| `installMethod`| string | `"installer"` / `"npm"` / `"brew"` | From provenance receipt. | +| `agentLabel` | string | `"cursor"` | From `agent_infer.go`. | + +Explicitly call out what we **never** send: + +- OAuth tokens or session refresh tokens. +- Agora App Certificate values. +- Project names or App IDs (use opaque hashes if needed). +- Local file paths beyond the log file path basename. + +### Step 5: Wire the consent banner + +Add a one-time interactive prompt on first run when stderr is a TTY, +not in CI, and not in JSON mode: + +> "Agora CLI sends anonymous error reports to help us fix bugs. You +> can disable this any time with `agora telemetry disable`. Continue? +> [Y/n]" + +The default is "yes" to match the current `cfg.TelemetryEnabled: true` +default and the TS predecessor. Persist the answer to config so the +prompt never re-appears. + +### Step 6: Add tests + +- `telemetry_test.go` covering each branch of `initTelemetry` + (DO_NOT_TRACK, config off, env=0, empty DSN, normal). +- A round-trip test that asserts `redactTelemetryFields` zeroes out + every key matching the documented pattern. +- A test asserting `Flush` honors a 100 ms timeout deterministically + (using a fake hub). + +### Step 7: Update CHANGELOG + +Under `[Unreleased] / Added`: + +> - Wire Agora CLI telemetry to Sentry. Telemetry is on by default, can +> be disabled with `agora telemetry disable`, +> `agora config update --telemetry-enabled=false`, `DO_NOT_TRACK=1`, +> or `AGORA_SENTRY_ENABLED=0`. Field schema is documented in +> [docs/telemetry.md](docs/telemetry.md). No tokens, app certificates, +> or project identifiers are transmitted. + +## Reasoning + +| # | Why | +|---|-----| +| 1 | TS already shipped Sentry. Migrating without it would silently regress error visibility for the same user base. | +| 2 | `internal/cli/telemetry.go` already exposes the right interface. Wire-up is one constant + one struct change. | +| 3 | `BeforeSend` redaction in the sink is a belt-and-braces guarantee: even if a future call site forgets to redact, fields never leave the host. | +| 4 | A documented one-time consent prompt aligns with the industry direction (Homebrew flipped to opt-in in 2024, npm honors `DO_NOT_TRACK`). We keep opt-out as the default but add explicit acknowledgement. | + +## Risks / open questions + +- **DSN exposure.** The TS DSN is already in a public npm package, so + embedding the same DSN in the Go binary does not change the threat + model. (Sentry DSNs are intentionally public; rate-limiting and + project-side filtering are the controls.) +- **Default opt-in vs opt-out.** Industry is shifting; we should + explicitly decide for v1 of the Go CLI rather than inheriting the TS + default by accident. +- **Sentry SDK size.** `sentry-go` adds ~3 MB to the static binary. + Acceptable for a CLI; document in the release notes. + +## Out of scope + +- Custom event sinks beyond Sentry (file sink, OTel exporter). Add + later if customer demand emerges. +- PII review beyond the field schema documented in step 4. diff --git a/docs/schema/envelope.v1.json b/docs/schema/envelope.v1.json new file mode 100644 index 0000000..f936ed2 --- /dev/null +++ b/docs/schema/envelope.v1.json @@ -0,0 +1,122 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agoraio.github.io/cli/schema/envelope.v1.json", + "title": "Agora CLI JSON envelope (v1)", + "description": "Stable JSON envelope shape returned by every JSON-mode `agora` command. Both success and failure responses use the same top-level shape so wrappers can branch on `ok`. Documented in /cli/md/automation.md and produced by internal/cli/envelope.go in the source repo.", + "type": "object", + "required": ["ok", "command", "data", "meta"], + "additionalProperties": false, + "properties": { + "ok": { + "type": "boolean", + "description": "true on success, false on failure. Wrappers branch on this field; do not infer success from `error` absence alone." + }, + "command": { + "type": "string", + "description": "Stable command label, e.g. \"project create\", \"auth status\", \"init\". Matches the `command` field of `agora introspect --json`." + }, + "data": { + "description": "Command-specific payload. Present on success. May be `null` for failure envelopes that have no partial payload, or a structured object when the command emits partial data alongside an error (e.g. `project doctor`).", + "oneOf": [ + { "type": "null" }, + { "type": "object" }, + { "type": "array" } + ] + }, + "error": { + "$ref": "#/$defs/error", + "description": "Present only when `ok` is false." + }, + "meta": { + "$ref": "#/$defs/meta", + "description": "Always present. Contains transport-level metadata (output mode, exit code)." + } + }, + "if": { + "properties": { "ok": { "const": false } } + }, + "then": { + "required": ["error"] + }, + "$defs": { + "error": { + "type": "object", + "required": ["message"], + "additionalProperties": false, + "properties": { + "message": { + "type": "string", + "description": "Human-readable error message." + }, + "code": { + "type": "string", + "description": "Stable error code from docs/error-codes.md, e.g. \"AUTH_UNAUTHENTICATED\", \"PROJECT_NO_CERTIFICATE\". Absent for unclassified errors." + }, + "httpStatus": { + "type": "integer", + "minimum": 0, + "maximum": 599, + "description": "Upstream HTTP status when the error originated from an Agora API call." + }, + "requestId": { + "type": "string", + "description": "Upstream request ID for support escalations when available." + }, + "logFilePath": { + "type": "string", + "description": "Absolute path to the rotating log file containing detailed context for this error." + } + } + }, + "meta": { + "type": "object", + "required": ["outputMode", "exitCode"], + "additionalProperties": true, + "properties": { + "outputMode": { + "type": "string", + "enum": ["json"], + "description": "Always \"json\" for envelope responses (pretty mode does not emit envelopes)." + }, + "exitCode": { + "type": "integer", + "description": "Process exit code the CLI will use after this envelope is written. 0 success; 1 generic error; 2 doctor warnings; 3 auth error; 130 user interrupt." + } + } + } + }, + "examples": [ + { + "ok": true, + "command": "auth status", + "data": { + "authenticated": true, + "scope": "basic_info,console", + "expiresAt": "2026-05-08T12:00:00Z" + }, + "meta": { "outputMode": "json", "exitCode": 0 } + }, + { + "ok": false, + "command": "auth status", + "data": null, + "error": { + "message": "No local Agora session found. Run agora login first.", + "code": "AUTH_UNAUTHENTICATED", + "logFilePath": "/Users/me/.agora/logs/agora-cli.log" + }, + "meta": { "outputMode": "json", "exitCode": 3 } + }, + { + "ok": false, + "command": "project doctor", + "data": { + "status": "warning", + "feature": "rtc", + "warnings": [{ "code": "ENV_FILE_MISSING", "message": "no .env.local found" }] + }, + "error": { "message": "project has non-blocking readiness warnings", "code": "PROJECT_DOCTOR_WARNING" }, + "meta": { "outputMode": "json", "exitCode": 2 } + } + ] +} diff --git a/docs/sitemap.xml b/docs/sitemap.xml index 5bce1c5..30b1d84 100644 --- a/docs/sitemap.xml +++ b/docs/sitemap.xml @@ -34,6 +34,11 @@ layout: none monthly 0.7 + + {{ site.url }}{{ site.baseurl }}/troubleshooting.html + monthly + 0.7 + {{ site.url }}{{ site.baseurl }}/md/index.md @@ -50,9 +55,54 @@ layout: none monthly 0.8 + + {{ site.url }}{{ site.baseurl }}/md/error-codes.md + monthly + 0.8 + + + {{ site.url }}{{ site.baseurl }}/md/install.md + monthly + 0.8 + + + {{ site.url }}{{ site.baseurl }}/md/telemetry.md + monthly + 0.7 + + + {{ site.url }}{{ site.baseurl }}/md/troubleshooting.md + monthly + 0.7 + + + {{ site.url }}{{ site.baseurl }}/md/agents/README.md + monthly + 0.7 + + + {{ site.url }}{{ site.baseurl }}/md/agents/cursor.mdc + monthly + 0.6 + + + {{ site.url }}{{ site.baseurl }}/md/agents/claude.md + monthly + 0.6 + + + {{ site.url }}{{ site.baseurl }}/md/agents/windsurf.md + monthly + 0.6 + {{ site.url }}{{ site.baseurl }}/llms.txt monthly 0.9 + + {{ site.url }}{{ site.baseurl }}/schema/envelope.v1.json + monthly + 0.7 + diff --git a/docs/telemetry.md b/docs/telemetry.md index f1bed1c..f3665f3 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -4,9 +4,25 @@ title: Telemetry # Telemetry -Agora CLI telemetry is limited to operational diagnostics such as command failures and local log metadata. It must never include OAuth tokens, app certificates, dotenv secrets, or project env values. +Agora CLI telemetry is limited to operational diagnostics such as command +failures and local log metadata. It **never** includes OAuth tokens, app +certificates, dotenv secrets, or project env values. Field redaction is +enforced by `redactTelemetryFields` in +[`internal/cli/telemetry.go`](https://github.com/AgoraIO/cli/blob/main/internal/cli/telemetry.go), +which matches the `token | secret | password | api[_-]?key | authorization` +key pattern (case-insensitive) and replaces the value with the literal +`[REDACTED]`. -Telemetry is enabled by default in the local config. You can inspect or change the setting: +> **Status (current release):** the on/off contract below is fully +> wired. The transport (Sentry SDK) is not yet linked into the binary, +> so all telemetry calls are no-ops at runtime. The next release will +> wire Sentry per +> [`docs/proposals/telemetry-sentry-wireup.md`](proposals/telemetry-sentry-wireup.md); +> the surface and field schema will not change. + +## Inspect or change the setting + +Telemetry is enabled by default in the local config. ```bash agora telemetry status @@ -14,20 +30,56 @@ agora telemetry disable agora telemetry enable ``` -For scripts, use JSON: +For scripts, prefer JSON: ```bash agora telemetry disable --json ``` -`DO_NOT_TRACK=1` disables telemetry at runtime even if the config file says telemetry is enabled. It also suppresses local diagnostic log writes for that process: +## Opt-out signals + +The CLI honors **all** of the following — any one of them disables +telemetry for the current process: + +| Signal | Notes | +|--------|-------| +| `DO_NOT_TRACK=` | Standard cross-tool convention. Also suppresses local file logs for that process. | +| `AGORA_SENTRY_ENABLED=0` | Hard env override. Wins over the config file. | +| `agora telemetry disable` | Persists `telemetryEnabled: false` to the config. | +| `agora config update --telemetry-enabled=false` | Equivalent to `agora telemetry disable`. | ```bash DO_NOT_TRACK=1 agora project list --json +AGORA_SENTRY_ENABLED=0 agora init my-app --template nextjs --json ``` -The config file location is available with: +## Field schema + +The wire-up plan documents the **exact** field set the Sentry-backed +sink will send. Until then the table below is forward-looking; treat it +as the specification, not the production behavior: + +| Field | Type | Example | Notes | +|-----------------|--------|------------------------------------|-------| +| `command` | string | `"project create"` | Stable label from `agora introspect --json`. | +| `exitCode` | int | `1` | Process exit code at failure. | +| `commitSha` | string | `"abc1234"` | Build-time injected. | +| `os` | string | `"darwin/arm64"` | Runtime, not host-specific. | +| `installMethod` | string | `"installer"` / `"npm"` / `"brew"` | From the install provenance receipt. | +| `agentLabel` | string | `"cursor"` | From `agent_infer.go`. | + +Explicitly **never** sent: + +- OAuth tokens or session refresh tokens. +- Agora App Certificate values. +- Project names or App IDs (use opaque hashes if needed in the future). +- Local file paths beyond the rotating log file path basename. + +## Where the config lives ```bash agora config path ``` + +The same directory holds the rotating log file (`agora-cli.log`) and +the project list cache used by shell completion. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..e5566ca --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,186 @@ +--- +title: Troubleshooting +--- + +# Troubleshooting + +Common issues and their fixes when running Agora CLI. For broader install +guidance see [Install](install.html). For programmatic error inspection, +prefer `agora project doctor --json` and `agora auth status --json`. + +## Diagnostics first + +Before opening an issue, capture these: + +```bash +agora --version +agora project doctor --json +agora auth status --json +``` + +The output above is what the [bug report +template](https://github.com/AgoraIO/cli/issues/new?template=bug_report.yml) +asks for and is the fastest path to a fix. + +## Login or browser issues + +Symptom: the OAuth browser window does not open, or you are running over +SSH / in a container. + +```bash +agora login --no-browser +``` + +This prints the login URL so you can open it on another machine and paste +the callback. You can also disable auto-open globally: + +```bash +agora config update --browser-auto-open=false +``` + +## "command not found: agora" + +The installer printed the install directory but it is not on `PATH`. + +```bash +# macOS / Linux +echo "$PATH" +sh install.sh # re-run installer (PATH wiring is auto-on by default) + +# Windows PowerShell +$env:Path -split ';' +.\install.ps1 # re-run installer (PATH wiring is auto-on by default) +``` + +## Multiple `agora` binaries on PATH + +The installer detects when another `agora` shadows the freshly installed +binary and warns. You can also check directly: + +```bash +which -a agora # macOS / Linux +where.exe agora # Windows PowerShell +``` + +Reorder `PATH` so the installer's directory comes first, or remove the +older binary. + +## `agora init` or `agora quickstart create` fails on `git clone` + +The CLI shells out to `git clone` for quickstarts. Verify: + +```bash +git --version +git ls-remote https://github.com/AgoraIO/agora-quickstart-nextjs.git +``` + +If `git` is missing, install it (Homebrew, apt, winget, etc.). If +network access fails, check proxies and corporate firewall rules. + +## "project does not have an app certificate" + +`quickstart env write`, `init`, and `project env --with-secrets` need a +project with an App Certificate. Either pick another project or enable +the certificate in [Agora Console](https://console.agora.io). + +```bash +agora project list --json +agora project use +agora project doctor --json +``` + +## `--yes` or `AGORA_NO_INPUT=1` is not skipping the OAuth browser + +This is intentional. `--yes` accepts the default for confirmation +prompts; it does not start a brand-new interactive OAuth flow in JSON, +CI, or non-TTY contexts. Authenticate once on the host first: + +```bash +agora login +``` + +Then re-run your automation. CI runners should authenticate as part of +their bootstrap, not as part of every command. + +## CI: "command requires authentication" without prompting + +CI auto-detection is intentional: in CI, the CLI never spawns an OAuth +browser flow even with `--yes`. Pre-authenticate the runner: + +```bash +agora login +``` + +Or set `AGORA_HOME=$(mktemp -d)` per job for an isolated session and +provision credentials via your secret store before invoking the CLI. + +## Output looks wrong in scripts (color codes, table widths) + +The CLI auto-detects CI and disables color and progress bars there. In +local TTYs you can override: + +```bash +agora --no-color +NO_COLOR=1 agora +agora --json +``` + +For wrappers that parse output, always pass `--json`. Pretty output is +not a stable contract. + +## "did you mean" suggestions + +If you mistype a subcommand the CLI prints the closest matches: + +```text +$ agora projct doctor +Error: unknown command "projct" for "agora" + +Did you mean this? + project +``` + +## Debug logging + +Use `--debug` (equivalent to `AGORA_DEBUG=1`) to mirror structured log +records to stderr. JSON envelopes and exit codes are unchanged. + +> v0.2.0 removed the legacy `--verbose` / `-v` alias and the +> `AGORA_VERBOSE` environment variable. If you still have a 0.1.x +> config file with a `verbose` key, it is silently migrated to +> `debug` on first load — no action required. Update any scripts +> that set `AGORA_VERBOSE=1` to set `AGORA_DEBUG=1` instead. + +```bash +agora --debug project list +AGORA_DEBUG=1 agora init my-demo --template nextjs --json +``` + +The same lines are written to a rotating log file. Print the path with: + +```bash +agora config path # parent directory +``` + +The log file is `agora-cli.log` next to the config file. + +## Telemetry / Sentry + +Telemetry is opt-out. Disable with any of: + +```bash +agora telemetry disable +agora config update --telemetry-enabled=false +DO_NOT_TRACK=1 agora +``` + +See [Telemetry](telemetry.html) for the field schema. + +## Still stuck? + +- Open a [GitHub Discussion](https://github.com/AgoraIO/cli/discussions) + for "how do I" questions. +- Open a [bug report](https://github.com/AgoraIO/cli/issues/new?template=bug_report.yml) + for a reproducible defect. +- Email **security@agora.io** for a suspected security vulnerability + (see [SECURITY.md](https://github.com/AgoraIO/cli/blob/main/SECURITY.md)). diff --git a/install.ps1 b/install.ps1 index 640b3dc..c686fc3 100644 --- a/install.ps1 +++ b/install.ps1 @@ -19,10 +19,16 @@ param( [string]$Version = $env:VERSION, [string]$InstallDir = $(if ($env:INSTALL_DIR) { $env:INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'Programs\Agora\bin' }), [string]$GitHubRepo = $(if ($env:GITHUB_REPO) { $env:GITHUB_REPO } else { 'AgoraIO/cli' }), - [switch]$AddToPath, [switch]$Force, [switch]$NoColor, - [switch]$Uninstall + [switch]$Uninstall, + # Shell-integration opt-outs. Default behavior matches modern + # installers (bun, fnm, deno, uv): auto-wire user PATH and + # PowerShell completion. Granular switches let callers decouple + # each piece; -SkipShell is the umbrella that disables both. + [switch]$NoPath, + [switch]$NoCompletion, + [switch]$SkipShell ) Set-StrictMode -Version Latest @@ -43,6 +49,7 @@ $InstallReceiptFileName = 'agora.install.json' $GitHubApiUrl = if ($env:GITHUB_API_URL) { $env:GITHUB_API_URL } else { 'https://api.github.com' } $ReleasesDownloadBaseUrl = if ($env:RELEASES_DOWNLOAD_BASE_URL) { $env:RELEASES_DOWNLOAD_BASE_URL } else { "https://github.com/$GitHubRepo/releases/download" } $ReleasesPageUrl = if ($env:RELEASES_PAGE_URL) { $env:RELEASES_PAGE_URL } else { "https://github.com/$GitHubRepo/releases" } +$DocsUrl = if ($env:DOCS_URL) { $env:DOCS_URL } else { "https://github.com/$GitHubRepo#readme" } $AuthToken = if ($env:GITHUB_TOKEN) { $env:GITHUB_TOKEN } elseif ($env:GH_TOKEN) { $env:GH_TOKEN } else { $null } # Color is suppressed when NO_COLOR env is set, -NoColor switch is passed, @@ -169,19 +176,112 @@ function Ensure-InstallDirectory { } } +# Show-ManualPathBlock prints the copy-pasteable manual PATH-setup +# block. Callers (Add-InstallDirToUserPath on failure, Show-PathInstructions +# on explicit opt-out) emit a single warn line, then call this helper +# so wording, indentation, and the example command stay identical +# across both paths. Mirrors install.sh's print_manual_path_block. +function Show-ManualPathBlock { + param([string]$BinaryPath) + Write-Host " agora is installed at $BinaryPath and is ready to run." + Write-Host " To add it to your user PATH, run one of:" + Write-Host "" + Write-Host " setx PATH `"$InstallDir;%PATH%`"" + Write-Host " [Environment]::SetEnvironmentVariable('Path', `"$InstallDir;`" + [Environment]::GetEnvironmentVariable('Path','User'), 'User')" + Write-Host "" + Write-Host " Then open a new terminal so the change takes effect." + Write-Host " For other options (custom INSTALL_DIR, containers), see $DocsUrl" +} + +# Add-InstallDirToUserPath appends $InstallDir to the user's persistent +# PATH. Best-effort: returns $true on success (added or already present), +# $false on any write failure. On failure it emits a plain-language +# branch message followed by the copy-pasteable manual block — the +# caller does NOT need to print any additional fallback hints. function Add-InstallDirToUserPath { - $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') - $segments = @() - if ($userPath) { - $segments = $userPath.Split(';', [System.StringSplitOptions]::RemoveEmptyEntries) + param([string]$BinaryPath = (Join-Path $InstallDir 'agora.exe')) + try { + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $segments = @() + if ($userPath) { + $segments = $userPath.Split(';', [System.StringSplitOptions]::RemoveEmptyEntries) + } + if ($segments -contains $InstallDir) { + Write-Info "$InstallDir is already on your user PATH." + return $true + } + $newPath = if ($userPath) { "$userPath;$InstallDir" } else { $InstallDir } + [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') + Write-Info "Added $InstallDir to your user PATH." + Write-Host "To use agora in this PowerShell session now, run:" + Write-Host " `$env:Path += ';$InstallDir'" + Write-Host "(Or open a new terminal - the change takes effect either way.)" + return $true + } catch { + Write-Host "Could not auto-update your user PATH (likely a permissions / UAC restriction)." + Show-ManualPathBlock -BinaryPath $BinaryPath + return $false + } +} + +# Show-PathInstructions is the manual-fallback hint shown when the user +# opted out of auto-PATH (-NoPath / -SkipShell) and the binary is not +# yet resolvable on PATH. Reuses Show-ManualPathBlock so the wording +# matches what the auto-failed path emits. +function Show-PathInstructions { + param([string]$BinaryPath) + Write-Warn "agora is not on your PATH yet." + Show-ManualPathBlock -BinaryPath $BinaryPath + Write-Host " (Tip: re-run the installer without -NoPath / -SkipShell to do this automatically.)" +} + +# Install-AgoraCompletion writes a small loader to the user's +# PowerShell profile so a fresh PowerShell session has tab-completion. +# Best-effort: failures never abort the install. Idempotent: subsequent +# runs detect the loader and skip. Caller is responsible for honoring +# -NoCompletion / -SkipShell before invoking. +function Install-AgoraCompletion { + param( + [string]$BinaryPath + ) + if (-not (Test-Path -LiteralPath $BinaryPath)) { + return } - if ($segments -contains $InstallDir) { - Write-Info "$InstallDir is already on your user PATH." + if (-not $PROFILE) { + Write-Host "No PowerShell profile path detected. Skipping completion install." return } - $newPath = if ($userPath) { "$userPath;$InstallDir" } else { $InstallDir } - [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') - Write-Info "Added $InstallDir to your user PATH." + $profileDir = Split-Path -Parent $PROFILE + try { + if (-not (Test-Path -LiteralPath $profileDir)) { + New-Item -ItemType Directory -Path $profileDir -Force | Out-Null + } + } catch { + Write-Warn "Could not create profile directory: $profileDir" + return + } + $marker = '# agora-cli completion (managed by install.ps1)' + if (Test-Path -LiteralPath $PROFILE) { + $existing = Get-Content -LiteralPath $PROFILE -Raw -ErrorAction SilentlyContinue + if ($existing -and $existing.Contains($marker)) { + Write-Info "Agora completion already wired in $PROFILE." + return + } + } + $loader = @" + +$marker +if (Get-Command agora -ErrorAction SilentlyContinue) { + agora completion powershell | Out-String | Invoke-Expression +} +"@ + try { + Add-Content -LiteralPath $PROFILE -Value $loader -ErrorAction Stop + Write-Info "Wired Agora CLI completion into $PROFILE." + Write-Host " Open a new PowerShell window or run: . `$PROFILE" + } catch { + Write-Warn "Could not append completion loader to $PROFILE. Run 'agora completion powershell | Out-String | Invoke-Expression' manually." + } } function Verify-Binary { @@ -401,18 +501,43 @@ try { Write-InstallReceipt -BinaryPath $destinationBinary Write-Info "Installed agora to $destinationBinary" + # ---- Shell setup (auto-by-default) ----------------------------------- + # PATH and completion are wired automatically by default. -NoPath, + # -NoCompletion, and -SkipShell are granular opt-outs (the umbrella + # -SkipShell implies both). Order matters: PATH first so completion + # can use the binary we just installed. $resolved = Get-Command agora -ErrorAction SilentlyContinue - if ($resolved) { - Write-Info "Current PATH resolves agora to $($resolved.Source)" + + if ($SkipShell -or $NoPath) { + if (-not $resolved) { + Show-PathInstructions -BinaryPath $destinationBinary + } elseif ($resolved.Source -ne $destinationBinary) { + Write-Warn "Another agora is earlier on PATH: $($resolved.Source)" + Write-Warn "Reorder PATH so $InstallDir comes first, or remove the other binary." + } else { + Write-Info "Resolved on PATH: $($resolved.Source)" + } } else { - Write-Warn "agora is not on your PATH yet." - Write-Host "Current session: `$env:Path = `"$InstallDir;`$env:Path`"" - Write-Host "Persistent user PATH: add $InstallDir in Windows Environment Variables, or rerun with -AddToPath." + if (-not $resolved) { + if (Add-InstallDirToUserPath -BinaryPath $destinationBinary) { + } + # On failure Add-InstallDirToUserPath already emitted a + # complete warn + manual block (rustup / uv / Stripe CLI + # convention). Do not double-print a fallback hint here. + } elseif ($resolved.Source -ne $destinationBinary) { + Write-Warn "Another agora is earlier on PATH: $($resolved.Source)" + Write-Warn "Reorder PATH so $InstallDir comes first, or remove the other binary." + } else { + Write-Info "Resolved on PATH: $($resolved.Source)" + } } - if ($AddToPath) { - Add-InstallDirToUserPath - Write-Host "Open a new terminal after updating PATH." + if ($SkipShell -or $NoCompletion) { + Write-Info "Shell completion skipped. Enable later with:" + Write-Host " agora completion powershell | Out-String | Invoke-Expression" + Write-Host " (or add the line above to your PowerShell `$PROFILE)" + } else { + Install-AgoraCompletion -BinaryPath $destinationBinary } Write-Color 'Done. Run: agora --help' -Color Green diff --git a/install.sh b/install.sh index cfe9501..da65cf9 100755 --- a/install.sh +++ b/install.sh @@ -45,13 +45,19 @@ NO_COLOR_ENV="${NO_COLOR:-}" # ---- Mode flags ------------------------------------------------------------ DRY_RUN=0 FORCE=0 -ADD_TO_PATH=0 LIST_VERSIONS=0 PRERELEASE=0 QUIET=0 VERBOSE=0 NO_COLOR_FLAG=0 UNINSTALL=0 +# Shell-integration opt-outs. Default behavior matches modern installers +# (bun, fnm, deno, uv, volta): auto-wire PATH and shell completion for +# the detected $SHELL. Granular opt-outs let users decouple each piece; +# --skip-shell is the umbrella that disables both. +NO_PATH=0 +NO_COMPLETION=0 +SKIP_SHELL=0 # ---- Exit codes ------------------------------------------------------------ EXIT_OK=0 @@ -186,12 +192,23 @@ ${BOLD}Options:${RESET} --list-versions Print recent published versions and exit. --force Reinstall even if the requested version is already present, or proceed past a Homebrew/npm-managed install warning. - --add-to-path Append the install directory to your shell rc file. + +${BOLD}Shell integration${RESET} ${DIM}(auto-on; pass an opt-out flag to disable)${RESET} + --no-path Don't append the install directory to your shell rc file. + The script will still print manual PATH instructions if + 'agora' is not already resolvable on PATH. + --no-completion Don't install shell completion. The script will still + print the manual 'agora completion ' command. + --skip-shell Umbrella for --no-path --no-completion. Install the + binary only and skip every shell modification. + +${BOLD}Other:${RESET} --dry-run Show what would happen without making changes. --uninstall Remove the installer-managed binary and receipt. --no-color Disable colored output. -q, --quiet Suppress non-error output. - -v, --verbose Verbose debug output. + -v, --verbose Verbose debug output (installer-internal; unrelated to + 'agora --debug'). --installer-version Print this installer's revision and exit. -h, --help Show this help. @@ -264,8 +281,16 @@ parse_args() { FORCE=1 shift ;; - --add-to-path) - ADD_TO_PATH=1 + --no-path) + NO_PATH=1 + shift + ;; + --no-completion) + NO_COMPLETION=1 + shift + ;; + --skip-shell) + SKIP_SHELL=1 shift ;; --dry-run) @@ -834,13 +859,33 @@ detect_platform() { } # ---- PATH guidance --------------------------------------------------------- +bash_writable_rc() { + for candidate in \ + "${XDG_CONFIG_HOME:+$XDG_CONFIG_HOME/bash/bashrc}" \ + "$HOME/.bashrc" \ + "$HOME/.bash_profile" \ + "$HOME/.profile"; do + if [ -z "$candidate" ]; then + continue + fi + if [ -w "$candidate" ] || { [ ! -e "$candidate" ] && [ -w "$(dirname "$candidate")" ]; }; then + printf '%s\n' "$candidate" + return 0 + fi + done + + # Fallback target for manual hints when no candidate can be written. + printf '%s\n' "$HOME/.bashrc" + return 0 +} + shell_rc_for_path() { shell_name="" if [ -n "${SHELL:-}" ]; then shell_name=$(basename "$SHELL" 2>/dev/null || true) fi case "$shell_name" in - bash) printf '%s\n' "$HOME/.bashrc" ;; + bash) bash_writable_rc ;; zsh) printf '%s\n' "$HOME/.zshrc" ;; fish) printf '%s\n' "$HOME/.config/fish/config.fish" ;; *) printf '%s\n' "$HOME/.profile" ;; @@ -858,15 +903,51 @@ shell_path_line() { esac } +shell_refresh_command() { + shell_name="" + if [ -n "${SHELL:-}" ]; then + shell_name=$(basename "$SHELL" 2>/dev/null || true) + fi + case "$shell_name" in + fish) printf 'source %s\n' "$(shell_rc_for_path)" ;; + *) printf 'exec %s\n' "${SHELL:-/bin/sh}" ;; + esac +} + +# print_path_instructions is the manual-fallback hint shown when the +# user opted out of auto-PATH (--no-path / --skip-shell) and the binary +# is not yet resolvable on PATH. It reuses print_manual_path_block so +# the wording and exact command match what the auto-failed path emits. print_path_instructions() { rcfile=$(shell_rc_for_path) line=$(shell_path_line) warn "agora is not on your PATH yet." - say " Add this to ${BOLD}${rcfile}${RESET}:" - say " ${GREEN}${line}${RESET}" - say " Or re-run with ${BOLD}--add-to-path${RESET} to do this automatically." + print_manual_path_block "$rcfile" "$line" + say " ${DIM}(Tip: re-run the installer without --no-path / --skip-shell to do this automatically.)${RESET}" } +# print_manual_path_block prints a complete, copy-pasteable manual +# PATH-setup block. Used as the body of every PATH failure message so +# the wording, indentation, and example command stay identical across +# the mkdir-failed, write-failed, and explicit-opt-out paths. +print_manual_path_block() { + rcfile=$1 + line=$2 + say " agora is installed at ${BOLD}${DESTINATION}${RESET} and is ready to run." + say " To add it to your PATH, append this line to a shell rc file you can write to:" + say "" + say " ${GREEN}${line}${RESET}" + say "" + say " Then open a new shell, or ${DIM}source${RESET} the file you edited." + say " ${DIM}For other options (custom INSTALL_DIR, containers), see ${DOCS_URL}${RESET}" +} + +# add_to_path appends INSTALL_DIR to the user's shell rc file. Best-effort: +# returns 0 on success (added or already present), 1 on any write failure. +# On failure it emits a plain-language branch message followed by a +# copy-pasteable manual block. The caller does NOT need to print any +# additional fallback hints. This mirrors the softer manual-fallback +# style used by bun and uv. add_to_path() { rcfile=$(shell_rc_for_path) line=$(shell_path_line) @@ -882,10 +963,159 @@ add_to_path() { return 0 fi - mkdir -p "$(dirname "$rcfile")" - printf '\n# Added by Agora CLI installer\n%s\n' "$line" >> "$rcfile" + rcdir=$(dirname "$rcfile") + if ! mkdir -p "$rcdir" 2>/dev/null; then + say "${rcfile} is not writable, so the installer can't add agora to your PATH automatically." + print_manual_path_block "$rcfile" "$line" + return 1 + fi + # Wrap the redirection in a brace group so the shell's own + # "Permission denied" message on the redirection (emitted before + # the command runs, so a bare `>> file 2>/dev/null` does not catch + # it) is suppressed. We surface only the friendlier warn below. + if ! { printf '\n# Added by Agora CLI installer\n%s\n' "$line" >> "$rcfile"; } 2>/dev/null; then + say "${rcfile} is not writable, so the installer can't add agora to your PATH automatically." + print_manual_path_block "$rcfile" "$line" + return 1 + fi say "Added ${INSTALL_DIR} to PATH in ${rcfile}." - say "Open a new shell to apply." + say "To use agora in this shell now, run:" + say " ${GREEN}$(shell_refresh_command)${RESET}" + say "${DIM}(Or open a new terminal - the change takes effect either way.)${RESET}" + return 0 +} + +# detect_user_shell returns "bash", "zsh", "fish", or "" for the user's +# login shell. Reads $SHELL because the installer often runs in a +# subshell that does not match the user's interactive shell. +detect_user_shell() { + case "${SHELL:-}" in + */bash) printf 'bash' ;; + */zsh) printf 'zsh' ;; + */fish) printf 'fish' ;; + *) printf '' ;; + esac +} + +# completion_target_for_shell prints the user-writable file path the +# completion script should be written to for the given shell. Empty +# output means "no known target", and the caller should print a manual +# hint instead. We deliberately prefer user-owned paths so the +# installer never needs sudo just to wire completion. +completion_target_for_shell() { + shell_name=$1 + case "$shell_name" in + bash) + # bash-completion v2 reads from XDG_DATA_HOME/bash-completion/completions. + # Falls back to ~/.local/share/... which is the documented default. + data_home="${XDG_DATA_HOME:-$HOME/.local/share}" + printf '%s/bash-completion/completions/agora' "$data_home" + ;; + zsh) + # ~/.zsh/completions is appended to fpath in our zsh hint below. + printf '%s/.zsh/completions/_agora' "$HOME" + ;; + fish) + # fish auto-loads ~/.config/fish/completions/*.fish. + config_home="${XDG_CONFIG_HOME:-$HOME/.config}" + printf '%s/fish/completions/agora.fish' "$config_home" + ;; + *) + printf '' + ;; + esac +} + +# print_completion_instructions tells the user how to enable completion +# manually when they opted out (--no-completion / --skip-shell) or when +# auto-detect failed. Mirrors print_path_instructions for symmetry. +print_completion_instructions() { + shell_name=$(detect_user_shell) + case "$shell_name" in + bash|zsh|fish) + say " ${GREEN}agora completion ${shell_name} > $(completion_target_for_shell "$shell_name")${RESET}" + ;; + *) + say " ${GREEN}agora completion --help${RESET}" + ;; + esac +} + +# install_completion writes the cobra-generated completion script to a +# user-writable location for the detected shell. Best-effort: failures +# never abort the install, and we always print the manual fallback +# command the user can copy-paste if anything goes wrong. Honors +# NO_COMPLETION and SKIP_SHELL. +install_completion() { + if [ "$NO_COMPLETION" = "1" ] || [ "$SKIP_SHELL" = "1" ]; then + return 0 + fi + + shell_name=$(detect_user_shell) + case "$shell_name" in + bash|zsh|fish) ;; + "") + say "Could not detect your shell from \$SHELL. To enable completion later, run 'agora completion --help'." + return 0 + ;; + *) + say "Shell completion auto-install not supported for ${shell_name}. Run 'agora completion --help' to wire it manually." + return 0 + ;; + esac + + target=$(completion_target_for_shell "$shell_name") + if [ -z "$target" ]; then + say "No known completion install path for ${shell_name}. Run 'agora completion --help' for manual instructions." + return 0 + fi + + if [ "$DRY_RUN" = "1" ]; then + say "[dry-run] Would install ${shell_name} completion to ${target}." + return 0 + fi + + if ! command -v agora >/dev/null 2>&1 && [ ! -x "$DESTINATION" ]; then + warn "Cannot install completion: agora binary not found yet." + return 0 + fi + + agora_bin="$DESTINATION" + if [ ! -x "$agora_bin" ]; then + agora_bin=$(command -v agora 2>/dev/null || printf '') + fi + if [ -z "$agora_bin" ]; then + return 0 + fi + + if ! mkdir -p "$(dirname "$target")" 2>/dev/null; then + warn "Could not create completion directory: $(dirname "$target")" + return 0 + fi + + if ! "$agora_bin" completion "$shell_name" > "$target" 2>/dev/null; then + warn "Could not generate ${shell_name} completion. You can enable it manually with 'agora completion ${shell_name}'." + rm -f "$target" 2>/dev/null || true + return 0 + fi + + say_ok "Shell completion installed: ${target}" + + # Print the one-time activation hint per shell. This is deliberately + # idempotent — we never modify rc files for completion (only PATH). + case "$shell_name" in + bash) + say " Activate now (or open a new shell): source \"${target}\"" + say " Note: requires bash-completion v2. Most distros install it by default; on macOS use 'brew install bash-completion@2'." + ;; + zsh) + say " Add this to ~/.zshrc if not already present:" + say " fpath=(\"\${HOME}/.zsh/completions\" \$fpath); autoload -Uz compinit && compinit" + ;; + fish) + say " Completion is auto-loaded on next fish session." + ;; + esac } # ---- Banner / footer ------------------------------------------------------- @@ -992,8 +1222,23 @@ main() { sudo_status="yes" fi say " sudo: ${sudo_status}" - if [ "$ADD_TO_PATH" = "1" ]; then - add_to_path + say " shell setup:" + if [ "$SKIP_SHELL" = "1" ]; then + say " PATH: skip (--skip-shell)" + say " completion: skip (--skip-shell)" + else + if [ "$NO_PATH" = "1" ]; then + say " PATH: skip (--no-path)" + else + say " PATH: auto-add to your shell rc file if needed" + add_to_path + fi + if [ "$NO_COMPLETION" = "1" ]; then + say " completion: skip (--no-completion)" + else + say " completion: auto-install for the detected shell" + install_completion + fi fi exit "$EXIT_OK" fi @@ -1054,20 +1299,85 @@ main() { write_install_receipt "$DESTINATION" say_ok "agora ${VERSION} installed." - if [ "$ADD_TO_PATH" = "1" ]; then - add_to_path + # ---- Shell setup (auto-by-default) ------------------------------------- + # PATH and completion are wired automatically by default. --no-path, + # --no-completion, and --skip-shell let users opt out granularly. + # Order matters: we resolve PATH first, then completion, so the + # completion step can use the binary we just installed. Both PATH and + # completion are best-effort — a write failure never aborts the + # install; we always fall back to printing exact manual instructions. + path_status="ok" # ok | added | auto_failed | skipped | shadowed | manual + completion_status="ok" # ok | installed | skipped + resolved="" + + if [ "$SKIP_SHELL" = "1" ] || [ "$NO_PATH" = "1" ]; then + if resolved=$(command -v agora 2>/dev/null); then + if [ "$resolved" = "$DESTINATION" ]; then + path_status="ok" + else + path_status="shadowed" + fi + else + path_status="manual" + fi + else + # Auto path-wiring: only modify rc when DESTINATION isn't already + # resolvable on PATH. INSTALL_DIR=/usr/local/bin (the macOS/Linux + # default) is normally already on PATH, so this is a no-op for the + # common case and only writes to ~/.zshrc / ~/.bashrc / fish config + # when needed (custom INSTALL_DIR like ~/.local/bin). + if resolved=$(command -v agora 2>/dev/null); then + if [ "$resolved" = "$DESTINATION" ]; then + path_status="ok" + else + # Another binary shadows ours; don't silently rewrite the user's + # rc file — that wouldn't fix the shadowing anyway. + path_status="shadowed" + fi + else + if add_to_path; then + path_status="added" + else + # Auto-add failed (rc unwritable, permission denied, etc.). + # Fall back to printing exact manual instructions so the user + # always knows the next step. + path_status="auto_failed" + fi + fi fi - resolved="" - if resolved=$(command -v agora 2>/dev/null); then - if [ "$resolved" = "$DESTINATION" ]; then + if [ "$SKIP_SHELL" = "1" ] || [ "$NO_COMPLETION" = "1" ]; then + completion_status="skipped" + else + install_completion + completion_status="installed" + fi + + # ---- Shell-setup summary footer --------------------------------------- + case "$path_status" in + ok) say "Resolved on PATH: ${DESTINATION}" - else + ;; + added) + : # already explained inside add_to_path + ;; + auto_failed) + : # add_to_path already printed a complete failure block — do + # not duplicate it here. The single warn + manual block style + # mirrors rustup / uv / Stripe CLI conventions. + ;; + shadowed) warn "Another agora is earlier on PATH: ${resolved}" warn "Reorder PATH so ${INSTALL_DIR} comes first, or remove the other binary." - fi - else - print_path_instructions + ;; + manual) + print_path_instructions + ;; + esac + + if [ "$completion_status" = "skipped" ]; then + say "Shell completion skipped. Enable later with:" + print_completion_instructions fi print_next_steps diff --git a/internal/cli/app.go b/internal/cli/app.go index 0b4620c..7baefb0 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -150,9 +150,10 @@ type App struct { rootQuiet bool rootNoColor bool rootUpgradeCheck bool - rootVerbose bool + rootDebug bool rootYes bool httpClient *http.Client + telemetry telemetryClient projectEnvProject string projectEnvFormat string projectEnvShell bool @@ -182,6 +183,11 @@ func NewApp() (*App, error) { } a.applyConfigToEnv() a.root = a.buildRoot() + // Best-effort initialize the telemetry sink. Returns a no-op when + // telemetry is disabled, when DO_NOT_TRACK is set, or when the + // Sentry DSN is empty (build without telemetry compiled in). Telemetry + // must never block the CLI from starting. + a.telemetry = initTelemetry(a.cfg.TelemetryEnabled, a.env, versionInfo()) return a, nil } @@ -193,6 +199,13 @@ func NewApp() (*App, error) { func (a *App) Execute() error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + // Best-effort telemetry flush at exit. Bounded by a short timeout so + // telemetry can never delay the CLI returning control to the shell. + defer func() { + if a.telemetry != nil { + a.telemetry.Flush(2 * time.Second) + } + }() a.root.SetContext(ctx) // Best-effort cache hygiene on every startup. Anything older than // projectListCacheMaxAge (24h) is removed so we never accumulate @@ -224,6 +237,14 @@ func (a *App) Execute() error { "error": err.Error(), "logFilePath": logPath, }) + // Forward the failure to telemetry. The sink is responsible for + // honoring opt-out, redacting sensitive fields, and never blocking. + if a.telemetry != nil { + a.telemetry.CaptureException(err, map[string]any{ + "command": a.guessCommandLabel(os.Args[1:]), + "exitCode": exitCode, + }) + } if mode == outputJSON { _ = emitErrorEnvelope(os.Stdout, a.guessCommandLabel(os.Args[1:]), err, exitCode, logPath) if exitCode != 1 { diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index 7c0e61c..4983ece 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -451,7 +451,7 @@ func TestEnsureAppConfigStateMigratesPartialAndCustomPreviousConfigs(t *testing. if err != nil { t.Fatal(err) } - if state.Status != "migrated" || state.Config.Output != outputJSON || !state.Config.Verbose { + if state.Status != "migrated" || state.Config.Output != outputJSON || !state.Config.Debug { t.Fatalf("unexpected migrated partial config: %+v", state) } @@ -468,6 +468,50 @@ func TestEnsureAppConfigStateMigratesPartialAndCustomPreviousConfigs(t *testing. } } +// TestEnsureAppConfigStateMigratesLegacyVerboseKeyToDebug proves that +// 0.1.x configs containing the legacy "verbose" key are transparently +// promoted to the new "debug" field on first load by 0.2.0+, and that +// the rewritten file no longer contains the legacy key. Regression +// guard for the v0.2.0 --verbose -> --debug rename. +func TestEnsureAppConfigStateMigratesLegacyVerboseKeyToDebug(t *testing.T) { + dir := t.TempDir() + configPath, err := resolveConfigFilePath(map[string]string{"XDG_CONFIG_HOME": dir}) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + legacy := `{"version":2,"output":"json","verbose":true}` + if err := os.WriteFile(configPath, []byte(legacy), 0o600); err != nil { + t.Fatal(err) + } + state, err := ensureAppConfigState(map[string]string{"XDG_CONFIG_HOME": dir}) + if err != nil { + t.Fatal(err) + } + if state.Status != "migrated" || !state.Config.Debug || state.Config.Output != outputJSON { + t.Fatalf("expected legacy verbose=true to migrate to Debug=true, got %+v", state) + } + rewritten, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + var rewrittenMap map[string]any + if err := json.Unmarshal(rewritten, &rewrittenMap); err != nil { + t.Fatal(err) + } + if _, hasLegacy := rewrittenMap["verbose"]; hasLegacy { + t.Fatalf("expected rewritten config to drop legacy verbose key, got %s", string(rewritten)) + } + if debug, ok := rewrittenMap["debug"].(bool); !ok || !debug { + t.Fatalf("expected rewritten config to contain debug=true, got %s", string(rewritten)) + } + if version, _ := rewrittenMap["version"].(float64); int(version) != currentAppConfigVersion { + t.Fatalf("expected rewritten config to be stamped with version %d, got %v", currentAppConfigVersion, rewrittenMap["version"]) + } +} + func TestConfigBannerFormattingAndPrintingRules(t *testing.T) { created := formatConfigBanner(configState{ Config: defaultConfig(), @@ -589,13 +633,13 @@ func TestResolveConfiguredOutputModeAndConfigApplication(t *testing.T) { app.cfg.APIBaseURL = "https://config.example.com" app.cfg.LogLevel = "warn" app.cfg.BrowserAutoOpen = false - app.cfg.Verbose = true + app.cfg.Debug = true app.applyConfigToEnv() if env["AGORA_API_BASE_URL"] != "https://env.example.com" || env["AGORA_OUTPUT"] != "json" { t.Fatalf("expected env values to win, got %+v", env) } - if env["AGORA_BROWSER_AUTO_OPEN"] != "0" || env["AGORA_LOG_LEVEL"] != "warn" || env["AGORA_VERBOSE"] != "1" { + if env["AGORA_BROWSER_AUTO_OPEN"] != "0" || env["AGORA_LOG_LEVEL"] != "warn" || env["AGORA_DEBUG"] != "1" { t.Fatalf("expected missing env values to be filled, got %+v", env) } @@ -944,7 +988,7 @@ func TestLogRotationFilteringAndVerboseMirror(t *testing.T) { t.Fatalf("unexpected filtered log contents: %s", string(saved)) } - verboseDir := t.TempDir() + debugDir := t.TempDir() readPipe, writePipe, err := os.Pipe() if err != nil { t.Fatal(err) @@ -952,12 +996,12 @@ func TestLogRotationFilteringAndVerboseMirror(t *testing.T) { originalStderr := os.Stderr os.Stderr = writePipe defer func() { os.Stderr = originalStderr }() - verboseEnv := map[string]string{ - "XDG_CONFIG_HOME": verboseDir, + debugEnv := map[string]string{ + "XDG_CONFIG_HOME": debugDir, "AGORA_LOG_LEVEL": "debug", - "AGORA_VERBOSE": "1", + "AGORA_DEBUG": "1", } - if err := appendAppLog("debug", "test.verbose.output", verboseEnv, map[string]any{ + if err := appendAppLog("debug", "test.debug.mirror", debugEnv, map[string]any{ "accessToken": "secret-value", "detail": "visible", }); err != nil { @@ -968,8 +1012,8 @@ func TestLogRotationFilteringAndVerboseMirror(t *testing.T) { if err != nil { t.Fatal(err) } - if !strings.Contains(string(mirrored), "test.verbose.output") || !strings.Contains(string(mirrored), `"detail":"visible"`) || !strings.Contains(string(mirrored), `"accessToken":"[REDACTED]"`) || strings.Contains(string(mirrored), "secret-value") { - t.Fatalf("unexpected verbose mirror output: %s", string(mirrored)) + if !strings.Contains(string(mirrored), "test.debug.mirror") || !strings.Contains(string(mirrored), `"detail":"visible"`) || !strings.Contains(string(mirrored), `"accessToken":"[REDACTED]"`) || strings.Contains(string(mirrored), "secret-value") { + t.Fatalf("unexpected debug mirror output: %s", string(mirrored)) } } diff --git a/internal/cli/commands.go b/internal/cli/commands.go index e9379e4..db1faeb 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -56,11 +56,14 @@ Use "agora --help --all --json" for a machine-readable command tree (agent tooli SilenceErrors: true, PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { mode := a.resolveOutputMode(cmd) - // --verbose mirrors AGORA_VERBOSE=1: echoes structured logs to - // stderr in addition to writing them to the log file. Setting - // it on a.env is enough because appendAppLog reads from there. - if a.rootVerbose { - a.env["AGORA_VERBOSE"] = "1" + // --debug mirrors AGORA_DEBUG=1: echoes structured log + // records to stderr in addition to writing them to the + // log file. v0.2.0 dropped the legacy --verbose / -v + // alias and the AGORA_VERBOSE env var; --debug / + // AGORA_DEBUG are the only supported names. See + // CHANGELOG.md for migration notes. + if a.rootDebug { + a.env["AGORA_DEBUG"] = "1" } ctx := context.WithValue(cmd.Context(), contextKeyOutputMode{}, mode) ctx = context.WithValue(ctx, contextKeyJSONPretty{}, a.rootPrettyJSON) @@ -69,6 +72,11 @@ Use "agora --help --all --json" for a machine-readable command tree (agent tooli cmd.SetContext(ctx) return nil }, + // Cobra's built-in suggestions: when a user mistypes a + // subcommand we print the closest matches alongside the + // "unknown command" error. Distance 2 matches gh/kubectl/git + // behavior (e.g. `agora projct doctor` suggests `project`). + SuggestionsMinimumDistance: 2, } root.Version = formattedVersion() root.PersistentFlags().StringVar(&a.rootOutput, "output", "", "output mode for command results: pretty or json") @@ -76,7 +84,12 @@ Use "agora --help --all --json" for a machine-readable command tree (agent tooli root.PersistentFlags().BoolVar(&a.rootPrettyJSON, "pretty", false, "pretty-print JSON output when used with --json") root.PersistentFlags().BoolVar(&a.rootQuiet, "quiet", false, "suppress success output (both pretty and JSON envelopes); rely on exit code. Errors still print on stderr.") root.PersistentFlags().BoolVar(&a.rootNoColor, "no-color", false, "disable ANSI color in pretty output") - root.PersistentFlags().BoolVarP(&a.rootVerbose, "verbose", "v", false, "echo structured logs to stderr (equivalent to AGORA_VERBOSE=1); does not change exit codes or JSON envelopes") + // --debug is the canonical name for runtime log echo (industry + // convention; matches gh, vercel, stripe, supabase). v0.2.0 + // dropped the legacy --verbose / -v alias and AGORA_VERBOSE env + // var; persisted configs containing "verbose" are auto-migrated + // to "debug" on first load. + root.PersistentFlags().BoolVar(&a.rootDebug, "debug", false, "echo structured logs to stderr (equivalent to AGORA_DEBUG=1); does not change exit codes or JSON envelopes") root.PersistentFlags().BoolVarP(&a.rootYes, "yes", "y", false, "assume the default answer to confirmation prompts (equivalent to AGORA_NO_INPUT=1); never starts new interactive flows in JSON/CI/non-TTY contexts") root.PersistentFlags().Bool("all", false, "show the full command tree in help output") root.PersistentFlags().BoolVar(&a.rootUpgradeCheck, "upgrade-check", false, "print non-interactive upgrade guidance and exit") @@ -94,8 +107,16 @@ Use "agora --help --all --json" for a machine-readable command tree (agent tooli root.AddCommand(a.buildUpgradeCommand()) root.AddCommand(a.buildOpenCommand()) root.AddCommand(a.buildMCPCommand()) - // Keep "add" unregistered until it has a real implementation. Calls to - // `agora add` should behave as unknown command for now. + root.AddCommand(a.buildDoctorCommand()) + root.AddCommand(a.buildEnvHelpCommand()) + root.AddCommand(a.buildSkillsCommand()) + // `agora add` is intentionally unregistered. Plugin/extension + // scaffolding is a deliberate non-goal until we have a concrete + // extension API; until then `agora add ...` returns the standard + // Cobra "unknown command" error (which now includes a "did you + // mean" suggestion thanks to SuggestionsMinimumDistance=2). When + // reintroducing this surface, document the contract in AGENTS.md + // before wiring a builder here. defaultHelp := root.HelpFunc() root.SetHelpFunc(func(cmd *cobra.Command, args []string) { if showAllHelp(cmd) && a.resolveOutputMode(cmd) == outputJSON { @@ -164,6 +185,10 @@ func (a *App) buildTelemetryCommand() *cobra.Command { cmd.AddCommand(&cobra.Command{ Use: "status", Short: "Show telemetry status", + Example: example(` + agora telemetry status + agora telemetry status --json +`), RunE: func(cmd *cobra.Command, _ []string) error { return a.renderTelemetry(cmd) }, @@ -171,6 +196,10 @@ func (a *App) buildTelemetryCommand() *cobra.Command { cmd.AddCommand(&cobra.Command{ Use: "enable", Short: "Enable telemetry", + Example: example(` + agora telemetry enable + agora telemetry enable --json +`), RunE: func(cmd *cobra.Command, _ []string) error { return a.setTelemetry(cmd, true) }, @@ -178,6 +207,11 @@ func (a *App) buildTelemetryCommand() *cobra.Command { cmd.AddCommand(&cobra.Command{ Use: "disable", Short: "Disable telemetry", + Example: example(` + agora telemetry disable + agora telemetry disable --json + DO_NOT_TRACK=1 agora # one-shot disable via env +`), RunE: func(cmd *cobra.Command, _ []string) error { return a.setTelemetry(cmd, false) }, @@ -222,7 +256,7 @@ func (a *App) buildUpgradeCommand() *cobra.Command { var check bool cmd := &cobra.Command{ Use: "upgrade", - Aliases: []string{"update"}, + Aliases: []string{"update", "self-update"}, Short: "Upgrade Agora CLI in place when installer-managed; otherwise print upgrade guidance", Long: `Upgrade Agora CLI to the latest release. @@ -235,6 +269,7 @@ Use --check to resolve the latest version and report what would happen without w agora upgrade agora upgrade --check --json agora update --json + agora self-update --check `), RunE: func(cmd *cobra.Command, _ []string) error { data, err := a.performSelfUpdate(check) @@ -452,7 +487,7 @@ func (a *App) buildConfigCommand() *cobra.Command { }) var cfg appConfig cfg = a.cfg - var telemetryEnabled, browserAutoOpen, verbose bool + var telemetryEnabled, browserAutoOpen, debug bool update := &cobra.Command{ Use: "update", Short: "Update persisted CLI defaults", @@ -461,6 +496,7 @@ func (a *App) buildConfigCommand() *cobra.Command { agora config update --output json agora config update --browser-auto-open=false agora config update --api-base-url https://agora-cli.agora.io + agora config update --debug=true `), RunE: func(cmd *cobra.Command, _ []string) error { next := a.cfg @@ -485,8 +521,8 @@ func (a *App) buildConfigCommand() *cobra.Command { if cmd.Flags().Changed("log-level") { next.LogLevel = cfg.LogLevel } - if cmd.Flags().Changed("verbose") { - next.Verbose = verbose + if cmd.Flags().Changed("debug") { + next.Debug = debug } if cmd.Flags().Changed("output") { next.Output = cfg.Output @@ -510,7 +546,7 @@ func (a *App) buildConfigCommand() *cobra.Command { update.Flags().BoolVar(&telemetryEnabled, "telemetry-enabled", false, "persist telemetry preference; use --telemetry-enabled=false to disable") update.Flags().BoolVar(&browserAutoOpen, "browser-auto-open", false, "persist browser auto-open preference; use --browser-auto-open=false to disable") update.Flags().StringVar(&cfg.LogLevel, "log-level", cfg.LogLevel, "persist default log level") - update.Flags().BoolVar(&verbose, "verbose", false, "persist verbose logging preference; use --verbose=false to disable") + update.Flags().BoolVar(&debug, "debug", false, "persist the --debug preference (echo structured logs to stderr); use --debug=false to disable") update.Flags().Var(newOutputModeValue((*string)(&cfg.Output)), "output", "persist default output mode (pretty or json)") cmd.AddCommand(update) return cmd @@ -697,14 +733,23 @@ func (a *App) buildProjectEnv() *cobra.Command { cmd := &cobra.Command{ Use: "env", Short: "Export project environment variables", - Long: `Render environment variables for a project in dotenv, shell, or JSON form. + Long: `Render environment variables for a project in dotenv, shell, or JSON envelope form. + +This is the one command whose default (non-JSON) output is raw stdout — without the unified JSON envelope — so it can be used with shell substitution: ` + "`source <(agora project env --shell)`" + `. Use --format to be explicit: + + --format dotenv KEY=value lines (default; ready for ` + "`>> .env`" + `) + --format shell shell export statements (ready for ` + "`source <(...)`" + `) + --format envelope unified JSON envelope (alias of --json) + --format json same as --format envelope -Use "project env write" when you want to persist the values into a managed dotenv file.`, +For automation, prefer --json (or --format envelope) so the result has the same shape as every other command. Use "project env write" when you want to persist the values into a managed dotenv file on disk.`, Example: example(` agora project env agora project env --shell + agora project env --format envelope agora project env --with-secrets --json agora project env --project my-agent-demo + source <(agora project env --format shell) `), RunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { @@ -737,7 +782,7 @@ Use "project env write" when you want to persist the values into a managed doten }, } cmd.Flags().StringVar(&a.projectEnvProject, "project", "", "project ID or exact project name; defaults to the current project context") - cmd.Flags().StringVar(&a.projectEnvFormat, "format", "", "output format: dotenv or shell; use --json for JSON output") + cmd.Flags().StringVar(&a.projectEnvFormat, "format", "", "output format: dotenv | shell | envelope | json (default dotenv; envelope/json emit the unified JSON envelope)") cmd.Flags().BoolVar(&a.projectEnvShell, "shell", false, "render shell export statements instead of dotenv lines") cmd.Flags().BoolVar(&a.projectEnvSecrets, "with-secrets", false, "include sensitive values such as the app certificate") write := &cobra.Command{ diff --git a/internal/cli/config.go b/internal/cli/config.go index 4cf05a7..ebd6769 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -16,7 +16,12 @@ type appConfig struct { OAuthScope string `json:"oauthScope"` Output outputMode `json:"output"` TelemetryEnabled bool `json:"telemetryEnabled"` - Verbose bool `json:"verbose"` + // Debug controls whether `appendAppLog` mirrors structured log + // records to stderr. v0.2.0 renamed this field from "verbose" to + // match the canonical --debug flag and AGORA_DEBUG env var. + // mergeConfig still reads the legacy "verbose" key so existing + // 0.1.x configs migrate transparently on first load. + Debug bool `json:"debug"` } // defaultConfig returns a fresh appConfig populated with the production @@ -24,7 +29,7 @@ type appConfig struct { // back to these values. func defaultConfig() appConfig { return appConfig{ - Version: 2, + Version: 3, APIBaseURL: "https://agora-cli.agora.io", BrowserAutoOpen: true, LogLevel: "info", @@ -33,7 +38,7 @@ func defaultConfig() appConfig { OAuthScope: "basic_info,console", Output: outputPretty, TelemetryEnabled: true, - Verbose: false, + Debug: false, } } @@ -65,8 +70,14 @@ func mergeConfig(cfg *appConfig, raw map[string]any) { if v, ok := raw["telemetryEnabled"].(bool); ok { cfg.TelemetryEnabled = v } - if v, ok := raw["verbose"].(bool); ok { - cfg.Verbose = v + // v0.2.0 renamed "verbose" -> "debug". Read the canonical key + // first; fall back to the legacy key so existing 0.1.x configs + // migrate on first load. The next config write drops "verbose" + // because the appConfig struct only emits "debug". + if v, ok := raw["debug"].(bool); ok { + cfg.Debug = v + } else if v, ok := raw["verbose"].(bool); ok { + cfg.Debug = v } } @@ -84,7 +95,7 @@ func (a *App) applyConfigToEnv() { a.setEnvIfMissing("AGORA_SENTRY_ENABLED", boolString(a.cfg.TelemetryEnabled)) a.setEnvIfMissing("AGORA_BROWSER_AUTO_OPEN", boolString(a.cfg.BrowserAutoOpen)) a.setEnvIfMissing("AGORA_LOG_LEVEL", a.cfg.LogLevel) - a.setEnvIfMissing("AGORA_VERBOSE", boolString(a.cfg.Verbose)) + a.setEnvIfMissing("AGORA_DEBUG", boolString(a.cfg.Debug)) if strings.TrimSpace(a.env["DO_NOT_TRACK"]) != "" { a.env["AGORA_SENTRY_ENABLED"] = "0" } diff --git a/internal/cli/env_help.go b/internal/cli/env_help.go new file mode 100644 index 0000000..8640b76 --- /dev/null +++ b/internal/cli/env_help.go @@ -0,0 +1,117 @@ +package cli + +import ( + "sort" + "strconv" + "strings" + + "github.com/spf13/cobra" +) + +// agoraEnvVar describes a single environment variable the CLI honors. +// The catalog below is the authoritative list; do not add new env vars +// without an entry here, otherwise `agora env-help` and the auto- +// generated docs will silently drift. +type agoraEnvVar struct { + Name string `json:"name"` + Description string `json:"description"` + Default string `json:"default,omitempty"` + Category string `json:"category"` + Effect string `json:"effect"` +} + +func agoraEnvCatalog() []agoraEnvVar { + return []agoraEnvVar{ + // Output / runtime UX + {Name: "AGORA_OUTPUT", Category: "output", Description: "Default output mode when --output / --json is not passed.", Default: "pretty", Effect: "pretty | json"}, + {Name: "AGORA_DEBUG", Category: "output", Description: "When 1, mirror structured log records to stderr (equivalent to --debug). v0.2.0 dropped the legacy AGORA_VERBOSE alias.", Default: "0", Effect: "0 | 1"}, + {Name: "AGORA_LOG_LEVEL", Category: "output", Description: "Minimum log level written to the rotating log file.", Default: "info", Effect: "debug | info | warn | error"}, + {Name: "AGORA_LOG_ENABLED", Category: "output", Description: "When 0, disable file logging entirely.", Default: "1", Effect: "0 | 1"}, + {Name: "AGORA_LOG_MAX_BYTES", Category: "output", Description: "Per-file size before log rotation.", Default: "1000000", Effect: "positive integer"}, + {Name: "AGORA_LOG_MAX_FILES", Category: "output", Description: "Number of rotated log files to keep.", Default: "5", Effect: "positive integer"}, + {Name: "NO_COLOR", Category: "output", Description: "Standard NO_COLOR convention; disables ANSI color in pretty output.", Effect: "any non-empty value"}, + // Interaction + {Name: "AGORA_NO_INPUT", Category: "interaction", Description: "When set, accept default for confirmation prompts (alias of --yes). Never starts a new interactive OAuth flow in JSON/CI/non-TTY contexts.", Default: "0", Effect: "0 | 1 | true | yes | y"}, + {Name: "AGORA_BROWSER_AUTO_OPEN", Category: "interaction", Description: "When 0, never auto-open a browser for OAuth login (forces --no-browser semantics).", Default: "1", Effect: "0 | 1"}, + {Name: "AGORA_LOGIN_TIMEOUT_MS", Category: "interaction", Description: "How long to wait for the OAuth callback before giving up.", Default: "300000", Effect: "milliseconds"}, + // Storage / paths + {Name: "AGORA_HOME", Category: "storage", Description: "Override the directory the CLI uses for config, session, context, cache, and logs.", Effect: "absolute path"}, + {Name: "AGORA_DISABLE_CACHE", Category: "storage", Description: "When 1, disable the on-disk project list cache used for shell completion.", Default: "0", Effect: "0 | 1"}, + {Name: "AGORA_PROJECT_CACHE_TTL_SECONDS", Category: "storage", Description: "TTL for the project list cache.", Default: "60", Effect: "seconds"}, + // Endpoints / OAuth + {Name: "AGORA_API_BASE_URL", Category: "endpoints", Description: "Override the Agora API base URL.", Default: "https://agora-cli.agora.io"}, + {Name: "AGORA_OAUTH_BASE_URL", Category: "endpoints", Description: "Override the OAuth authorization server.", Default: "https://sso2.agora.io"}, + {Name: "AGORA_OAUTH_CLIENT_ID", Category: "endpoints", Description: "Override the OAuth client ID.", Default: "agora_web_cli"}, + {Name: "AGORA_OAUTH_SCOPE", Category: "endpoints", Description: "Override the OAuth scope set requested at login.", Default: "basic_info,console"}, + {Name: "AGORA_CONSOLE_URL", Category: "endpoints", Description: "Override the URL used by `agora open --target console`."}, + {Name: "AGORA_DOCS_URL", Category: "endpoints", Description: "Override the URL used by `agora open --target docs`."}, + {Name: "AGORA_PRODUCT_DOCS_URL", Category: "endpoints", Description: "Override the URL used by `agora open --target product-docs`."}, + // Telemetry + {Name: "DO_NOT_TRACK", Category: "telemetry", Description: "Standard DO_NOT_TRACK convention; hard opt-out of telemetry and file logging.", Effect: "any non-empty value"}, + {Name: "AGORA_SENTRY_ENABLED", Category: "telemetry", Description: "When 0, disable telemetry transport even if config has telemetryEnabled=true.", Default: "1", Effect: "0 | 1"}, + {Name: "AGORA_SENTRY_ENVIRONMENT", Category: "telemetry", Description: "Override the environment tag used in telemetry events.", Default: "production"}, + {Name: "AGORA_RELEASE", Category: "telemetry", Description: "Override the release tag used in telemetry events.", Default: ""}, + // Agent integrations + {Name: "AGORA_AGENT", Category: "agent", Description: "Coarse agent label appended to the User-Agent header. Auto-inferred from CURSOR / CLAUDE_AGENT / etc. when unset.", Effect: "free-form string"}, + } +} + +// buildEnvHelpCommand exposes the catalog as `agora env-help`. It mirrors +// gh's `gh env-help` and Stripe CLI's `stripe env`. JSON mode emits an +// envelope so wrappers can enumerate every variable the CLI reads. +func (a *App) buildEnvHelpCommand() *cobra.Command { + return &cobra.Command{ + Use: "env-help", + Short: "List every AGORA_* environment variable the CLI honors", + Long: `Print the canonical list of environment variables that affect the CLI. + +This is the authoritative reference: any variable read by the CLI must +appear here, otherwise the env-help drift check (run via "make lint") +fails. Use this command instead of grepping the source for AGORA_*. + +Use --json for a machine-readable envelope grouped by category.`, + Example: example(` + agora env-help + agora env-help --json +`), + RunE: func(cmd *cobra.Command, _ []string) error { + catalog := agoraEnvCatalog() + sort.SliceStable(catalog, func(i, j int) bool { + if catalog[i].Category != catalog[j].Category { + return catalog[i].Category < catalog[j].Category + } + return catalog[i].Name < catalog[j].Name + }) + grouped := groupEnvByCategory(catalog) + return renderResult(cmd, "env-help", map[string]any{ + "action": "env-help", + "catalog": catalog, + "byCategory": grouped, + "summary": summarizeEnvCatalog(catalog), + }) + }, + } +} + +func groupEnvByCategory(catalog []agoraEnvVar) map[string][]agoraEnvVar { + out := map[string][]agoraEnvVar{} + for _, v := range catalog { + out[v.Category] = append(out[v.Category], v) + } + return out +} + +func summarizeEnvCatalog(catalog []agoraEnvVar) string { + categories := map[string]struct{}{} + for _, v := range catalog { + categories[v.Category] = struct{}{} + } + names := make([]string, 0, len(categories)) + for c := range categories { + names = append(names, c) + } + sort.Strings(names) + return "Documented " + + strconv.Itoa(len(catalog)) + " environment variable(s) across " + + strconv.Itoa(len(categories)) + " category(ies): " + strings.Join(names, ", ") +} diff --git a/internal/cli/install_doctor.go b/internal/cli/install_doctor.go new file mode 100644 index 0000000..fe50d77 --- /dev/null +++ b/internal/cli/install_doctor.go @@ -0,0 +1,484 @@ +package cli + +import ( + "fmt" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" +) + +// buildDoctorCommand registers the top-level `agora doctor` command. It +// diagnoses the *install* (binary location, PATH resolution, version, +// network reachability of the API and OAuth endpoints, MCP-host config, +// auth state, and config sanity), as opposed to `agora project doctor` +// which diagnoses a remote Agora project's readiness for development. +// +// The two surfaces deliberately share the doctor envelope shape so +// wrappers can reuse the same parser: +// +// { +// "ok": true, +// "command":"doctor", +// "data": { +// "status": "healthy" | "warning" | "not_ready" | "auth_error", +// "checks": [{"category": "install", "status": "...", "items": [...]}], +// "summary": "...", +// "warnings": [...], +// "blockingIssues": [...] +// }, +// "meta": { "outputMode": "json", "exitCode": 0|1|2|3 } +// } +func (a *App) buildDoctorCommand() *cobra.Command { + return &cobra.Command{ + Use: "doctor", + Short: "Diagnose the local Agora CLI install (PATH, version, network, auth, MCP host)", + Long: `Run a self-test of the Agora CLI install on this machine. + +Distinct from "agora project doctor", which validates a remote Agora +project. This command answers questions like: + + - Is the agora binary on PATH and is it the one I expect? + - Is this the latest version, or is an upgrade available? + - Can the CLI reach the configured API and OAuth endpoints? + - Am I authenticated, and is the session still valid? + - Is AGORA_HOME pointing at a writable directory? + - Do I have a known MCP host (Cursor / Claude / Windsurf) configured? + +Exit codes match "agora project doctor": + 0 healthy + 1 blocking install issues + 2 warnings + 3 auth or session issues + +Use --json for a machine-readable envelope. The same data is emitted +under both formats; the JSON envelope is the stable contract.`, + Example: example(` + agora doctor + agora doctor --json + agora doctor --quiet +`), + RunE: func(cmd *cobra.Command, _ []string) error { + result := a.runInstallDoctor() + code := installDoctorExitCode(result) + if a.resolveOutputMode(cmd) == outputJSON && code != 0 { + logPath := resolveLogFilePathForDisplay(a.env) + err := &cliError{Message: result.Summary, Code: "INSTALL_DOCTOR_" + strings.ToUpper(result.Status)} + _ = emitFailureEnvelopeWithData(cmd.OutOrStdout(), "doctor", result, err, code, logPath, jsonPrettyFromContext(cmd)) + return &exitError{code: code} + } + if err := renderResult(cmd, "doctor", result); err != nil { + return err + } + if code != 0 { + return &exitError{code: code} + } + return nil + }, + } +} + +// runInstallDoctor performs the actual diagnostic. Each category is +// independent and never aborts the others, so a single network failure +// never prevents the user from seeing PATH / auth status. +func (a *App) runInstallDoctor() projectDoctorResult { + result := projectDoctorResult{ + Action: "doctor", + Feature: "install", + Mode: "install", + BlockingIssues: []doctorIssue{}, + Warnings: []doctorIssue{}, + Checks: []doctorCheckCategory{}, + } + + result.Checks = append(result.Checks, + a.installDoctorBinaryCheck(), + a.installDoctorVersionCheck(), + a.installDoctorAgoraHomeCheck(), + a.installDoctorNetworkCheck(), + a.installDoctorAuthCheck(), + a.installDoctorMCPHostCheck(), + ) + + for _, category := range result.Checks { + for _, item := range category.Items { + switch item.Status { + case "fail": + result.BlockingIssues = append(result.BlockingIssues, doctorIssue{ + Code: upper(category.Category) + "_" + upper(item.Name), + Message: item.Message, + SuggestedCommand: item.SuggestedCommand, + }) + case "warn": + result.Warnings = append(result.Warnings, doctorIssue{ + Code: upper(category.Category) + "_" + upper(item.Name), + Message: item.Message, + SuggestedCommand: item.SuggestedCommand, + }) + } + } + } + + switch { + case categoryHasFail(result.Checks, "auth"): + result.Status = "auth_error" + result.Summary = "Authentication failed. Run agora login." + case len(result.BlockingIssues) > 0: + result.Status = "not_ready" + result.Summary = fmt.Sprintf("%d blocking install issue(s) detected.", len(result.BlockingIssues)) + case len(result.Warnings) > 0: + result.Status = "warning" + result.Summary = fmt.Sprintf("Install is healthy but %d warning(s) found.", len(result.Warnings)) + default: + result.Status = "healthy" + result.Summary = "Agora CLI install is healthy." + } + result.Healthy = result.Status == "healthy" + return result +} + +// installDoctorBinaryCheck verifies the running binary's location and +// whether the same `agora` resolves on PATH from a fresh shell. +func (a *App) installDoctorBinaryCheck() doctorCheckCategory { + items := []doctorCheckItem{} + var resolvedExe string + exe, err := os.Executable() + if err != nil || exe == "" { + items = append(items, doctorCheckItem{ + Name: "binary_path", + Status: "warn", + Message: "Could not determine the running binary path.", + }) + } else { + resolvedExe, _ = filepath.EvalSymlinks(exe) + if resolvedExe == "" { + resolvedExe = exe + } + items = append(items, doctorCheckItem{ + Name: "binary_path", + Status: "pass", + Message: "Running binary: " + resolvedExe, + }) + } + pathBinary, lookErr := exec.LookPath("agora") + switch { + case lookErr != nil: + installDir := "" + if resolvedExe != "" { + installDir = filepath.Dir(resolvedExe) + } + items = append(items, doctorCheckItem{ + Name: "path_resolution", + Status: "fail", + Message: "agora is not resolvable on PATH.", + SuggestedCommand: pathFixSuggestion(installDir, a.env), + }) + case resolvedExe != "" && filepath.Clean(pathBinary) != filepath.Clean(resolvedExe): + items = append(items, doctorCheckItem{ + Name: "path_resolution", + Status: "warn", + Message: "PATH resolves agora to " + pathBinary + " (different from running binary).", + SuggestedCommand: "Reorder PATH so the installer's directory comes first, or remove the older binary.", + }) + default: + items = append(items, doctorCheckItem{ + Name: "path_resolution", + Status: "pass", + Message: "agora resolves on PATH to the running binary.", + }) + } + return categoryWithStatus("install", items) +} + +func (a *App) installDoctorVersionCheck() doctorCheckCategory { + info := versionInfo() + current, _ := info["version"].(string) + items := []doctorCheckItem{{ + Name: "current_version", + Status: "pass", + Message: fmt.Sprintf("Installed version: %s", current), + }} + return categoryWithStatus("version", items) +} + +func (a *App) installDoctorAgoraHomeCheck() doctorCheckCategory { + items := []doctorCheckItem{} + cfgPath, err := resolveConfigFilePath(a.env) + if err != nil { + items = append(items, doctorCheckItem{ + Name: "config_path", + Status: "fail", + Message: "Could not resolve AGORA_HOME / config path: " + err.Error(), + SuggestedCommand: "Check that $HOME or %APPDATA% is set and writable.", + }) + return categoryWithStatus("agora_home", items) + } + dir := filepath.Dir(cfgPath) + probe := filepath.Join(dir, ".agora-doctor-probe") + if err := os.MkdirAll(dir, 0o700); err != nil { + items = append(items, doctorCheckItem{ + Name: "config_dir_writable", + Status: "fail", + Message: "Cannot create config directory: " + dir, + SuggestedCommand: "Fix permissions on " + dir + " or set AGORA_HOME to a writable directory.", + }) + return categoryWithStatus("agora_home", items) + } + if err := os.WriteFile(probe, []byte("ok"), 0o600); err != nil { + items = append(items, doctorCheckItem{ + Name: "config_dir_writable", + Status: "fail", + Message: "Config directory is not writable: " + dir, + SuggestedCommand: "Fix permissions on " + dir + " or set AGORA_HOME to a writable directory.", + }) + return categoryWithStatus("agora_home", items) + } + _ = os.Remove(probe) + items = append(items, doctorCheckItem{ + Name: "config_dir_writable", + Status: "pass", + Message: "Config directory is writable: " + dir, + }) + return categoryWithStatus("agora_home", items) +} + +func (a *App) installDoctorNetworkCheck() doctorCheckCategory { + items := []doctorCheckItem{} + endpoints := []struct { + name string + url string + }{ + {"api", a.env["AGORA_API_BASE_URL"]}, + {"oauth", a.env["AGORA_OAUTH_BASE_URL"]}, + } + client := &http.Client{Timeout: 5 * time.Second} + for _, ep := range endpoints { + if strings.TrimSpace(ep.url) == "" { + items = append(items, doctorCheckItem{ + Name: ep.name + "_endpoint", + Status: "skipped", + Message: ep.name + " endpoint not configured.", + }) + continue + } + parsed, err := url.Parse(ep.url) + if err != nil || parsed.Host == "" { + items = append(items, doctorCheckItem{ + Name: ep.name + "_endpoint_parse", + Status: "warn", + Message: "Could not parse " + ep.name + " URL: " + ep.url, + }) + continue + } + // DNS lookup: cheap, deterministic, no auth. + if _, err := net.LookupHost(parsed.Hostname()); err != nil { + items = append(items, doctorCheckItem{ + Name: ep.name + "_dns", + Status: "fail", + Message: "DNS lookup failed for " + parsed.Hostname() + ": " + err.Error(), + SuggestedCommand: "Check network connectivity and any corporate proxy settings.", + }) + continue + } + // HEAD/GET probe: tolerate 4xx since we are unauthenticated. + req, _ := http.NewRequest(http.MethodGet, ep.url, nil) + resp, err := client.Do(req) + if err != nil { + items = append(items, doctorCheckItem{ + Name: ep.name + "_reachability", + Status: "warn", + Message: "Could not reach " + ep.url + ": " + err.Error(), + SuggestedCommand: "Check firewall/proxy. The CLI works offline for read-only commands but needs network for login and project operations.", + }) + continue + } + _ = resp.Body.Close() + items = append(items, doctorCheckItem{ + Name: ep.name + "_reachability", + Status: "pass", + Message: ep.name + " endpoint reachable: " + ep.url, + }) + } + return categoryWithStatus("network", items) +} + +func (a *App) installDoctorAuthCheck() doctorCheckCategory { + items := []doctorCheckItem{} + data, err := a.authStatus() + if err != nil { + items = append(items, doctorCheckItem{ + Name: "session", + Status: "fail", + Message: "Could not read local session: " + err.Error(), + SuggestedCommand: "agora login", + }) + return categoryWithStatus("auth", items) + } + if auth, _ := data["authenticated"].(bool); !auth { + items = append(items, doctorCheckItem{ + Name: "session", + Status: "fail", + Message: "No active Agora session.", + SuggestedCommand: "agora login", + }) + return categoryWithStatus("auth", items) + } + items = append(items, doctorCheckItem{ + Name: "session", + Status: "pass", + Message: "Authenticated.", + }) + return categoryWithStatus("auth", items) +} + +func (a *App) installDoctorMCPHostCheck() doctorCheckCategory { + items := []doctorCheckItem{} + hosts := detectMCPHostConfig() + if len(hosts) == 0 { + items = append(items, doctorCheckItem{ + Name: "mcp_host", + Status: "skipped", + Message: "No known MCP host config detected (Cursor/Claude/Windsurf). Install one to use `agora mcp serve`.", + }) + return categoryWithStatus("mcp", items) + } + sort.Strings(hosts) + items = append(items, doctorCheckItem{ + Name: "mcp_host", + Status: "pass", + Message: "Detected MCP host(s): " + strings.Join(hosts, ", "), + }) + return categoryWithStatus("mcp", items) +} + +// detectMCPHostConfig probes well-known IDE config paths for MCP host +// installations. We only check existence, never read or parse the file +// (privacy / least surprise). +func detectMCPHostConfig() []string { + var found []string + home, err := os.UserHomeDir() + if err != nil { + return found + } + candidates := map[string]string{} + switch runtime.GOOS { + case "darwin": + candidates["Cursor"] = filepath.Join(home, "Library/Application Support/Cursor/User/globalStorage/cursor.cursor-mcp") + candidates["Claude Desktop"] = filepath.Join(home, "Library/Application Support/Claude/claude_desktop_config.json") + candidates["Windsurf"] = filepath.Join(home, ".codeium/windsurf/mcp_config.json") + case "linux": + candidates["Cursor"] = filepath.Join(home, ".config/Cursor/User/globalStorage/cursor.cursor-mcp") + candidates["Claude Desktop"] = filepath.Join(home, ".config/Claude/claude_desktop_config.json") + candidates["Windsurf"] = filepath.Join(home, ".codeium/windsurf/mcp_config.json") + case "windows": + appdata := os.Getenv("APPDATA") + if appdata != "" { + candidates["Cursor"] = filepath.Join(appdata, "Cursor/User/globalStorage/cursor.cursor-mcp") + candidates["Claude Desktop"] = filepath.Join(appdata, "Claude/claude_desktop_config.json") + candidates["Windsurf"] = filepath.Join(appdata, "Codeium/windsurf/mcp_config.json") + } + } + for name, path := range candidates { + if _, err := os.Stat(path); err == nil { + found = append(found, name) + } + } + return found +} + +func categoryWithStatus(name string, items []doctorCheckItem) doctorCheckCategory { + cat := doctorCheckCategory{Category: name, Items: items} + cat.Status = summarizeCategoryStatus(items) + return cat +} + +func categoryHasFail(checks []doctorCheckCategory, category string) bool { + for _, c := range checks { + if c.Category != category { + continue + } + for _, item := range c.Items { + if item.Status == "fail" { + return true + } + } + } + return false +} + +func installDoctorExitCode(result projectDoctorResult) int { + switch result.Status { + case "auth_error": + return 3 + case "not_ready": + return 1 + case "warning": + return 2 + default: + return 0 + } +} + +func upper(s string) string { return strings.ToUpper(s) } + +// pathFixSuggestion returns the exact command the user can paste to add +// installDir to their PATH. The command is tailored to the detected +// $SHELL on POSIX (or the platform on Windows). Falls back to a +// generic, copy-pastable export when the shell or installDir is +// unknown so the suggestion is *always* actionable. +// +// Mirrors the rc-file detection logic in install.sh (shell_rc_for_path +// + shell_path_line) so the doctor's advice matches what a fresh +// installer run would do automatically. +func pathFixSuggestion(installDir string, env map[string]string) string { + if installDir == "" { + return "Re-run the installer (PATH wiring is now automatic by default), then open a new shell." + } + if runtime.GOOS == "windows" { + // PowerShell users get the persistent setx form because + // it survives shell restarts; show both setx and a + // session-only fallback. + return fmt.Sprintf( + "Add %s to your user PATH: setx PATH \"%s;%%PATH%%\" (then open a new terminal). PowerShell session-only: $env:Path = \"%s;\" + $env:Path", + installDir, installDir, installDir, + ) + } + shell := "" + if env != nil { + shell = strings.TrimSpace(env["SHELL"]) + } + if shell == "" { + shell = strings.TrimSpace(os.Getenv("SHELL")) + } + switch filepath.Base(shell) { + case "fish": + return fmt.Sprintf("fish_add_path %s", installDir) + case "zsh": + return fmt.Sprintf( + "echo 'export PATH=\"%s:$PATH\"' >> ~/.zshrc && source ~/.zshrc", + installDir, + ) + case "bash": + // ~/.bashrc is the right target for interactive non-login + // bash on Linux; macOS users typically rely on ~/.bash_profile + // but ~/.bashrc is symlinked or sourced from it in nearly all + // modern setups. Stay consistent with install.sh's rc choice. + return fmt.Sprintf( + "echo 'export PATH=\"%s:$PATH\"' >> ~/.bashrc && source ~/.bashrc", + installDir, + ) + default: + return fmt.Sprintf( + "Add %s to PATH: echo 'export PATH=\"%s:$PATH\"' >> ~/.profile && source ~/.profile", + installDir, installDir, + ) + } +} diff --git a/internal/cli/install_doctor_test.go b/internal/cli/install_doctor_test.go new file mode 100644 index 0000000..900fe66 --- /dev/null +++ b/internal/cli/install_doctor_test.go @@ -0,0 +1,95 @@ +package cli + +import ( + "runtime" + "strings" + "testing" +) + +// TestPathFixSuggestionShellAware proves the doctor returns the exact +// shell-rc command the user can paste to fix a missing PATH entry, per +// detected $SHELL. Mirrors install.sh's shell_rc_for_path / +// shell_path_line so the doctor's advice matches what a fresh +// installer run would do automatically. +func TestPathFixSuggestionShellAware(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX shell branches; windows branch is exercised separately") + } + const installDir = "/Users/dev/.local/bin" + + cases := []struct { + name string + shell string + mustHave []string + }{ + { + name: "zsh writes to ~/.zshrc and sources it", + shell: "/bin/zsh", + mustHave: []string{ + `export PATH="` + installDir + `:$PATH"`, + "~/.zshrc", + "source ~/.zshrc", + }, + }, + { + name: "bash writes to ~/.bashrc and sources it", + shell: "/usr/local/bin/bash", + mustHave: []string{ + `export PATH="` + installDir + `:$PATH"`, + "~/.bashrc", + "source ~/.bashrc", + }, + }, + { + name: "fish uses fish_add_path", + shell: "/opt/homebrew/bin/fish", + mustHave: []string{ + "fish_add_path " + installDir, + }, + }, + { + name: "unknown shell falls back to ~/.profile and is still actionable", + shell: "/bin/ksh", + mustHave: []string{ + installDir, + "~/.profile", + }, + }, + { + name: "empty shell still emits a copy-pastable command", + shell: "", + mustHave: []string{ + installDir, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + env := map[string]string{"SHELL": tc.shell} + got := pathFixSuggestion(installDir, env) + if got == "" { + t.Fatal("expected non-empty suggestion") + } + for _, must := range tc.mustHave { + if !strings.Contains(got, must) { + t.Errorf("expected suggestion to contain %q, got %q", must, got) + } + } + }) + } +} + +// TestPathFixSuggestionEmptyInstallDirFallsBackToInstallerHint covers +// the case where os.Executable() failed and we have no install dir to +// suggest. The suggestion must still tell the user something actionable +// (re-run the installer) rather than echoing an empty path. +func TestPathFixSuggestionEmptyInstallDirFallsBackToInstallerHint(t *testing.T) { + got := pathFixSuggestion("", map[string]string{"SHELL": "/bin/zsh"}) + if !strings.Contains(strings.ToLower(got), "installer") { + t.Fatalf("expected fallback to mention re-running the installer, got %q", got) + } + if strings.Contains(got, `""`) || strings.Contains(got, ":$PATH") { + t.Fatalf("expected no half-built export line when install dir is empty, got %q", got) + } +} diff --git a/internal/cli/integration_auth_test.go b/internal/cli/integration_auth_test.go index ebba9a3..e52c2ae 100644 --- a/internal/cli/integration_auth_test.go +++ b/internal/cli/integration_auth_test.go @@ -25,7 +25,7 @@ func TestCLILoginAndWhoAmIParity(t *testing.T) { "AGORA_BROWSER_AUTO_OPEN": "0", "AGORA_LOGIN_TIMEOUT_MS": "2000", "AGORA_LOG_LEVEL": "error", - "AGORA_VERBOSE": "0", + "AGORA_DEBUG": "0", }, onStderr: func(stderr string) bool { u := parseAuthURL(stderr) if u == "" { @@ -47,7 +47,7 @@ func TestCLILoginAndWhoAmIParity(t *testing.T) { status := runCLI(t, []string{"whoami", "--json"}, cliRunOptions{env: map[string]string{ "XDG_CONFIG_HOME": configHome, "AGORA_LOG_LEVEL": "error", - "AGORA_VERBOSE": "0", + "AGORA_DEBUG": "0", }}) if status.exitCode != 0 { t.Fatalf("expected exit 0, got %d stderr=%s", status.exitCode, status.stderr) @@ -75,7 +75,7 @@ func TestCLIAuthStatusExitCodeParity(t *testing.T) { result := runCLI(t, []string{"auth", "status", "--json"}, cliRunOptions{env: map[string]string{ "XDG_CONFIG_HOME": t.TempDir(), "AGORA_LOG_LEVEL": "error", - "AGORA_VERBOSE": "0", + "AGORA_DEBUG": "0", }}) if result.exitCode != 3 || !strings.Contains(result.stdout, `"ok":false`) || !strings.Contains(result.stdout, `"code":"AUTH_UNAUTHENTICATED"`) || !strings.Contains(result.stdout, `"exitCode":3`) || result.stderr != "" { t.Fatalf("expected structured unauthenticated status error, got exit=%d stdout=%s stderr=%s", result.exitCode, result.stdout, result.stderr) diff --git a/internal/cli/integration_project_test.go b/internal/cli/integration_project_test.go index daed21e..bdda7ca 100644 --- a/internal/cli/integration_project_test.go +++ b/internal/cli/integration_project_test.go @@ -35,7 +35,7 @@ func TestCLIProjectAndEnvAndDoctorParity(t *testing.T) { "AGORA_API_BASE_URL": api.baseURL, "AGORA_AGENT": "cursor-test", "AGORA_LOG_LEVEL": "error", - "AGORA_VERBOSE": "0", + "AGORA_DEBUG": "0", }}) if envResult.exitCode != 0 || !strings.Contains(envResult.stdout, "AGORA_PROJECT_ID=prj_123456") { t.Fatalf("unexpected project env result: exit=%d stdout=%s stderr=%s", envResult.exitCode, envResult.stdout, envResult.stderr) @@ -62,7 +62,7 @@ func TestCLIProjectAndEnvAndDoctorParity(t *testing.T) { "XDG_CONFIG_HOME": configHome, "AGORA_API_BASE_URL": api.baseURL, "AGORA_LOG_LEVEL": "error", - "AGORA_VERBOSE": "0", + "AGORA_DEBUG": "0", }, workdir: projectDir}) if writeResult.exitCode != 0 { t.Fatalf("unexpected env write result: exit=%d stderr=%s", writeResult.exitCode, writeResult.stderr) @@ -75,7 +75,7 @@ func TestCLIProjectAndEnvAndDoctorParity(t *testing.T) { "XDG_CONFIG_HOME": configHome, "AGORA_API_BASE_URL": api.baseURL, "AGORA_LOG_LEVEL": "error", - "AGORA_VERBOSE": "0", + "AGORA_DEBUG": "0", }}) if doctorResult.exitCode != 1 { t.Fatalf("expected doctor exit 1, got %d stdout=%s stderr=%s", doctorResult.exitCode, doctorResult.stdout, doctorResult.stderr) diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index 3c53b2d..c1fcd2e 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -58,6 +58,10 @@ Notes for agents: - Long-running tools (init, quickstart create, project create) emit no NDJSON progress over MCP. The result payload is returned as a single tool response. - ` + "`agora.auth.login`" + ` is intentionally not exposed because OAuth requires an interactive browser. Run ` + "`agora login`" + ` once on the host before starting the MCP server. - All tools return JSON-stringified payloads in the standard MCP ` + "`content[0].text`" + ` slot.`, + Example: example(` + agora mcp serve --transport stdio + agora mcp # alias of 'mcp serve --transport stdio' +`), RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Help() }, diff --git a/internal/cli/projects.go b/internal/cli/projects.go index 7beb3a1..987428d 100644 --- a/internal/cli/projects.go +++ b/internal/cli/projects.go @@ -440,17 +440,39 @@ const ( envJSON envFormat = "json" ) +// projectEnvFormatChoices is the documented enum for `--format`. Kept as +// a single source of truth so help text, error messages, introspect, and +// validation stay in lockstep. "envelope" is accepted as an explicit +// alias of "json" so callers can opt into the unified envelope shape +// without remembering that --json is the cross-cutting flag. +var projectEnvFormatChoices = []string{"dotenv", "shell", "envelope", "json"} + +// resolveProjectEnvOutputFormat is the single source of truth for the +// project env output format. It enforces the contract documented in +// docs/automation.md: `project env` is the one command whose default +// (non-JSON) output is raw stdout for `eval $(...)` ergonomics, and +// `--format` lets callers be explicit. +// +// Precedence: +// 1. Conflicting flags → typed error. +// 2. `--json` (and `--format=envelope|json`) → envelope shape. +// 3. `--format=shell` or `--shell` → shell exports. +// 4. `--format=dotenv` (default) → dotenv lines. func resolveProjectEnvOutputFormat(format string, shell bool, mode outputMode) (envFormat, error) { + format = strings.TrimSpace(strings.ToLower(format)) if format != "" && shell { return "", errors.New("`--format` and `--shell` cannot be used together.") } - if format != "" && mode == outputJSON { - return "", errors.New("`--format` and `--json` cannot be used together.") - } if shell && mode == outputJSON { return "", errors.New("`--shell` and `--json` cannot be used together.") } - if mode == outputJSON { + // --format=envelope/json is a no-op alongside --json: both ask for + // the unified envelope shape. Only reject conflicting requests + // (e.g. --format=dotenv --json). + if format != "" && mode == outputJSON && !isProjectEnvJSONFormat(format) { + return "", fmt.Errorf("`--format=%s` and `--json` cannot be used together (use --format=envelope or --format=json for the JSON envelope, or drop --json)", format) + } + if mode == outputJSON || isProjectEnvJSONFormat(format) { return envJSON, nil } if shell { @@ -459,7 +481,17 @@ func resolveProjectEnvOutputFormat(format string, shell bool, mode outputMode) ( if format == "" { return envDotenv, nil } - return envFormat(format), nil + switch format { + case "dotenv": + return envDotenv, nil + case "shell": + return envShell, nil + } + return "", fmt.Errorf("`--format` must be one of: %s (got %q)", strings.Join(projectEnvFormatChoices, ", "), format) +} + +func isProjectEnvJSONFormat(format string) bool { + return format == "envelope" || format == "json" } func (a *App) projectEnvValues(projectArg string, withSecrets bool) (map[string]any, error) { diff --git a/internal/cli/quickstart.go b/internal/cli/quickstart.go index 1dd6b3b..49d03ff 100644 --- a/internal/cli/quickstart.go +++ b/internal/cli/quickstart.go @@ -120,7 +120,7 @@ Use this group when you want a standalone demo or onboarding project.`, func (a *App) buildQuickstartList() *cobra.Command { var showAll bool - var verbose bool + var details bool cmd := &cobra.Command{ Use: "list", Short: "List available official quickstarts", @@ -128,6 +128,7 @@ func (a *App) buildQuickstartList() *cobra.Command { Example: example(` agora quickstart list agora quickstart list --show-all + agora quickstart list --details agora quickstart list --json `), RunE: func(cmd *cobra.Command, _ []string) error { @@ -151,12 +152,12 @@ func (a *App) buildQuickstartList() *cobra.Command { return renderResult(cmd, "quickstart list", map[string]any{ "action": "list", "items": items, - "verbose": verbose, + "details": details, }) }, } cmd.Flags().BoolVar(&showAll, "show-all", false, "include upcoming or unavailable templates in the list") - cmd.Flags().BoolVar(&verbose, "verbose", false, "show repository, runtime, and env details in pretty output") + cmd.Flags().BoolVar(&details, "details", false, "show repository, runtime, and env details in pretty output") return cmd } diff --git a/internal/cli/render.go b/internal/cli/render.go index de67e5c..76221d2 100644 --- a/internal/cli/render.go +++ b/internal/cli/render.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "sort" "strconv" "strings" @@ -70,7 +71,7 @@ func renderResult(cmd *cobra.Command, command string, data any) error { 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"])) - if verbose, _ := m["verbose"].(bool); verbose { + if details, _ := m["details"].(bool); details { fmt.Fprintf(out, " Available: %s\n", asString(item["available"])) fmt.Fprintf(out, " Runtime: %s\n", asString(item["runtime"])) fmt.Fprintf(out, " Supports Init: %s\n", asString(item["supportsInit"])) @@ -145,9 +146,18 @@ func renderResult(cmd *cobra.Command, command string, data any) error { fmt.Fprintln(out) } } - case "project doctor": + case "project doctor", "doctor": noColor, _ := cmd.Context().Value(contextKeyNoColor{}).(bool) return printDoctor(out, data.(projectDoctorResult), noColor || strings.TrimSpace(os.Getenv("NO_COLOR")) != "") + case "env-help": + printEnvHelp(out, data.(map[string]any)) + return nil + case "skills list", "skills search": + printSkillsList(out, data.(map[string]any)) + return nil + case "skills show": + printSkillsShow(out, data.(map[string]any)) + return nil case "version": m := data.(map[string]any) printBlock(out, "Version", [][2]string{{"Version", asString(m["version"])}, {"Commit", asString(m["commit"])}, {"Built", asString(m["date"])}}) @@ -329,3 +339,102 @@ func doctorMarker(status string, noColor bool) string { } return map[string]string{"pass": "✓", "warn": "!", "skipped": "-", "fail": "✗"}[status] } + +// printSkillsList renders the skills list / search results as a +// readable bullet list with the title, category, and tags. +func printSkillsList(out io.Writer, data map[string]any) { + items, _ := data["items"].([]skill) + total := len(items) + if query, ok := data["query"].(string); ok && query != "" { + fmt.Fprintf(out, "Skills matching %q (%d)\n", query, total) + } else { + fmt.Fprintf(out, "Skills (%d)\n", total) + } + fmt.Fprintln(out) + if total == 0 { + fmt.Fprintln(out, "No skills matched. Run 'agora skills list' for the full catalog.") + return + } + for _, sk := range items { + fmt.Fprintf(out, " %s\n", sk.ID) + fmt.Fprintf(out, " %s\n", sk.Title) + if sk.Category != "" || len(sk.Tags) > 0 { + fmt.Fprintf(out, " [%s] tags: %s\n", sk.Category, strings.Join(sk.Tags, ", ")) + } + } + fmt.Fprintln(out) + fmt.Fprintln(out, "Run 'agora skills show ' for details.") +} + +// printSkillsShow renders a single skill: title, description, steps, +// next steps, and a docs URL when available. +func printSkillsShow(out io.Writer, data map[string]any) { + sk, _ := data["skill"].(skill) + fmt.Fprintf(out, "%s\n%s\n\n", sk.Title, strings.Repeat("=", len(sk.Title))) + fmt.Fprintf(out, "ID: %s\n", sk.ID) + fmt.Fprintf(out, "Category: %s\n", sk.Category) + if len(sk.Tags) > 0 { + fmt.Fprintf(out, "Tags: %s\n", strings.Join(sk.Tags, ", ")) + } + if sk.DocsURL != "" { + fmt.Fprintf(out, "Docs: %s\n", sk.DocsURL) + } + fmt.Fprintln(out) + if sk.Description != "" { + fmt.Fprintln(out, sk.Description) + fmt.Fprintln(out) + } + if len(sk.Steps) > 0 { + fmt.Fprintln(out, "Steps") + for i, step := range sk.Steps { + fmt.Fprintf(out, " %d. %s\n", i+1, step) + } + fmt.Fprintln(out) + } + if len(sk.NextSteps) > 0 { + fmt.Fprintln(out, "Next Steps") + for _, step := range sk.NextSteps { + fmt.Fprintf(out, " - %s\n", step) + } + } +} + +// printEnvHelp renders the env-help catalog as a human-readable list, +// one block per category. Mirrors `gh env-help` and `stripe env help`. +func printEnvHelp(out io.Writer, data map[string]any) { + fmt.Fprintln(out, "Agora CLI environment variables") + fmt.Fprintln(out) + if summary, _ := data["summary"].(string); summary != "" { + fmt.Fprintln(out, summary) + fmt.Fprintln(out) + } + grouped, ok := data["byCategory"].(map[string][]agoraEnvVar) + if !ok { + // Fallback when the payload was round-tripped through JSON. + raw, _ := data["catalog"].([]agoraEnvVar) + fallback := map[string][]agoraEnvVar{} + for _, v := range raw { + fallback[v.Category] = append(fallback[v.Category], v) + } + grouped = fallback + } + categories := make([]string, 0, len(grouped)) + for c := range grouped { + categories = append(categories, c) + } + sort.Strings(categories) + for _, category := range categories { + fmt.Fprintf(out, "[%s]\n", strings.ToUpper(category)) + for _, v := range grouped[category] { + defaultPart := "" + if v.Default != "" { + defaultPart = " (default: " + v.Default + ")" + } + fmt.Fprintf(out, " %s%s\n %s\n", v.Name, defaultPart, v.Description) + if v.Effect != "" { + fmt.Fprintf(out, " Values: %s\n", v.Effect) + } + } + fmt.Fprintln(out) + } +} diff --git a/internal/cli/runtime_support.go b/internal/cli/runtime_support.go index 0907bd9..9c976c2 100644 --- a/internal/cli/runtime_support.go +++ b/internal/cli/runtime_support.go @@ -15,7 +15,13 @@ import ( ) const ( - currentAppConfigVersion = 2 + // currentAppConfigVersion is the schema version stamped on every + // config write. Bumping it forces ensureAppConfigState to mark + // the load as "migrated" so the migration banner runs once. v3 + // renamed the persisted "verbose" key to "debug" (see + // mergeConfig); v2 was the API/OAuth base-URL flip from staging + // to production. + currentAppConfigVersion = 3 previousAPIBaseURL = "https://agora-cli-bff.staging.la3.agoralab.co" previousOAuthBaseURL = "https://staging-sso.agora.io" previousOAuthClientID = "cli_demo" @@ -278,7 +284,7 @@ func appendAppLog(level, event string, env map[string]string, fields map[string] if _, err := f.Write(append(payload, '\n')); err != nil { return err } - if env["AGORA_VERBOSE"] == "1" { + if env["AGORA_DEBUG"] == "1" { _, _ = fmt.Fprintf(os.Stderr, "[agora-cli] %s\n", string(payload)) } return nil diff --git a/internal/cli/skills.go b/internal/cli/skills.go new file mode 100644 index 0000000..34e86d7 --- /dev/null +++ b/internal/cli/skills.go @@ -0,0 +1,359 @@ +package cli + +import ( + "errors" + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" +) + +// skill is the curated, in-binary catalog entry. It mirrors the +// `SkillManifest` shape from `agora-cli-ts/packages/cli-contracts/src/index.ts` +// so a future port to dynamic, fetched skills can keep the same field +// names. +// +// Today the catalog is read-only and lives in Go code (no remote fetch, +// no file load). That keeps the surface trivially testable and avoids +// any "where did this skill come from" supply-chain question. The +// future direction (port from agora-cli-ts) is documented in +// docs/proposals/skills-platform.md. +type skill struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Category string `json:"category"` + Tags []string `json:"tags"` + Steps []string `json:"steps"` + NextSteps []string `json:"nextSteps,omitempty"` + DocsURL string `json:"docsUrl,omitempty"` +} + +// skillsCatalog is the canonical curated list. Add new skills here. +// Keep entries small and action-oriented: every skill is a recipe an +// agent can execute end-to-end with the documented steps. +func skillsCatalog() []skill { + return []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.", + Category: "scaffold", + Tags: []string{"nextjs", "rtc", "video", "init"}, + Steps: []string{ + "agora login", + "agora init my-nextjs-demo --template nextjs --json", + "cd my-nextjs-demo && npm install && npm run dev", + }, + NextSteps: []string{ + "Open http://localhost:3000 to verify the app boots.", + "Run agora project doctor --json to confirm RTC is enabled.", + }, + DocsURL: "https://agoraio.github.io/cli/install.html", + }, + { + 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"}, + Steps: []string{ + "agora login", + "agora init my-voice-agent --template python --feature convoai --json", + "cd my-voice-agent/server && pip install -r requirements.txt", + }, + NextSteps: []string{ + "Configure your model provider keys in server/.env (already created with Agora App ID + Certificate).", + "Run agora project doctor --feature convoai --json before going live.", + }, + }, + { + ID: "create-go-token-service", + Title: "Create a Go token service", + Description: "Stand up a Go server that mints Agora RTC tokens, with project metadata and env wiring.", + Category: "scaffold", + Tags: []string{"go", "rtc", "token", "backend", "init"}, + Steps: []string{ + "agora login", + "agora init my-go-token-service --template go --feature rtc --json", + "cd my-go-token-service/server-go && go run .", + }, + NextSteps: []string{ + "Curl GET /token to verify the service mints tokens against the bound project.", + }, + }, + { + ID: "rotate-and-export-env", + Title: "Rotate project credentials into a running app", + Description: "Re-export Agora App ID and App Certificate into an existing repo's dotenv files after switching projects.", + Category: "ops", + Tags: []string{"env", "rotate", "credentials"}, + Steps: []string{ + "agora project use my-other-project", + "agora project env write apps/web/.env.local --overwrite --json", + "agora project env --shell # for ad-hoc shell sourcing", + }, + }, + { + ID: "diagnose-install", + Title: "Diagnose a broken Agora CLI install", + Description: "Run the install doctor and follow its remediation suggestions.", + Category: "ops", + Tags: []string{"doctor", "diagnose", "ci"}, + Steps: []string{ + "agora doctor --json", + "agora project doctor --json", + "agora env-help --json | jq '.data.byCategory.telemetry'", + }, + NextSteps: []string{ + "If 'auth' fails, run agora login.", + "If 'network' fails, check proxies / corporate firewall.", + }, + }, + { + ID: "wire-mcp-server", + Title: "Expose Agora CLI to an AI agent via MCP", + Description: "Add Agora CLI as a Model Context Protocol stdio server so a coding agent can drive it as a tool.", + Category: "agent", + Tags: []string{"mcp", "cursor", "claude", "windsurf"}, + Steps: []string{ + "agora login # MCP does not expose OAuth; authenticate on the host first", + "agora mcp serve --transport stdio # smoke test that it speaks MCP", + "In your IDE settings, add a server that runs 'agora mcp serve --transport stdio'.", + }, + DocsURL: "https://agoraio.github.io/cli/md/agents/README.md", + }, + { + ID: "drop-in-agent-rules", + Title: "Drop in agent rules for Cursor / Claude / Windsurf", + Description: "Write Agora-specific rules into the IDE's known config file with safe append-when-exists semantics.", + Category: "agent", + Tags: []string{"cursor", "claude", "windsurf", "rules"}, + Steps: []string{ + "agora init my-app --template nextjs --add-agent-rules cursor", + "# inspect the result", + "cat .cursor/rules/agora.mdc", + }, + }, + } +} + +// buildSkillsCommand registers `agora skills`. It is intentionally +// read-only in this release: list, show, search. Future releases will +// add `skills run`, `skills install`, and `skills eval` per the +// upstream design in agora-cli-ts. +func (a *App) buildSkillsCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "skills", + Short: "Browse curated Agora workflows for humans and AI agents", + Long: `Skills are short, named, executable recipes that take a developer (human +or AI) from "I want to do X" to a working command sequence. + +Today the catalog is curated and shipped in the binary. Future releases +will support fetched skills, evals, and 'agora skills run' to execute +a skill end-to-end. The shape of the JSON output is stable so wrappers +written today keep working when more sources are added.`, + Example: example(` + agora skills list + agora skills list --category scaffold + agora skills show create-nextjs-video-app + agora skills search voice + agora skills list --json +`), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + return fmt.Errorf("unknown command %q for %q", args[0], cmd.CommandPath()) + } + return cmd.Help() + }, + } + cmd.AddCommand(a.buildSkillsListCommand()) + cmd.AddCommand(a.buildSkillsShowCommand()) + cmd.AddCommand(a.buildSkillsSearchCommand()) + return cmd +} + +func (a *App) buildSkillsListCommand() *cobra.Command { + var category, tag string + cmd := &cobra.Command{ + Use: "list", + Short: "List available skills", + Example: example(` + agora skills list + agora skills list --category scaffold + agora skills list --tag nextjs + agora skills list --json +`), + RunE: func(cmd *cobra.Command, _ []string) error { + items := filterSkills(skillsCatalog(), category, tag) + return renderResult(cmd, "skills list", map[string]any{ + "action": "list", + "items": items, + "category": category, + "tag": tag, + "total": len(items), + }) + }, + } + cmd.Flags().StringVar(&category, "category", "", "filter by category (scaffold, ops, agent)") + cmd.Flags().StringVar(&tag, "tag", "", "filter by tag (e.g. nextjs, rtc, mcp)") + cmd.RegisterFlagCompletionFunc("category", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completeSkillCategories(toComplete), cobra.ShellCompDirectiveNoFileComp + }) + cmd.RegisterFlagCompletionFunc("tag", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completeSkillTags(toComplete), cobra.ShellCompDirectiveNoFileComp + }) + return cmd +} + +func (a *App) buildSkillsShowCommand() *cobra.Command { + return &cobra.Command{ + Use: "show ", + Short: "Show one skill in detail", + Example: example(` + agora skills show create-nextjs-video-app + agora skills show create-nextjs-video-app --json +`), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 || strings.TrimSpace(args[0]) == "" { + return errors.New("skill id is required") + } + id := strings.TrimSpace(args[0]) + for _, sk := range skillsCatalog() { + if sk.ID == id { + return renderResult(cmd, "skills show", map[string]any{ + "action": "show", + "skill": sk, + }) + } + } + return &cliError{Message: fmt.Sprintf("no skill with id %q. Run 'agora skills list' to see available IDs.", id), Code: "SKILL_NOT_FOUND"} + }, + ValidArgsFunction: func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completeSkillIDs(toComplete), cobra.ShellCompDirectiveNoFileComp + }, + } +} + +func (a *App) buildSkillsSearchCommand() *cobra.Command { + return &cobra.Command{ + Use: "search ", + Short: "Search skills by id, title, description, or tag", + Example: example(` + agora skills search voice + agora skills search nextjs --json +`), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 || strings.TrimSpace(args[0]) == "" { + return errors.New("search query is required") + } + query := strings.ToLower(strings.TrimSpace(args[0])) + matches := []skill{} + for _, sk := range skillsCatalog() { + if skillMatchesQuery(sk, query) { + matches = append(matches, sk) + } + } + return renderResult(cmd, "skills search", map[string]any{ + "action": "search", + "query": args[0], + "items": matches, + "total": len(matches), + }) + }, + } +} + +func filterSkills(catalog []skill, category, tag string) []skill { + out := []skill{} + categoryNorm := strings.ToLower(strings.TrimSpace(category)) + tagNorm := strings.ToLower(strings.TrimSpace(tag)) + for _, sk := range catalog { + if categoryNorm != "" && strings.ToLower(sk.Category) != categoryNorm { + continue + } + if tagNorm != "" && !skillHasTag(sk, tagNorm) { + continue + } + out = append(out, sk) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} + +func skillHasTag(sk skill, tag string) bool { + for _, t := range sk.Tags { + if strings.ToLower(t) == tag { + return true + } + } + return false +} + +func skillMatchesQuery(sk skill, query string) bool { + if strings.Contains(strings.ToLower(sk.ID), query) { + return true + } + if strings.Contains(strings.ToLower(sk.Title), query) { + return true + } + if strings.Contains(strings.ToLower(sk.Description), query) { + return true + } + if strings.Contains(strings.ToLower(sk.Category), query) { + return true + } + for _, tag := range sk.Tags { + if strings.Contains(strings.ToLower(tag), query) { + return true + } + } + return false +} + +func completeSkillIDs(toComplete string) []string { + prefix := strings.ToLower(toComplete) + out := []string{} + for _, sk := range skillsCatalog() { + if strings.HasPrefix(strings.ToLower(sk.ID), prefix) { + out = append(out, fmt.Sprintf("%s\t%s", sk.ID, sk.Title)) + } + } + sort.Strings(out) + return out +} + +func completeSkillCategories(toComplete string) []string { + prefix := strings.ToLower(toComplete) + seen := map[string]struct{}{} + for _, sk := range skillsCatalog() { + if strings.HasPrefix(strings.ToLower(sk.Category), prefix) { + seen[sk.Category] = struct{}{} + } + } + out := make([]string, 0, len(seen)) + for c := range seen { + out = append(out, c) + } + sort.Strings(out) + return out +} + +func completeSkillTags(toComplete string) []string { + prefix := strings.ToLower(toComplete) + seen := map[string]struct{}{} + for _, sk := range skillsCatalog() { + for _, t := range sk.Tags { + if strings.HasPrefix(strings.ToLower(t), prefix) { + seen[t] = struct{}{} + } + } + } + out := make([]string, 0, len(seen)) + for t := range seen { + out = append(out, t) + } + sort.Strings(out) + return out +} diff --git a/internal/cli/telemetry.go b/internal/cli/telemetry.go new file mode 100644 index 0000000..2fd865d --- /dev/null +++ b/internal/cli/telemetry.go @@ -0,0 +1,167 @@ +package cli + +import ( + "regexp" + "strings" + "time" +) + +// telemetryClient is the abstract interface every telemetry sink in the +// CLI implements. It is intentionally minimal so the production sink can +// be swapped (e.g. for the Sentry SDK or a file sink in tests) without +// touching call sites in app.go / commands.go. +// +// Contract for all implementations: +// - All methods MUST be safe to call from any goroutine. +// - All methods MUST be cheap when telemetry is disabled (the noop +// client is the default in that case). +// - No method may block longer than its bounded timeout. Telemetry +// must never delay the CLI from returning control to the shell. +// - All methods MUST redact any field whose key matches the +// sensitive-field pattern (token, secret, password, api[_-]?key, +// authorization). The shared redactTelemetryFields helper enforces +// this so individual call sites cannot forget. +// +// Today this file ships a no-op default. The Sentry-backed sink is +// scaffolded as `sentryClient` below so the next release can flip the +// constructor over without changing the public API. See +// docs/proposals/telemetry-sentry-wireup.md for the wire-up plan. +type telemetryClient interface { + // Enabled reports whether the underlying sink will actually emit. + Enabled() bool + // CaptureException forwards a CLI error to the telemetry sink with + // optional context fields. Sensitive fields are redacted before + // transport. + CaptureException(err error, fields map[string]any) + // CaptureEvent forwards an arbitrary structured event with optional + // fields. Used for non-error operational signals (e.g. completion + // of long-running flows). + CaptureEvent(name, level string, fields map[string]any) + // Flush blocks until the sink has flushed pending events or the + // timeout elapses, whichever comes first. Must be safe to call + // from defers in main paths. + Flush(timeout time.Duration) bool +} + +// agoraSentryDSN is the Sentry project DSN that the CLI ships with. +// Empty string disables Sentry transport entirely (the default until the +// Sentry SDK is wired in for the next release). Mirrors the value in +// agora-cli-ts apps/agora-cli/src/telemetry.ts so the two surfaces report +// to the same project once enabled. +const agoraSentryDSN = "" + +// initTelemetry returns the telemetry client appropriate for the current +// runtime. The decision tree mirrors the TS predecessor for parity: +// +// 1. DO_NOT_TRACK is set → noop (Console-style hard opt-out). +// 2. config.telemetryEnabled is false → noop. +// 3. AGORA_SENTRY_ENABLED=0 in the environment → noop. +// 4. agoraSentryDSN is empty (default in the current build) → noop. +// 5. Otherwise → Sentry-backed client (placeholder until SDK wired). +// +// initTelemetry never returns nil; callers can rely on a usable +// interface value without a nil check. +func initTelemetry(configEnabled bool, env map[string]string, _ versionInformation) telemetryClient { + if strings.TrimSpace(env["DO_NOT_TRACK"]) != "" { + return noopTelemetry{} + } + if !configEnabled { + return noopTelemetry{} + } + if strings.TrimSpace(env["AGORA_SENTRY_ENABLED"]) == "0" { + return noopTelemetry{} + } + if agoraSentryDSN == "" { + // Sentry SDK not compiled in for this build. The wire-up is + // scheduled for the next release; until then this is the + // expected path. See docs/proposals/telemetry-sentry-wireup.md. + return noopTelemetry{} + } + return newSentryClient(agoraSentryDSN, env) +} + +// versionInformation is a narrow alias used by initTelemetry so the +// telemetry constructor signature does not couple to the full +// versionInfo() return shape. +type versionInformation = map[string]any + +// noopTelemetry is the default and the implementation used whenever +// telemetry is disabled, opted out, or not compiled in. +type noopTelemetry struct{} + +func (noopTelemetry) Enabled() bool { return false } +func (noopTelemetry) CaptureException(_ error, _ map[string]any) {} +func (noopTelemetry) CaptureEvent(_, _ string, _ map[string]any) {} +func (noopTelemetry) Flush(_ time.Duration) bool { return true } + +// sentryClient is the placeholder for the Sentry-backed sink. Until the +// Sentry SDK is wired in, every method is a no-op so the surface and +// envelope contract stay stable. The next release will: +// +// 1. Add `github.com/getsentry/sentry-go` to go.mod. +// 2. Replace the unexported fields below with a real *sentry.Client. +// 3. Implement CaptureException using sentry.CaptureException with a +// scope carrying the redacted fields. +// 4. Implement Flush by delegating to sentry.Flush(timeout). +// +// The CLI today already exposes the on/off contract (`agora telemetry +// enable|disable`, `AGORA_SENTRY_ENABLED`, `DO_NOT_TRACK`) and the +// documented field shape, so flipping to the real SDK is a one-file +// change with no observable contract break. +type sentryClient struct { + dsn string + environment string + release string +} + +func newSentryClient(dsn string, env map[string]string) *sentryClient { + environment := strings.TrimSpace(env["AGORA_SENTRY_ENVIRONMENT"]) + if environment == "" { + environment = "production" + } + release := strings.TrimSpace(env["AGORA_RELEASE"]) + return &sentryClient{ + dsn: dsn, + environment: environment, + release: release, + } +} + +func (c *sentryClient) Enabled() bool { return c != nil && c.dsn != "" } + +func (c *sentryClient) CaptureException(_ error, _ map[string]any) { + // Wire to sentry.CaptureException once SDK is added. Today: noop. +} + +func (c *sentryClient) CaptureEvent(_, _ string, _ map[string]any) { + // Wire to sentry.CaptureEvent once SDK is added. Today: noop. +} + +func (c *sentryClient) Flush(_ time.Duration) bool { + // Wire to sentry.Flush once SDK is added. Today: trivially true. + return true +} + +// telemetrySensitiveFieldPattern is shared with sanitizeFields and +// matches the TS implementation in agora-cli-ts. Any field whose key +// matches this pattern is replaced with the literal "[REDACTED]" before +// it leaves the process. +var telemetrySensitiveFieldPattern = regexp.MustCompile(`(?i)token|secret|password|api[_-]?key|authorization`) + +// redactTelemetryFields returns a copy of fields with any sensitive key +// replaced by the redaction sentinel. Telemetry sinks MUST call this +// before transporting fields off the host. +func redactTelemetryFields(fields map[string]any) map[string]any { + if fields == nil { + return nil + } + out := make(map[string]any, len(fields)) + for k, v := range fields { + if telemetrySensitiveFieldPattern.MatchString(k) { + out[k] = "[REDACTED]" + continue + } + out[k] = v + } + return out +} diff --git a/internal/cli/testdata/golden/introspect-global-flags.json b/internal/cli/testdata/golden/introspect-global-flags.json index 6801208..7545294 100644 --- a/internal/cli/testdata/golden/introspect-global-flags.json +++ b/internal/cli/testdata/golden/introspect-global-flags.json @@ -1,4 +1,9 @@ [ + { + "name": "debug", + "type": "bool", + "usage": "echo structured logs to stderr (equivalent to AGORA_DEBUG=1); does not change exit codes or JSON envelopes" + }, { "name": "json", "type": "bool", @@ -29,11 +34,6 @@ "type": "bool", "usage": "print non-interactive upgrade guidance and exit" }, - { - "name": "verbose", - "type": "bool", - "usage": "echo structured logs to stderr (equivalent to AGORA_VERBOSE=1); does not change exit codes or JSON envelopes" - }, { "name": "yes", "type": "bool", diff --git a/internal/cli/upgrade.go b/internal/cli/upgrade.go index 6ed590f..d74bc46 100644 --- a/internal/cli/upgrade.go +++ b/internal/cli/upgrade.go @@ -369,12 +369,12 @@ func upgradeCommandForInstallMethod(method string) string { if runtime.GOOS == "windows" { return "irm https://raw.githubusercontent.com/AgoraIO/cli/main/install.ps1 | iex" } - return "curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh | sh -s -- --add-to-path" + return "curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh | sh" default: if runtime.GOOS == "windows" { return "irm https://raw.githubusercontent.com/AgoraIO/cli/main/install.ps1 | iex" } - return "curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh | sh -s -- --add-to-path" + return "curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh | sh" } } diff --git a/scripts/test-installer-messages.sh b/scripts/test-installer-messages.sh new file mode 100644 index 0000000..8d8d3e2 --- /dev/null +++ b/scripts/test-installer-messages.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env sh +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +INSTALLER="$ROOT/install.sh" +TMPROOT=$(mktemp -d) +ASSERTIONS=0 + +cleanup() { + rm -rf "$TMPROOT" 2>/dev/null || true +} +trap cleanup EXIT HUP INT TERM + +fail() { + printf 'FAIL: %s\n' "$*" >&2 + exit 1 +} + +assert_contains() { + file=$1 + needle=$2 + if ! grep -qF "$needle" "$file"; then + printf '--- output ---\n' >&2 + sed 's/^/ /' "$file" >&2 + fail "missing \"$needle\"" + fi + ASSERTIONS=$((ASSERTIONS + 1)) +} + +assert_not_contains() { + file=$1 + needle=$2 + if grep -qF "$needle" "$file"; then + printf '--- output ---\n' >&2 + sed 's/^/ /' "$file" >&2 + fail "unexpected \"$needle\"" + fi + ASSERTIONS=$((ASSERTIONS + 1)) +} + +extract_helpers() { + awk ' + /^bash_writable_rc\(\) \{/,/^\}/ { print } + /^shell_rc_for_path\(\) \{/,/^\}/ { print } + /^shell_path_line\(\) \{/,/^\}/ { print } + /^shell_refresh_command\(\) \{/,/^\}/ { print } + /^print_manual_path_block\(\) \{/,/^\}/ { print } + /^add_to_path\(\) \{/,/^\}/ { print } + ' "$INSTALLER" +} + +run_case() { + name=$1 + shell_path=$2 + body=$3 + case_dir="$TMPROOT/$name" + helper_file="$case_dir/helpers.sh" + output_file="$case_dir/output.txt" + home_dir="$case_dir/home" + install_dir="$home_dir/.local/bin" + + mkdir -p "$case_dir" "$home_dir" + extract_helpers > "$helper_file" + + ( + set -eu + QUIET=0 + BOLD="" + RESET="" + DIM="" + GREEN="" + DOCS_URL="https://github.com/AgoraIO/cli#readme" + HOME=$home_dir + SHELL=$shell_path + INSTALL_DIR=$install_dir + BINARY_NAME=agora + DESTINATION="$INSTALL_DIR/$BINARY_NAME" + DRY_RUN=0 + XDG_CONFIG_HOME="" + export HOME SHELL INSTALL_DIR BINARY_NAME DESTINATION DRY_RUN XDG_CONFIG_HOME DOCS_URL + say() { printf '%s\n' "$*"; } + warn() { printf 'Warning: %s\n' "$*" >&2; } + . "$helper_file" + eval "$body" + ) >"$output_file" 2>&1 + + printf '%s\n' "$output_file" +} + +success_output=$(run_case "success-zsh" "/bin/zsh" ' + : > "$HOME/.zshrc" + mkdir -p "$INSTALL_DIR" + add_to_path +') +assert_contains "$success_output" "Added $TMPROOT/success-zsh/home/.local/bin to PATH in $TMPROOT/success-zsh/home/.zshrc." +assert_contains "$success_output" "To use agora in this shell now, run:" +assert_contains "$success_output" "exec /bin/zsh" +assert_contains "$success_output" "(Or open a new terminal - the change takes effect either way.)" + +failure_output=$(run_case "unwritable-zsh" "/bin/zsh" ' + : > "$HOME/.zshrc" + chmod 444 "$HOME/.zshrc" + mkdir -p "$INSTALL_DIR" + if add_to_path; then + echo "expected add_to_path to fail" + exit 1 + fi +') +assert_not_contains "$failure_output" "Warning:" +assert_not_contains "$failure_output" "warn:" +assert_contains "$failure_output" "$TMPROOT/unwritable-zsh/home/.zshrc is not writable, so the installer can't add agora to your PATH automatically." +assert_contains "$failure_output" "agora is installed at $TMPROOT/unwritable-zsh/home/.local/bin/agora and is ready to run." +assert_contains "$failure_output" "export PATH=\"$TMPROOT/unwritable-zsh/home/.local/bin:\$PATH\"" +assert_contains "$failure_output" "For other options (custom INSTALL_DIR, containers), see https://github.com/AgoraIO/cli#readme" + +bash_walk_output=$(run_case "bash-walk" "/bin/bash" ' + : > "$HOME/.bashrc" + chmod 444 "$HOME/.bashrc" + : > "$HOME/.bash_profile" + mkdir -p "$INSTALL_DIR" + add_to_path + grep -qF "$INSTALL_DIR" "$HOME/.bash_profile" +') +assert_contains "$bash_walk_output" "Added $TMPROOT/bash-walk/home/.local/bin to PATH in $TMPROOT/bash-walk/home/.bash_profile." + +printf 'OK: %s installer message assertions passed\n' "$ASSERTIONS" From 2077f09bcc5b5999d04b5007a6fed2ad9e97331d Mon Sep 17 00:00:00 2001 From: digitallysavvy Date: Fri, 1 May 2026 16:59:00 -0400 Subject: [PATCH 18/26] docs(cli): remove legacy CLI references and rename integration tests Drop the README migration blurb and TS-oriented copy from AGENTS, skills, and telemetry comments. Rewrite the Sentry proposal context without referencing another codebase. Rename integration tests that used a "Parity" suffix for clearer intent. --- AGENTS.md | 8 +--- README.md | 4 -- docs/proposals/telemetry-sentry-wireup.md | 51 +++++++++++------------ internal/cli/app_test.go | 2 +- internal/cli/integration_auth_test.go | 4 +- internal/cli/integration_project_test.go | 2 +- internal/cli/skills.go | 16 +++---- internal/cli/telemetry.go | 14 +++---- 8 files changed, 42 insertions(+), 59 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3485a18..8282cb4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Repo Purpose -This repository contains Agora CLI, the native CLI for Agora developer onboarding. It ships as a single binary with no runtime dependencies and is the primary distribution going forward. It is feature-parity with (and successor to) the TypeScript CLI `agoraio-cli`. +This repository contains Agora CLI, the native CLI for Agora developer onboarding. It ships as a single binary with no runtime dependencies and is the primary distribution. The same binary is also published via npm as `agoraio-cli` (a thin shim that runs the native executable). ## Quick Reference @@ -193,7 +193,7 @@ When adding a command: 3. Accept `--json` via `a.resolveOutputMode(cmd)`; return results through `renderResult(cmd, "command label", data)` 4. Add the command to the README command model 5. Add a stable JSON result shape to `docs/automation.md` -6. If the command overlaps the legacy TypeScript CLI, verify project resolution, JSON field names, error messages, and exit codes against the legacy behavior before changing the Go contract. +6. Call out breaking JSON or exit-code changes in `CHANGELOG.md` and migration notes in `docs/automation.md` when behavior is intentional. ## CI and Release @@ -262,7 +262,3 @@ npx agoraio-cli --help # or via npx without global install ``` The shell installer remains the primary install mechanism. npm is a convenience path for developers already in a Node.js ecosystem and benefits from the supply-chain provenance attestations attached at publish time. - -## Parity with agora-cli-ts - -When implementing or modifying a command that overlaps the TypeScript CLI, verify JSON field names, project resolution precedence, error messages, and exit codes against the legacy behavior. The Go CLI is the reference going forward, so document intentional divergences in `CHANGELOG.md` and `docs/automation.md`. diff --git a/README.md b/README.md index dea4d6f..3c85f4f 100644 --- a/README.md +++ b/README.md @@ -352,7 +352,3 @@ go build -o agora . ``` Requires the Go toolchain pinned in [go.mod](go.mod). For direct installer options and source install notes, see [docs/install.md](docs/install.md). - -## Migration - -This project mirrors the `agora-cli-ts` command surface in a native Go binary so the CLI no longer depends on the Node.js runtime. diff --git a/docs/proposals/telemetry-sentry-wireup.md b/docs/proposals/telemetry-sentry-wireup.md index e7d9a84..9444b1f 100644 --- a/docs/proposals/telemetry-sentry-wireup.md +++ b/docs/proposals/telemetry-sentry-wireup.md @@ -18,28 +18,26 @@ target-release: next ## Context -The TS predecessor (`agora-cli-ts`) ships Sentry. Concretely: - -- `agora-cli-ts/packages/cli-telemetry/package.json` depends on - `@sentry/node ^10.18.0`. -- `agora-cli-ts/apps/agora-cli/src/telemetry.ts` initializes Sentry - with the project DSN - `https://07bf9b5275eef5259abebe89fa247cec@o4510955723292672.ingest.us.sentry.io/4511189164687360`, - reads the `AGORA_SENTRY_ENABLED` env var, redacts sensitive fields, - and skips `command.failed` to avoid double reporting. -- The Go CLI mirrors the on/off contract in +Operational error reporting should eventually reach Sentry using the same +project DSN used across Agora CLI distributions: + +- Target DSN: + `https://07bf9b5275eef5259abebe89fa247cec@o4510955723292672.ingest.us.sentry.io/4511189164687360` + (embed in the Go binary when wiring the SDK; see Step 2). +- Reads `AGORA_SENTRY_ENABLED`, redacts sensitive keys before transport, + and avoids duplicate failure reporting where applicable. +- The Go CLI already mirrors the on/off contract in [`internal/cli/config.go`](../../internal/cli/config.go) (the `applyConfigToEnv` map sets `AGORA_SENTRY_ENABLED` from - `cfg.TelemetryEnabled`) but had no actual transport. The new - `telemetry.go` adds a `telemetryClient` interface and a noop - default; callsites in `app.go` already invoke `CaptureException` on - error. + `cfg.TelemetryEnabled`) but has no live transport until this proposal + lands. [`internal/cli/telemetry.go`](../../internal/cli/telemetry.go) + defines a `telemetryClient` interface and a noop default; call sites in + `app.go` already invoke `CaptureException` on error. ## Goals -1. The Go CLI reports the same operational diagnostics to the same - Sentry project the TS CLI reports to, so the migration does not lose - error visibility. +1. The Go CLI reports operational diagnostics to the configured Sentry + project so operators retain error visibility end to end. 2. The contract surface (`agora telemetry`, env vars, log fields) does not change. Existing wrappers and CI configs keep working. 3. Field redaction is enforced inside the sink (defense in depth) in @@ -136,7 +134,7 @@ already expects. ### Step 4: Document fields Update [`docs/telemetry.md`](../telemetry.md) with the **exact** -field schema we send. Suggested initial event vocabulary (mirrors TS): +field schema we send. Suggested initial event vocabulary: | Field | Type | Example | Notes | |----------------|--------|----------------------------------|-------| @@ -164,8 +162,7 @@ not in CI, and not in JSON mode: > [Y/n]" The default is "yes" to match the current `cfg.TelemetryEnabled: true` -default and the TS predecessor. Persist the answer to config so the -prompt never re-appears. +default. Persist the answer to config so the prompt never re-appears. ### Step 6: Add tests @@ -191,20 +188,20 @@ Under `[Unreleased] / Added`: | # | Why | |---|-----| -| 1 | TS already shipped Sentry. Migrating without it would silently regress error visibility for the same user base. | +| 1 | Shipping without Sentry leaves blind spots for production CLI failures that users cannot easily paste into issues. | | 2 | `internal/cli/telemetry.go` already exposes the right interface. Wire-up is one constant + one struct change. | | 3 | `BeforeSend` redaction in the sink is a belt-and-braces guarantee: even if a future call site forgets to redact, fields never leave the host. | | 4 | A documented one-time consent prompt aligns with the industry direction (Homebrew flipped to opt-in in 2024, npm honors `DO_NOT_TRACK`). We keep opt-out as the default but add explicit acknowledgement. | ## Risks / open questions -- **DSN exposure.** The TS DSN is already in a public npm package, so - embedding the same DSN in the Go binary does not change the threat - model. (Sentry DSNs are intentionally public; rate-limiting and - project-side filtering are the controls.) +- **DSN exposure.** The Sentry DSN is embedded in shipped CLI binaries; + this matches common practice for Sentry client SDKs. (Sentry DSNs are + intended for client inclusion; rate-limiting and project-side filtering + are the controls.) - **Default opt-in vs opt-out.** Industry is shifting; we should - explicitly decide for v1 of the Go CLI rather than inheriting the TS - default by accident. + explicitly decide for telemetry defaults rather than inheriting them by + accident. - **Sentry SDK size.** `sentry-go` adds ~3 MB to the static binary. Acceptable for a CLI; document in the release notes. diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index 4983ece..d8e9121 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -883,7 +883,7 @@ func TestAPIRequestReturnsStructuredErrors(t *testing.T) { } } -func TestPathsLogsAndArtifactsParity(t *testing.T) { +func TestPathsLogsAndArtifacts(t *testing.T) { dir := t.TempDir() env := map[string]string{"XDG_CONFIG_HOME": dir} agoraDir, err := resolveAgoraDirectory(env) diff --git a/internal/cli/integration_auth_test.go b/internal/cli/integration_auth_test.go index e52c2ae..ae06549 100644 --- a/internal/cli/integration_auth_test.go +++ b/internal/cli/integration_auth_test.go @@ -12,7 +12,7 @@ import ( "time" ) -func TestCLILoginAndWhoAmIParity(t *testing.T) { +func TestCLILoginAndWhoAmI(t *testing.T) { configHome := t.TempDir() oauth := newFakeOAuthServer() defer oauth.server.Close() @@ -71,7 +71,7 @@ func TestCLILoginAndWhoAmIParity(t *testing.T) { } } -func TestCLIAuthStatusExitCodeParity(t *testing.T) { +func TestCLIAuthStatusExitCode(t *testing.T) { result := runCLI(t, []string{"auth", "status", "--json"}, cliRunOptions{env: map[string]string{ "XDG_CONFIG_HOME": t.TempDir(), "AGORA_LOG_LEVEL": "error", diff --git a/internal/cli/integration_project_test.go b/internal/cli/integration_project_test.go index bdda7ca..09cde05 100644 --- a/internal/cli/integration_project_test.go +++ b/internal/cli/integration_project_test.go @@ -12,7 +12,7 @@ import ( "testing" ) -func TestCLIProjectAndEnvAndDoctorParity(t *testing.T) { +func TestCLIProjectEnvAndDoctor(t *testing.T) { configHome := t.TempDir() projectDir := t.TempDir() api := newFakeCLIBFF() diff --git a/internal/cli/skills.go b/internal/cli/skills.go index 34e86d7..fcae739 100644 --- a/internal/cli/skills.go +++ b/internal/cli/skills.go @@ -9,16 +9,12 @@ import ( "github.com/spf13/cobra" ) -// skill is the curated, in-binary catalog entry. It mirrors the -// `SkillManifest` shape from `agora-cli-ts/packages/cli-contracts/src/index.ts` -// so a future port to dynamic, fetched skills can keep the same field -// names. +// skill is the curated, in-binary catalog entry. Field names are stable so +// future dynamic or fetched skills can use the same JSON shape. // // Today the catalog is read-only and lives in Go code (no remote fetch, // no file load). That keeps the surface trivially testable and avoids -// any "where did this skill come from" supply-chain question. The -// future direction (port from agora-cli-ts) is documented in -// docs/proposals/skills-platform.md. +// any "where did this skill come from" supply-chain question. type skill struct { ID string `json:"id"` Title string `json:"title"` @@ -140,9 +136,9 @@ func skillsCatalog() []skill { } // buildSkillsCommand registers `agora skills`. It is intentionally -// read-only in this release: list, show, search. Future releases will -// add `skills run`, `skills install`, and `skills eval` per the -// upstream design in agora-cli-ts. +// read-only in this release: list, show, search. Future releases may +// add `skills run`, `skills install`, and `skills eval` while keeping +// the same JSON shapes documented in docs/automation.md. func (a *App) buildSkillsCommand() *cobra.Command { cmd := &cobra.Command{ Use: "skills", diff --git a/internal/cli/telemetry.go b/internal/cli/telemetry.go index 2fd865d..e74443a 100644 --- a/internal/cli/telemetry.go +++ b/internal/cli/telemetry.go @@ -45,13 +45,12 @@ type telemetryClient interface { // agoraSentryDSN is the Sentry project DSN that the CLI ships with. // Empty string disables Sentry transport entirely (the default until the -// Sentry SDK is wired in for the next release). Mirrors the value in -// agora-cli-ts apps/agora-cli/src/telemetry.ts so the two surfaces report -// to the same project once enabled. +// Sentry SDK is wired in for the next release). When set, events go to the +// Agora CLI Sentry project (see docs/proposals/telemetry-sentry-wireup.md). const agoraSentryDSN = "" // initTelemetry returns the telemetry client appropriate for the current -// runtime. The decision tree mirrors the TS predecessor for parity: +// runtime. Decision order: // // 1. DO_NOT_TRACK is set → noop (Console-style hard opt-out). // 2. config.telemetryEnabled is false → noop. @@ -142,10 +141,9 @@ func (c *sentryClient) Flush(_ time.Duration) bool { return true } -// telemetrySensitiveFieldPattern is shared with sanitizeFields and -// matches the TS implementation in agora-cli-ts. Any field whose key -// matches this pattern is replaced with the literal "[REDACTED]" before -// it leaves the process. +// telemetrySensitiveFieldPattern is shared with sanitizeFields. Any field +// whose key matches this pattern is replaced with the literal "[REDACTED]" +// before it leaves the process. var telemetrySensitiveFieldPattern = regexp.MustCompile(`(?i)token|secret|password|api[_-]?key|authorization`) // redactTelemetryFields returns a copy of fields with any sensitive key From ee93570734510e6c73065494696c5ab8c3d9c980 Mon Sep 17 00:00:00 2001 From: digitallysavvy Date: Fri, 1 May 2026 16:59:56 -0400 Subject: [PATCH 19/26] chore: align package comment and bug template with agora-cli name Use agora-cli in the cli package doc comment and the bug report version placeholder so issue filings match released binary branding. --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- internal/cli/app.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index e81d61c..b11d339 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -18,7 +18,7 @@ body: attributes: label: CLI version description: Output of `agora --version` - placeholder: "e.g. agora-cli-go 0.2.0 (commit abc1234, built 2026-05-01)" + placeholder: "e.g. agora-cli 0.2.0 (commit abc1234, built 2026-05-01)" validations: required: true diff --git a/internal/cli/app.go b/internal/cli/app.go index 7baefb0..a64830b 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -1,4 +1,4 @@ -// Package cli implements the agora-cli-go binary. The package is structured +// Package cli implements the agora-cli binary. The package is structured // into focused files so each concern can be reasoned about independently: // // - app.go — App struct, Execute() entry point, output-mode resolver, From a16db1deb5355e679bcd895356b851faac558150 Mon Sep 17 00:00:00 2001 From: digitallysavvy Date: Fri, 1 May 2026 17:00:25 -0400 Subject: [PATCH 20/26] docs: add prioritized developer-experience backlog Add devex-backlog.md with P0/P1/P2 items, target paths, and acceptance criteria for follow-up work. --- docs/devex-backlog.md | 156 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 docs/devex-backlog.md diff --git a/docs/devex-backlog.md b/docs/devex-backlog.md new file mode 100644 index 0000000..338a22f --- /dev/null +++ b/docs/devex-backlog.md @@ -0,0 +1,156 @@ +--- +title: DevEx backlog (prioritized) +--- + +# Developer experience backlog + +Prioritized follow-ups from the DevEx review (human + agentic developers). +Update this file as items ship or priorities change. + +## Legend + +| Priority | Meaning | +| -------- | ------- | +| **P0** | Blocks adoption or trust (wrong paths, misleading guarantees, security perception). | +| **P1** | High-impact polish for clarity, consistency, or agent reliability. | +| **P2** | Strategic improvements, coordination outside this repo, or deeper test coverage. | + +--- + +## P0 + +### P0-1 — Contributor clone path matches canonical repo layout + +**Problem:** `CONTRIBUTING.md` instructs `cd cli/agora-cli-go` after cloning `AgoraIO/cli`. If the default clone layout differs (single-package repo vs monorepo), new contributors hit a broken path immediately. + +**Target files** + +- [`CONTRIBUTING.md`](../CONTRIBUTING.md) +- Optionally [`README.md`](../README.md) “Build from source” if it duplicates the path + +**Acceptance criteria** + +- [ ] Paths match the **actual** default layout of `github.com/AgoraIO/cli` (verify on default branch). +- [ ] If multiple layouts exist (e.g. subtree mirror), document **both** with a one-line “use this if…” disambiguation. +- [ ] `make test` / `go build` instructions run verbatim from a fresh clone. + +--- + +### P0-2 — Enterprise-friendly install entry point + +**Problem:** Teams that block `curl | sh` need a obvious first-class path (npm, package managers, manual tarball + verify) without digging through long install docs. + +**Target files** + +- [`README.md`](../README.md) — short **Enterprise / locked-down environments** subsection near Install. +- [`docs/install.md`](install.md) — optional anchor target if README stays minimal. + +**Acceptance criteria** + +- [ ] README surfaces **non–pipe-to-shell** options in under ~15 lines (links out to `docs/install.md` for detail). +- [ ] Mentions checksum / Cosign verification pointers (link to existing README or install sections). +- [ ] No new promises beyond what installers actually support today. + +--- + +## P1 + +### P1-1 — Align user-facing copy with `project doctor --deep` stability + +**Problem:** `AGENTS.md` notes `--deep` is not fully stable yet. User-facing docs must not imply guarantees agents/contributors do not have. + +**Target files** + +- [`README.md`](../README.md), [`docs/commands.md`](commands.md) (generated), [`docs/troubleshooting.md`](troubleshooting.md) +- [`internal/cli/`](../internal/cli/) — Cobra `Long:` / `Example:` for `project doctor` if needed + +**Acceptance criteria** + +- [ ] Public docs describe `--deep` **exactly** as implemented (or omit until stable). +- [ ] `go run ./cmd/gendocs` / `make docs-commands` refreshed if command text changes. + +--- + +### P1-2 — Agent discovery: MCP prerequisites + tool surface in one place + +**Problem:** Agents that fetch `llms.txt` but do not run the binary first still need a compact map of MCP tools and auth prerequisites (`agora login` on host). + +**Target files** + +- [`docs/llms.txt`](llms.txt) +- [`docs/automation.md`](automation.md) — short subsection if you want normative detail beyond llms.txt + +**Acceptance criteria** + +- [ ] `llms.txt` links or summarizes: MCP auth model (login on host), transport (`stdio`), and pointer to [`docs/automation.md`](automation.md) for JSON/MCP alignment. +- [ ] Tool list stays maintainable (either generated snippet from code or explicit list with “refresh when MCP surface changes” note in [`AGENTS.md`](../AGENTS.md)). + +--- + +### P1-3 — JSON schema / envelope versioning policy + +**Problem:** [`docs/schema/envelope.v1.json`](schema/envelope.v1.json) implies future versions; agents need a single place for deprecation and additive-only vs breaking rules. + +**Target files** + +- [`docs/automation.md`](automation.md) +- [`CHANGELOG.md`](../CHANGELOG.md) — for any breaking envelope changes + +**Acceptance criteria** + +- [ ] Document how `envelope.vN.json` relates to releases (e.g. additive fields OK in minor; breaking only major). +- [ ] Document where breaking changes are announced (changelog + automation.md “Migration” subsection). + +--- + +## P2 + +### P2-1 — Contract tests for automation examples + +**Problem:** Golden tests cover slices of introspect; broader drift between `docs/automation.md` examples and actual CLI output can still slip through. + +**Target files** + +- [`internal/cli/integration_*_test.go`](../internal/cli/), [`internal/cli/golden_test.go`](../internal/cli/golden_test.go) +- [`docs/automation.md`](automation.md) + +**Acceptance criteria** + +- [ ] Representative commands from automation.md are exercised in CI (or key paths extracted to shared test fixtures). +- [ ] Failure messages point authors to `docs/automation.md` / `make docs-commands` / error-code script as appropriate. + +--- + +### P2-2 — Cross-link from Agora product / Console developer surfaces + +**Problem:** Developers and agents often land on product docs first; discovery of CLI mirror URLs is faster with inbound links. + +**Target:** Agora properties **outside** this repository (Console, developer portal, RTC docs). Track as a DevRel coordination task. + +**Acceptance criteria** + +- [ ] At least one canonical product doc page links to `https://agoraio.github.io/cli/` and `https://agoraio.github.io/cli/md/`. +- [ ] Optional: link `https://agoraio.github.io/cli/llms.txt` for agent retrieval. + +--- + +### P2-3 — GitHub Pages accessibility pass + +**Problem:** Custom theme is good for branding; WCAG contrast and keyboard nav should be validated. + +**Target files** + +- [`docs/_layouts/default.html`](_layouts/default.html), [`docs/assets/css/site.css`](assets/css/site.css) + +**Acceptance criteria** + +- [ ] Spot-check focus states, heading order, and contrast on home + one inner page (light/dark). +- [ ] Fix any **obvious** issues (missing button labels already partially addressed on index copy button — extend as needed). + +--- + +## Changelog + +| Date | Change | +| ---- | ------ | +| 2026-05-01 | Initial backlog from DevEx review. | From 566a9dbf4f337b8d1bc590d8afd9765a524da14d Mon Sep 17 00:00:00 2001 From: digitallysavvy Date: Fri, 1 May 2026 17:05:43 -0400 Subject: [PATCH 21/26] ci: install golangci-lint via go install for Go 1.26 toolchain compatibility Use golangci/golangci-lint-action install-mode goinstall so the linter matches go.mod. Document go install for local lint setups. --- .github/workflows/ci.yml | 5 +++++ AGENTS.md | 8 +++++++- CONTRIBUTING.md | 7 ++++--- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dde65b4..5d6eda7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,11 +61,16 @@ jobs: # run `make docs-commands` locally and commit the result. go run ./cmd/gendocs -check + # Prebuilt golangci-lint binaries may be compiled with an older Go than + # go.mod; loading .golangci.yml then fails ("Go language version used to + # build golangci-lint is lower than the targeted Go version"). Building + # from source with setup-go matches the project's toolchain. - name: golangci-lint if: runner.os == 'Linux' uses: golangci/golangci-lint-action@v6 with: version: v1.64.8 + install-mode: goinstall args: --timeout=5m - name: Build CLI diff --git a/AGENTS.md b/AGENTS.md index 8282cb4..3246210 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -172,7 +172,13 @@ make lint # gofmt + golangci-lint + error-code audit golangci-lint run # standalone (config: .golangci.yml) ``` -CI uses `golangci-lint v1.64.8`. Install locally with: +CI uses `golangci-lint v1.64.8`, installed via `go install` so the linter is built with the same Go version as `go.mod`. Install locally to match: + +```bash +go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.8 +``` + +Alternatively, download the release binary (must be built with a Go version ≥ `go.mod`; if config load fails, prefer `go install` above): ```bash curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh \ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7d2812e..7be66f2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,13 +75,14 @@ Documentation work: - Run `make docs-commands` after command-tree changes; CI uses `go run ./cmd/gendocs -check`. - For GitHub Pages content, use `make docs-preview` (see `scripts/preview-pages-site.sh`). Published docs resolve `@@CLI_DOCS_*@@` tokens via `scripts/prepare-pages-site.py` and `docs/site.env` as documented in `docs/automation.md`. -Install `golangci-lint` (matches the CI version): +Install `golangci-lint` **v1.64.8** (matches CI). CI builds it with `go install` against your toolchain; locally prefer: ```bash -curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh \ - | sh -s -- -b "$(go env GOPATH)/bin" v1.64.8 +go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.8 ``` +Or use the [install script](https://golangci-lint.run/welcome/install/) if the binary supports your `go.mod` Go version. + When changing release packaging, also run a snapshot release: ```bash From 0e95cedce9de922c2e909b583e39147c5e1e3ab2 Mon Sep 17 00:00:00 2001 From: digitallysavvy Date: Sat, 2 May 2026 07:54:56 -0400 Subject: [PATCH 22/26] fix(cli): satisfy golangci-lint (errcheck, unused, errorlint, ineffassign) Ignore RegisterFlagCompletionFunc errors like other commands; drop unused projectCredentialEnvValues wrapper; call redactTelemetryFields from noop telemetry sinks; use errors.As in agent_infer test helper; fix separator init in agent rule append path. --- internal/cli/agent_infer_test.go | 10 +++++++--- internal/cli/agent_rules.go | 4 +--- internal/cli/projects.go | 4 ---- internal/cli/skills.go | 4 ++-- internal/cli/telemetry.go | 10 ++++++++-- 5 files changed, 18 insertions(+), 14 deletions(-) diff --git a/internal/cli/agent_infer_test.go b/internal/cli/agent_infer_test.go index 6b7da93..98cbdc4 100644 --- a/internal/cli/agent_infer_test.go +++ b/internal/cli/agent_infer_test.go @@ -1,6 +1,9 @@ package cli -import "testing" +import ( + "errors" + "testing" +) func TestAgentLabelFromOSEnv(t *testing.T) { tests := []struct { @@ -92,8 +95,9 @@ func asCliError(err error, target **cliError) bool { if err == nil { return false } - if c, ok := err.(*cliError); ok { - *target = c + var ce *cliError + if errors.As(err, &ce) { + *target = ce return true } return false diff --git a/internal/cli/agent_rules.go b/internal/cli/agent_rules.go index 82ea048..fa2bb5c 100644 --- a/internal/cli/agent_rules.go +++ b/internal/cli/agent_rules.go @@ -139,13 +139,11 @@ func writeOrAppendAgentRuleBlock(path, body, target string) (string, error) { } return "updated", nil } - separator := "\n" + var separator string if len(existing) > 0 && existing[len(existing)-1] != '\n' { separator = "\n\n" } else if len(existing) >= 2 && string(existing[len(existing)-2:]) != "\n\n" { separator = "\n" - } else { - separator = "" } next := append([]byte{}, existing...) next = append(next, []byte(separator)...) diff --git a/internal/cli/projects.go b/internal/cli/projects.go index 987428d..56977b3 100644 --- a/internal/cli/projects.go +++ b/internal/cli/projects.go @@ -549,10 +549,6 @@ func credentialLayoutFromProjectType(projectType string) projectEnvCredentialLay } } -func projectCredentialEnvValues(project projectDetail) (map[string]any, error) { - return projectCredentialEnvValuesForLayout(project, projectEnvLayoutStandard) -} - func projectCredentialEnvValuesForLayout(project projectDetail, layout projectEnvCredentialLayout) (map[string]any, error) { if project.SignKey == nil || *project.SignKey == "" { return nil, &cliError{Message: fmt.Sprintf("project %q does not have an app certificate. Enable one in Agora Console or use a different project with `agora project use`.", project.Name), Code: "PROJECT_NO_CERTIFICATE"} diff --git a/internal/cli/skills.go b/internal/cli/skills.go index fcae739..066bd9d 100644 --- a/internal/cli/skills.go +++ b/internal/cli/skills.go @@ -194,10 +194,10 @@ func (a *App) buildSkillsListCommand() *cobra.Command { } cmd.Flags().StringVar(&category, "category", "", "filter by category (scaffold, ops, agent)") cmd.Flags().StringVar(&tag, "tag", "", "filter by tag (e.g. nextjs, rtc, mcp)") - cmd.RegisterFlagCompletionFunc("category", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + _ = cmd.RegisterFlagCompletionFunc("category", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { return completeSkillCategories(toComplete), cobra.ShellCompDirectiveNoFileComp }) - cmd.RegisterFlagCompletionFunc("tag", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + _ = cmd.RegisterFlagCompletionFunc("tag", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { return completeSkillTags(toComplete), cobra.ShellCompDirectiveNoFileComp }) return cmd diff --git a/internal/cli/telemetry.go b/internal/cli/telemetry.go index e74443a..90134d7 100644 --- a/internal/cli/telemetry.go +++ b/internal/cli/telemetry.go @@ -89,8 +89,14 @@ type versionInformation = map[string]any type noopTelemetry struct{} func (noopTelemetry) Enabled() bool { return false } -func (noopTelemetry) CaptureException(_ error, _ map[string]any) {} -func (noopTelemetry) CaptureEvent(_, _ string, _ map[string]any) {} +func (noopTelemetry) CaptureException(_ error, fields map[string]any) { + // Contract: redact before any sink transports fields; keep call so + // redactTelemetryFields stays covered until Sentry wiring lands. + _ = redactTelemetryFields(fields) +} +func (noopTelemetry) CaptureEvent(_, _ string, fields map[string]any) { + _ = redactTelemetryFields(fields) +} func (noopTelemetry) Flush(_ time.Duration) bool { return true } // sentryClient is the placeholder for the Sentry-backed sink. Until the From 857e541f5d3bdb66c296d3cb096e04f6688f3e3d Mon Sep 17 00:00:00 2001 From: digitallysavvy Date: Sat, 2 May 2026 07:58:15 -0400 Subject: [PATCH 23/26] docs(release): set v0.2.0 changelog date to 2026-05-04 Postpone the documented ship date to Monday; update examples in CONTRIBUTING, bug report template, and version.go ldflags comment. --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- CHANGELOG.md | 4 ++-- CONTRIBUTING.md | 2 +- docs/devex-backlog.md | 1 + internal/cli/version.go | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index b11d339..671c248 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -18,7 +18,7 @@ body: attributes: label: CLI version description: Output of `agora --version` - placeholder: "e.g. agora-cli 0.2.0 (commit abc1234, built 2026-05-01)" + placeholder: "e.g. agora-cli 0.2.0 (commit abc1234, built 2026-05-04)" validations: required: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 90a12c5..666e726 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). When tagging a new release, rename the `[Unreleased]` section to the new version -(e.g. `[0.2.0] - 2026-05-01`), add a fresh empty `[Unreleased]` heading at the top, +(e.g. `[0.2.0] - 2026-05-04`), add a fresh empty `[Unreleased]` heading at the top, and update the link references at the bottom of this file. When adding a new entry, link the change to the PR or commit that introduced it @@ -15,7 +15,7 @@ Earlier entries pre-date this convention and only carry their version's compare ## [Unreleased] -## [0.2.0] - 2026-05-01 +## [0.2.0] - 2026-05-04 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7be66f2..b18f6f7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -164,7 +164,7 @@ change; prefer adding a new code and deprecating the old one over a rename. for user-facing changes (new commands, behavior changes, breaking changes, CLI exit code changes, error code additions). When cutting a release, move those bullets into a dated `## [x.y.z] - YYYY-MM-DD` section per the note at - the top of `CHANGELOG.md` (for example, v0.2.0 shipped as `## [0.2.0] - 2026-05-01`). + the top of `CHANGELOG.md` (for example, v0.2.0 shipped as `## [0.2.0] - 2026-05-04`). - For UI/UX-affecting changes (pretty output, prompts, progress events, errors), include before/after copy-paste samples in the PR description. - New commands MUST include a per-command example block in the Cobra diff --git a/docs/devex-backlog.md b/docs/devex-backlog.md index 338a22f..f05f04e 100644 --- a/docs/devex-backlog.md +++ b/docs/devex-backlog.md @@ -154,3 +154,4 @@ Update this file as items ship or priorities change. | Date | Change | | ---- | ------ | | 2026-05-01 | Initial backlog from DevEx review. | +| 2026-05-04 | v0.2.0 release date in `CHANGELOG.md` set to Monday 2026-05-04. | diff --git a/internal/cli/version.go b/internal/cli/version.go index 96dba2f..e75b05e 100644 --- a/internal/cli/version.go +++ b/internal/cli/version.go @@ -7,7 +7,7 @@ import "fmt" // // go build -ldflags '-X github.com/.../internal/cli.version=v0.2.0 // -X github.com/.../internal/cli.commit=abc1234 -// -X github.com/.../internal/cli.date=2026-05-01' +// -X github.com/.../internal/cli.date=2026-05-04' // // Snapshot/local builds keep the placeholder values below. var ( From 8a1cfde3a21442b49c8f3dee456d2dd3794069b2 Mon Sep 17 00:00:00 2001 From: digitallysavvy Date: Sat, 2 May 2026 07:59:16 -0400 Subject: [PATCH 24/26] chore: gofmt telemetry and regenerate docs/commands.md Fix CI: gofmt drift on internal/cli/telemetry.go and refresh the command reference so gendocs -check matches the live cobra tree. --- docs/commands.md | 2 +- internal/cli/telemetry.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index f814422..11cc77a 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -4,7 +4,7 @@ title: Command Reference # Agora CLI — Command Reference -> Generated from `agora introspect --json` on 2026-05-01. Do not edit by hand — run `make docs-commands` or rely on the release workflow to regenerate. +> Generated from `agora introspect --json` on 2026-05-02. Do not edit by hand — run `make docs-commands` or rely on the release workflow to regenerate. This page lists every enumerable command and its local flags. For long descriptions, examples, and inherited flags, run `agora --help` or read the source in `internal/cli/`. diff --git a/internal/cli/telemetry.go b/internal/cli/telemetry.go index 90134d7..f4bfbcd 100644 --- a/internal/cli/telemetry.go +++ b/internal/cli/telemetry.go @@ -88,7 +88,7 @@ type versionInformation = map[string]any // telemetry is disabled, opted out, or not compiled in. type noopTelemetry struct{} -func (noopTelemetry) Enabled() bool { return false } +func (noopTelemetry) Enabled() bool { return false } func (noopTelemetry) CaptureException(_ error, fields map[string]any) { // Contract: redact before any sink transports fields; keep call so // redactTelemetryFields stays covered until Sentry wiring lands. @@ -97,7 +97,7 @@ func (noopTelemetry) CaptureException(_ error, fields map[string]any) { func (noopTelemetry) CaptureEvent(_, _ string, fields map[string]any) { _ = redactTelemetryFields(fields) } -func (noopTelemetry) Flush(_ time.Duration) bool { return true } +func (noopTelemetry) Flush(_ time.Duration) bool { return true } // sentryClient is the placeholder for the Sentry-backed sink. Until the // Sentry SDK is wired in, every method is a no-op so the surface and From 28c5267468f0033830f2fdace9e429d106cbed55 Mon Sep 17 00:00:00 2001 From: digitallysavvy Date: Mon, 4 May 2026 14:38:21 -0400 Subject: [PATCH 25/26] updated mcp user facing docs, to remove transport flag, local subprocess is default and only option --- AGENTS.md | 4 ++-- CHANGELOG.md | 2 +- README.md | 2 +- docs/automation.md | 2 +- docs/commands.md | 4 +--- internal/cli/mcp.go | 7 +++---- internal/cli/skills.go | 6 +++--- 7 files changed, 12 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3246210..4d4d85d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ internal/cli/ config.go appConfig type, defaults, env injection version.go Build-time version vars, versionInfo, formattedVersion introspect.go agora introspect + buildIntrospectionData (agent discovery contract) - mcp.go agora mcp serve — JSON-RPC MCP stdio transport + tool dispatch + mcp.go agora mcp serve — JSON-RPC MCP tool dispatch open_targets.go Canonical URLs for agora open (docs, Console, product docs) features.go Product feature catalog (rtc/rtm/convoai) shared by doctor, introspect, init defaults cache.go Short-lived on-disk API caches (project list for shell completion) @@ -78,7 +78,7 @@ agora ├── env-help Catalog of every AGORA_* env var the CLI honors ├── skills Curated workflow recipes for humans and AI agents (list / show / search) ├── open Open Console, CLI docs (human or /md/), or product docs -├── mcp MCP stdio server for agent tool integrations +├── mcp Local MCP server for agent tool integrations ├── telemetry Telemetry status/enable/disable ├── upgrade (alias: update, self-update) In-place self-update on installer-managed installs; otherwise prints upgrade guidance ├── project diff --git a/CHANGELOG.md b/CHANGELOG.md index 666e726..12b5923 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ Earlier entries pre-date this convention and only carry their version's compare - Add global `--yes` / `-y` and `AGORA_NO_INPUT=1` support to accept defaults and suppress prompts. - Add pretty-mode progress status lines for long-running clone, OAuth, and project creation work. - Add dynamic shell completions for project names, quickstart templates, and project features, with an on-disk completion cache under `/cache/projects.json` so `agora project use ` is instant on warm caches. Configurable via `AGORA_PROJECT_CACHE_TTL_SECONDS` and disable-able via `AGORA_DISABLE_CACHE=1`. -- Add `agora mcp serve --transport stdio` so MCP-capable agents can use local Agora CLI tools, exposing the full surface (`agora.version`, `agora.introspect`, `agora.auth.*`, `agora.config.*`, `agora.telemetry.status`, `agora.upgrade.check`, `agora.project.*` including `create`/`env`/`feature.{list,status,enable}`, `agora.quickstart.*`, and `agora.init`). +- Add `agora mcp serve` so MCP-capable agents can use local Agora CLI tools, exposing the full surface (`agora.version`, `agora.introspect`, `agora.auth.*`, `agora.config.*`, `agora.telemetry.status`, `agora.upgrade.check`, `agora.project.*` including `create`/`env`/`feature.{list,status,enable}`, `agora.quickstart.*`, and `agora.init`). - Add drop-in agent rule snippets under `docs/agents/` and `agora init --add-agent-rules` with safe append-when-exists semantics: subsequent runs update only the Agora-managed block between sentinel markers and never destroy pre-existing user content. - Add `install.sh --uninstall` and `install.ps1 -Uninstall`. - Add CODEOWNERS, Dependabot, and a scheduled `govulncheck` workflow. diff --git a/README.md b/README.md index 3c85f4f..aa8abac 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ Opens curated URLs: Console (`--target console`), human CLI docs on GitHub Pages ### `mcp` -Runs the CLI as an MCP stdio server so MCP-capable clients can call Agora workflows as tools. Authenticate with `agora login` on the host first; OAuth is not exposed through MCP. +Runs the CLI as a local MCP server so MCP-capable clients can call Agora workflows as tools. Authenticate with `agora login` on the host first; OAuth is not exposed through MCP. ### `version` diff --git a/docs/automation.md b/docs/automation.md index 681e740..d97393d 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -41,7 +41,7 @@ Use this guide for: - Interactive login prompts only appear in interactive pretty-mode TTY runs. Automation should authenticate up front with `agora login`; `--json`, `AGORA_OUTPUT=json`, detected CI environments, and non-TTY stdin all skip the prompt and fail with `AUTH_UNAUTHENTICATED`. - Output mode precedence is: explicit CLI flag (`--json` or `--output`) first, user-set `AGORA_OUTPUT` second, then user-customized config file value, then **CI auto-detect → JSON** (see below), then pretty. - Set `AGORA_AGENT=` in automated environments to explicitly label agent traffic in the API `User-Agent`. When unset, the CLI may infer a coarse label such as `cursor`, `claude-code`, `cline`, `windsurf`, `codex`, or `aider` from known agent environment markers. Set `AGORA_AGENT_DISABLE_INFER=1` to disable inference. -- Use `agora mcp serve --transport stdio` to expose local Agora CLI tools to MCP-capable agents. The full surface is exposed: `agora.version`, `agora.introspect`, `agora.auth.{status,logout}`, `agora.config.{path,get}`, `agora.telemetry.status`, `agora.upgrade.check`, `agora.project.{list,show,use,create,doctor,env,env_write}`, `agora.project.feature.{list,status,enable}`, `agora.quickstart.{list,create,env_write}`, and `agora.init`. Authentication is intentionally **not** exposed via MCP because OAuth requires an interactive browser; run `agora login` once on the host first. +- Use `agora mcp serve` to expose local Agora CLI tools to MCP-capable agents. The full surface is exposed: `agora.version`, `agora.introspect`, `agora.auth.{status,logout}`, `agora.config.{path,get}`, `agora.telemetry.status`, `agora.upgrade.check`, `agora.project.{list,show,use,create,doctor,env,env_write}`, `agora.project.feature.{list,status,enable}`, `agora.quickstart.{list,create,env_write}`, and `agora.init`. Authentication is intentionally **not** exposed via MCP because OAuth requires an interactive browser; run `agora login` once on the host first. - Use `agora open --target docs` for the human GitHub Pages docs and `agora open --target docs-md` for the agent-facing raw Markdown index. The Markdown tree is published under predictable `/md/` URLs, for example `/md/commands.md`, `/md/automation.md`, and `/md/error-codes.md`. - Docs publishing reads `docs/site.env` for `CLI_DOCS_BASE_URL` and `CLI_DOCS_MD_BASE_URL`; staging Pages builds can override those environment variables at workflow time without changing docs content. The resolved values are published as `/docs.env` for transparency. - The CLI maintains a short-lived on-disk completion cache for `agora project use ` under `/cache/projects.json`. The cache is only used for completions when a **local unexpired session exists** (`session.json` with a non-empty access token and a future `expiresAt`, when present), so Tab does not suggest stale project names after logout or local session expiry. The cache TTL is 5 minutes by default; override with `AGORA_PROJECT_CACHE_TTL_SECONDS=` (set to `0` to disable). Cache files older than 24 h are pruned at every CLI startup. Set `AGORA_DISABLE_CACHE=1` to drop the cache on the next startup. The cache is invalidated automatically by `agora logout` and `agora project create` (the latter clears the file; it does not embed the new project until the next successful list fetch). To **force-refresh** the cached completion page, run `agora project list --refresh-cache` while authenticated; that command fetches the unfiltered first page used by completion and rewrites `projects.json` when it succeeds. diff --git a/docs/commands.md b/docs/commands.md index 11cc77a..70edcea 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -150,9 +150,7 @@ _No local flags. Inherited global flags still apply (see [Global Flags](#global- Serve Agora CLI tools over MCP -| Flag | Type | Default | Description | -|------|------|---------|-------------| -| `--transport` | `string` | `stdio` | MCP transport: stdio | +_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ ### `agora open` diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index c1fcd2e..105c682 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -50,7 +50,7 @@ func (a *App) buildMCPCommand() *cobra.Command { cmd := &cobra.Command{ Use: "mcp", Short: "Run Agora CLI as a local MCP server", - Long: `Expose Agora CLI tools to MCP-capable agents over stdio. + Long: `Expose Agora CLI tools to MCP-capable agents. Use this when an MCP client (Cursor, Claude Code, Windsurf, custom) wants to drive Agora workflows directly. The full Agora command surface is exposed as MCP tools so agents can authenticate, discover, manage projects, scaffold quickstarts, and run readiness checks without shelling out. @@ -59,8 +59,7 @@ Notes for agents: - ` + "`agora.auth.login`" + ` is intentionally not exposed because OAuth requires an interactive browser. Run ` + "`agora login`" + ` once on the host before starting the MCP server. - All tools return JSON-stringified payloads in the standard MCP ` + "`content[0].text`" + ` slot.`, Example: example(` - agora mcp serve --transport stdio - agora mcp # alias of 'mcp serve --transport stdio' + agora mcp serve `), RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Help() @@ -72,7 +71,6 @@ Notes for agents: Short: "Serve Agora CLI tools over MCP", Example: example(` agora mcp serve - agora mcp serve --transport stdio `), RunE: func(cmd *cobra.Command, _ []string) error { if transport != "stdio" { @@ -82,6 +80,7 @@ Notes for agents: }, } serve.Flags().StringVar(&transport, "transport", "stdio", "MCP transport: stdio") + _ = serve.Flags().MarkHidden("transport") cmd.AddCommand(serve) return cmd } diff --git a/internal/cli/skills.go b/internal/cli/skills.go index 066bd9d..88672f4 100644 --- a/internal/cli/skills.go +++ b/internal/cli/skills.go @@ -110,13 +110,13 @@ func skillsCatalog() []skill { { ID: "wire-mcp-server", Title: "Expose Agora CLI to an AI agent via MCP", - Description: "Add Agora CLI as a Model Context Protocol stdio server so a coding agent can drive it as a tool.", + Description: "Add Agora CLI as a local Model Context Protocol server so a coding agent can drive it as a tool.", Category: "agent", Tags: []string{"mcp", "cursor", "claude", "windsurf"}, Steps: []string{ "agora login # MCP does not expose OAuth; authenticate on the host first", - "agora mcp serve --transport stdio # smoke test that it speaks MCP", - "In your IDE settings, add a server that runs 'agora mcp serve --transport stdio'.", + "agora mcp serve # smoke test that it speaks MCP", + "In your IDE settings, add a server that runs 'agora mcp serve'.", }, DocsURL: "https://agoraio.github.io/cli/md/agents/README.md", }, From 4620b92575cf55697c5053f59703f1a45be76053 Mon Sep 17 00:00:00 2001 From: digitallysavvy Date: Mon, 4 May 2026 14:42:04 -0400 Subject: [PATCH 26/26] regenerated commands to fix build errors --- docs/commands.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/commands.md b/docs/commands.md index 70edcea..a26792b 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -4,7 +4,7 @@ title: Command Reference # Agora CLI — Command Reference -> Generated from `agora introspect --json` on 2026-05-02. Do not edit by hand — run `make docs-commands` or rely on the release workflow to regenerate. +> Generated from `agora introspect --json` on 2026-05-04. Do not edit by hand — run `make docs-commands` or rely on the release workflow to regenerate. This page lists every enumerable command and its local flags. For long descriptions, examples, and inherited flags, run `agora --help` or read the source in `internal/cli/`.