Skip to content

Commit 7ec1249

Browse files
AaronDDMclaude
andcommitted
feat(setup): add first-time setup wizard (nylas init)
Introduce a guided onboarding experience for new CLI users. Running `nylas` with no args now detects first-run state and shows a welcome message pointing to `nylas init`. The wizard walks through four steps: 1. Account — register or login via SSO, or paste an existing API key 2. Application — auto-create or select from existing apps 3. API Key — generate and activate into the keyring 4. Grants — sync existing email accounts from the Nylas API Key design decisions: - Re-entrant: running `nylas init` again skips completed steps - Non-interactive: `nylas init --api-key <key>` for CI/scripts - Graceful recovery: each step prints manual commands on failure - Shared grant sync: extracted from auth/config.go for reuse New files: internal/cli/setup/ (detect, wizard, grants, helpers, tests) internal/cli/dashboard/exports.go (exported service wrappers) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent bda3826 commit 7ec1249

17 files changed

Lines changed: 1234 additions & 85 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ Credentials stored in system keyring (service: `"nylas"`) via `nylas auth config
106106

107107
**Quick lookup:** CLI helpers in `internal/cli/common/`, HTTP in `client.go`, Air at `internal/air/`, Chat at `internal/chat/`
108108

109-
**CLI packages:** admin, ai, audit, auth, calendar, config, contacts, email, inbound, mcp, notetaker, otp, scheduler, slack, timezone, webhook
109+
**CLI packages:** admin, ai, audit, auth, calendar, config, contacts, email, inbound, mcp, notetaker, otp, scheduler, setup, slack, timezone, webhook
110110

111111
**Additional packages:**
112112
- `internal/ports/output.go` - OutputWriter interface for pluggable formatting
@@ -115,6 +115,7 @@ Credentials stored in system keyring (service: `"nylas"`) via `nylas auth config
115115
- `internal/adapters/gpg/` - GPG/PGP email signing service (2026)
116116
- `internal/adapters/mime/` - RFC 3156 PGP/MIME message builder (2026)
117117
- `internal/chat/` - AI chat interface with local agent support (2026)
118+
- `internal/cli/setup/` - First-time setup wizard (`nylas init`)
118119

119120
**Full inventory:** `docs/ARCHITECTURE.md`
120121

@@ -173,6 +174,7 @@ Credentials stored in system keyring (service: `"nylas"`) via `nylas auth config
173174
| `make ci-full` | Complete CI (quality + tests) - **run before commits** |
174175
| `make ci` | Quick quality checks (no integration) |
175176
| `make build` | Build binary |
177+
| `nylas init` | First-time setup wizard |
176178
| `nylas air` | Start Air web UI (localhost:7365) |
177179
| `nylas chat` | Start AI chat interface (localhost:7367) |
178180

README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,17 @@ go install github.com/nylas/cli/cmd/nylas@latest
2828
nylas tui --demo
2929
```
3030

