Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "0.1.0-alpha.13"
".": "0.1.0-alpha.14"
}
6 changes: 3 additions & 3 deletions .stats.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
configured_endpoints: 15
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/stainless%2Fstainless-v0-2b3066c21238e834fda5197e6afbbfaaf41e189a1cc9b3c14a892b90d5a209ba.yml
openapi_spec_hash: 96d0b5cd724b242a0943129fdc79bdca
config_hash: 6ae0d102d10842637680db3e63e42c51
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/stainless%2Fstainless-v0-345f2d483473fda78e89643698c6084bbd6fe9b565044aa79ad1b21e6bdf4e09.yml
openapi_spec_hash: 6780cb3fbff80ca1791cac9f073019ca
config_hash: 5fc708f77aa1d07b7376eb5cbb78f389
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## 0.1.0-alpha.14 (2025-06-17)

Full Changelog: [v0.1.0-alpha.13...v0.1.0-alpha.14](https://github.com/stainless-api/stainless-api-cli/compare/v0.1.0-alpha.13...v0.1.0-alpha.14)

### Features

* add --openapi-spec and --stainless-config flags to workspace init ([530581e](https://github.com/stainless-api/stainless-api-cli/commit/530581e8ee3bd6d52b4a3745d229de40f87e4167))
* also automatically find openapi.json ([14eeeb4](https://github.com/stainless-api/stainless-api-cli/commit/14eeeb4c3635645059243d80509e4da7e05db28a))
* **api:** manual updates ([f4e6172](https://github.com/stainless-api/stainless-api-cli/commit/f4e61722f341bb7248e40dc22473408e7eef5b05))
* flesh out project create form ([c773199](https://github.com/stainless-api/stainless-api-cli/commit/c77319958ee6b285bd74191e1535a91bc77fbfce))
* sdkjson generation API ([9f908f1](https://github.com/stainless-api/stainless-api-cli/commit/9f908f1ff8be1c8455c533f0e494a5b6cc5cf4b2))

## 0.1.0-alpha.13 (2025-06-17)

Full Changelog: [v0.1.0-alpha.12...v0.1.0-alpha.13](https://github.com/stainless-api/stainless-api-cli/compare/v0.1.0-alpha.12...v0.1.0-alpha.13)
Expand Down
5 changes: 4 additions & 1 deletion pkg/cmd/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,10 @@ var buildsCompare = cli.Command{
}

func handleBuildsCreate(ctx context.Context, cmd *cli.Command) error {
cc := getAPICommandContext(cmd)
cc, err := getAPICommandContextWithWorkspaceDefaults(cmd)
if err != nil {
return err
}
fmt.Fprintf(os.Stderr, "%s Creating build...\n", au.BrightCyan("✱"))
params := stainlessv0.BuildNewParams{}
res, err := cc.client.Builds.New(
Expand Down
43 changes: 43 additions & 0 deletions pkg/cmd/form_theme.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package cmd

import (
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
)

// GetFormTheme returns the standard huh theme used across all forms
func GetFormTheme() *huh.Theme {
theme := huh.ThemeBase()
theme.Group.Title = theme.Group.Title.Foreground(lipgloss.Color("8")).PaddingBottom(1)

theme.Focused.Title = theme.Focused.Title.Foreground(lipgloss.Color("6")).Bold(true)
theme.Focused.Base = theme.Focused.Base.BorderForeground(lipgloss.Color("240"))
theme.Focused.Description = theme.Focused.Description.Foreground(lipgloss.Color("8"))
theme.Focused.TextInput.Placeholder = theme.Focused.TextInput.Placeholder.Foreground(lipgloss.Color("8"))

theme.Blurred.Title = theme.Blurred.Title.Foreground(lipgloss.Color("6")).Bold(true)
theme.Blurred.Base = theme.Blurred.Base.Foreground(lipgloss.Color("251"))
theme.Blurred.Description = theme.Blurred.Description.Foreground(lipgloss.Color("8"))
theme.Blurred.TextInput.Placeholder = theme.Blurred.TextInput.Placeholder.Foreground(lipgloss.Color("8"))

return theme
}

// GetFormKeyMap returns the standard huh keymap used across all forms
func GetFormKeyMap() *huh.KeyMap {
keyMap := huh.NewDefaultKeyMap()
keyMap.Input.AcceptSuggestion = key.NewBinding(
key.WithKeys("tab"),
key.WithHelp("tab", "complete"),
)
keyMap.Input.Next = key.NewBinding(
key.WithKeys("tab", "down", "enter"),
key.WithHelp("tab/↓/enter", "next"),
)
keyMap.Input.Prev = key.NewBinding(
key.WithKeys("shift+tab", "up"),
key.WithHelp("shift+tab/↑", "previous"),
)
return keyMap
}
274 changes: 274 additions & 0 deletions pkg/cmd/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import (
"context"
"fmt"
"os"
"strings"

"github.com/charmbracelet/huh"
"github.com/logrusorgru/aurora/v4"
"github.com/stainless-api/stainless-api-cli/pkg/jsonflag"
"github.com/stainless-api/stainless-api-go"
"github.com/stainless-api/stainless-api-go/option"
Expand Down Expand Up @@ -52,6 +55,21 @@ var projectsCreate = cli.Command{
Path: "targets.-1",
},
},
&cli.StringFlag{
Name: "openapi-spec",
Aliases: []string{"oas"},
Usage: "Path to OpenAPI spec file",
},
&cli.StringFlag{
Name: "stainless-config",
Aliases: []string{"config"},
Usage: "Path to Stainless config file",
},
&cli.BoolFlag{
Name: "init-workspace",
Usage: "Initialize workspace configuration after creating project",
Value: true, // Default to true
},
},
Action: handleProjectsCreate,
HideHelpCommand: true,
Expand Down Expand Up @@ -119,6 +137,190 @@ var projectsList = cli.Command{
}

func handleProjectsCreate(ctx context.Context, cmd *cli.Command) error {
// Define available target languages
availableTargets := []huh.Option[string]{
huh.NewOption("TypeScript", "typescript").Selected(true),
huh.NewOption("Python", "python").Selected(true),
huh.NewOption("Go", "go"),
huh.NewOption("Java", "java"),
huh.NewOption("Kotlin", "kotlin"),
huh.NewOption("Ruby", "ruby"),
huh.NewOption("Terraform", "terraform"),
huh.NewOption("C#", "csharp"),
huh.NewOption("PHP", "php"),
}

// Get values from flags
org := cmd.String("org")
projectName := cmd.String("display-name") // Keep display-name flag for compatibility
if projectName == "" {
projectName = cmd.String("slug") // Also check slug flag for compatibility
}
targetsFlag := cmd.String("targets")
openAPISpec := cmd.String("openapi-spec")
stainlessConfig := cmd.String("stainless-config")
initWorkspace := cmd.Bool("init-workspace")

// Convert comma-separated targets flag to slice for multi-select
var selectedTargets []string
if targetsFlag != "" {
for _, target := range strings.Split(targetsFlag, ",") {
selectedTargets = append(selectedTargets, strings.TrimSpace(target))
}
}

// Pre-fill OpenAPI spec and Stainless config if found and not provided via flags
if openAPISpec == "" {
openAPISpec = findOpenAPISpec()
}
if stainlessConfig == "" {
stainlessConfig = findStainlessConfig()
}

// Check if all required values are provided via flags
// OpenAPI spec and Stainless config are optional
allValuesProvided := org != "" && projectName != ""

if !allValuesProvided {
fmt.Println("Creating a new project...")
fmt.Println()

// Fetch available organizations for suggestions
orgs := fetchUserOrgs(ctx)

// Auto-fill with first organization if org is empty and orgs are available
if org == "" && len(orgs) > 0 {
org = orgs[0]
}

form := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("organization").
Value(&org).
Suggestions(orgs).
Description("Enter the organization for this project").
Validate(func(s string) error {
if strings.TrimSpace(s) == "" {
return fmt.Errorf("organization is required")
}
return nil
}),
huh.NewInput().
Title("project name").
Value(&projectName).
DescriptionFunc(func() string {
if projectName == "" {
return "Project name, slug will be 'my-project'."
}
slug := nameToSlug(projectName)
return fmt.Sprintf("Project name, slug will be '%s'.", slug)
}, &projectName).
Placeholder("My Project").
Validate(func(s string) error {
if strings.TrimSpace(s) == "" {
return fmt.Errorf("project name is required")
}
return nil
}),
).Title("Page (1/2)"),
huh.NewGroup(
huh.NewMultiSelect[string]().
Title("targets").
Description("Select target languages for code generation").
Options(availableTargets...).
Value(&selectedTargets),
huh.NewInput().
Title("OpenAPI spec path").
Description("Relative path to your OpenAPI spec file").
Placeholder("openapi.yml").
Validate(func(s string) error {
s = strings.TrimSpace(s)
if s == "" {
return fmt.Errorf("openapi spec is required")
}
if _, err := os.Stat(s); os.IsNotExist(err) {
return fmt.Errorf("file '%s' does not exist", s)
}
return nil
}).
Value(&openAPISpec),
huh.NewInput().
Title("Stainless config path (optional)").
Description("Relative path to your Stainless config file").
Placeholder("openapi.stainless.yml").
Validate(func(s string) error {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
if _, err := os.Stat(s); os.IsNotExist(err) {
return fmt.Errorf("file '%s' does not exist", s)
}
return nil
}).
Value(&stainlessConfig),
).Title("Page (2/2)"),
).WithTheme(GetFormTheme()).WithKeyMap(GetFormKeyMap())

if err := form.Run(); err != nil {
return fmt.Errorf("failed to get project configuration: %v", err)
}

// Generate slug from project name
slug := nameToSlug(projectName)

fmt.Printf("%s organization: %s\n", aurora.Bold("✱"), org)
fmt.Printf("%s project name: %s\n", aurora.Bold("✱"), projectName)
fmt.Printf("%s slug: %s\n", aurora.Bold("✱"), slug)
if len(selectedTargets) > 0 {
fmt.Printf("%s targets: %s\n", aurora.Bold("✱"), strings.Join(selectedTargets, ", "))
}
if openAPISpec != "" {
fmt.Printf("%s openapi spec: %s\n", aurora.Bold("✱"), openAPISpec)
}
if stainlessConfig != "" {
fmt.Printf("%s stainless config: %s\n", aurora.Bold("✱"), stainlessConfig)
}
fmt.Println()

// Set the flag values so the JSONFlag middleware can pick them up
cmd.Set("org", org)
cmd.Set("display-name", projectName)
cmd.Set("slug", slug)
if len(selectedTargets) > 0 {
cmd.Set("targets", strings.Join(selectedTargets, ","))
}
if openAPISpec != "" {
cmd.Set("openapi-spec", openAPISpec)
}
if stainlessConfig != "" {
cmd.Set("stainless-config", stainlessConfig)
}
} else {
// Generate slug from project name for non-interactive mode too
slug := nameToSlug(projectName)
cmd.Set("slug", slug)
}

// Inject file contents into the API payload if files are provided or found
if openAPISpec != "" {
content, err := os.ReadFile(openAPISpec)
if err == nil {
// Inject the actual file content into the project creation payload
jsonflag.Register(jsonflag.Body, "revision.openapi\\.yml.content", string(content))
}
}

if stainlessConfig != "" {
content, err := os.ReadFile(stainlessConfig)
if err == nil {
// Inject the actual file content into the project creation payload
jsonflag.Register(jsonflag.Body, "revision.openapi\\.stainless\\.yml.content", string(content))
}
}

// Use the original logic - let the JSONFlag middleware handle parameter construction
cc := getAPICommandContext(cmd)
params := stainlessv0.ProjectNewParams{}
res, err := cc.client.Projects.New(
Expand All @@ -130,7 +332,27 @@ func handleProjectsCreate(ctx context.Context, cmd *cli.Command) error {
return err
}

if !allValuesProvided {
fmt.Printf("%s %s\n", aurora.BrightGreen("✱"), fmt.Sprintf("Project created successfully"))
}
fmt.Printf("%s\n", ColorizeJSON(res.RawJSON(), os.Stdout))

// Initialize workspace if requested
if initWorkspace {
fmt.Println()
fmt.Printf("Initializing workspace configuration...\n")

// Use the same project name (slug) for workspace initialization
slug := nameToSlug(projectName)
err := InitWorkspaceConfig(slug, openAPISpec, stainlessConfig)
if err != nil {
fmt.Printf("%s Failed to initialize workspace: %v\n", aurora.BrightRed("✱"), err)
return fmt.Errorf("project created but workspace initialization failed: %v", err)
}

fmt.Printf("%s %s\n", aurora.BrightGreen("✱"), fmt.Sprintf("Workspace initialized"))
}

return nil
}

Expand Down Expand Up @@ -187,3 +409,55 @@ func handleProjectsList(ctx context.Context, cmd *cli.Command) error {
fmt.Printf("%s\n", ColorizeJSON(res.RawJSON(), os.Stdout))
return nil
}

// fetchUserOrgs retrieves the list of organizations the user has access to
func fetchUserOrgs(ctx context.Context) []string {
client := stainlessv0.NewClient(getClientOptions()...)

res, err := client.Orgs.List(ctx)
if err != nil {
// Return empty slice if we can't fetch orgs
return []string{}
}

var orgs []string
for _, org := range res.Data {
if org.Slug != "" {
orgs = append(orgs, org.Slug)
}
}

return orgs
}

// nameToSlug converts a project name to a URL-friendly slug
func nameToSlug(name string) string {
// Convert to lowercase
slug := strings.ToLower(name)

// Replace spaces and common punctuation with hyphens
slug = strings.ReplaceAll(slug, " ", "-")
slug = strings.ReplaceAll(slug, "_", "-")
slug = strings.ReplaceAll(slug, ".", "-")
slug = strings.ReplaceAll(slug, "/", "-")
slug = strings.ReplaceAll(slug, "\\", "-")

// Remove any characters that aren't alphanumeric or hyphens
var result strings.Builder
for _, r := range slug {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
result.WriteRune(r)
}
}
slug = result.String()

// Remove multiple consecutive hyphens
for strings.Contains(slug, "--") {
slug = strings.ReplaceAll(slug, "--", "-")
}

// Trim hyphens from start and end
slug = strings.Trim(slug, "-")

return slug
}
Loading
Loading