31-
**Ready to connect your account?** [Get API credentials](https://dashboard.nylas.com/) (free tier available), then:
31+
**Ready to connect your account?** The setup wizard handles everything:
3232
```bash
33-
nylas auth config # Enter your API key
34-
nylas auth login # Connect your email provider
33+
nylas init # Guided setup — account, app, API key, done
3534
nylas email list # You're ready!
3635
```
3736

37+
Already have an API key? Skip the wizard:
38+
```bash
39+
nylas init --api-key <your-key>
40+
```
41+
3842
## Basic Commands
3943

4044
| Command | Example |

cmd/nylas/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/nylas/cli/internal/cli/notetaker"
2424
"github.com/nylas/cli/internal/cli/otp"
2525
"github.com/nylas/cli/internal/cli/scheduler"
26+
"github.com/nylas/cli/internal/cli/setup"
2627
"github.com/nylas/cli/internal/cli/slack"
2728
"github.com/nylas/cli/internal/cli/timezone"
2829
"github.com/nylas/cli/internal/cli/update"
@@ -45,6 +46,7 @@ func main() {
4546
rootCmd.AddCommand(calendar.NewCalendarCmd())
4647
rootCmd.AddCommand(contacts.NewContactsCmd())
4748
rootCmd.AddCommand(dashboard.NewDashboardCmd())
49+
rootCmd.AddCommand(setup.NewSetupCmd())
4850
rootCmd.AddCommand(scheduler.NewSchedulerCmd())
4951
rootCmd.AddCommand(admin.NewAdminCmd())
5052
rootCmd.AddCommand(webhook.NewWebhookCmd())

docs/ARCHITECTURE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ internal/
4040
notetaker/ # Meeting notetaker
4141
otp/ # OTP extraction
4242
scheduler/ # Booking pages
43+
setup/ # First-time setup wizard (nylas init)
4344
slack/ # Slack integration
4445
timezone/ # Timezone utilities
4546
update/ # Self-update

docs/COMMANDS.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,25 @@ nylas completion powershell >> $PROFILE
7171

7272
---
7373

74+
## Getting Started
75+
76+
```bash
77+
nylas init # Guided first-time setup
78+
nylas init --api-key <key> # Quick setup with existing API key
79+
nylas init --api-key <key> --region eu # Setup with EU region
80+
nylas init --google # Setup with Google SSO shortcut
81+
```
82+
83+
The `init` command walks you through:
84+
1. Creating or logging into your Nylas account (SSO)
85+
2. Selecting or creating an application
86+
3. Generating and activating an API key
87+
4. Syncing existing email accounts
88+
89+
Run `nylas init` again after partial setup — it skips completed steps.
90+
91+
---
92+
7493
## Authentication
7594

7695
```bash

docs/DEVELOPMENT.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ internal/
7070
├── domain/ # Domain models
7171
├── ports/ # Interfaces
7272
├── adapters/ # Implementations
73-
└── cli/ # Commands
73+
├── cli/ # Commands (incl. setup/ for nylas init)
74+
└── ...
7475
```
7576

7677
---

docs/INDEX.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Quick navigation guide to find the right documentation for your needs.
1010

1111
### Get Started
1212

13+
- **First-time setup**`nylas init` ([details](COMMANDS.md#getting-started))
1314
- **Learn about Nylas CLI**[README.md](../README.md)
1415
- **Quick command reference**[COMMANDS.md](COMMANDS.md)
1516
- **See examples**[COMMANDS.md](COMMANDS.md) and [commands/](commands/)

internal/cli/auth/config.go

Lines changed: 20 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
nylasadapter "github.com/nylas/cli/internal/adapters/nylas"
1313
"github.com/nylas/cli/internal/cli/common"
14+
"github.com/nylas/cli/internal/cli/setup"
1415
"github.com/nylas/cli/internal/domain"
1516
)
1617

@@ -222,113 +223,53 @@ The CLI only requires your API Key - Client ID is auto-detected.`,
222223
fmt.Println()
223224
fmt.Println("Checking for existing grants...")
224225

225-
client := nylasadapter.NewHTTPClient()
226-
client.SetRegion(region)
227-
client.SetCredentials(clientID, "", apiKey)
228-
229-
ctx, cancel := common.CreateContext()
230-
defer cancel()
231-
232-
grants, err := client.ListGrants(ctx)
226+
grantStore, err := createGrantStore()
233227
if err != nil {
234-
_, _ = common.Yellow.Printf(" Could not fetch grants: %v\n", err)
235-
fmt.Println()
236-
fmt.Println("Next steps:")
237-
fmt.Println(" nylas auth login Authenticate with your email provider")
228+
_, _ = common.Yellow.Printf(" Could not access grant store: %v\n", err)
238229
return nil
239230
}
240231

241-
if len(grants) == 0 {
242-
fmt.Println(" No existing grants found")
232+
result, err := setup.SyncGrants(grantStore, apiKey, clientID, region)
233+
if err != nil {
234+
_, _ = common.Yellow.Printf(" Could not fetch grants: %v\n", err)
243235
fmt.Println()
244236
fmt.Println("Next steps:")
245237
fmt.Println(" nylas auth login Authenticate with your email provider")
246238
return nil
247239
}
248240

249-
// Get grant store to save grants locally
250-
grantStore, err := createGrantStore()
251-
if err != nil {
252-
_, _ = common.Yellow.Printf(" Could not save grants locally: %v\n", err)
253-
return nil
254-
}
255-
256-
// First pass: Add all valid grants without setting default
257-
var validGrants []domain.Grant
258-
for _, grant := range grants {
259-
if !grant.IsValid() {
260-
continue
261-
}
262-
263-
grantInfo := domain.GrantInfo{
264-
ID: grant.ID,
265-
Email: grant.Email,
266-
Provider: grant.Provider,
267-
}
268-
269-
if err := grantStore.SaveGrant(grantInfo); err != nil {
270-
continue
271-
}
272-
273-
validGrants = append(validGrants, grant)
274-
_, _ = common.Green.Printf(" ✓ Added %s (%s)\n", grant.Email, grant.Provider.DisplayName())
275-
}
276-
277-
if len(validGrants) == 0 {
241+
if len(result.ValidGrants) == 0 {
278242
fmt.Println(" No valid grants found")
279243
fmt.Println()
280244
fmt.Println("Next steps:")
281245
fmt.Println(" nylas auth login Authenticate with your email provider")
282246
return nil
283247
}
284248

285-
// Second pass: Set default grant
286-
var defaultGrantID string
287-
if len(validGrants) == 1 {
288-
// Single grant - auto-select as default
289-
defaultGrantID = validGrants[0].ID
290-
_ = grantStore.SetDefaultGrant(defaultGrantID)
249+
// Set default grant
250+
defaultGrantID := result.DefaultGrantID
251+
if defaultGrantID != "" {
252+
// Single grant, auto-selected
291253
fmt.Println()
292-
_, _ = common.Green.Printf("✓ Set %s as default account\n", validGrants[0].Email)
293-
} else {
294-
// Multiple grants - let user choose default
295-
fmt.Println()
296-
fmt.Println("Select default account:")
297-
for i, grant := range validGrants {
298-
fmt.Printf(" [%d] %s (%s)\n", i+1, grant.Email, grant.Provider.DisplayName())
299-
}
300-
fmt.Println()
301-
fmt.Print("Select default account (1-", len(validGrants), "): ")
302-
input, _ := reader.ReadString('\n')
303-
choice := strings.TrimSpace(input)
304-
305-
var selected int
306-
if _, err := fmt.Sscanf(choice, "%d", &selected); err != nil || selected < 1 || selected > len(validGrants) {
307-
// If invalid selection, default to first
308-
_, _ = common.Yellow.Printf("Invalid selection, defaulting to %s\n", validGrants[0].Email)
309-
defaultGrantID = validGrants[0].ID
310-
} else {
311-
defaultGrantID = validGrants[selected-1].ID
312-
}
313-
314-
_ = grantStore.SetDefaultGrant(defaultGrantID)
315-
selectedGrant := validGrants[0]
316-
for _, g := range validGrants {
254+
_, _ = common.Green.Printf("✓ Set %s as default account\n", result.ValidGrants[0].Email)
255+
} else if len(result.ValidGrants) > 1 {
256+
// Multiple grants, prompt
257+
defaultGrantID, _ = setup.PromptDefaultGrant(grantStore, result.ValidGrants)
258+
for _, g := range result.ValidGrants {
317259
if g.ID == defaultGrantID {
318-
selectedGrant = g
260+
_, _ = common.Green.Printf("✓ Set %s as default account\n", g.Email)
319261
break
320262
}
321263
}
322-
_, _ = common.Green.Printf("✓ Set %s as default account\n", selectedGrant.Email)
323264
}
324265

325266
fmt.Println()
326-
fmt.Printf("Added %d grant(s). Run 'nylas auth list' to see all accounts.\n", len(validGrants))
267+
fmt.Printf("Added %d grant(s). Run 'nylas auth list' to see all accounts.\n", len(result.ValidGrants))
327268

328269
// Update config file with default grant and grants list
329270
cfg.DefaultGrant = defaultGrantID
330-
cfg.Grants = make([]domain.GrantInfo, len(validGrants))
331-
for i, grant := range validGrants {
271+
cfg.Grants = make([]domain.GrantInfo, len(result.ValidGrants))
272+
for i, grant := range result.ValidGrants {
332273
cfg.Grants[i] = domain.GrantInfo{
333274
ID: grant.ID,
334275
Email: grant.Email,

internal/cli/dashboard/exports.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package dashboard
2+
3+
import (
4+
dashboardapp "github.com/nylas/cli/internal/app/dashboard"
5+
"github.com/nylas/cli/internal/ports"
6+
)
7+
8+
// CreateAuthService creates the dashboard auth service chain (exported for setup wizard).
9+
func CreateAuthService() (*dashboardapp.AuthService, ports.SecretStore, error) {
10+
return createAuthService()
11+
}
12+
13+
// CreateAppService creates the dashboard app management service (exported for setup wizard).
14+
func CreateAppService() (*dashboardapp.AppService, error) {
15+
return createAppService()
16+
}
17+
18+
// RunSSO executes the SSO device-code flow (exported for setup wizard).
19+
func RunSSO(provider, mode string, privacyAccepted bool) error {
20+
return runSSO(provider, mode, privacyAccepted)
21+
}
22+
23+
// AcceptPrivacyPolicy prompts for privacy policy acceptance (exported for setup wizard).
24+
func AcceptPrivacyPolicy() error {
25+
return acceptPrivacyPolicy()
26+
}
27+
28+
// ActivateAPIKey stores an API key in the keyring and configures the CLI (exported for setup wizard).
29+
func ActivateAPIKey(apiKey, clientID, region string) error {
30+
return activateAPIKey(apiKey, clientID, region)
31+
}
32+
33+
// GetActiveOrgID retrieves the active organization ID (exported for setup wizard).
34+
func GetActiveOrgID() (string, error) {
35+
return getActiveOrgID()
36+
}
37+
38+
// ReadLine prompts for a line of text input (exported for setup wizard).
39+
func ReadLine(prompt string) (string, error) {
40+
return readLine(prompt)
41+
}

internal/cli/dashboard/register.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,3 @@ func runSSORegister(provider string) error {
5656
}
5757
return runSSO(provider, "register", true)
5858
}
59-

0 commit comments

Comments
 (0)